Skip to main content

Procedure types

import { router, publicProcedure, scopedProcedure } from '@synthetiq/app-framework/server';
TypeAuthenticationUse case
publicProcedureNone (ctx.userId may be null)Health checks, public data
scopedProcedure([])Required (ctx.userId guaranteed)Any authenticated user
scopedProcedure(['scope'])Required + scope checkUsers with specific scopes
scopedProcedure(['a', 'b'])Required + all scopes (AND)Users with multiple scopes
scopedProcedure(['a', 'b'], 'any')Required + any scope (OR)Users with at least one scope

publicProcedure example

export const utilsRouter = router({
  healthCheck: publicProcedure
    .meta({ description: 'Check if the app is running' })
    .query(() => ({ status: 'ok' })),
});

Context object

PropertyTypeDescription
ctx.userIdstringAuthenticated user’s ID
ctx.userNamestring | nullUser’s display name
ctx.userEmailstring | nullUser’s email
ctx.userPicturestring | nullUser’s profile picture URL
ctx.userEmailVerifiedboolean | nullEmail verification status
ctx.currentOrgIdstring | nullUser’s selected organization ID
ctx.userOrgIdsstring[]All organization IDs the user belongs to
ctx.dbPrismaClientPrisma client with RLS context
ctx.appSettingsRecord<string, string>App settings from config.ts

Metadata

Every procedure must include .meta({ description }). The build fails if metadata is missing.
FieldRequiredDescription
descriptionYesUsed by AI agent, HTTP API docs, MCP server, and /docs/api page
mcpNoSet to false to exclude from MCP exposure
internalSync: scopedProcedure([])
  .meta({ description: 'Internal data sync', mcp: false })
  .mutation(...)

Streaming procedures

Procedures can return async generators for streaming responses:
streamUpdates: scopedProcedure([])
  .meta({ description: 'Stream real-time updates' })
  .input(z.object({ topic: z.string() }))
  .query(async function* ({ input }) {
    const client = new MyServiceClient();
    for await (const update of client.subscribe(input.topic)) {
      yield update;
    }
  }),

Router structure

// src/server/router.ts
import { setAppRouter } from "./init";
import { router, adminRouter, userRouter, utilsRouter, aiAgentRouter,
         docsRouter, servicesRouter, oauthAdminRouter, oauthAppsRouter,
         workflowAdminRouter, logsRouter, metricsRouter } from "@synthetiq/app-framework/server";
import { tasksRouter } from "./routes/tasks";

export const appRouter = router({
  // System routers (always include all)
  utils: utilsRouter,
  user: userRouter,
  ai: aiAgentRouter,
  admin: adminRouter,
  docs: docsRouter,
  services: servicesRouter,
  oauthAdmin: oauthAdminRouter,
  oauthApps: oauthAppsRouter,
  workflowAdmin: workflowAdminRouter,
  logs: logsRouter,
  metrics: metricsRouter,
  // App routers
  tasks: tasksRouter,
});

setAppRouter(appRouter);

export type AppRouter = typeof appRouter;
Procedures must be defined in src/server/routes/*.ts. The router validator blocks inline procedure definitions in router.ts.

Frontend usage

// Direct call
const tasks = await trpc.tasks.getMyTasks.query();

// React Query hooks
const { data } = trpcReact.tasks.getMyTasks.useQuery();
const mutation = trpcReact.tasks.createTask.useMutation({
  onSuccess: () => utils.tasks.getMyTasks.invalidate(),
});
After a mutation, remember to invalidate related queries (as shown with utils.tasks.getMyTasks.invalidate()) so the UI reflects the latest data without a hard refresh.