Procedure types
import { router, publicProcedure, scopedProcedure } from '@synthetiq/app-framework/server';
| Type | Authentication | Use case |
|---|
publicProcedure | None (ctx.userId may be null) | Health checks, public data |
scopedProcedure([]) | Required (ctx.userId guaranteed) | Any authenticated user |
scopedProcedure(['scope']) | Required + scope check | Users 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
| Property | Type | Description |
|---|
ctx.userId | string | Authenticated user’s ID |
ctx.userName | string | null | User’s display name |
ctx.userEmail | string | null | User’s email |
ctx.userPicture | string | null | User’s profile picture URL |
ctx.userEmailVerified | boolean | null | Email verification status |
ctx.currentOrgId | string | null | User’s selected organization ID |
ctx.userOrgIds | string[] | All organization IDs the user belongs to |
ctx.db | PrismaClient | Prisma client with RLS context |
ctx.appSettings | Record<string, string> | App settings from config.ts |
Every procedure must include .meta({ description }). The build fails if metadata is missing.
| Field | Required | Description |
|---|
description | Yes | Used by AI agent, HTTP API docs, MCP server, and /docs/api page |
mcp | No | Set 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.