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:
| Request | Allowed when | Otherwise |
|---|---|---|
| GET / HEAD | the procedure is a .get() read | 405 |
| PUT / PATCH / DELETE (RPC) | never | 405 |
| POST, JSON | Content-Type: application/json | 415 |
| POST, multipart | X-Dendra: 1 header | 403 |
| POST, plain HTML form | allowFormPosts: 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.