Skip to Content
Features

Also in the box

Beyond typed RPC and the AI/MCP story, every procedure definition gives you:

REST + OpenAPI 3.1

A procedure with a method and path (route.get('/notes')) is a REST endpoint; toOpenApiDocument(appRouter, { info }) emits an OpenAPI 3.1 spec (file uploads are documented as multipart/form-data). Same validation, same handler.

.get() also marks a procedure as a read: only reads answer GET (and so can sit behind a link or <img src>). The path is optional — route.get() is an RPC-only read.

Streaming & real-time

route.stream(async function* () { … }) — streaming is just an async generator, delivered as NDJSON over HTTP. Pair it with a pub/sub (createInMemoryPubSub or createRedisPubSub) for live subscriptions. On the client, for await (…) over the same typed method.

WebSocket transport

toNodeHandler and a WS server expose the same procedures over WebSocket — the client is unchanged.

In-process caller

In a Server Component, skip HTTP entirely: createCaller(appRouter, ctx) runs the procedure in-process with no round-trip and no serialization.

Middleware & auth

dendra never authenticates — you guard procedures with .use():

const authed = route.use(({ ctx }) => { if (!ctx.user) throw new DendraError('UNAUTHORIZED', 'Sign in') return { user: ctx.user } // refines ctx for downstream handlers })

A tool behind .use(auth) is auth-guarded for the model too.

After-hooks (audit, usage, metrics)

.after() runs once a call settles — success or failure — on every transport, including AI/MCP tool calls. It’s awaited, and a hook that throws is only logged:

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

A hook only sees calls that passed the .use()s before it — put it before the auth guard to also record rejections.

Files in and out

Uploads and downloads are ordinary procedures, so .use() and .after() cover them:

upload: tenant .input(s.object({ workspaceId: s.integer(), image: s.file({ types: ['image/png', 'image/webp'], maxBytes: 8_000_000 }) })) .handler(({ input }) => store(input.image)), // input.image: File cover: tenant .get() // reads answer GET → usable in <img> .input(s.object({ id: s.integer() })) .handler(() => file(bytes, { type: 'image/webp', inline: true })),

On the client, api.upload({ …, image }) sends multipart, a file() result arrives as a Blob, and urlFor(api.cover, { id }) gives an <img src>. File types are checked from the bytes, bodies are capped (10 MB default), file responses send nosniff, and HTML/SVG never render inline.

Audit hooks see { file: { type, filename, size } } for a file result, never the bytes. A procedure with file input can’t be .ai() — models can’t send files.

Security: CSRF by default

dendra’s client sends X-Dendra: 1 on every request, and the server only accepts what a cross-site page can’t forge without a CORS preflight:

RequestAllowed whenOtherwise
GET / HEADthe procedure is a .get() read405
PUT / PATCH / DELETE (RPC)never405
POST, JSONContent-Type: application/json415
POST, multipartX-Dendra: 1 header403
POST, plain HTML formallowFormPosts: true (then CSRF is your job)415

File responses always send X-Content-Type-Options: nosniff, sanitize the filename, and never render HTML/SVG/XML inline unless you set allowUnsafeInline.

WebSockets bypass CORS: check the upgrade request’s Origin against your allowed origins before handing the socket to attachWs.

Class controllers (NestJS-style, no decorators)

A class whose fields are procedures; put an instance in the router. Keep injected deps in #private fields:

class UsersController { constructor(#db) {} list = route.handler(({ ctx }) => this.#db.users.all()) create = route.input(s.object({ name: s.string() })).handler(/* … */) } const appRouter = { users: new UsersController(db) /* … */ }

Request batching

The typed client can batch concurrent unary calls into one HTTP request — transparent to your code.

Agent helpers

Opt-in peer-dep subpaths: dendra/anthropic (runAgent — the model↔tool loop), dendra/react (useAssistant — the chat thread + streaming), and dendra/memory (persistent recall). Use them, or bring your own loop/UI — the core tool-shaping primitives are enough on their own.

Standard Schema

Validate with the built-in s.* (including s.file()), or any Standard Schema library — Zod 3 and 4, Valibot, or ArkType — interchangeably. Each is type-checked in dendra’s test suite, so input infers correctly.

Last updated on