Skip to Content

Router

A router is a plain object whose leaves are procedures. You build each procedure with the route builder from initDendra(), and the same router then serves RPC, REST, WebSockets, the in-process caller and AI/MCP tools. For the concepts, see Features → Middleware & auth and Features → After-hooks.

import { initDendra, inspectRouter, DendraError, DendraClientError, file } from 'dendra' import type { RouterRecord, Middleware, AfterHook, RouteInfo, ErrorCode } from 'dendra'

Defining a router

initDendra()

Binds the context type (and optionally a metadata type) and returns the route builder. Call it once per app and define every procedure off route.

function initDendra<Ctx = Record<string, never>, Meta extends object = Record<string, unknown>>(): { route: Builder<Ctx, void> }

route starts with no input, no path and the method POST. Every builder method returns a new builder and never changes the one it was called on, so you can keep a partly built builder (say, one with an auth guard) and reuse it as a base.

import type { Ctx } from './context' type Meta = { restricted: 'approver' | 'admin' } const { route } = initDendra<Ctx, Meta>() const authed = route.use(function requireUser({ ctx }) { if (!ctx.user) throw new DendraError('UNAUTHORIZED', 'Sign in') return { user: ctx.user } })

Meta types the argument of .meta(): each call accepts a Partial<Meta>.

RouterRecord

The shape of a router: an object of procedures and nested routers. Nested keys become dotted paths (notes.create). Use satisfies RouterRecord to check a plain-object router without widening its type.

interface RouterRecord { [k: string]: AnyProc | RouterRecord }
export const appRouter = { add: route.input(s.object({ a: s.number(), b: s.number() })).handler(({ input }) => input.a + input.b), notes: { list: route.get('/notes').handler(({ ctx }) => ctx.db.notes.all()) }, } satisfies RouterRecord export type AppRouter = typeof appRouter

Class controllers

A class whose fields are procedures works as a sub-router: put an instance in the router. Keep injected dependencies in #private fields. They aren’t enumerable, so they never show up as router keys, and they stay out of the public type the client sees.

