Adding API Endpoints
How to add new API endpoints to beebox using tRPC. Follow an existing router like calendar.ts as a reference.
When to Use tRPC vs REST
Use tRPC for standard request/response endpoints — queries (reads) and mutations (writes) — and for real-time/streaming, which is tRPC subscriptions over the WebSocket (events.subscribe is the global event-bus stream, events.turnStream the resumable per-turn chat stream; the client routes subscriptions through wsLink via the splitLink in src/frontend/src/lib/trpc/). This is the default for all new endpoints.
Keep as REST (raw Fastify routes in src/webapp/routes/) only for what doesn't fit the tRPC shape:
- File uploads/downloads (
multipart/form-data, streamed bodies) - Bounded, cacheable image representations (image transforms)
- OAuth redirect flows
- Webhook receivers (external services POST to us)
- The
/chat/sendPOST (needs the request's user + the session registry) - An endpoint that must gate on which credential authenticated the request. The tRPC context collapses cookie, hub header, mobile token, browse key and agent bearer into one
authedflag, soPOST /api/secrets/resolve(routes/secrets.ts) checks the agent bearer itself — it is the only interface that discloses a stored secret to box code (secrets.md).
Older raw routes are tech debt — migrate when you touch the area.
Files to Touch
1. Create or Edit a Router
Routers live in src/webapp/trpc/routers/, one member per router: a flat <name>.ts file, or <name>/router.ts beside that unit's own helpers when a router needs more than one file (e.g. chat/router.ts, health/router.ts). Each router groups related procedures.
import { z } from "zod";
import { router, publicProcedure } from "../procedures.js";
import { TRPCError } from "@trpc/server";
export const myRouter = router({
// Query — read-only, called with useQuery() on frontend
getThings: publicProcedure
.input(z.object({
count: z.number().int().positive().default(10),
filter: z.string().optional(),
}))
.query(async ({ input, ctx }) => {
// ctx.boxRoot, ctx.services, ctx.eventBus, etc.
const items = await loadItems(ctx.boxRoot, input.count);
return { items };
}),
// Mutation — write operation, called with useMutation() on frontend
createThing: publicProcedure
.input(z.object({
name: z.string().min(1),
content: z.string(),
}))
.mutation(async ({ input, ctx }) => {
const path = await saveThing(ctx.boxRoot, input);
ctx.eventBus.emit("file-change", { path }); // notify subscribed clients
return { success: true, path };
}),
});
Queries are for reading data. They can be cached, deduplicated, and refetched automatically.
Mutations are for writes/side effects. They don't cache. Use ctx.eventBus.emit() if other clients should see the change.
2. Register the Router
Add it to the routerMembers object in src/webapp/trpc/routers.ts, keyed by the router's camelCase name (the layout check verifies the key matches the file or directory name in identifier form). That one object is passed to both defineRegistry (completeness) and router(...) (the actual appRouter):
import { myRouter } from "./routers/my.js";
const routerMembers = {
// ... existing routers
my: myRouter,
};
3. Use It in the Frontend
import { trpc } from "../lib/trpc";
// Query — automatic loading/error states
function MyComponent() {
const { data, isLoading, error } = trpc.my.getThings.useQuery({ count: 20 });
// Conditional query (only runs when path is truthy)
const detail = trpc.my.getThing.useQuery(
{ path: selectedPath! },
{ enabled: !!selectedPath }
);
}
// Mutation
function CreateForm() {
const utils = trpc.useUtils();
const mutation = trpc.my.createThing.useMutation({
onSuccess: () => {
utils.my.getThings.invalidate(); // refetch after mutation
},
});
const handleSubmit = () => {
mutation.mutate({ name: "test", content: "hello" });
};
return <button disabled={mutation.isPending} onClick={handleSubmit}>Create</button>;
}
4. Frontend Types
Use RouterOutput for type inference — don't manually define frontend interfaces:
import type { RouterOutput } from "../lib/trpc";
// Derive types from the router's actual return values
type ThingItem = RouterOutput["my"]["getThings"]["items"][number];
Context
Every procedure receives ctx with:
| Field | Type | Description |
|---|---|---|
ctx.boxRoot | string | Absolute path to the box directory |
ctx.boxSlug | string | URL slug for the box (e.g., "test1") |
ctx.eventBus | EventBus | SQLite-backed event bus feeding events.subscribe subscriptions (see src/core/event-bus/core.ts) |
ctx.services | Services | Injected services (calendar, dropbox, claude CLI) |
ctx.chatSession | ChatSession | Per-box chat session singleton |
Error Handling
Use TRPCError with appropriate codes:
import { TRPCError } from "@trpc/server";
throw new TRPCError({ code: "NOT_FOUND", message: "Item not found" });
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid input" });
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Service not configured" });
On the frontend, errors are available as error.message from query/mutation hooks.
Input Validation
All inputs are validated with Zod schemas. Common patterns:
// Required string
z.string().min(1)
// Optional with default
z.number().int().positive().default(10)
// Enum
z.enum(["great", "ok", "meh"])
// Cross-field validation
z.object({
comment: z.string().optional(),
audioData: z.string().optional(),
}).refine((d) => d.comment || d.audioData, {
message: "Either comment or audioData is required",
})
Gotchas
Define explicit return interfaces for complex data
TypeScript inference breaks down when procedures return data with:
- Index signatures (
[key: string]: unknown) on source types - Conditional spreads (
...(flag ? { x: 1 } : {})) - Empty array fallbacks typed as
Record<string, unknown>
Fix: define an interface and type the return array explicitly:
interface MyItem {
name: string;
status: "active" | "inactive";
extra?: { count: number } | undefined; // note: | undefined for exactOptionalPropertyTypes
}
// In the procedure:
const items: MyItem[] = [];
// ... populate ...
return { items };
// NOT: return { items: [] as Array<Record<string, unknown>> };
Don't pass queryKey to tRPC hooks
tRPC manages query keys internally. To trigger refetches, use:
utils.my.getThings.invalidate()— after a mutationuseEffectwatching a trigger prop — for external signals (like subscription events)
exactOptionalPropertyTypes
The tsconfig has exactOptionalPropertyTypes: true. Optional properties that could be undefined need | undefined:
interface MyResult {
name: string;
extra?: string | undefined; // NOT just: extra?: string
}
Queries without input
For procedures with no input, call with no arguments:
// Backend
myQuery: publicProcedure.query(async ({ ctx }) => { ... })
// Frontend
const { data } = trpc.my.myQuery.useQuery();
Audio as base64
For endpoints that accept audio (brief feedback, query responses), convert Blob to base64 on the frontend and send as a string field. Don't use file uploads for small audio clips:
async function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve((reader.result as string).split(",")[1]);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
// Then in the mutation call:
const audioData = await blobToBase64(audioBlob);
mutation.mutate({ briefPath, targetId, audioData, audioMimeType: audioBlob.type });