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/<name>.ts. Each router groups related procedures.

import { z } from "zod";
import { router, publicProcedure } from "../trpc.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 src/webapp/trpc/router.ts:

import { myRouter } from "./routers/my.js";

export const appRouter = router({
  // ... 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.ts)
ctx.servicesServicesInjected services (calendar, telegram, 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 });