export class UsersController { #db: Db constructor(db: Db) { this.#db = db } list = route.get('/users').handler(() => this.#db.users.all()) create = route .post('/users') .input(s.object({ name: s.string() })) .ai({ description: 'Create a user' }) .handler(({ input }) => this.#db.users.insert(input)) } export const appRouter = { users: new UsersController(db) }

A router that contains a class instance can’t use satisfies RouterRecord. The functions that take a router (toNextHandler, createCaller, inspectRouter, toToolset and so on) accept any object, and Client<AppRouter> checks the shape instead. inspectRouter lists class-controller procedures like any others.

Route builder

Each method below is called on route (or on a builder derived from it). Calls that configure the procedure come first; the chain ends with .handler(), .stream() or .background(), which return the procedure.

.get(path?)

Declares a read. Only .get() procedures answer HTTP GET (and HEAD); everything else answers POST only, and a GET to it gets 405. With a path, the procedure is also a REST route; without one it’s an RPC-only read. A .get() procedure is branded with ReadBrand, which urlFor requires.

get(path?: string): Builder
cover: route.get().input(s.object({ id: s.integer() })).handler(({ input }) => file(bytes, { type: 'image/webp', inline: true }))

.post(path)

Sets the method to POST and the REST path. POST is already the default, so you only need this to give the procedure a REST route.

post(path: string): Builder

.put(path)

Sets the method to PUT and the REST path. Over RPC the procedure is still called with POST.

put(path: string): Builder

.patch(path)

Sets the method to PATCH and the REST path. Over RPC the procedure is still called with POST.

patch(path: string): Builder

.delete(path)

Sets the method to DELETE and the REST path. Over RPC the procedure is still called with POST.

delete(path: string): Builder
remove: route.delete('/notes/:id').input(s.object({ id: s.string() })).handler(({ input, ctx }) => ctx.db.notes.remove(input.id))

.describe(description)

A human description of the procedure. It’s the operation summary in OpenAPI, and the tool description when .ai() doesn’t give one.

describe(description: string): Builder

.ai(config?)

Exposes the procedure to LLMs and MCP clients as a tool. Tools are opt-in: only .ai() procedures appear in toToolset, mcpToolDefinitions and the tool executor. The tool’s description is config.description, else the .describe() text, else the procedure path.

ai(config?: { description?: string confirm?: boolean maxItems?: number timeoutMs?: number annotations?: ToolAnnotations }): Builder
OptionTypeDefaultDescription
descriptionstringthe .describe() text, else the pathWhat the model reads to decide when to call the tool.
confirmbooleanfalseA tool call only proposes the action; it runs after a person approves it. See createApprovals in AI.
maxItemsnumber1000.stream() tools only: stop collecting after this many chunks. A positive integer.
annotationsToolAnnotationsderivedMCP hints, over the defaults (.get() is read-only, a confirm tool is destructive). See mcpToolDefinitions.
timeoutMsnumber30000.stream() tools only: stop collecting after this long. A positive integer. The tool answers with what arrived, and the handler’s signal aborts.
create: route .post('/notes') .input(s.object({ title: s.string(), body: s.string() })) .ai({ description: 'Create a note' }) .handler(({ input, ctx }) => ctx.db.notes.insert(input))

Throws an Error where the procedure is defined if it is also .noAi(), if maxItems or timeoutMs is set on a procedure that isn’t .stream() (or isn’t a positive integer), if annotations has both readOnlyHint and destructiveHint, or readOnlyHint: false on a .get() procedure, if its input has an s.file() field (models can’t send files through tool calls), or if any part of its input has no JSON Schema (the model wouldn’t know the parameters; see third-party schemas). The file and JSON Schema checks run when .handler(), .stream() or .background() builds the procedure; the error gives the library’s reason.

.noAi()

Marks a procedure a model must never call. Adding .ai() to it (before or after) throws an Error where the procedure is defined. inspectRouter reports it as noAi: true.

noAi(): Builder

.meta(meta)

Attaches metadata for inspectRouter, such as { restricted: 'approver' }. The argument is typed as Partial<Meta> from initDendra<Ctx, Meta>(). Repeated calls merge shallowly; a later value wins a shared key. dendra itself never reads it.

meta(meta: Partial<Meta>): Builder

.use(middleware, opts?)

Adds a middleware. Middlewares run in the order they were added, before the handler, on every transport: RPC, REST, the in-process caller, WebSocket and AI/MCP tool calls.

use<CtxAdd extends Record<string, unknown> | void>( middleware: Middleware<Ctx, In, CtxAdd>, opts?: { name?: string }, ): Builder // ctx is now Ctx & CtxAdd
  • To reject the call, throw (a DendraError for a message the caller should see).
  • To enrich the context, return an object. It’s shallow-merged into ctx for every later middleware and the handler, and the type of ctx downstream includes it.
  • Returning nothing (or a non-object) leaves ctx unchanged.
OptionTypeDefaultDescription
namestringthe function’s name, else 'anonymous'How inspectRouter lists this middleware.
const tenant = authed .input(s.object({ workspaceId: s.integer() })) .use(async function requireMember({ ctx, input }) { const role = await ctx.db.members.role(input.workspaceId, ctx.user.id) if (!role) throw new DendraError('FORBIDDEN', 'Not a member') return { role } })

Input is validated before any middleware runs, so a failed validation never reaches a middleware. A middleware receives the whole validated input, but its input type only includes what was declared with .input() above it. See .input() for declaring input a guard needs.

.after(hook)

Adds an after-hook: it runs once the call settles, on success or failure, on every transport (including AI/MCP tool calls). Use it for audit logs, usage metering and metrics.

after(hook: AfterHook<Ctx, In>): Builder
const audited = authed.after(({ ctx, path, transport, ok, code, durationMs }) => ctx.db.audit.insert({ user: ctx.user.id, path, transport, ok, code, durationMs }), )

How it runs:

