The backend API is built with tRPC, providing end-to-end type safety from server to client. Every procedure is a typed function with input validation, access control, and metadata — making the API self-documenting and available as an AI agent tool.
Task routes
The task tracker needs routes to create, list, and update tasks. Create src/server/routes/tasks.ts:
import { router, scopedProcedure } from '@synthetiq/app-framework/server';
import { z } from 'zod';
export const tasksRouter = router({
getMyTasks: scopedProcedure([])
.meta({ description: "Get current user's tasks" })
.query(async ({ ctx }) => {
return ctx.db.task.findMany({
where: { userId: ctx.userId },
orderBy: { createdAt: 'desc' },
});
}),
getAllTasks: scopedProcedure(['tasks:viewAll'])
.meta({ description: 'Get all tasks across all users (admin)' })
.query(async ({ ctx }) => {
return ctx.db.task.findMany({
include: { user: { select: { id: true, name: true } } },
orderBy: { createdAt: 'desc' },
});
}),
createTask: scopedProcedure([])
.meta({ description: 'Create a new task' })
.input(z.object({
title: z.string().min(1),
priority: z.number().min(1).max(5).optional(),
}))
.mutation(async ({ ctx, input }) => {
return ctx.db.task.create({
data: { title: input.title, priority: input.priority, userId: ctx.userId },
});
}),
updateTask: scopedProcedure([])
.meta({ description: 'Update a task' })
.input(z.object({
id: z.string(),
title: z.string().min(1).optional(),
status: z.enum(['active', 'completed', 'archived']).optional(),
priority: z.number().min(1).max(5).optional(),
}))
.mutation(async ({ ctx, input }) => {
return ctx.db.task.update({
where: { id: input.id, userId: ctx.userId },
data: input,
});
}),
});
Key points:
scopedProcedure([]) requires authentication — any logged-in user can call the procedure
scopedProcedure(['tasks:viewAll']) additionally requires the tasks:viewAll scope
- Every procedure must include
.meta({ description }) — the build fails without it. The description is used by the AI agent, HTTP API docs, MCP server, and built-in docs pages.
- Input is validated with Zod — invalid input returns a 400 error before the handler executes
ctx.userId identifies the authenticated user — never accept user identity as input
Registering routers
Register the new tasksRouter with the app in src/server/router.ts:
// 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({
utils: utilsRouter,
user: userRouter,
ai: aiAgentRouter,
admin: adminRouter,
docs: docsRouter,
services: servicesRouter,
oauthAdmin: oauthAdminRouter,
oauthApps: oauthAppsRouter,
workflowAdmin: workflowAdminRouter,
logs: logsRouter,
metrics: metricsRouter,
tasks: tasksRouter,
});
setAppRouter(appRouter);
export type AppRouter = typeof appRouter;
Always include all system routers. The built-in admin pages, docs pages, and monitoring pages depend on them.
For the full context object, procedure type variants, streaming procedures, and MCP exclusion, see the Procedures reference.