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:

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:

FieldTypeDescription
ctx.boxRootstringAbsolute path to the box directory
ctx.boxSlugstringURL slug for the box (e.g., "test1")
ctx.eventBusEventBusSQLite-backed event bus feeding events.subscribe subscriptions (see src/core/event-bus/core.ts)
ctx.servicesServicesInjected services (calendar, dropbox, claude CLI)
ctx.chatSessionChatSessionPer-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:

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:

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 });