  • Position matters. A hook remembers how many middlewares were added before it. It runs only if all of those passed, and it sees ctx as it was at that point. Put a hook before an auth guard to also record the calls the guard rejects; put it after the guard to see the guard’s additions to ctx.
  • Order. Hooks run one at a time, in the order they were added, and each is awaited. For a unary call they finish before the result is returned. For a stream they run when the stream ends: when it completes, throws, or the consumer stops early (which counts as success, with result: undefined).
  • Errors in a hook are logged with console.error and skipped. They never change the call’s result or stop later hooks.
  • Invalid input fails before any middleware runs, so no hook runs for it.
  • File results reach the hook as { file: { type, filename?, size? } }, never the bytes. size is missing for a stream body.

AfterHook

The function .after() takes.

type AfterHook<Ctx, In> = (info: AfterInfo<Ctx, In>) => void | Promise<void>

AfterInfo

What an after-hook receives. ok tells success (with result) from failure (with error and code).

FieldTypeDescription
ctxCtxThe context at the hook’s position in the chain.
inputInThe validated input.
pathstringDotted procedure path, e.g. 'notes.create'.
transport'rpc' | 'rest' | 'caller' | 'ws' | 'tool'Where the call came from.
durationMsnumberMilliseconds from the start of the middleware chain until the call settled.
okbooleantrue on success, false on failure.
resultunknownOn success: the handler’s return value (file metadata for a file; undefined for a stream).
errorunknownOn failure: what was thrown.
codeErrorCodeOn failure: the DendraError’s code, or 'INTERNAL' for anything else.
approvalApprovalOnly on .ai({ confirm: true }) tool calls and the decisions on them.

.input(schema)

Declares the input schema. Any Standard Schema works: the built-in s.*, Zod, Valibot or ArkType. The handler’s input is typed from the schema’s output, and a call whose input fails validation is rejected with BAD_REQUEST (message 'Input validation failed', details = the list of issues as { message, path }).

input<S extends StandardSchemaV1>(schema: S): Builder // In is now InferOutput<S>

Chained .input(). Calling .input() again extends the input instead of replacing it. This lets a shared guard declare the fields it needs once:

const tenant = route .input(s.object({ workspaceId: s.integer() })) .use(({ ctx, input }) => checkMember(ctx, input.workspaceId)) const rename = tenant .input(s.object({ name: s.string().min(1) })) .handler(({ input }) => input) // { workspaceId: number; name: string }

The rules:

