| name | api-endpoints |
| description | Adding or changing routes in `apps/api`. One source of truth (`defineApiEndpoint` + a Zod schema) becomes an HTTP endpoint, an OpenAPI operation, an MCP tool, and a TS SDK method — descriptions and contracts must be written with all four readers in mind. |
Adding API endpoints
When to use: Adding a new endpoint to apps/api, changing an existing one, or wondering why mcp.json / openapi.json / the SDK aren't in sync.
Before you start — reuse the UI's logic via the domain layer
When you add a new API endpoint, check whether the same action or read is already available in the web UI. The plan only ships a specific list of API endpoints (see the inventory in plans/mcp-oauth-api-expansion.md); the goal isn't full surface parity, it's not duplicating logic that the web already implements.
For each new endpoint, open apps/web/src/domains/<entity>/<entity>.functions.ts. Three cases:
- The web's server fn already calls a domain use-case (imports
*UseCase from @domain/*): reuse that use-case in the API route handler. Don't reimplement the logic in apps/api.
- The web's server fn has the logic inline (raw repository calls, validation, side effects in the server fn body itself): extract it into a new domain use-case first, then have both the web server fn AND your API route call it. The domain use-case becomes the shared seam.
- The web's server fn delegates to a third-party API like
getBetterAuth().api.*: the API process can't reach the same in-process instance. Write a domain use-case that replicates that behavior (carefully — read the third-party source so your use-case matches its rules), then point both the web and API at the use-case. Adds parity tests so the migration doesn't silently drift.
The domain use-case is the shared seam between web and API. Duplicating logic in both surfaces creates drift — one gets a bug fix the other doesn't.
If the entity doesn't have a .functions.ts because the UI doesn't expose this action yet, you're designing fresh. That's fine; just don't lose the option to share later — put the business logic in a @domain/* use-case from the start rather than inline in the route handler.
What you're really doing
Every endpoint in apps/api is one declaration that fans out four ways:
| Surface | Generated from | Consumed by |
|---|
| HTTP route (Hono) | route.method + route.path + handler | curl, internal services |
| OpenAPI operation | route.name (→ operationId), route.description, request/response schemas | apps/api/openapi.json → Fern → TS SDK (@latitude/sdk-typescript) |
| MCP tool | route.name, route.description, flattened input + 2xx-JSON output schema | apps/api/mcp.json, runtime /v1/mcp transport |
| SDK method | Fern reads the OpenAPI doc | end-user TypeScript code |
You don't write three configs. You write one. The infra in apps/api/src/mcp/* derives the other surfaces.
This means: the descriptions you put on routes and on schema fields are read by SDK users AND by AI agents calling the MCP. Treat every description as user-facing copy. Vague or absent descriptions are bugs.
Recipe: add a new route file
1. Create apps/api/src/routes/<resource>.ts
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"
import { defineApiEndpoint } from "../mcp/index.ts"
import { errorResponse, jsonBody, jsonResponse, openApiResponses, PROTECTED_SECURITY } from "../openapi/schemas.ts"
import type { OrganizationScopedEnv } from "../types.ts"
export const widgetsPath = "/widgets"
const widgetEndpoint = defineApiEndpoint<OrganizationScopedEnv>(widgetsPath)
const WidgetSchema = z
.object({
id: z.string().describe("Stable identifier; safe to use as a primary key in client storage."),
name: z.string().describe("Human-readable label, unique within an organization."),
createdAt: z.string().describe("ISO-8601 timestamp of creation."),
})
.openapi("Widget")
const CreateWidgetBody = z
.object({
name: z.string().min(1).describe("Display name for the new widget. Must be non-empty."),
})
.openapi("CreateWidgetBody")
const createWidget = widgetEndpoint({
route: createRoute({
method: "post",
path: "/",
name: "createWidget",
summary: "Create widget",
description: "Creates a widget in the caller's organization. Returns the persisted record.",
tags: ["Widgets"],
security: PROTECTED_SECURITY,
request: { body: jsonBody(CreateWidgetBody) },
responses: openApiResponses({ status: 201, schema: WidgetSchema, description: "Widget created" }),
}),
handler: async (c) => {
const { name } = c.req.valid("json")
return c.json({ id: "...", name, createdAt: new Date().toISOString() }, 201)
},
})
const listWidgets = widgetEndpoint({
route: createRoute({
method: "get",
path: "/",
name: "listWidgets",
summary: "List widgets",
description: "Returns every widget in the caller's organization, ordered by creation date.",
tags: ["Widgets"],
security: PROTECTED_SECURITY,
responses: {
200: jsonResponse(z.object({ widgets: z.array(WidgetSchema) }).openapi("WidgetList"), "List of widgets"),
401: errorResponse("Unauthorized"),
},
}),
handler: async (c) => c.json({ widgets: [] }, 200),
})
export const createWidgetsRoutes = () => {
const app = new OpenAPIHono<OrganizationScopedEnv>()
createWidget.mountHttp(app, createTierRateLimiter("low"))
listWidgets.mountHttp(app, createTierRateLimiter("low"))
return app
}
2. Wire it up in apps/api/src/routes/index.ts
import { createWidgetsRoutes, widgetsPath } from "./widgets.ts"
routes.route(widgetsPath, createWidgetsRoutes())
Just the mount — no routes.use(prefix, createTierRateLimiter(...)) here. Tier middleware lives next to each endpoint inside createWidgetsRoutes() (see "Rate limiting" below).
3. Regenerate manifests
pnpm openapi:emit
pnpm mcp:emit
Both files are checked in. CI guards against drift, so commit them alongside the route file.
The TS SDK regenerates from openapi.json via Fern (pnpm generate:sdk). Run it locally if your PR is supposed to expose the new method through the SDK — otherwise the next SDK release picks it up.
4. Tests
- HTTP-level integration tests live in
apps/api/src/routes/<resource>.test.ts. Test through app.fetch() so middleware runs end-to-end.
- MCP-level integration tests for new tools live in
apps/api/src/mcp/server.test.ts (see the existing createApiKey / listApiKeys / revokeApiKey cases). Add a case there if the route exposes behavior worth pinning at the MCP layer too.
Schema descriptions — the rule that matters most
Every field in every request/response schema needs a description unless the field name is self-explanatory. Descriptions reach two distinct audiences:
- SDK users read them as TypeScript JSDoc on the generated SDK methods (Fern emits them as
@param / property comments).
- AI agents read them via the MCP tool's
inputSchema / outputSchema to decide what to put in a tool call.
Write each description as one short sentence in present tense, like a microcopy label. Examples:
name: z.string().describe("Human-readable label, unique within an organization."),
nextCursor: z
.string()
.nullable()
.describe("Opaque cursor for the next page. `null` when there are no more pages."),
name: z.string().describe("The name."),
filters: filterSetSchema,
.describe() vs .meta() vs .openapi()
| API | When to use |
|---|
.describe("…") | Default for field-level descriptions. Sugar for .meta({ description }). Visible to OpenAPI AND MCP. |
.meta({ description, examples, default, ... }) | Equivalent to .describe() plus JSON-Schema-standard fields (examples, default, title). Visible to both surfaces. |
.openapi("Name") | Schema-component registration only — gives the schema a name under components.schemas in OpenAPI. Required for Fern to emit reusable types. Has nothing to do with descriptions. |
.openapi({ description, format, example, ... }) | OpenAPI-only metadata — format, example, param: { in, name }, etc. Lives in the openapi-extension WeakMap and does not propagate to MCP. Avoid for descriptions; use only for things that have no Zod-native equivalent. |
TL;DR: prefer .describe() / .meta(). Use .openapi("Name") to register named schema components. Reach for .openapi({...}) for fields ONLY when you need an OpenAPI-only knob like format: "uri".
If you find yourself writing .openapi({ description }), replace it with .describe() — descriptions hidden in the openapi WeakMap are invisible to MCP clients, which silently degrades agent UX.
Don't leak internal implementation into descriptions
User-facing descriptions (route description, schema .describe(), openApiResponses({ description })) are read by SDK users and AI agents. They aren't release notes for our backend. Keep them about the contract, not how we implement it.
Concretely, avoid:
- Storage mechanics: "soft-deletes", "hard-deletes", "marks as deleted", "removes from cache", "writes to outbox", "RLS-scoped", "via the admin connection". Just say "deletes" / "revokes" / "creates".
- Side-effect details on related data: "Traces remain in storage but the project no longer appears in lists.", "The associated rows are kept for auditing." If the caller can't observe it through the API, don't mention it.
- Internal table or column names, queue names, worker names, event-bus topics.
- Comments about why the code is structured a certain way — those belong in code comments, not in
description:.
Examples:
description: "Soft-deletes a project by slug. Traces remain in storage but the project no longer appears in lists."
description: "Deletes a project by slug."
description: "Revokes an API key by setting deletedAt and busting the Redis cache."
description: "Revokes an API key."
deletedAt: z.string().nullable().describe("ISO-8601 timestamp at which the project was soft-deleted...")
deletedAt: z.string().nullable().describe("ISO-8601 timestamp at which the project was deleted...")
Same rule for the verbs used in route/operation summary: "Delete project" beats "Soft-delete project".
Rate limiting — every endpoint needs its own tier
createTierRateLimiter(tier) is keyed on the authenticated org id (not IP), so one tenant's traffic doesn't eat another's quota. Pass it as a variadic middleware argument to mountHttp(app, ...middlewares) — the middleware is attached to this exact (method, path) pair only, so a stricter tier on DELETE /:id doesn't fire on GET /:id:
export const createWidgetsRoutes = () => {
const app = new OpenAPIHono<OrganizationScopedEnv>()
createWidget.mountHttp(app, createTierRateLimiter("low"))
getWidget.mountHttp(app, createTierRateLimiter("low"))
updateWidget.mountHttp(app, createTierRateLimiter("low"))
deleteWidget.mountHttp(app, createTierRateLimiter("medium"))
exportWidgets.mountHttp(app, createTierRateLimiter("critical"))
return app
}
Under the hood, mountHttp injects the middleware via the OpenAPIHono route config's middleware field, which Hono scopes to that single endpoint. Don't use app.use(path, mw) for tiers — it's path-matched (not method-matched), so it ends up running for every method that shares the path and stacks when called multiple times for the same path.
Picking a tier
Default to low. Most CRUD endpoints don't need more — low is 100 req/min/org, which comfortably covers SDK polling, MCP tool calls, and human-driven dashboards. Step up only when the endpoint genuinely warrants tighter limits.
| Tier | Quota (per org / min) | Pick this when… |
|---|
low | 100 | The default: id-keyed CRUD, list of bounded size, simple lookups, account/settings reads. Most endpoints land here. |
medium | 60 | Mutations with non-trivial side effects (sending email, fan-out writes, destructive ops that cascade across user surfaces). |
high | 15 | Bulk reads with filter / search / semantic / vector load that scan large data sets per request. |
critical | 3 | Workflow-kicking ops: imports, exports, monitor-issue, anything that sends email or enqueues a heavy job. |
Don't be harsh. A tighter tier doesn't make the API safer in any meaningful way for cheap endpoints — it just frustrates legitimate callers. When in doubt, pick low and bump it later if a specific endpoint shows up in incident traffic.
Always pass a tier
mountHttp(app) without a tier middleware means the endpoint inherits no per-route limit at all — only the global IP-keyed brute-force guard. The tier belongs in the same line; an unparented mountHttp(app) is an easy oversight in PR review.
Choosing route names and shapes
name is camelCase, verb-first, and reads like an SDK method: createApiKey, listProjects, assignSavedSearch. Avoid resource-prefixed names that read awkwardly as SDK calls (apiKeysList → use listApiKeys).
description on the route is the single-line tool/method blurb. Treat it as the first sentence an SDK user or AI agent sees when discovering the operation.
summary is optional, shorter, and becomes the MCP tool title. Falls back to name when omitted.
Opting out of MCP per-route
Some routes shouldn't be tools — they make sense for HTTP/SDK clients but not for AI agents (e.g. internal lifecycle endpoints, web-only callbacks). Pass tool: false:
const internalReindex = widgetEndpoint({
route: createRoute({ ... }),
handler: async (c) => { ... },
tool: false,
})
Verification checklist
Run before opening the PR:
pnpm --filter @app/api typecheck
pnpm --filter @app/api test
pnpm openapi:emit && git diff --exit-code apps/api/openapi.json
pnpm mcp:emit && git diff --exit-code apps/api/mcp.json
Spot-check both manifests by hand: open apps/api/mcp.json and apps/api/openapi.json, find your operation, confirm every field has a description. If something is missing, it'll silently degrade SDK docs and agent UX — fix it at the Zod schema, not in the JSON output.
And glance at your createXxxRoutes() factory — every mountHttp(app, …) call should pass a createTierRateLimiter("…") as its second argument. A bare ep.mountHttp(app) leaves the endpoint uncapped per-org.
Where the machinery lives
If you need to debug the auto-generation pipeline:
apps/api/src/mcp/define-endpoint.ts — defineApiEndpoint factory; baked-in prefix, mountHttp registers with the MCP registry on tool-eligible mounts.
apps/api/src/mcp/registry.ts — module-global endpoint registry; collectToolDescriptors() emits the snapshot used by both the runtime MCP transport and mcp:emit.
apps/api/src/mcp/server.ts — per-request MCP server, dispatches each tool call back through rootApp.fetch() so the full middleware chain (auth, rate-limit, org-context, validation) re-runs on every inner call.
apps/api/scripts/emit-openapi.ts / apps/api/scripts/emit-mcp.ts — boot the route registry with stub clients and serialize the manifests.
apps/api/src/openapi/schemas.ts and apps/api/src/openapi/pagination.ts — shared boundary primitives (security scheme, Paginated(...), common param schemas).
Related skills
- code-style — Zod-first contracts, naming conventions, literal-union enums.
- architecture-boundaries — web vs API split, machine-facing surface invariants.
- authentication — how
c.var.auth / c.var.organization get populated on protected routes.
- testing — Vitest harness layout,
setupTestApi for HTTP-level integration tests.