  • The first .input() can be any schema. After that, both the existing input and the new schema must be objects; anything else is a type error.
  • The earlier schema wins a shared key. The new schema only adds keys that aren’t there yet, at the type level and at runtime, so a value a guard checked is never swapped out before the handler sees it.
  • Two built-in s.object() schemas merge into one object schema. If either was .strict(), the merged one is strict (so unknown-key checks cover the combined keys); otherwise, if either was .passthrough(), it passes unknown keys through; otherwise it strips them.
  • Any other pair (a third-party schema on either side) runs both schemas on the raw input and reports every issue from both together. Each must produce a plain object (or the call fails with 'Chained input must be an object'), and the two outputs are merged, earlier keys winning.
  • JSON Schema (for OpenAPI and tools) is the merged object’s own schema for two s.object()s. Otherwise it’s an object combining the properties of both (earlier wins) and the union of their required keys. A part with no JSON Schema is left out, and makes .ai() throw.
  • Validation always covers the whole chain and runs once, before the first middleware.

.output(schema)

Declares the response shape. It documents the response in OpenAPI and constrains the handler’s return type to the schema’s output type. It does not validate the returned value at runtime.

output<S extends StandardSchemaV1>(schema: S): Builder // handler must return InferOutput<S>
get: route .get('/notes/:id') .input(s.object({ id: s.string() })) .output(s.object({ id: s.string(), title: s.string() })) .handler(({ input, ctx }) => ctx.db.notes.get(input.id))

OpenAPI uses the schema’s JSON Schema: every s.* schema has one, and Zod 4, ArkType and wrapped Valibot schemas publish one (third-party schemas). Without one the handler is still typed, but the response is undocumented ({}). .output() doesn’t validate the result at runtime.

.handler(fn)

Ends the chain with a unary procedure: fn returns a value (or a promise of one). It receives the validated input and the ctx after all middlewares.

handler<Out>(fn: (o: { input: In; ctx: Ctx }) => Out | Promise<Out>): Proc<Ctx, In, Awaited<Out>>

Return file() to send a file download instead of JSON. Throw a DendraError for an error the caller should read; anything else reaches HTTP clients, WebSockets and tools as INTERNAL: Internal error and is logged.

.stream(fn)

Ends the chain with a streaming procedure: fn returns an async iterable, usually an async function*. Each yielded value is sent as it’s produced (NDJSON or SSE over HTTP, frames over WebSocket); the client consumes it with for await.

stream<Out>(fn: (o: { input: In; ctx: Ctx; signal: AbortSignal }) => AsyncIterable<Out>): Proc<Ctx, In, Out, 'stream'>

signal aborts when the consumer goes away: the client disconnects, breaks out of its loop, unsubscribes, or aborts its own { signal }. It’s also aborted once the stream ends. Pass it to pubsub.subscribe(topic, { signal }), or check it in long work, so the stream stops with its consumer.

watch: route.stream(async function* ({ ctx, signal }) { for await (const ev of ctx.pubsub.subscribe('notes', { signal })) yield ev })

.background(job, map?)

Ends the chain with a procedure that enqueues a background job and returns { jobId } right away. The input is validated and the middlewares run first, as for any procedure. The job runs later with its own context (from createJobs({ context })), not the request’s, so use map to pass what it needs from this call, such as a tenant or user id. Without map, the procedure’s input is the job’s input.

background(job: Job<In>): Proc<Ctx, In, { jobId: string }> background<J>(job: Job<J>, map: (o: { input: In; ctx: Ctx }) => J | Promise<J>): Proc<Ctx, In, { jobId: string }>

job is anything with enqueue(input) returning Promise<{ jobId: string }>, such as a job from jobs.define() (see Jobs).

summarize: authed .input(s.object({ docId: s.string() })) .background(summarizeJob, ({ input, ctx }) => ({ docId: input.docId, userId: ctx.user.id }))

Types

HttpMethod

The method a procedure answers on its REST route. GET comes from .get(); POST is the default.

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'

Middleware

The function .use() takes. It may throw to reject, return an object to merge into ctx, or return nothing.

type Middleware<Ctx, In, CtxAdd> = (opts: { ctx: Ctx; input: In }) => CtxAdd | void | Promise<CtxAdd | void>

ReadBrand

A type-level marker on procedures declared with .get(): reads, safe to reach by a link or <img src>. urlFor only accepts procedures that carry it.

type ReadBrand = { readonly _read: true }

AiConfig

How a procedure is exposed as a tool, as stored by .ai().

FieldTypeDescription
exposebooleantrue once .ai() is called.
descriptionstringThe .ai({ description }) text, if given.
confirmbooleanTool calls wait for a person’s approval.
maxItemsnumberA streaming tool’s chunk limit, if set (default 1,000 when collecting).
timeoutMsnumberA streaming tool’s time limit in ms, if set (default 30 s when collecting).
annotationsToolAnnotationsThe .ai({ annotations }) given, if any (not the derived defaults; inspectRouter shows those).

ToolAnnotations

MCP’s tool annotations: hints an MCP client uses to decide what to ask the user about. Set them with .ai({ annotations }); mcpToolDefinitions lists them, and inspectRouter shows the effective set.

FieldTypeMeaning
titlestringA human-readable name for the tool.
readOnlyHintbooleanThe tool doesn’t change anything. Default true for .get().
destructiveHintbooleanA change it makes may destroy or overwrite data. Only meaningful when not read-only. Default true for confirm tools.
idempotentHintbooleanCalling it again with the same arguments has no further effect.
openWorldHintbooleanIt reaches outside the app, e.g. the web or another company’s API.

Approval

Where an .ai({ confirm: true }) call stands. After-hooks see it as info.approval. pending means the model proposed the call and the handler didn’t run; approved and rejected mean a person decided.

interface Approval { id: string status: 'pending' | 'approved' | 'rejected' reason?: string }

Inspecting a router

inspectRouter(router)

Lists every procedure in a router (class-controller instances included) as plain data. Use it in a test to fail the build when a procedure lacks its guard, or when a restricted procedure is a tool.

function inspectRouter<Meta = Record<string, unknown>>(router: object): RouteInfo<Meta>[]
import { expect, it } from 'vitest' it('guards every workspace procedure', () => { for (const r of inspectRouter(appRouter)) { if (r.path.startsWith('workspace.')) expect(r.middlewares).toContain('requireMember') if (r.meta.restricted) expect(r.ai).toBe(false) } })

Name inline arrow middlewares with .use(mw, { name }); otherwise they’re listed as 'anonymous'.

RouteInfo

One entry from inspectRouter.

FieldTypeDescription
pathstringDotted router path, e.g. 'notes.create'.
kind'unary' | 'stream'.handler()/.background() or .stream().
methodHttpMethod'GET' for .get(), else the method set, default 'POST'.
restPathstringThe REST route, only when the procedure declared one.
middlewaresstring[]Middleware names in run order.
aibooleanExposed as a tool with .ai().
confirmboolean.ai({ confirm: true }).
noAibooleanMarked .noAi().
annotationsToolAnnotationsThe MCP annotations the tool is listed with: the derived defaults plus .ai({ annotations }). {} for a procedure that isn’t a tool, or has none.
metaMetaMerged .meta() values; {} when none were set.

Errors

DendraError

The error to throw from a handler or middleware when the caller (a person or a model) should read the message. It crosses the wire as { code, message, details } with the HTTP status for its code. Any other thrown value reaches HTTP clients, WebSockets, job snapshots and tools as { code: 'INTERNAL', message: 'Internal error' }, and the original is logged.

class DendraError extends Error { constructor(code: ErrorCode, message: string, status?: number, details?: unknown) readonly code: ErrorCode readonly status: number // `status` if given, else the code's status (table below) readonly details?: unknown }
throw new DendraError('NOT_FOUND', 'No such note') throw new DendraError('CONFLICT', 'Title taken', undefined, { field: 'title' })

details is sent to the caller as-is, so only put in what they may see.

DendraClientError

What the typed client throws when a call fails. It carries the server’s code, message and details, so error handling looks the same on both sides.

class DendraClientError extends Error { readonly code: string // the server's ErrorCode; 'INTERNAL' if none was sent readonly details?: unknown }

The message is 'Request failed' when the server sent none.

try { await api.notes.create({ title, body }) } catch (e) { if (e instanceof DendraClientError && e.code === 'BAD_REQUEST') showIssues(e.details) }

ErrorCode

Every error code, with the HTTP status a DendraError gets when you don’t pass one.

CodeStatus
BAD_REQUEST400
UNAUTHORIZED401
FORBIDDEN403
NOT_FOUND404
METHOD_NOT_ALLOWED405
CONFLICT409
PAYLOAD_TOO_LARGE413
UNSUPPORTED_MEDIA_TYPE415
UNPROCESSABLE422
TOO_MANY_REQUESTS429
INTERNAL500
NOT_IMPLEMENTED501
BAD_GATEWAY502

File results

file(body, options)

Returns a file from a handler. The server sends it as a raw HTTP response, not JSON. The typed client and createCaller both resolve it to a Blob. To use it in an <img src> or link, declare the procedure with .get() and build the URL with urlFor.

function file(body: FileBody, options: FileOptions): FileResult
report: route .get() .input(s.object({ id: s.string() })) .handler(async ({ input, ctx }) => file(await ctx.db.reports.csv(input.id), { type: 'text/csv', filename: `report-${input.id}.csv` }), )

What the response carries:

  • Content-Type from type, X-Content-Type-Options: nosniff, and Cache-Control from cacheControl.
  • Content-Disposition: attachment (with the sanitized filename when given), or inline when inline is set and the type isn’t active content. HTML, XHTML, SVG, XML and any +xml type are sent as attachments even with inline, unless allowUnsafeInline is set.
  • With etag, an ETag header; a request whose If-None-Match matches gets an empty 304.

A file can’t be sent over a batch, a WebSocket or as a tool result: those calls fail with NOT_IMPLEMENTED (and after-hooks record the failure).

FileOptions

OptionTypeDefaultDescription
typestring(required)The response’s Content-Type.
filenamestringnoneDownload name for Content-Disposition. Quotes, backslashes and control characters are dropped from the ASCII fallback; the exact name is sent as filename*.
inlinebooleanfalseShow in the browser instead of downloading. Ignored for HTML/SVG/XML unless allowUnsafeInline is set.
cacheControlstring'private, no-store'The Cache-Control header. The default keeps tenant data out of shared caches.
etagstringnoneSent as a quoted ETag; a matching If-None-Match gets a bodiless 304.
allowUnsafeInlinebooleanfalseAllow inline for HTML, SVG and XML. Only for content you fully control.
headersRecord<string, string>noneExtra headers. Can’t override Content-Type, Content-Disposition, X-Content-Type-Options or X-Dendra.

FileResult

What file() returns. Servers send it as a raw response; clients and callers see a Blob; after-hooks see { file: { type, filename?, size? } }.

interface FileResult { readonly _dendraFile: true readonly body: FileBody readonly options: FileOptions }

FileBody

The bodies file() accepts. A ReadableStream is streamed as-is (its size isn’t known up front).

type FileBody = Blob | ReadableStream<Uint8Array> | ArrayBuffer | Uint8Array | string

For file uploads, see s.file().

Last updated on