stitchkit 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/llms-full.txt ADDED
@@ -0,0 +1,3025 @@
1
+ # stitchkit — full documentation
2
+
3
+ > The complete guide + API reference, inlined for offline agent use. Generated
4
+ > from the `docs/` tree (the source of truth) by `bun run gen:llms`.
5
+
6
+
7
+ ==============================================================================
8
+ # Guide: Getting started (docs/guide/getting-started.md)
9
+ ==============================================================================
10
+
11
+ # Getting started
12
+
13
+ stitchkit turns one contract into an HTTP API, MCP tools, AI-agent tools and a
14
+ typed client. This page gets a working app running; the rest of the guide goes
15
+ deep on each piece.
16
+
17
+ ## Requirements
18
+
19
+ - [Bun](https://bun.sh) `>= 1.2` (recommended) or [Node.js](https://nodejs.org)
20
+ `>= 22`. Bun is first-class; Node is supported via `stitchkit/node`.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ bun add stitchkit zod
26
+ ```
27
+
28
+ `zod` is a required peer — schemas are the source of truth everywhere. Other
29
+ peers are optional and pulled in only when you use the matching feature
30
+ (`@modelcontextprotocol/sdk` for MCP, `ai` for agents, `socket.io*` for
31
+ realtime, `@tanstack/react-query` + `react-query-kit` for React). See
32
+ [deps](#dependencies) below.
33
+
34
+ ## Entrypoints
35
+
36
+ stitchkit ships five entrypoints. Each is import-safe for one environment —
37
+ keeping server-only code (`Bun.serve`, the MCP SDK) out of browser bundles.
38
+
39
+ | Import | Use in | Holds |
40
+ |--------|--------|-------|
41
+ | `stitchkit` | browser **and** server | `defineContract`, `createClient`, `createHttpClient`, `createSocketIOClient`, `parseSSE`, the error model |
42
+ | `stitchkit/contract` | browser **and** server | the contract layer alone — `defineContract`, errors, pagination |
43
+ | `stitchkit/server` | server | `createServer`, `implement`, hooks, auth, Socket.IO server, server primitives |
44
+ | `stitchkit/tools` | server | `createMcpHandler`, `mountMcp`, `mountAgent` |
45
+ | `stitchkit/react` | browser | `createCursorQuery`, `createCacheBridge` |
46
+
47
+ Rule of thumb: browser code imports `stitchkit` and `stitchkit/react`; server
48
+ code adds `stitchkit/server` and `stitchkit/tools`.
49
+
50
+ ## Project layout
51
+
52
+ A contract is shared by both sides, so it lives in its own folder:
53
+
54
+ ```
55
+ src/
56
+ ├── shared/contracts.ts the contract — imported by server and client
57
+ ├── server/index.ts implement() + createServer()
58
+ └── client/api.ts createClient()
59
+ ```
60
+
61
+ ## A first app
62
+
63
+ ### 1. Define the contract
64
+
65
+ ```ts
66
+ // src/shared/contracts.ts
67
+ import { defineContract } from 'stitchkit'
68
+ import { z } from 'zod'
69
+
70
+ const Note = z.object({ id: z.string(), text: z.string() })
71
+
72
+ export const notes = defineContract({ prefix: 'notes' }, {
73
+ list: { method: 'GET', path: '/', desc: 'List notes', output: z.array(Note) },
74
+ create: { method: 'POST', path: '/', desc: 'Create note', input: z.object({ text: z.string() }), output: Note },
75
+ get: { method: 'GET', path: '/:id', desc: 'Get a note', params: z.object({ id: z.string() }), output: Note },
76
+ })
77
+ ```
78
+
79
+ ### 2. Implement and serve
80
+
81
+ ```ts
82
+ // src/server/index.ts
83
+ import { implement, createServer } from 'stitchkit/server'
84
+ import { notes } from '../shared/contracts'
85
+
86
+ const service = implement(notes, {
87
+ list: () => db.list(),
88
+ create: (ctx) => db.create(ctx.input.text), // ctx.input is typed
89
+ get: (ctx) => db.get(ctx.params.id), // ctx.params is typed
90
+ })
91
+
92
+ createServer({ services: [service], port: 3000 })
93
+ ```
94
+
95
+ ### 3. Call it, typed
96
+
97
+ ```ts
98
+ // src/client/api.ts
99
+ import { createClient, createHttpClient } from 'stitchkit'
100
+ import { notes } from '../shared/contracts'
101
+
102
+ export const api = createClient(notes, createHttpClient({ baseUrl: '/api' }))
103
+
104
+ await api.list() // GET /notes → Note[]
105
+ await api.create({ text: 'hi' }) // POST /notes → Note
106
+ await api.get({ id: '1' }) // GET /notes/1 → Note
107
+ ```
108
+
109
+ Run the server with `bun run src/server/index.ts`. The contract is the single
110
+ source of truth — change it and both the handler and the client are re-typed at
111
+ once.
112
+
113
+ #### On Node
114
+
115
+ `createServer` is Bun's `Bun.serve`. On **Node ≥ 22**, swap it for `serveNode`
116
+ (from `stitchkit/node`, built on `srvx`) — the contract, `implement` and the
117
+ client are identical:
118
+
119
+ ```ts
120
+ import { serveNode } from 'stitchkit/node'
121
+
122
+ serveNode({ services: [service], port: 3000 })
123
+ ```
124
+
125
+ Add `@types/bun` as a dev dependency on Node (an optional peer — it types the
126
+ shared `stitchkit/server` surface). See [deployment](./testing-and-deployment.md#deploy-on-node).
127
+
128
+ ## Dependencies
129
+
130
+ `ky` is the **only** bundled runtime dependency. Everything else is an *optional
131
+ peer* — your app installs only what the features it uses need, and owns the
132
+ version (one shared instance, no dual-version skew). This matrix is the install
133
+ map — feature → packages:
134
+
135
+ | Feature you use | Install |
136
+ |-----------------|---------|
137
+ | anything (validation) | `zod` |
138
+ | `createServer` (Bun) | — (uses `Bun.serve`) |
139
+ | `serveNode` (Node ≥ 22) | `srvx` (+ `@types/bun` dev) |
140
+ | MCP tools (`stitchkit/tools`) | `@modelcontextprotocol/sdk` |
141
+ | MCP Apps UI widgets | `@modelcontextprotocol/ext-apps` |
142
+ | agent tools (`stitchkit/tools`) | `ai` |
143
+ | React data layer (`stitchkit/react`) | `@tanstack/react-query` `react-query-kit` |
144
+ | **Socket.IO server on Bun** | `socket.io` `@socket.io/bun-engine` |
145
+ | **Socket.IO server on Node** | `socket.io` |
146
+ | Socket.IO client | `socket.io-client` |
147
+
148
+ ```bash
149
+ bun add socket.io @socket.io/bun-engine # e.g. the Socket.IO server on Bun
150
+ ```
151
+
152
+ If an optional peer is missing, the feature that needs it fails with an
153
+ actionable error naming the package and the install command — not a bare
154
+ `Cannot find module`.
155
+
156
+ ## Next
157
+
158
+ - [Contracts](./contracts.md) — every endpoint field, in depth.
159
+ - [HTTP server](./server.md) — `createServer`, hooks, raw routes, primitives.
160
+ - [Typed client](./client.md) — the client, React data layer, SSE.
161
+ - [MCP & agents](./mcp-and-agents.md) — contracts as AI tools.
162
+ - [Realtime](./realtime.md) — Socket.IO and the cache bridge.
163
+ - [Auth & errors](./auth-and-errors.md) — scopes, auth hooks, the error model.
164
+ - [Testing & deployment](./testing-and-deployment.md).
165
+ - [API reference](../api/reference.md) — every export, by entrypoint.
166
+
167
+ A complete runnable app is in [`packages/starter`](../../packages/starter).
168
+
169
+
170
+ ==============================================================================
171
+ # Guide: Contracts (docs/guide/contracts.md)
172
+ ==============================================================================
173
+
174
+ # Contracts
175
+
176
+ A **contract** describes a set of operations once — method, path, schemas,
177
+ scope, which transports each is exposed on. From it stitchkit derives the HTTP
178
+ route, the MCP tool, the agent tool and the typed client. One declaration; the
179
+ surfaces cannot drift.
180
+
181
+ ## `defineContract`
182
+
183
+ ```ts
184
+ import { defineContract } from 'stitchkit'
185
+
186
+ const contract = defineContract(meta, endpoints)
187
+ ```
188
+
189
+ - **`meta`** — `{ prefix: string }`, or `{ prefix: string, scope: string }` to
190
+ give every endpoint a default scope.
191
+ - **`endpoints`** — a map of `key → endpoint definition`. The key becomes the
192
+ client method name and the handler name.
193
+
194
+ `defineContract` throws at definition time if two endpoints declare the same
195
+ `toolName` on the same transport — a tool-name clash is a bug, caught early.
196
+
197
+ ## An endpoint
198
+
199
+ ```ts
200
+ export const users = defineContract({ prefix: 'users' }, {
201
+ list: {
202
+ method: 'GET',
203
+ path: '/',
204
+ desc: 'List all users',
205
+ output: z.array(UserSchema),
206
+ },
207
+ create: {
208
+ method: 'POST',
209
+ path: '/',
210
+ desc: 'Create a user',
211
+ input: CreateUserSchema,
212
+ output: UserSchema,
213
+ },
214
+ get: {
215
+ method: 'GET',
216
+ path: '/:id',
217
+ desc: 'Get a user by id',
218
+ params: z.object({ id: z.string() }),
219
+ output: UserSchema,
220
+ },
221
+ })
222
+ ```
223
+
224
+ ### Fields
225
+
226
+ | Field | Required | Purpose |
227
+ |-------|----------|---------|
228
+ | `method` | yes | `GET` · `POST` · `PUT` · `PATCH` · `DELETE` |
229
+ | `path` | yes | route path under the contract `prefix`; `:name` marks a path param |
230
+ | `desc` | yes | human description — also the MCP / agent tool description |
231
+ | `params` | no | Zod schema for **path params** (`:id`, …) |
232
+ | `input` | no | Zod schema for the **request body** (or query, for GET/DELETE) |
233
+ | `output` | no | Zod schema for the **response body** |
234
+ | `scope` | no | access scope for this endpoint — see [Auth & errors](./auth-and-errors.md) |
235
+ | `expose` | no | which transports carry this endpoint — see [below](#transports) |
236
+ | `toolName` | no | explicit MCP / agent tool name (defaults to `prefix_key`) |
237
+ | `multipart` | no | field name of a file upload — see [below](#file-uploads) |
238
+ | `timeout` | no | per-endpoint client timeout in ms, for slow endpoints |
239
+ | `meta` | no | opaque app metadata — read in hooks / on tool mounts, never in OpenAPI ([below](#endpoint-metadata-meta)) |
240
+
241
+ ## `params` vs `input` vs `output`
242
+
243
+ The three schemas are distinct on purpose:
244
+
245
+ - **`params`** — values in the URL path. `path: '/:id'` ⇒
246
+ `params: z.object({ id: z.string() })`. The client takes them from the call
247
+ argument and substitutes them into the URL.
248
+ - **`input`** — the request payload. For `POST` / `PUT` / `PATCH` it is the JSON
249
+ body; for `GET` / `DELETE` it is the query string. The handler reads it as
250
+ `ctx.input`.
251
+ - **`output`** — the response shape. When set, the client parses the response
252
+ through it; the handler's return value is type-checked against it.
253
+
254
+ The typed client merges `params` and `input` into one argument object — the
255
+ caller passes a single flat object, the client routes each field to the path or
256
+ the body.
257
+
258
+ ```ts
259
+ // path: '/:id', params: { id }, input: { text }
260
+ await api.update({ id: '1', text: 'new' }) // PUT /users/1 body: { text: 'new' }
261
+ ```
262
+
263
+ ### Input vs. output types
264
+
265
+ For the client, an endpoint's argument type is the schema's **input** type
266
+ (pre-parse). A field with `.default()` is therefore optional for the caller but
267
+ present (required) in the handler's parsed `ctx.input`. The contract handles the
268
+ two type views; you do not.
269
+
270
+ ## Transports
271
+
272
+ By default an endpoint is exposed on **every** surface — HTTP, MCP and agent
273
+ tools. Narrow it with `expose`:
274
+
275
+ ```ts
276
+ {
277
+ method: 'POST', path: '/', desc: 'Internal sync',
278
+ expose: ['HTTP'], // HTTP only — not an MCP or agent tool
279
+ }
280
+ {
281
+ method: 'GET', path: '/search', desc: 'Search the catalog',
282
+ expose: ['HTTP', 'MCP', 'AGENT'], // explicit — all three
283
+ }
284
+ ```
285
+
286
+ - `expose: ['HTTP']` — HTTP only. The endpoint never becomes a tool.
287
+ - `expose: ['MCP', 'AGENT']` — a tool only; no HTTP route.
288
+ - omit `expose` — all transports.
289
+
290
+ Tool transports (`MCP`, `AGENT`) skip `multipart` endpoints automatically — a
291
+ file upload is not a tool call.
292
+
293
+ ## `toolName`
294
+
295
+ When an endpoint is exposed as a tool, its name defaults to `prefix_key`
296
+ (`users` + `create` ⇒ `users_create`). Set `toolName` for an explicit, stable
297
+ name:
298
+
299
+ ```ts
300
+ { method: 'POST', path: '/', desc: 'Create a user', toolName: 'create_user', /* … */ }
301
+ ```
302
+
303
+ ## Endpoint metadata (`meta`)
304
+
305
+ `meta` is an **opaque, app-defined** bag the core attaches no meaning to — the
306
+ same escape-hatch spirit as `scope` being a free string ([ADR 0002](../decisions/0002-generic-core.md) /
307
+ [ADR 0021](../decisions/0021-endpoint-meta-passthrough.md)). Declare app concerns
308
+ the generic core does not model — a feature gate, a rate tier, a cache hint, a
309
+ doc/owner tag — right next to the endpoint:
310
+
311
+ ```ts
312
+ broadcast: {
313
+ method: 'POST', path: '/broadcast', desc: 'Send a broadcast',
314
+ input: BroadcastInput, output: Broadcast,
315
+ meta: { requiredFeature: 'broadcasts' }, // opaque to the core
316
+ }
317
+ ```
318
+
319
+ It rides through to `MethodDef.meta`, readable in lifecycle hooks (the second
320
+ argument is the endpoint) and on tool mounts. The consumer narrows the type when
321
+ reading:
322
+
323
+ ```ts
324
+ beforeHandle: (ctx, endpoint) => {
325
+ const feature = endpoint.meta?.requiredFeature
326
+ if (typeof feature === 'string' && !ctx.user?.features?.includes(feature)) {
327
+ throw forbidden('feature not enabled')
328
+ }
329
+ }
330
+ ```
331
+
332
+ `meta` is **app-private** — it is never serialized into the OpenAPI document.
333
+
334
+ > **Declare a meta type as a `type`, an inline literal, or with `satisfies` — not
335
+ > an `interface`.** A TS `interface` has no implicit index signature (it can be
336
+ > augmented by declaration merging), so it is not assignable to `meta`'s
337
+ > `Record<string, unknown>` — and on the overloaded `defineContract` the error
338
+ > misleadingly surfaces as a `scope` mismatch. Use
339
+ > `type EndpointMeta = { requiredFeature?: PlanFeature }`, or
340
+ > `meta: { requiredFeature: 'x' } satisfies EndpointMeta`. The read side is
341
+ > unchanged — `endpoint.meta?.x` is `unknown`, narrow it in the hook.
342
+
343
+ ## File uploads
344
+
345
+ `multipart` names the form field carrying a file. The handler receives it as
346
+ `ctx.file`:
347
+
348
+ ```ts
349
+ upload: {
350
+ method: 'POST',
351
+ path: '/avatar',
352
+ desc: 'Upload an avatar',
353
+ multipart: 'file',
354
+ output: z.object({ url: z.string() }),
355
+ }
356
+ ```
357
+
358
+ The client sends a `multipart/form-data` request; the field value must be a
359
+ `Blob`. See [HTTP server → multipart](./server.md#multipart).
360
+
361
+ ## Pagination
362
+
363
+ Every list endpoint should return the cursor envelope — one shape, one infinite-
364
+ query helper:
365
+
366
+ ```ts
367
+ import { paginatedSchema } from 'stitchkit'
368
+
369
+ feed: {
370
+ method: 'GET',
371
+ path: '/',
372
+ desc: 'Paginated feed',
373
+ input: z.object({ limit: z.coerce.number().default(20) }),
374
+ output: paginatedSchema(PostSchema), // { items: Post[], nextCursor: string | null }
375
+ }
376
+ ```
377
+
378
+ `paginatedSchema(itemSchema)` produces `{ items, nextCursor }`. The page size
379
+ default lives in the contract (`limit`'s `.default()`), never on the client —
380
+ the two cannot diverge. The client side is [`createCursorQuery`](./client.md#cursor-pagination).
381
+
382
+ The **format** of `nextCursor` is the server's choice — keep it opaque. For the
383
+ usual keyset cursor (resume after the last row's `(sortValue, id)`), encode it
384
+ with **`encodeCursor`** and read it back with **`decodeCursor`** (Zod-validated;
385
+ a missing or garbage cursor decodes to `null`, i.e. "start from the top"):
386
+
387
+ ```ts
388
+ import { encodeCursor, decodeCursor } from 'stitchkit'
389
+
390
+ const Cursor = z.object({ v: z.string(), id: z.string() }) // your keyset shape
391
+
392
+ const after = decodeCursor(ctx.input.cursor, Cursor) // { v, id } | null
393
+ const rows = await db.list({ after, take: limit + 1 }) // your keyset WHERE
394
+ const nextCursor =
395
+ rows.length > limit ? encodeCursor({ v: last.createdAt, id: last.id }) : null
396
+ ```
397
+
398
+ The codec is base64url over UTF-8 (`btoa`/`atob`, not Node `Buffer`) — server,
399
+ client and browser safe, and a non-ASCII sort value round-trips. The keyset
400
+ WHERE clause is yours (it's ORM-specific); stitchkit only carries the string.
401
+
402
+ ## Scope
403
+
404
+ An endpoint may carry a `scope` string; the contract `meta.scope` is the default
405
+ for every endpoint that declares none. Scopes are free strings — the framework
406
+ attaches no meaning, your auth hook does. See
407
+ [Auth & errors](./auth-and-errors.md).
408
+
409
+ ## One source of truth
410
+
411
+ A contract is plain data — no classes, no decorators, no codegen. It is imported
412
+ by the server (to `implement`), by the client (to `createClient`) and by the
413
+ tool layer (to `mountMcp` / `mountAgent`). Change an endpoint and every surface
414
+ is re-typed by the compiler at once.
415
+
416
+
417
+ ==============================================================================
418
+ # Guide: HTTP server (docs/guide/server.md)
419
+ ==============================================================================
420
+
421
+ # HTTP server
422
+
423
+ stitchkit serves contracts on `Bun.serve()` directly — no Hono, no Elysia, no
424
+ Express. You bind a contract to handlers with `implement()`, then mount the
425
+ result on `createServer()`.
426
+
427
+ ## `implement`
428
+
429
+ `implement(contract, handlers)` type-checks each handler against its endpoint's
430
+ schemas and returns a `ServiceDef` to mount.
431
+
432
+ ```ts
433
+ import { implement } from 'stitchkit/server'
434
+ import { users } from '../shared/contracts'
435
+
436
+ const usersService = implement(users, {
437
+ list: () => db.users.findMany(),
438
+ create: (ctx) => db.users.create(ctx.input), // ctx.input: typed
439
+ get: (ctx) => db.users.findById(ctx.params.id), // ctx.params: typed
440
+ delete: (ctx) => db.users.delete(ctx.params.id),
441
+ })
442
+ ```
443
+
444
+ A handler may be sync or async. Its return value is checked against the
445
+ endpoint's `output` schema; an endpoint without `output` returns nothing.
446
+
447
+ ### The handler context
448
+
449
+ Every handler receives one `ctx` argument:
450
+
451
+ | `ctx` field | Type | Source |
452
+ |-------------|------|--------|
453
+ | `params` | inferred from `params` schema | parsed path params |
454
+ | `input` | inferred from `input` schema | parsed body / query |
455
+ | `file` | `File` | the `multipart` upload, if any |
456
+ | `source` | `'http' \| 'mcp' \| 'agent'` | the transport that invoked the handler |
457
+ | `traceId` | `string` | per-request trace id |
458
+ | `ipAddress` | `string` | caller IP |
459
+ | `userAgent` | `string` | caller user-agent |
460
+
461
+ The same handler runs for HTTP, MCP and agent calls — `ctx.source` tells you
462
+ which. Anything an auth hook attaches (e.g. the resolved user) is also on `ctx`.
463
+
464
+ ### `createImplement` — a fixed context type
465
+
466
+ To type `ctx` with your app's extras (the user an auth hook injects), fix the
467
+ context type once:
468
+
469
+ ```ts
470
+ import { createImplement } from 'stitchkit/server'
471
+
472
+ interface AppContext extends RuntimeContext { user: User | null }
473
+
474
+ export const implement = createImplement<AppContext>()
475
+ // every implement() call now has ctx.user typed
476
+ ```
477
+
478
+ ## `createServer`
479
+
480
+ `createServer(config)` builds the router and starts `Bun.serve()`. It returns
481
+ the Bun server instance.
482
+
483
+ ```ts
484
+ import { createServer } from 'stitchkit/server'
485
+
486
+ createServer({
487
+ services: [usersService, postsService],
488
+ port: 3000,
489
+ cors: { origin: 'https://app.example.com' },
490
+ hooks: { /* … */ },
491
+ logging: true,
492
+ })
493
+ ```
494
+
495
+ `createHandler(config)` is the same router as a bare `(req) => Promise<Response>`
496
+ function — no `Bun.serve`. Use it in tests, or to embed stitchkit in another
497
+ server. See [Testing & deployment](./testing-and-deployment.md).
498
+
499
+ ### `ServerConfig`
500
+
501
+ | Field | Purpose |
502
+ |-------|---------|
503
+ | `services` | `ServiceDef[]` mounted at the root |
504
+ | `groups` | route groups — a shared path prefix and hooks (see below) |
505
+ | `scopePrefixes` | `scope → path prefix` map — mount `services` by `service.scope` (see below) |
506
+ | `rawRoutes` | non-contract routes (see below) |
507
+ | `maxUploadBytes` | default multipart upload cap (bytes); per-route `EndpointDef.maxUploadBytes` overrides |
508
+ | `port` / `hostname` | listen address — port defaults to `3000` |
509
+ | `cors` | CORS policy — `{ origin, … }` |
510
+ | `hooks` | lifecycle hooks (see below) |
511
+ | `logging` | `true` for built-in request logs, or a custom `StitchLogger` |
512
+ | `traceId` | override per-request trace-id resolution |
513
+ | `websocket` | Bun WebSocket handlers — e.g. from `createSocketIOServer` |
514
+ | `routes` / `development` / `bun` | passthrough to `Bun.serve` |
515
+
516
+ ## Route groups
517
+
518
+ A group gives a set of services a shared path prefix and its own hooks:
519
+
520
+ ```ts
521
+ createServer({
522
+ groups: [
523
+ { pathPrefix: '/api', services: [usersService, postsService] },
524
+ { pathPrefix: '/api/admin', services: [adminService], hooks: { beforeHandle: adminAuth } },
525
+ ],
526
+ })
527
+ ```
528
+
529
+ Each service's own `prefix` is appended to the group prefix — `usersService`
530
+ above is served at `/api/users`.
531
+
532
+ ### Param prefixes (resource-scoped paths)
533
+
534
+ A group `pathPrefix` may contain `:param` segments — the spine of a multi-tenant
535
+ or resource-scoped API:
536
+
537
+ ```ts
538
+ createServer({
539
+ groups: [
540
+ { pathPrefix: '/tenants/:tenantId', services: [widgetsService], hooks: { beforeHandle: auth } },
541
+ ],
542
+ })
543
+ // widgetsService (prefix 'widgets') → /tenants/:tenantId/widgets/...
544
+ ```
545
+
546
+ **Where the prefix param lands.** The router matches the *full* path (group
547
+ prefix + service prefix + endpoint path) and collects every `:param` — from the
548
+ prefix and from the endpoint alike — into one set. Each is spread onto the
549
+ context root, so it is available as **`ctx.tenantId`** (a raw `string`) in both
550
+ the handler and `beforeHandle`/`afterHandle`/`onError`:
551
+
552
+ ```ts
553
+ beforeHandle: (ctx) => {
554
+ const tenantId = ctx.tenantId // string — from the group prefix
555
+ }
556
+ ```
557
+
558
+ `ctx.tenantId` is typed `unknown` (it rides the context index signature) — narrow
559
+ it (`String(ctx.tenantId)` / a guard) at the read site.
560
+
561
+ **Relation to the endpoint `params` schema.** `ctx.params` is the endpoint's
562
+ `params` schema **parsed against all collected path params** (prefix + endpoint).
563
+ So to get the prefix param *inside* typed `ctx.params`, add it to that schema:
564
+
565
+ ```ts
566
+ // endpoint under /tenants/:tenantId
567
+ { method: 'GET', path: '/:widgetId', desc: 'Get a widget',
568
+ params: z.object({ tenantId: z.string(), widgetId: z.string() }) }
569
+ // → ctx.params.tenantId and ctx.params.widgetId both typed
570
+ ```
571
+
572
+ ⚠️ A **`z.strictObject`** params schema that omits the prefix param **rejects the
573
+ request** (the extra `tenantId` key fails the strict parse). Either include every
574
+ prefix param in the schema, use a non-strict `z.object` (extra keys are dropped
575
+ from `ctx.params`, but `ctx.tenantId` still works), or read the param off the
576
+ context root.
577
+
578
+ ### Scope-driven mounting (`scopePrefixes`)
579
+
580
+ With several scopes, hand-partitioning services into `groups` duplicates the
581
+ scope↔prefix mapping. Instead, map `scope → prefix` once and pass the flat
582
+ `services` list — each entry mounts under `scopePrefixes[service.scope]`:
583
+
584
+ ```ts
585
+ createServer({
586
+ services, // mixed scopes, listed once
587
+ scopePrefixes: { tenant: 'tenants/:tenantId', project: 'projects/:projectId' },
588
+ })
589
+ // scope 'tenant' → /tenants/:tenantId/<prefix>/...
590
+ // scope 'project' → /projects/:projectId/<prefix>/...
591
+ // unmapped scope → mounted flat
592
+ ```
593
+
594
+ A prefix may carry `:param` segments (they land on the context exactly as above).
595
+ Services listed under explicit `groups` are unaffected — the group prefix wins.
596
+ Scope stays a free string; the core attaches no meaning beyond this lookup
597
+ (→ ADR 0024). When each scope needs a different handler-context shape, declare one
598
+ `createImplement<Ctx>()` per scope rather than a single superset context.
599
+
600
+ ## Lifecycle hooks
601
+
602
+ Four hooks wrap every contract request, in order:
603
+
604
+ ```ts
605
+ createServer({
606
+ services,
607
+ hooks: {
608
+ onRequest(req) { /* logging, global rate limit — may return a Response to short-circuit */ },
609
+ beforeHandle(ctx, endpoint) { /* auth, scope checks — throw to reject */ },
610
+ afterHandle(ctx, result, ep) { /* transform the result, set cache headers */ },
611
+ onError(ctx, error, ep) { /* custom error response — return a Response */ },
612
+ },
613
+ })
614
+ ```
615
+
616
+ - **`onRequest`** — runs first, with the raw `Request`. Return a `Response` to
617
+ short-circuit (a rate-limit 429, a redirect); return nothing to continue.
618
+ - **`beforeHandle`** — runs after the context is built, before the handler.
619
+ Throw an `AppError` to reject. This is where auth lives —
620
+ [`createAuthHook`](./auth-and-errors.md#createauthhook) is a `beforeHandle`.
621
+ - **`afterHandle`** — receives the handler result; return a replacement to
622
+ transform it.
623
+ - **`onError`** — receives any thrown error; return a `Response` to customise
624
+ the error body. Without it, errors render through the standard envelope.
625
+
626
+ Hooks see `RuntimeContext` (loose types); handlers see `HandlerContext` (typed).
627
+ That split is deliberate — see [ADR 0003](../decisions/0003-two-context-types.md).
628
+
629
+ ## Raw routes
630
+
631
+ Some routes cannot be a clean JSON contract — an OAuth redirect, a webhook with
632
+ signature verification, static files, the Socket.IO endpoint. `rawRoutes` are
633
+ plain `Request → Response` handlers, matched by the same router (shared CORS and
634
+ `onRequest`) but with no schema parsing and no `beforeHandle` gate — a raw route
635
+ authorises itself.
636
+
637
+ ```ts
638
+ createServer({
639
+ services,
640
+ rawRoutes: [
641
+ {
642
+ method: 'GET',
643
+ path: '/health',
644
+ handler: () => Response.json({ status: 'ok' }),
645
+ },
646
+ {
647
+ method: 'POST',
648
+ path: '/webhooks/:provider',
649
+ handler: (req, ctx) => handleWebhook(ctx.params.provider, req),
650
+ },
651
+ ],
652
+ })
653
+ ```
654
+
655
+ A path may be exact, carry `:param` segments, or end in `/*` for a prefix
656
+ wildcard — and the two combine: `/app/:slug/*` matches `/app/x/a/b` with
657
+ `ctx.params.slug === 'x'` and the remainder in `ctx.params['*']` (a SPA
658
+ deep-link fallback). List more specific routes before the wildcard — the first
659
+ match wins. `staticRoute()` builds a raw route that serves a directory.
660
+
661
+ ### Raw-route helpers
662
+
663
+ A raw route gives up the contract pipeline, so three things get re-implemented in
664
+ every one. `stitchkit/server` ships them — conveniences, not a second pipeline
665
+ (no auth, no schema gate beyond `parseBody`):
666
+
667
+ ```ts
668
+ import { respondJson, errorResponse, parseBody, badRequest } from 'stitchkit/server'
669
+
670
+ handler: async (req) => {
671
+ try {
672
+ const body = await parseBody(req, MySchema) // typed value, or null (no throw)
673
+ if (!body) throw badRequest('invalid body') // helpers THROW an AppError
674
+ return respondJson(await myService(body)) // JSON; 204 when null/undefined
675
+ } catch (err) {
676
+ return errorResponse(err) // same envelope as a contract route
677
+ }
678
+ }
679
+ ```
680
+
681
+ The error helpers (`badRequest`, `notFound`, …) **throw** an `AppError`, so raise
682
+ them inside the `try` and let `errorResponse(err)` render it — that runs any
683
+ thrown value through the framework's `normalizeError`, so a raw route returns the
684
+ **identical** `{ error: { code, message, … } }` shape (and `x-request-id`) a
685
+ contract route does.
686
+
687
+ ### Serving files & Range requests
688
+
689
+ `staticRoute` is basic on purpose — it reads the whole file into memory and has
690
+ no `Range` or conditional support; put a CDN in front, or use it for small web
691
+ assets. To serve **media** (video / audio / large downloads) that a browser must
692
+ seek and cache, use **`serveFile`** (Bun) — it streams the requested byte range
693
+ and speaks the conditional-request half of RFC 7233 / 9110:
694
+
695
+ ```ts
696
+ import { serveFile } from 'stitchkit/server'
697
+
698
+ createServer({
699
+ services,
700
+ rawRoutes: [
701
+ {
702
+ // `ALL` — so a HEAD probe also reaches serveFile (raw routes match the
703
+ // method exactly, and `HEAD` is not a contract `HttpMethod`); serveFile
704
+ // itself handles GET + HEAD and answers 405 for anything else.
705
+ method: 'ALL',
706
+ path: '/media/:id',
707
+ handler: (req, ctx) =>
708
+ serveFile(req, { path: pathForId(ctx.params.id), filename: 'clip.mp4' }),
709
+ },
710
+ ],
711
+ })
712
+ ```
713
+
714
+ `serveFile` returns `206` (range, with `Content-Range` + `Content-Length`), `200`
715
+ (full), `416` (unsatisfiable, `Content-Range: bytes */size`), `304`
716
+ (`If-None-Match` / `If-Modified-Since`), `404` (missing) or `405` (non GET/HEAD).
717
+ It always sets `Accept-Ranges: bytes`, a weak `ETag` and `Last-Modified` (so
718
+ `If-Range` keeps a changing file from being stitched from stale + fresh bytes),
719
+ and `nosniff`. `Content-Type` is auto-detected from the path — override it, or
720
+ pass `disposition` / `cacheControl` / `etag: false`, via the options.
721
+
722
+ `serveFile` takes an explicit `path` and trusts it — **the caller owns
723
+ containment**. For a URL-derived path use `staticRoute` (which enforces it) or
724
+ `isWithinDir` first. The byte-range parser is exported on its own as
725
+ `parseByteRange(header, size)` for direct use and testing. → ADR 0023.
726
+
727
+ ## Server primitives
728
+
729
+ `stitchkit/server` also exports the primitives most APIs need. Each is a small,
730
+ focused helper — not a sub-framework.
731
+
732
+ | Helper | Does |
733
+ |--------|------|
734
+ | `serveFile()` | serve a file with `Range` / `304` / `HEAD` (media seeking) |
735
+ | `streamSSE()` | turn an `AsyncGenerator` into a Server-Sent-Events `Response` |
736
+ | `parseMultipart()` | parse a `multipart/form-data` request with a size cap |
737
+ | `createRateLimiter()` | per-key token-bucket rate limiting |
738
+ | `createCache()` + `cacheHeaders()` | in-memory TTL cache; `Cache-Control` builder |
739
+ | `createEventBus<EventMap>()` | typed in-process pub/sub |
740
+
741
+ ### SSE streaming
742
+
743
+ ```ts
744
+ import { streamSSE } from 'stitchkit/server'
745
+
746
+ async function* tokens() { yield 'a'; yield 'b' }
747
+ return streamSSE(tokens()) // → a text/event-stream Response
748
+ ```
749
+
750
+ The client side is [`parseSSE`](./client.md#sse).
751
+
752
+ ### Multipart
753
+
754
+ ```ts
755
+ import { parseMultipart } from 'stitchkit/server'
756
+
757
+ const { file, fields } = await parseMultipart(req, { maxBytes: 10_000_000 })
758
+ ```
759
+
760
+ When an endpoint declares `multipart`, the framework parses the upload for you
761
+ and the file arrives as `ctx.file` — call `parseMultipart` directly only from a
762
+ raw route.
763
+
764
+ The upload cap defaults to **25 MB**. Raise it per route with
765
+ `EndpointDef.maxUploadBytes`, or set a server-wide default with
766
+ `createServer({ maxUploadBytes })` — a per-route value wins over the global:
767
+
768
+ ```ts
769
+ // contract
770
+ upload: { method: 'POST', path: '/', desc: 'Upload a video',
771
+ multipart: 'file', maxUploadBytes: 200 * 1024 * 1024 }
772
+
773
+ // server — default for every multipart route that declares no own cap
774
+ createServer({ services, maxUploadBytes: 50 * 1024 * 1024 })
775
+ ```
776
+
777
+ ### Rate limiting
778
+
779
+ ```ts
780
+ import { createRateLimiter } from 'stitchkit/server'
781
+
782
+ const limiter = createRateLimiter({ capacity: 60, refillPerSecond: 1 })
783
+ // in onRequest: if (!limiter.take(ip)) return new Response('Too many', { status: 429 })
784
+ ```
785
+
786
+ ### Event bus
787
+
788
+ ```ts
789
+ import { createEventBus } from 'stitchkit/server'
790
+
791
+ const bus = createEventBus<{ 'user.created': { id: string } }>()
792
+ bus.on('user.created', ({ id }) => sendWelcome(id))
793
+ bus.emit('user.created', { id: '1' })
794
+ ```
795
+
796
+ A typed in-process pub/sub — decouple a handler from the side effects of its
797
+ write without reaching for an external queue.
798
+
799
+
800
+ ==============================================================================
801
+ # Guide: Typed client (docs/guide/client.md)
802
+ ==============================================================================
803
+
804
+ # Typed client
805
+
806
+ From a contract, `createClient` builds a fully-typed client — one method per
807
+ endpoint, arguments and result inferred from the schemas. There is no codegen
808
+ step: the types come straight from the contract import.
809
+
810
+ ## `createHttpClient`
811
+
812
+ The HTTP client is the transport. It wraps [`ky`](https://github.com/sindresorhus/ky)
813
+ and adds cookie auth, SSR cookie forwarding, error parsing into `ApiError`, a
814
+ `401 → unauthorized` event stream and safe transport retry.
815
+
816
+ ```ts
817
+ import { createHttpClient } from 'stitchkit'
818
+
819
+ const http = createHttpClient({ baseUrl: '/api' })
820
+ ```
821
+
822
+ ### `HttpClientConfig`
823
+
824
+ | Field | Default | Purpose |
825
+ |-------|---------|---------|
826
+ | `baseUrl` | — | URL prefix for every request |
827
+ | `timeout` | `30000` | request timeout, ms |
828
+ | `credentials` | `'include'` | fetch credentials mode |
829
+ | `retry` | 2× GET, network errors only | transport retry policy |
830
+ | `headers` | — | extra headers — an object, or a function re-run per request |
831
+ | `authEndpoints` | `['auth/']` | paths that should **not** emit `unauthorized` on 401 |
832
+ | `parseError` | built-in | map an error body to `{ code, message, details, hint }` |
833
+
834
+ `headers` as a function is the hook for runtime tokens — a bearer token or any
835
+ short-lived credential — re-evaluated on every request.
836
+
837
+ Retry is deliberately conservative: only a connection that never landed (a
838
+ network error), only on idempotent `GET`. A server that *responded* with a 5xx
839
+ is the data layer's call (TanStack Query), not the transport's — retrying in
840
+ both places multiplies attempts.
841
+
842
+ ## `createClient`
843
+
844
+ ```ts
845
+ import { createClient } from 'stitchkit'
846
+ import { users } from '../shared/contracts'
847
+
848
+ export const api = createClient(users, http)
849
+
850
+ await api.list() // GET /users
851
+ await api.create({ name: 'Max' }) // POST /users body: { name }
852
+ await api.get({ id: '1' }) // GET /users/1
853
+ await api.update({ id: '1', name: 'M' }) // PUT /users/1 body: { name }
854
+ await api.delete({ id: '1' }) // DELETE /users/1
855
+ ```
856
+
857
+ Each call takes one argument object. The client routes each field by the
858
+ contract:
859
+
860
+ - a **path param** (`:id`) is substituted into the URL,
861
+ - for `GET` / `DELETE`, the remaining fields become the **query string**
862
+ (arrays become repeated keys),
863
+ - for `POST` / `PUT` / `PATCH`, they become the **JSON body**,
864
+ - a `multipart` field must be a `Blob` and is sent as `form-data`.
865
+
866
+ ### Many contracts at once
867
+
868
+ ```ts
869
+ import { createClients } from 'stitchkit'
870
+
871
+ export const api = createClients({ users, posts, billing }, http)
872
+ await api.users.list()
873
+ await api.posts.create({ title: 'Hi' })
874
+ ```
875
+
876
+ `createClients` builds one typed client per contract from a registry — list the
877
+ contracts once, get the whole API typed.
878
+
879
+ ## `ApiError`
880
+
881
+ A non-2xx response is thrown as an `ApiError`:
882
+
883
+ ```ts
884
+ import { ApiError } from 'stitchkit'
885
+
886
+ try {
887
+ await api.get({ id: 'missing' })
888
+ } catch (err) {
889
+ if (ApiError.is(err)) {
890
+ err.code // 'NOT_FOUND'
891
+ err.status // 404
892
+ err.message // 'Note not found'
893
+ err.details // structured details, if any
894
+ err.hint // optional hint
895
+ }
896
+ }
897
+ ```
898
+
899
+ The error model is shared with the server — see [Auth & errors](./auth-and-errors.md).
900
+
901
+ ## Auth events
902
+
903
+ The HTTP client emits events your app can react to globally:
904
+
905
+ ```ts
906
+ const unsubscribe = http.subscribe((event) => {
907
+ if (event.type === 'unauthorized') redirectToLogin() // a 401 outside authEndpoints
908
+ if (event.type === 'network_error') showOfflineBanner()
909
+ })
910
+
911
+ http.logout() // mark logged out — suppresses further unauthorized events
912
+ http.resetLogoutState() // clear it after a fresh login
913
+ ```
914
+
915
+ ## Server-side rendering
916
+
917
+ For SSR, forward the incoming request's cookies so the API call runs as the
918
+ logged-in user:
919
+
920
+ ```ts
921
+ http.setServerContext(request.headers.get('cookie') ?? '')
922
+ ```
923
+
924
+ ## A bare fetch client
925
+
926
+ If you do not need cookie auth, retry or the event stream, pass a plain config
927
+ instead of an `HttpClient` — `createClient` then builds a minimal `fetch`-based
928
+ client:
929
+
930
+ ```ts
931
+ const api = createClient(users, {
932
+ baseUrl: 'https://api.example.com',
933
+ headers: () => ({ Authorization: `Bearer ${token()}` }),
934
+ onError: (status, body) => console.warn(status, body),
935
+ })
936
+ ```
937
+
938
+ ## `ContractClientConfig` — per-tenant / resource-scoped clients
939
+
940
+ `createClient` takes an optional **third** argument that prepends a dynamic
941
+ segment to every URL — the client half of a multi-tenant API
942
+ ([Route groups → param prefixes](./server.md#param-prefixes-resource-scoped-paths)):
943
+
944
+ ```ts
945
+ interface ContractClientConfig {
946
+ /** Prepended to every request URL. A function is called per request with the
947
+ * call's argument object, so the prefix can depend on the arguments. */
948
+ pathPrefix?: string | ((args: Record<string, unknown>) => string)
949
+ /** Argument keys consumed by `pathPrefix` — stripped from the query/body so
950
+ * they are not also sent there (the endpoint's own path `:params` are
951
+ * stripped automatically; list any *extra* keys here). */
952
+ stripPrefixKeys?: string[]
953
+ }
954
+ ```
955
+
956
+ A per-tenant client — `tenantId` goes into the URL, not the body:
957
+
958
+ ```ts
959
+ const widgets = createClient(widgetsContract, http, {
960
+ pathPrefix: (args) => `tenants/${args.tenantId}/`,
961
+ stripPrefixKeys: ['tenantId'],
962
+ })
963
+
964
+ widgets.list({ tenantId: 't_123' }) // GET /tenants/t_123/widgets
965
+ widgets.create({ tenantId: 't_123', name: 'A' }) // POST /tenants/t_123/widgets body: { name }
966
+ ```
967
+
968
+ "Keys it consumes" = a key the `pathPrefix` function reads (here `tenantId`).
969
+ List it in `stripPrefixKeys` so it lands in the URL **and is removed from the
970
+ query/body** — otherwise it would be sent twice. Endpoint path `:params` are
971
+ stripped for you; only extra prefix keys need listing.
972
+
973
+ ## React data layer
974
+
975
+ stitchkit does not ship a hook engine. Pair the typed client with
976
+ [`react-query-kit`](https://github.com/liaoliao666/react-query-kit) — wrap the
977
+ client methods directly:
978
+
979
+ ```ts
980
+ import { createQuery, createMutation } from 'react-query-kit'
981
+ import { api } from './api'
982
+
983
+ export const useUsers = createQuery({ queryKey: ['users'], fetcher: () => api.list() })
984
+ export const useCreateUser = createMutation({ mutationFn: api.create })
985
+ ```
986
+
987
+ ### Cursor pagination
988
+
989
+ For a cursor-paginated list, `createCursorQuery` is the canonical helper:
990
+
991
+ ```ts
992
+ import { createCursorQuery } from 'stitchkit/react'
993
+ import { api } from './api'
994
+
995
+ export const useFeed = createCursorQuery({
996
+ queryKey: ['feed'],
997
+ endpoint: api.feed, // the contract method
998
+ })
999
+ ```
1000
+
1001
+ It injects `cursor` from the page param and bakes in `getNextPageParam` /
1002
+ `initialPageParam` — an infinite hook is just `queryKey + endpoint`. The page
1003
+ size is the server's call (the contract's `limit` default); the client never
1004
+ sends one. The result keeps the full `react-query-kit` surface (`.getKey()`,
1005
+ `useSuspenseInfiniteQuery`, every option). The endpoint must return the
1006
+ `{ items, nextCursor }` envelope — see [Contracts → pagination](./contracts.md#pagination).
1007
+
1008
+ ## SSE
1009
+
1010
+ For a streaming endpoint, consume the response with `parseSSE`:
1011
+
1012
+ ```ts
1013
+ import { parseSSE } from 'stitchkit'
1014
+
1015
+ const res = await fetch('/api/chat/stream', { method: 'POST', body })
1016
+ for await (const event of parseSSE(res)) {
1017
+ console.log(event.data)
1018
+ }
1019
+ ```
1020
+
1021
+ The server side is [`streamSSE`](./server.md#sse-streaming).
1022
+
1023
+
1024
+ ==============================================================================
1025
+ # Guide: MCP & agents (docs/guide/mcp-and-agents.md)
1026
+ ==============================================================================
1027
+
1028
+ # MCP & AI agents
1029
+
1030
+ The same contract that drives the HTTP API also drives AI tooling. An endpoint
1031
+ exposed on `MCP` becomes a [Model Context Protocol](https://modelcontextprotocol.io)
1032
+ tool — callable from Claude, Cursor and other MCP clients. An endpoint exposed on
1033
+ `AGENT` becomes a [Vercel AI SDK](https://sdk.vercel.ai) tool — callable from an
1034
+ agent loop. No tool is hand-written; both come from the contract.
1035
+
1036
+ ## Which endpoints become tools
1037
+
1038
+ By default every endpoint is a tool on every transport. `expose` narrows it:
1039
+
1040
+ ```ts
1041
+ { method: 'GET', path: '/search', desc: 'Search the catalog' } // HTTP + MCP + AGENT
1042
+ { method: 'POST', path: '/sync', desc: 'Internal sync', expose: ['HTTP'] } // HTTP only
1043
+ { method: 'GET', path: '/lookup', desc: 'Look up a price', expose: ['MCP'] } // MCP tool only
1044
+ ```
1045
+
1046
+ `desc` is the tool description the model reads — write it for the model, not
1047
+ just for a human. A `multipart` endpoint is never a tool. The tool name defaults
1048
+ to a verb-aware name from the method + prefix (`list` → `list_widgets`, `get` →
1049
+ `get_widget`); set `toolName` for an explicit one. See
1050
+ [Contracts → transports](./contracts.md#transports).
1051
+
1052
+ ## MCP — `createMcpHandler`
1053
+
1054
+ `createMcpHandler` builds a complete Streamable-HTTP MCP server as a single
1055
+ `Request → Response` handler. It owns the whole MCP lifecycle — the SSE event
1056
+ store, per-session transports, the server instances — so your app never imports
1057
+ `@modelcontextprotocol/sdk` itself.
1058
+
1059
+ ```ts
1060
+ import { createMcpHandler } from 'stitchkit/tools'
1061
+
1062
+ const handleMcp = createMcpHandler({
1063
+ serverInfo: { name: 'my-app', version: '1.0.0' },
1064
+ auth: (req) => resolveApiKey(req), // → an identity, or null for 401
1065
+ services: [usersService, catalogService],
1066
+ })
1067
+ ```
1068
+
1069
+ Mount the returned handler on a raw route — typically `/mcp`:
1070
+
1071
+ ```ts
1072
+ createServer({
1073
+ services,
1074
+ rawRoutes: [{ method: 'ALL', path: '/mcp', handler: (req) => handleMcp(req) }],
1075
+ })
1076
+ ```
1077
+
1078
+ ### `McpHandlerConfig`
1079
+
1080
+ | Field | Purpose |
1081
+ |-------|---------|
1082
+ | `serverInfo` | MCP server identity — `{ name, version }` |
1083
+ | `auth` | `(req) => identity \| null` — `null` rejects with 401 |
1084
+ | `services` | the services to expose — an array, or `(auth) => ServiceDef[]` |
1085
+ | `context` | `(auth) => {…}` — values merged into every tool handler's `ctx` |
1086
+ | `lifecycle` | `beforeHandle` / `afterHandle` — the tool-side auth gate (see below) |
1087
+ | `hooks` | tool-call observability hooks — `afterToolCall` fires on every result |
1088
+ | `onIncompatibleSchema` | `'throw'` (default) · `'skip'` · `'warn'` — see below |
1089
+ | `logger` | a `StitchLogger` for the `'warn'` policy |
1090
+ | `nativeTools` | register non-contract tools directly on the `McpServer` |
1091
+ | `instructions` | a short host-facing usage hint, surfaced to MCP tool-search |
1092
+
1093
+ `services` and `context` receive the resolved identity, so a tenant can be
1094
+ shown only its own tools and every handler can read `ctx.tenantId`.
1095
+
1096
+ ### Guarding tools — `lifecycle`
1097
+
1098
+ A tool call runs the same handler an HTTP request would. `lifecycle` makes it
1099
+ run the same gate: a `beforeHandle` (throw to reject) and an `afterHandle`
1100
+ (transform the result) — the tool-side twin of `createServer`'s hooks. Pass the
1101
+ **same** [`createAuthHook`](./auth-and-errors.md#createauthhook) result you give
1102
+ the HTTP server and tool calls are scope-checked by the identical rules:
1103
+
1104
+ ```ts
1105
+ createMcpHandler({ serverInfo, auth, services, lifecycle: { beforeHandle: authHook } })
1106
+ ```
1107
+
1108
+ Without it, a tool call bypasses the HTTP `beforeHandle` — the contract's
1109
+ `scope` is not enforced on the MCP / agent surface. `mountMcp`, `mountAgent` and
1110
+ `buildMcpServer` take `lifecycle` too.
1111
+
1112
+ The observability `hooks` are symmetric with the HTTP side too: `beforeToolCall`
1113
+ and `afterToolCall` receive the resolved **`MethodDef`** as their last argument —
1114
+ the tool-side twin of `afterHandle(ctx, result, endpoint)`. Read
1115
+ `endpoint.serviceName` / `.key` / `.meta` directly for an audit row; you do not
1116
+ need to rebuild a `toolName → identity` map:
1117
+
1118
+ ```ts
1119
+ hooks: {
1120
+ afterToolCall: (toolName, args, result, ms, ctx, endpoint) => {
1121
+ audit({ service: endpoint.serviceName, action: endpoint.key, ok: result.ok, ms })
1122
+ },
1123
+ }
1124
+ ```
1125
+
1126
+ > **Tool-path identity.** A tool call has no `req`, so `createAuthHook` resolves
1127
+ > identity through `resolveFromContext`, not `resolve`. Set it (read the
1128
+ > identity your `auth` / `context` injected) — without it a scoped tool call
1129
+ > has no identity and **fails closed**. See
1130
+ > [Auth on the tool surface](./auth-and-errors.md#auth-on-the-tool-surface--resolvefromcontext).
1131
+
1132
+ ### Incompatible schemas — `onIncompatibleSchema`
1133
+
1134
+ A contract schema that JSON Schema cannot represent (a `z.date()`, a `z.map()`)
1135
+ cannot become a tool. `onIncompatibleSchema` decides what happens:
1136
+
1137
+ - `'throw'` (default) — fail the build, listing every offending tool. A static
1138
+ `services` array is checked when `createMcpHandler` is constructed, so a bad
1139
+ schema fails the deploy, not the first request. Better than a tool that
1140
+ silently vanishes from the surface.
1141
+ - `'warn'` — log through `logger` and drop the tool.
1142
+ - `'skip'` — drop the tool silently.
1143
+
1144
+ `validateMcpSchemas(services)` runs the same check on its own — useful in a
1145
+ startup assertion or a test.
1146
+
1147
+ ## `mountMcp`
1148
+
1149
+ If you already run an `McpServer` from the SDK, `mountMcp` adds contract tools
1150
+ to it instead of owning the lifecycle:
1151
+
1152
+ ```ts
1153
+ import { mountMcp } from 'stitchkit/tools'
1154
+
1155
+ mountMcp(mcpServer, [usersService], { context: { source: 'mcp' } })
1156
+ ```
1157
+
1158
+ `createMcpHandler` is the batteries-included path; `mountMcp` is the building
1159
+ block under it.
1160
+
1161
+ ## MCP over stdio — `createStdioMcpServer`
1162
+
1163
+ `createMcpHandler` serves MCP over HTTP. `createStdioMcpServer` serves the same
1164
+ contract tools over **stdio** — the server runs as a subprocess of the MCP
1165
+ client (Claude Desktop, Claude Code, Cursor, Codex), on the user's machine, so
1166
+ it can reach the local filesystem.
1167
+
1168
+ ```ts
1169
+ import { createStdioMcpServer } from 'stitchkit/tools'
1170
+
1171
+ await createStdioMcpServer({
1172
+ serverInfo: { name: 'my-app', version: '1.0.0' },
1173
+ auth: resolveIdentity(), // resolved once at startup, not per request
1174
+ services: [usersService],
1175
+ })
1176
+ ```
1177
+
1178
+ A stdio server is a single process serving one client, so `auth` is a value (or
1179
+ a promise of one) resolved once at startup — typically from an env var — rather
1180
+ than a per-request `(req) => …`. Keep all logging on **stderr**: stdout is the
1181
+ JSON-RPC channel.
1182
+
1183
+ Both transports build the server through the shared `buildMcpServer` — same
1184
+ contract pipeline, same `services` / `context` / `hooks` / `nativeTools` /
1185
+ `instructions`.
1186
+
1187
+ ## OAuth 2.1 — a native remote connector
1188
+
1189
+ A remote MCP server is connectable from Claude (Desktop / web "custom
1190
+ connector") only through the MCP authorization spec: OAuth 2.1 with PKCE, plus
1191
+ the discovery documents (RFC 9728 / 8414), Dynamic Client Registration
1192
+ (RFC 7591) and resource indicators (RFC 8707). A Bearer-only server returns a
1193
+ bare `401` and the connector never establishes.
1194
+
1195
+ stitchkit ships the OAuth **protocol mechanics**; the app supplies only
1196
+ **identity and storage**. Three pieces wire it together:
1197
+
1198
+ ```ts
1199
+ import { createMcpHandler, mountOAuthProvider, oauthProtectedResourceRoute } from 'stitchkit/tools'
1200
+ import { createServer } from 'stitchkit/server'
1201
+
1202
+ const resource = 'https://api.example.com/mcp'
1203
+ const issuer = 'https://api.example.com'
1204
+
1205
+ // 1. Resource server — the 401 now points at the metadata.
1206
+ const handleMcp = createMcpHandler({
1207
+ serverInfo: { name: 'my-app', version: '1.0.0' },
1208
+ auth: resolveOAuthToken, // validate the Bearer JWT (verifyJwt + audience)
1209
+ services,
1210
+ protectedResource: { resource, authorizationServers: [issuer] },
1211
+ })
1212
+
1213
+ // 2. Authorization server — DCR, /authorize (PKCE), /token.
1214
+ const oauthRoutes = mountOAuthProvider({
1215
+ issuer,
1216
+ resource,
1217
+ signingSecret: env.OAUTH_SECRET,
1218
+ clients, codes, refreshTokens, // your stores (DB or in-memory)
1219
+ authorizeUser, // your login + consent → { userId } | Response
1220
+ })
1221
+
1222
+ createServer({
1223
+ services,
1224
+ rawRoutes: [
1225
+ { method: 'ALL', path: '/mcp', handler: (req) => handleMcp(req) },
1226
+ oauthProtectedResourceRoute({ resource, authorizationServers: [issuer] }),
1227
+ ...oauthRoutes,
1228
+ ],
1229
+ })
1230
+ ```
1231
+
1232
+ Access tokens are signed HS256 JWTs (`signJwt`) whose `aud` is the resource —
1233
+ validate them in `auth` with `verifyJwt(token, secret, { audience: resource })`.
1234
+ `authorizeUser` is where the app authenticates the user (reuse an existing
1235
+ session) and records consent; return `{ userId }` to issue a code, or a
1236
+ `Response` to redirect the browser to a login page first. The AS and resource
1237
+ server can co-locate or live on separate origins. See
1238
+ [ADR 0015](../decisions/0015-oauth-resource-server.md).
1239
+
1240
+ ## Proxying a remote API — `implementRemote`
1241
+
1242
+ `implement` binds a contract to local handlers. `implementRemote` binds it to a
1243
+ remote HTTP API instead — every handler forwards the call to a deployed server
1244
+ through the contract's typed client:
1245
+
1246
+ ```ts
1247
+ import { createHttpClient } from 'stitchkit'
1248
+ import { createStdioMcpServer, implementRemote } from 'stitchkit/tools'
1249
+
1250
+ const http = createHttpClient({
1251
+ baseUrl: 'https://api.example.com',
1252
+ headers: () => ({ Authorization: `Bearer ${apiKey}` }),
1253
+ })
1254
+
1255
+ await createStdioMcpServer({
1256
+ serverInfo: { name: 'my-app', version: '1.0.0' },
1257
+ auth: null,
1258
+ services: contracts.map((c) => implementRemote(c, http)),
1259
+ })
1260
+ ```
1261
+
1262
+ This is how you ship a thin **local** MCP server for an API that already runs in
1263
+ the cloud: the local process owns only the transport and any filesystem-facing
1264
+ native tools, while every contract tool proxies to the remote API. One contract,
1265
+ no duplicated business logic.
1266
+
1267
+ `implementRemote(contract, http, { transformArgs })` takes an optional
1268
+ `transformArgs` hook that rewrites a call's arguments before they are forwarded
1269
+ — e.g. to upload a local file referenced in the args and swap in its URL.
1270
+
1271
+ ## Structured output
1272
+
1273
+ When a contract endpoint declares an object `output`, its MCP tool registers
1274
+ that as the tool `outputSchema` and the result carries `structuredContent`
1275
+ alongside the text block — the structured payload an MCP App UI consumes.
1276
+
1277
+ ## AI agents — `mountAgent`
1278
+
1279
+ `mountAgent` turns a service into a Vercel AI SDK `ToolSet`, ready for
1280
+ `generateText` / `streamText`:
1281
+
1282
+ ```ts
1283
+ import { mountAgent } from 'stitchkit/tools'
1284
+ import { generateText } from 'ai'
1285
+
1286
+ const tools = mountAgent(usersService, { context: { userId: 'agent-1' } })
1287
+
1288
+ const result = await generateText({
1289
+ model,
1290
+ tools,
1291
+ prompt: 'Create a user named Max',
1292
+ })
1293
+ ```
1294
+
1295
+ Each contract endpoint exposed on `AGENT` becomes a tool whose input schema is
1296
+ the merged `params` + `input`. `context` is merged into every tool handler's
1297
+ `ctx`, alongside `source: 'agent'`.
1298
+
1299
+ ### `AgentMountConfig`
1300
+
1301
+ | Field | Purpose |
1302
+ |-------|---------|
1303
+ | `context` | values merged into every tool handler's `ctx` |
1304
+ | `lifecycle` | `beforeHandle` / `afterHandle` — the tool-side auth gate (see [Guarding tools](#guarding-tools--lifecycle)) |
1305
+ | `hooks` | tool-call observability hooks — `afterToolCall` fires on every result |
1306
+ | `extend` | add extra args resolved before the handler runs (see below) |
1307
+
1308
+ `lifecycle` works the same as on the MCP server — without it an agent tool call
1309
+ bypasses the HTTP `beforeHandle` auth gate. Pass your `createAuthHook` result.
1310
+
1311
+ ### Adding tool-only args — `extend`
1312
+
1313
+ `extend` adds **tool-only** arguments — fields the model fills that are resolved
1314
+ into context, then stripped before the contract handler sees them. Use it when a
1315
+ tool needs an argument the HTTP endpoint does not — the classic case being a
1316
+ **multi-tenant** server reached by one API key, where the model passes `tenantId`
1317
+ on every call. It is the same `ToolExtend` shape on `mountMcp`, `createMcpHandler`
1318
+ and `mountAgent`:
1319
+
1320
+ ```ts
1321
+ interface ToolExtend {
1322
+ /** Extra Zod fields added to every matching tool's input schema. */
1323
+ schema: Record<string, z.ZodType>
1324
+ /** Turn the extra args into context merged into the handler's ctx. */
1325
+ resolve: (args: Record<string, unknown>) => Partial<Ctx> | Promise<Partial<Ctx>>
1326
+ /** Limit the extension to specific methods — default: every method. */
1327
+ filter?: (service: ServiceDef, method: MethodDef) => boolean
1328
+ }
1329
+ ```
1330
+
1331
+ On the MCP server — add `tenantId` to each tool, validate access in `resolve`,
1332
+ inject the resolved tenant into `ctx`, and `filter` to the tenant-scoped tools:
1333
+
1334
+ ```ts
1335
+ createMcpHandler({
1336
+ serverInfo, auth,
1337
+ services: [widgetsService],
1338
+ extend: {
1339
+ schema: { tenantId: z.string().describe('Tenant to act on') },
1340
+ resolve: async ({ tenantId }) => {
1341
+ const id = String(tenantId)
1342
+ if (!(await tenantExists(id))) throw new Error(`unknown tenant ${id}`)
1343
+ return { tenantId: id } // merged into ctx → ctx.tenantId
1344
+ },
1345
+ filter: (_service, method) => method.scope === 'tenant',
1346
+ },
1347
+ })
1348
+ ```
1349
+
1350
+ The model calls `widgets_list({ tenantId: 't_123' })`; `resolve` checks access and
1351
+ puts `tenantId` on `ctx`; the contract handler reads `ctx.tenantId` (same place
1352
+ as the HTTP prefix param — see
1353
+ [Route groups → param prefixes](./server.md#param-prefixes-resource-scoped-paths)),
1354
+ so one handler serves both surfaces. Pair `extend` with `lifecycle` (your
1355
+ `createAuthHook`) so the tool call is still scope-gated.
1356
+
1357
+ ## Native multimodal tools
1358
+
1359
+ Contract tools return JSON. For a tool that returns an image or other
1360
+ multimodal content, register a native tool. `mountViewFile` is the built-in one
1361
+ — it lets a model fetch and view a file (with SSRF and path-traversal
1362
+ defenses):
1363
+
1364
+ ```ts
1365
+ import { createMcpHandler, mountViewFile } from 'stitchkit/tools'
1366
+
1367
+ const handleMcp = createMcpHandler({
1368
+ serverInfo: { name: 'my-app', version: '1.0.0' },
1369
+ auth,
1370
+ services: [service],
1371
+ nativeTools: (server) => mountViewFile(server, { baseDir: '/srv/uploads' }),
1372
+ })
1373
+ ```
1374
+
1375
+ ## One handler, three callers
1376
+
1377
+ A contract handler runs the same for an HTTP request, an MCP tool call and an
1378
+ agent tool call. `ctx.source` (`'http'` · `'mcp'` · `'agent'`) tells it which —
1379
+ the rest of the context (`params`, `input`, anything `context` injects) is
1380
+ identical. Write the handler once; it serves every surface.
1381
+
1382
+
1383
+ ==============================================================================
1384
+ # Guide: CLI (docs/guide/cli.md)
1385
+ ==============================================================================
1386
+
1387
+ # CLI
1388
+
1389
+ The same contract that drives the HTTP API, MCP tools and agent tools also
1390
+ drives a command-line program. `createCli` turns contract methods into commands
1391
+ — `myapp generate "a fox" --wait`, `myapp models list --json | jq …` — run
1392
+ through the very same validation, auth gate and error model as every other
1393
+ surface (HTTP ≡ MCP ≡ agent ≡ CLI, [ADR 0014](../decisions/0014-tool-http-parity.md)).
1394
+
1395
+ It exists for what the other three surfaces cannot do: a generation kicked off
1396
+ with `Bash(run_in_background)` that notifies on exit, a `SKILL.md` that shells
1397
+ out in one line, a pipeable terminal command.
1398
+
1399
+ ## Exposure is opt-in
1400
+
1401
+ Unlike MCP and agent — where an endpoint with no `expose` is a tool by default —
1402
+ **a method becomes a CLI command only when its `expose` lists `'CLI'`.** Adding
1403
+ the CLI never silently turns your existing API tools into shell commands.
1404
+
1405
+ ```ts
1406
+ { method: 'POST', path: '/', desc: 'Generate media', toolName: 'generate',
1407
+ expose: ['CLI', 'MCP', 'AGENT'], input: GenerateInput, output: Generation } // CLI + MCP + agent
1408
+ { method: 'GET', path: '/models', desc: 'List models', toolName: 'list_models',
1409
+ expose: ['CLI'] } // CLI only
1410
+ { method: 'GET', path: '/search', desc: 'Search' } // HTTP + MCP + AGENT — NOT CLI
1411
+ ```
1412
+
1413
+ A fresh contract shows **zero** CLI commands until methods opt in — that is the
1414
+ design, not a bug. The command name is the tool name — `toolName` if set, else a
1415
+ verb-aware name from the method + prefix (`list` → `list_widgets`, `get` →
1416
+ `get_widget`), not a literal `prefix_key`.
1417
+
1418
+ ## A minimal CLI
1419
+
1420
+ stitchkit ships no binary — you write the executable and point your app's `bin`
1421
+ at it:
1422
+
1423
+ ```ts
1424
+ #!/usr/bin/env node
1425
+ // src/cli.ts
1426
+ import { createCli } from 'stitchkit/cli'
1427
+ import { catalogService, generateService } from './services'
1428
+
1429
+ await createCli({
1430
+ name: 'myapp',
1431
+ version: '1.0.0',
1432
+ services: [catalogService, generateService],
1433
+ auth: process.env.MYAPP_TOKEN, // resolved ONCE, like a stdio MCP server
1434
+ })
1435
+ ```
1436
+
1437
+ ```json
1438
+ // package.json
1439
+ { "bin": { "myapp": "./dist/cli.js" } }
1440
+ ```
1441
+
1442
+ `stitchkit/cli` pulls in neither the MCP SDK nor `ai`, so a CLI binary needs no
1443
+ MCP/agent peer dependencies.
1444
+
1445
+ ## Calling commands
1446
+
1447
+ ```
1448
+ <app> <command> [positional] [--flags]
1449
+ ```
1450
+
1451
+ Arguments are coerced to the contract schema's types — every argv token is a
1452
+ string, the schema says what it should be:
1453
+
1454
+ | Zod field | CLI |
1455
+ | -------------------- | ---------------------------------------------- |
1456
+ | `z.string()` | `--name "box"` or a positional |
1457
+ | `z.number()` | `--count 3` → `3` |
1458
+ | `z.boolean()` | `--active` (presence) / `--no-active` |
1459
+ | `z.enum([...])` | `--size large` |
1460
+ | `z.array(z.string())`| `--tag a --tag b` → `["a","b"]` |
1461
+ | `z.object({...})` | `--opts '{"k":"v"}'` (JSON) or `--opts.k v` |
1462
+ | `.optional()` / `.default()` | not required |
1463
+
1464
+ Positional arguments fill non-boolean fields in declaration order, so
1465
+ `myapp generate "a fox"` is `--prompt "a fox"`. A piped value fills the first
1466
+ unset field: `echo "a fox" | myapp generate`.
1467
+
1468
+ The advertised schema is never mutated — a CLI call validates against the exact
1469
+ same Zod schema an HTTP or MCP call does.
1470
+
1471
+ ## Global flags
1472
+
1473
+ | Flag | Effect |
1474
+ | --------------------- | ---------------------------------------------------------- |
1475
+ | `--json` | Raw JSON on stdout for piping / scripts |
1476
+ | `--wait` | Block-poll an async result to a terminal state |
1477
+ | `--wait-timeout <s>` | Override the `--wait` timeout |
1478
+ | `--output-dir <dir>` | Download result media into a directory |
1479
+ | `--quiet` | Suppress non-essential stderr output |
1480
+ | `--dry-run` | Print the resolved call without executing |
1481
+ | `--help`, `-h` | Usage — top-level or per-command flag table |
1482
+
1483
+ stdout carries the result; errors and progress go to stderr, so `--json` stays
1484
+ pipeable and `2>/dev/null` stays clean. The process exit code carries the error
1485
+ class (`0` ok, `VALIDATION_ERROR → 1`, `UNAUTHORIZED → 2`, `FORBIDDEN → 3`,
1486
+ `NOT_FOUND → 4`, …) — override per app with `exitCodes`.
1487
+
1488
+ ## `--wait` — background-friendly generation
1489
+
1490
+ `--wait` polls an async result until it is done. It is generic — the core knows
1491
+ nothing about "generations": you say how to read the poll target, which command
1492
+ to re-call and when it is done.
1493
+
1494
+ ```ts
1495
+ await createCli({
1496
+ name: 'myapp',
1497
+ version: '1.0.0',
1498
+ services,
1499
+ wait: {
1500
+ generate: {
1501
+ tool: 'get_generation',
1502
+ poll: (r) => (isRecord(r) && typeof r.id === 'string' ? { id: r.id } : null),
1503
+ done: (r) => isRecord(r) && r.status === 'COMPLETED',
1504
+ },
1505
+ },
1506
+ })
1507
+ ```
1508
+
1509
+ ```bash
1510
+ # foreground
1511
+ myapp generate "a fox" --wait --output-dir ./out
1512
+
1513
+ # background — frees the agent; a notification fires on exit
1514
+ myapp generate "a fox" --wait --json > result.json &
1515
+ ```
1516
+
1517
+ ## Auth parity
1518
+
1519
+ A scoped command is guarded by the same `createAuthHook` your HTTP server uses —
1520
+ pass it as `lifecycle`, and inject the identity through `context` so
1521
+ `resolveFromContext` can read it:
1522
+
1523
+ ```ts
1524
+ const authHook = createAuthHook({ /* resolve, resolveFromContext, rules */ })
1525
+
1526
+ await createCli({
1527
+ name: 'myapp',
1528
+ version: '1.0.0',
1529
+ auth: await resolveIdentityFromToken(process.env.MYAPP_TOKEN),
1530
+ context: (identity) => ({ user: identity }), // resolveFromContext reads this
1531
+ lifecycle: { beforeHandle: authHook }, // same gate as HTTP
1532
+ services,
1533
+ })
1534
+ ```
1535
+
1536
+ Without `lifecycle`, a scoped command runs **unguarded** — the scope check lives
1537
+ entirely inside the `createAuthHook` result, so with no hook wired in there is
1538
+ nothing to enforce a method's `scope`. This matches the MCP / agent surfaces
1539
+ exactly ([ADR 0014](../decisions/0014-tool-http-parity.md)): on every tool
1540
+ transport the auth gate is opt-in, so a contract with scoped methods **must** be
1541
+ given a `lifecycle` (and `context` identity) to be protected. The gate only
1542
+ *fails closed* once the hook **is** present but `resolveFromContext` is missing —
1543
+ then a scoped call has no identity and is rejected.
1544
+
1545
+ ## Typed context
1546
+
1547
+ Use `createToolkit<AppContext>()` to type the injected `context` against your
1548
+ app's context shape — the tool-side mirror of `createImplement`
1549
+ ([ADR 0017](../decisions/0017-typed-tool-context.md)):
1550
+
1551
+ ```ts
1552
+ const tools = createToolkit<{ user: User }>()
1553
+ await tools.createCli({
1554
+ name: 'myapp',
1555
+ version: '1.0.0',
1556
+ services,
1557
+ context: (identity) => ({ user: identity }), // checked against { user: User }
1558
+ })
1559
+ ```
1560
+
1561
+ ## Not in v1
1562
+
1563
+ File-upload (`multipart`) endpoints are CLI-invisible, the same as on MCP /
1564
+ agent — a dedicated upload command (auto-uploading local paths) is future work.
1565
+ Streaming (SSE) output is not yet piped to stdout.
1566
+
1567
+
1568
+ ==============================================================================
1569
+ # Guide: Realtime (docs/guide/realtime.md)
1570
+ ==============================================================================
1571
+
1572
+ # Realtime
1573
+
1574
+ stitchkit's realtime layer is [Socket.IO](https://socket.io) — `polling`
1575
+ fallback, heartbeats, acks, a mature client. stitchkit does not ship its own
1576
+ WebSocket engine; it ships thin, typed wrappers over Socket.IO and a bridge that
1577
+ syncs socket events into the TanStack Query cache. See
1578
+ [ADR 0008](../decisions/0008-thin-wrappers.md).
1579
+
1580
+ ## Typed events
1581
+
1582
+ Declare the event maps once, in the shared module — both sides import them:
1583
+
1584
+ ```ts
1585
+ // shared/contracts.ts
1586
+ export interface ServerToClientEvents {
1587
+ 'note:created': (note: Note) => void
1588
+ 'note:deleted': (id: string) => void
1589
+ }
1590
+ export interface ClientToServerEvents {
1591
+ 'room:join': (room: string) => void
1592
+ }
1593
+ ```
1594
+
1595
+ Every `emit` and `on` on both the server and client wrapper is typed against
1596
+ these maps.
1597
+
1598
+ ## Server — `createSocketIOServer`
1599
+
1600
+ ```ts
1601
+ import { createServer, createSocketIOServer } from 'stitchkit/server'
1602
+
1603
+ const socket = await createSocketIOServer<ServerToClientEvents, ClientToServerEvents>({
1604
+ cors: { origin: 'https://app.example.com' },
1605
+ })
1606
+
1607
+ socket.io.on('connection', (s) => {
1608
+ s.on('room:join', (room) => s.join(room)) // rooms, handshake auth — your logic
1609
+ })
1610
+ ```
1611
+
1612
+ It returns a handle with three pieces, all wired into `createServer`:
1613
+
1614
+ ```ts
1615
+ createServer({
1616
+ services,
1617
+ websocket: socket.websocket, // → Bun.serve websocket handlers
1618
+ rawRoutes: [socket.route], // ready-made /socket.io/* route
1619
+ })
1620
+
1621
+ // elsewhere — broadcast:
1622
+ socket.io.emit('note:created', note)
1623
+ ```
1624
+
1625
+ | Handle field | Purpose |
1626
+ |--------------|---------|
1627
+ | `io` | the typed Socket.IO server — attach `connection` handlers, broadcast |
1628
+ | `websocket` | Bun WebSocket handlers — pass to `createServer({ websocket })` |
1629
+ | `route` | the `/socket.io/*` raw route — pass to `createServer({ rawRoutes })` |
1630
+
1631
+ `SocketIOServerConfig` also takes `path`, `transports`, `pingTimeout` and
1632
+ `pingInterval`. For anything else socket.io's `ServerOptions` exposes, use the
1633
+ typed **`serverOptions`** passthrough — most often `maxHttpBufferSize` to lift the
1634
+ 1 MB default for large emits:
1635
+
1636
+ ```ts
1637
+ await createSocketIOServer({
1638
+ cors: { origin: 'https://app.example.com' },
1639
+ serverOptions: { maxHttpBufferSize: 5 * 1024 * 1024 }, // 5 MB
1640
+ })
1641
+ ```
1642
+
1643
+ The wrapper-owned fields (`cors` / `path` / `transports` / `ping*`) take
1644
+ precedence over the same keys in `serverOptions`. On Bun the engine-level options
1645
+ (`maxHttpBufferSize`, the ping heartbeat, `upgradeTimeout`) are forwarded to
1646
+ `@socket.io/bun-engine` too — so a configured `maxHttpBufferSize` actually applies
1647
+ instead of silently truncating at 1 MB.
1648
+
1649
+ ## Client — `createSocketIOClient`
1650
+
1651
+ ```ts
1652
+ import { createSocketIOClient } from 'stitchkit'
1653
+
1654
+ const socket = createSocketIOClient<ServerToClientEvents, ClientToServerEvents>({
1655
+ url: 'https://api.example.com',
1656
+ })
1657
+
1658
+ socket.connect()
1659
+ socket.on('note:created', (note) => { /* typed note */ })
1660
+ socket.emit('room:join', 'general') // typed
1661
+ ```
1662
+
1663
+ ### Durable subscriptions
1664
+
1665
+ `socket.on(...)` returns an unsubscribe and is **durable** — the handler is
1666
+ re-attached to every socket the client builds, so it survives a reconnect. You
1667
+ subscribe once; reconnection is the wrapper's problem, not yours.
1668
+
1669
+ `SocketIOClientConfig` takes `url`, `path`, `withCredentials` (cookies on the
1670
+ handshake — default `true`), `auth`, `query`, `extraHeaders`, `transports`,
1671
+ `reconnectionAttempts` and `reconnectionDelay`.
1672
+
1673
+ ### Handshake auth — cookie or token
1674
+
1675
+ By default the handshake carries cookies (`withCredentials: true`) — the right
1676
+ fit for a browser app with a session cookie. A client that holds a token
1677
+ explicitly (desktop, mobile, CLI, server-to-server) authenticates the
1678
+ handshake with **`auth`** instead — the token reaches the server as
1679
+ `socket.handshake.auth`:
1680
+
1681
+ ```ts
1682
+ // client — a function is re-read on every (re)connect, so a rotated token is
1683
+ // picked up automatically; no need to recreate the client (or lose durable
1684
+ // subscriptions). It may be async.
1685
+ const socket = createSocketIOClient<ServerToClientEvents, ClientToServerEvents>({
1686
+ url: 'https://api.example.com',
1687
+ auth: () => ({ token: getAccessToken() }),
1688
+ })
1689
+ ```
1690
+
1691
+ ```ts
1692
+ // server — the gate is your logic, on socket.handshake.auth
1693
+ import { verifyJwt } from 'stitchkit/server'
1694
+
1695
+ socket.io.use(async (s, next) => {
1696
+ const token = s.handshake.auth.token
1697
+ if (typeof token !== 'string') return next(new Error('unauthorized'))
1698
+ try {
1699
+ s.data.user = await verifyJwt(token, secret)
1700
+ next()
1701
+ } catch {
1702
+ next(new Error('unauthorized'))
1703
+ }
1704
+ })
1705
+ ```
1706
+
1707
+ A static object (`auth: { token }`) works too, but only the function form
1708
+ re-reads on reconnect — prefer it for rotating tokens. `query` adds handshake
1709
+ URL params (`socket.handshake.query`); `extraHeaders` adds handshake headers,
1710
+ but in a browser those apply to the **polling** transport only (a WebSocket
1711
+ upgrade cannot set request headers) — for browser WebSocket auth use `auth`.
1712
+ If an auth producer throws or rejects, the wrapper sends an empty auth object so
1713
+ the server gate can reject it instead of leaving the handshake waiting forever.
1714
+
1715
+ On a hard auth failure Socket.IO emits `connect_error` and keeps retrying
1716
+ (`reconnectionAttempts` defaults to `Infinity`) — set a finite value if a
1717
+ rejected token should stop hammering the server.
1718
+
1719
+ ## Cache bridge
1720
+
1721
+ `createCacheBridge` syncs socket events into the TanStack Query cache — a server
1722
+ push updates the UI with no refetch. It is transport-agnostic: it takes any
1723
+ emitter with `on(event, handler) => unsubscribe`, which the
1724
+ `createSocketIOClient` result satisfies.
1725
+
1726
+ ```ts
1727
+ import { createCacheBridge } from 'stitchkit/react'
1728
+
1729
+ const bridge = createCacheBridge({
1730
+ socket,
1731
+ queryClient,
1732
+ handlers: {
1733
+ 'note:created': (note, ctx) => {
1734
+ if (ctx.isFresh(['notes'])) return // skip the echo of our own mutation
1735
+ ctx.queryClient.invalidateQueries({ queryKey: ['notes'] })
1736
+ },
1737
+ },
1738
+ })
1739
+ bridge.connect()
1740
+ ```
1741
+
1742
+ ### The echo problem
1743
+
1744
+ When the client makes a mutation, it updates the cache itself — and the server
1745
+ also broadcasts the change back over the socket. Without care the UI updates
1746
+ twice. `markFresh` plus a short freshness window solves it: mark a key fresh
1747
+ right after a local mutation, and the bridge handler skips the echo.
1748
+
1749
+ ```ts
1750
+ // in the mutation:
1751
+ onSuccess: () => bridge.markFresh(['notes'])
1752
+ // in the handler: if (ctx.isFresh(['notes'])) return
1753
+ ```
1754
+
1755
+ `createCacheBridge` is a convenience, not a requirement — any code can subscribe
1756
+ to the socket and call `queryClient` directly. The bridge just centralises the
1757
+ event-to-cache mapping and the echo guard.
1758
+
1759
+ ## Raw binary lane (Bun)
1760
+
1761
+ Socket.IO carries binary fine — for most streams a binary event (`pcm(frame)`)
1762
+ is enough. But a *truly* high-throughput binary channel (video, large
1763
+ transfers) may want a raw WebSocket with no Socket.IO framing, on the **same**
1764
+ port. On Bun that is awkward: `Bun.serve` has a single `websocket` handler, and
1765
+ `createSocketIOServer().websocket` claims it.
1766
+
1767
+ `composeWebSocketHandlers` composes that one handler from several lanes. A raw
1768
+ lane stamps its own marker onto `ws.data` at upgrade and is matched positively;
1769
+ Socket.IO is the catch-all (`socketIoLane`, placed last) — so the engine's
1770
+ opaque `ws.data` is never inspected, and the whole thing stays cast-free.
1771
+
1772
+ ```ts
1773
+ import {
1774
+ composeWebSocketHandlers,
1775
+ createServer,
1776
+ createSocketIOServer,
1777
+ socketIoLane,
1778
+ webSocketLane,
1779
+ } from 'stitchkit/server'
1780
+ import type { RawRoute } from 'stitchkit/server'
1781
+ import type { ServerWebSocket, WebSocketHandler } from 'bun'
1782
+
1783
+ const socket = await createSocketIOServer<ServerToClientEvents, ClientToServerEvents>({
1784
+ cors: { origin: 'https://app.example.com' },
1785
+ })
1786
+
1787
+ // 1. Discriminate the raw lane by a marker on ws.data (a type guard, cast-free
1788
+ // — the `in` operator narrows, no `as`).
1789
+ interface PcmData { lane: 'pcm'; roomId: string }
1790
+ function isPcm(ws: ServerWebSocket<unknown>): ws is ServerWebSocket<PcmData> {
1791
+ const data = ws.data
1792
+ return typeof data === 'object' && data !== null && 'lane' in data && data.lane === 'pcm'
1793
+ }
1794
+
1795
+ // 2. Raw handlers — ws.data is typed PcmData, no casts.
1796
+ const pcmHandlers: WebSocketHandler<PcmData> = {
1797
+ message(ws, frame) { ws.publish(ws.data.roomId, frame) },
1798
+ }
1799
+
1800
+ // 3. An upgrade route stamps the marker (your auth + data live here).
1801
+ const pcmRoute: RawRoute = {
1802
+ method: 'GET',
1803
+ path: '/ws/pcm',
1804
+ handler: (req, ctx) => {
1805
+ if (!ctx.server) throw new Error('needs a running Bun server')
1806
+ const ok = ctx.server.upgrade(req, { data: { lane: 'pcm', roomId: '…' } })
1807
+ return ok ? new Response(null) : new Response(null, { status: 400 })
1808
+ },
1809
+ }
1810
+
1811
+ // 4. Compose — raw lane first, Socket.IO last. The tuning is server-wide, so
1812
+ // set maxPayloadLength to the most permissive lane (Socket.IO's default is
1813
+ // 1 MB, its maxHttpBufferSize).
1814
+ const websocket = composeWebSocketHandlers(
1815
+ [webSocketLane({ match: isPcm, handlers: pcmHandlers }), socketIoLane(socket.websocket)],
1816
+ { maxPayloadLength: 16 * 1024 * 1024 },
1817
+ )
1818
+
1819
+ createServer({
1820
+ services,
1821
+ websocket,
1822
+ rawRoutes: [socket.route, pcmRoute],
1823
+ })
1824
+ ```
1825
+
1826
+ Notes:
1827
+
1828
+ - **Bun-only.** On Node, Socket.IO attaches to the `node:http.Server` `upgrade`
1829
+ event (`serveNode({ socket })`); a raw lane there is a separate upgrade
1830
+ handler, not this composition. See [ADR 0020](../decisions/0020-raw-websocket-lane.md).
1831
+ - The upgrade path must not collide with `/socket.io/*`.
1832
+ - The tuning (`maxPayloadLength`, `idleTimeout`, `backpressureLimit`, …) is
1833
+ global — keep `idleTimeout` ≥ Socket.IO needs (> 2 × `pingInterval`).
1834
+ - For high throughput, handle backpressure in the raw lane: `ws.send()` returns
1835
+ `-1` under pressure; resume on the `drain` callback.
1836
+
1837
+
1838
+ ==============================================================================
1839
+ # Guide: Auth & errors (docs/guide/auth-and-errors.md)
1840
+ ==============================================================================
1841
+
1842
+ # Auth & errors
1843
+
1844
+ stitchkit carries no domain model — it does not know what a user is. What it
1845
+ provides is the *control flow*: a scope on every endpoint, one hook that
1846
+ enforces it, and one error model shared by every transport. The identity and
1847
+ the scope vocabulary are yours.
1848
+
1849
+ ## Scopes
1850
+
1851
+ An endpoint declares a `scope` — a free string. The contract `meta.scope` is the
1852
+ default for endpoints that declare none.
1853
+
1854
+ ```ts
1855
+ export const posts = defineContract({ prefix: 'posts', scope: 'user' }, {
1856
+ list: { method: 'GET', path: '/', desc: 'List posts', scope: 'public', output: /* … */ },
1857
+ create: { method: 'POST', path: '/', desc: 'Create', /* inherits 'user' */ },
1858
+ remove: { method: 'DELETE', path: '/:id', desc: 'Delete', scope: 'admin' },
1859
+ })
1860
+ ```
1861
+
1862
+ The framework attaches no meaning to the strings — `'public'`, `'user'`,
1863
+ `'admin'` mean whatever your auth hook decides.
1864
+
1865
+ ## `createAuthHook`
1866
+
1867
+ `createAuthHook` builds a `beforeHandle` hook that enforces `endpoint.scope`.
1868
+ Every request runs the same three steps — resolve the identity, read the scope,
1869
+ allow / 401 / 403 — so the flow lives in the framework and you supply only
1870
+ `resolve` and a `rules` map.
1871
+
1872
+ ```ts
1873
+ import { createAuthHook, createServer } from 'stitchkit/server'
1874
+
1875
+ const authHook = createAuthHook<User>({
1876
+ resolve: (ctx) => resolveSession(ctx), // → a User, or null
1877
+ rules: {
1878
+ public: 'public', // always pass
1879
+ user: 'authenticated', // any resolved identity passes
1880
+ admin: (user) => user.isAdmin, // custom predicate
1881
+ },
1882
+ inject: (ctx, user) => { ctx.user = user },
1883
+ })
1884
+
1885
+ createServer({ services, hooks: { beforeHandle: authHook } })
1886
+ ```
1887
+
1888
+ ### `AuthRule`
1889
+
1890
+ The value of each `rules` entry, keyed by scope:
1891
+
1892
+ - **`'public'`** — always passes; the identity is attached if present.
1893
+ - **`'authenticated'`** — any resolved identity passes; no identity ⇒ 401.
1894
+ - **a function** `(identity, ctx) => boolean | Promise<boolean>` — a custom
1895
+ check. It receives the full context, so a resource-scoped rule can read the
1896
+ request's path params and do a DB lookup. May be async.
1897
+
1898
+ #### Resource-scoped rule — reading a path/prefix param
1899
+
1900
+ For a multi-tenant API (`pathPrefix: '/tenants/:tenantId'`, see
1901
+ [Route groups → param prefixes](./server.md#param-prefixes-resource-scoped-paths)),
1902
+ the rule gates access to the tenant in the path. A `:param` from the group prefix
1903
+ or the endpoint is on the **context root** as `ctx.<name>` (a raw `string`):
1904
+
1905
+ ```ts
1906
+ const authHook = createAuthHook<User>({
1907
+ resolve: sessionResolver,
1908
+ rules: {
1909
+ public: 'public',
1910
+ // gate the tenant in the path — ctx.tenantId comes from the group prefix
1911
+ tenant: async (user, ctx) => userCanAccessTenant(user.id, String(ctx.tenantId)),
1912
+ } satisfies Record<Scope, AuthRule<User>>,
1913
+ // derive per-request facts onto ctx for handlers (role within this tenant, …)
1914
+ inject: async (ctx, user) => {
1915
+ ctx.user = user
1916
+ if (user) ctx.tenantRole = await roleInTenant(user.id, String(ctx.tenantId))
1917
+ },
1918
+ })
1919
+ ```
1920
+
1921
+ `inject` runs on every request (identity may be `null`) — use it to put the
1922
+ identity *and* any derived values (role, parent-id) on `ctx` for handlers. Read
1923
+ them back as `ctx.tenantRole` (typed `unknown` — narrow at the read site). The
1924
+ endpoints under the group declare `scope: 'tenant'`.
1925
+
1926
+ ### `AuthHookConfig`
1927
+
1928
+ | Field | Purpose |
1929
+ |-------|---------|
1930
+ | `resolve` | `(ctx) => identity \| null` — HTTP identity from `ctx.req` (cookie / bearer + lookup) |
1931
+ | `resolveFromContext` | `(ctx) => identity \| null` — identity on a tool call (no `req`) |
1932
+ | `rules` | access rule per scope; `endpoint.scope` is the key |
1933
+ | `defaultScope` | scope applied when an endpoint declares none |
1934
+ | `inject` | write the resolved identity onto `ctx` for handlers |
1935
+ | `onAnonymous` | thrown when a scope needs an identity and there is none (default 401) |
1936
+ | `onForbidden` | thrown when an identity is present but the rule rejects it (default 403) |
1937
+
1938
+ Annotate `rules` with `satisfies Record<MyScope, AuthRule<User>>` so the compiler
1939
+ catches a scope you forgot to cover.
1940
+
1941
+ ### Auth on the tool surface — `resolveFromContext`
1942
+
1943
+ The hook runs in `beforeHandle`, so it guards **every transport** — HTTP, MCP
1944
+ and agent calls all pass through it. But identity is resolved differently per
1945
+ surface:
1946
+
1947
+ - **HTTP** — `resolve(ctx)` reads `ctx.req` (a cookie or bearer token).
1948
+ - **Tool calls (MCP / agent)** — there is no `req`. The transport authenticated
1949
+ the caller (an MCP API key) and `buildMcpServer`'s `context` injected the
1950
+ identity into `ctx`. `resolveFromContext(ctx)` locates it.
1951
+
1952
+ ```ts
1953
+ const authHook = createAuthHook<User>({
1954
+ resolve: (ctx) => resolveSession(ctx), // HTTP — from ctx.req
1955
+ resolveFromContext: (ctx) => ctx.user ?? null, // tool — from injected ctx
1956
+ rules: { public: 'public', user: 'authenticated', admin: (u) => u.isAdmin },
1957
+ })
1958
+ ```
1959
+
1960
+ The **scope check is identical** on both surfaces — only identity resolution
1961
+ differs. If you omit `resolveFromContext`, a scoped tool call has no identity
1962
+ and **fails closed** (rejected by `onAnonymous`) — the hook never silently
1963
+ passes a tool call it cannot authenticate. → [ADR 0014](../decisions/0014-tool-http-parity.md)
1964
+
1965
+ ## `createBearerResolver`
1966
+
1967
+ For API-key or bearer-token auth (the usual MCP case), `createBearerResolver`
1968
+ strips `Authorization: Bearer <token>` and hands the raw token to your lookup:
1969
+
1970
+ ```ts
1971
+ import { createBearerResolver } from 'stitchkit/server'
1972
+
1973
+ const resolve = createBearerResolver<User>({
1974
+ lookup: (token, req) => db.apiKeys.resolve(token), // → User, or null
1975
+ })
1976
+ ```
1977
+
1978
+ Use it as `createMcpHandler`'s `auth`, or inside `createAuthHook`'s `resolve`.
1979
+
1980
+ ## JWT
1981
+
1982
+ `verifyJwt(token, secret)` verifies an HS256 JWT and returns its payload, or
1983
+ throws `unauthorized`. It pins the algorithm (the token's own `alg` can never
1984
+ pick the scheme), and checks `exp` and `nbf`.
1985
+
1986
+ ```ts
1987
+ import { verifyJwt, extractToken } from 'stitchkit/server'
1988
+
1989
+ const token = extractToken(req) // from Authorization, or a cookie name
1990
+ const payload = await verifyJwt(token, process.env.JWT_SECRET!)
1991
+ ```
1992
+
1993
+ `extractToken(req, cookieName?)` reads a bearer token from the `Authorization`
1994
+ header, or from the named cookie.
1995
+
1996
+ ## Cookies
1997
+
1998
+ ```ts
1999
+ import { defineCookie } from 'stitchkit/server'
2000
+
2001
+ const session = defineCookie({ name: 'sid', httpOnly: true, secure: true, sameSite: 'Lax', path: '/' })
2002
+
2003
+ session.get(req) // string | undefined
2004
+ session.set('abc123') // → a Set-Cookie header value
2005
+ session.clear() // → a Set-Cookie value that expires it
2006
+ ```
2007
+
2008
+ `defineCookie` bundles a cookie's name and options into a typed handle, so the
2009
+ config is not repeated at every call site. `parseCookies(header)` and
2010
+ `serializeCookie(name, value, opts)` are the lower-level primitives.
2011
+
2012
+ ## The error model
2013
+
2014
+ One error type, `AppError`, is shared by the contract, the server and the
2015
+ client. It carries a stable `code`, an HTTP `status`, and optional structured
2016
+ `details` and a `hint`.
2017
+
2018
+ ### Throwing errors
2019
+
2020
+ Throw `AppError` directly, or use a typed helper — the idiomatic path:
2021
+
2022
+ | Helper | Status | Code |
2023
+ |--------|--------|------|
2024
+ | `notFound(message?)` | 404 | `NOT_FOUND` |
2025
+ | `badRequest(message, details?)` | 400 | `BAD_REQUEST` |
2026
+ | `unauthorized(message?)` | 401 | `UNAUTHORIZED` |
2027
+ | `forbidden(message?)` | 403 | `FORBIDDEN` |
2028
+ | `conflict(message?, details?)` | 409 | `CONFLICT` |
2029
+ | `rateLimited(message?)` | 429 | `RATE_LIMITED` |
2030
+ | `appError(code, message?, details?)` | mapped, else 500 | any `code` |
2031
+
2032
+ ```ts
2033
+ import { notFound, badRequest } from 'stitchkit/server'
2034
+
2035
+ const note = db.get(ctx.params.id)
2036
+ if (!note) notFound('Note not found')
2037
+ if (ctx.input.title.length > 200) badRequest('Title too long', { max: 200 })
2038
+ ```
2039
+
2040
+ Each helper has a `never` return type — TypeScript knows execution stops, so the
2041
+ code after it is correctly narrowed.
2042
+
2043
+ ### The error envelope
2044
+
2045
+ `AppError.toJSON()` renders the public envelope every transport returns:
2046
+
2047
+ ```json
2048
+ { "error": { "code": "NOT_FOUND", "message": "Note not found" } }
2049
+ ```
2050
+
2051
+ `details` and `hint` are included when present. On HTTP this is the response
2052
+ body with the matching status; for an MCP or agent call the same `code` and
2053
+ `details` come back as a tool error. A schema-validation failure on the request
2054
+ is turned into a `400 VALIDATION_ERROR` automatically.
2055
+
2056
+ ### On the client
2057
+
2058
+ The client parses that envelope back into an `ApiError` with the same `code`,
2059
+ `status`, `details` and `hint` — see [Typed client → ApiError](./client.md#apierror).
2060
+ The error round-trips: one model, server to client.
2061
+
2062
+ ### Stitch codes vs your codes
2063
+
2064
+ A `code` is a free string — your app codes (`BOT_NOT_FOUND`, …) are yours and the
2065
+ core never models them (ADR 0002). But stitchkit itself emits a fixed set:
2066
+ `BAD_REQUEST`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `METHOD_NOT_ALLOWED`,
2067
+ `CONFLICT`, `RATE_LIMITED`, `VALIDATION_ERROR`, `INTERNAL_SERVER_ERROR`. They are
2068
+ published as **`STITCH_ERROR_STATUS`** (the `code → status` map) and
2069
+ **`StitchErrorCode`** (its `keyof`), with **`isStitchErrorCode()`** (→ ADR 0026).
2070
+
2071
+ If you translate stitch's framework errors into your own wire codes in an
2072
+ `onError` hook, key the map by `StitchErrorCode` so it stays exhaustive — a code
2073
+ stitch adds or renames becomes a compile error, not a silent `500`:
2074
+
2075
+ ```ts
2076
+ const STITCH_TO_APP: Record<StitchErrorCode, AppCode> = {
2077
+ NOT_FOUND: 'NOT_FOUND', METHOD_NOT_ALLOWED: 'METHOD_NOT_ALLOWED',
2078
+ BAD_REQUEST: 'VALIDATION_ERROR', VALIDATION_ERROR: 'VALIDATION_ERROR',
2079
+ UNAUTHORIZED: 'UNAUTHORIZED', FORBIDDEN: 'FORBIDDEN', CONFLICT: 'CONFLICT',
2080
+ RATE_LIMITED: 'RATE_LIMITED', INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
2081
+ }
2082
+ onError: (ctx, err) => {
2083
+ if (AppError.is(err) && isStitchErrorCode(err.code)) {
2084
+ return jsonError(STITCH_TO_APP[err.code], err.status) // keep stitch's status
2085
+ }
2086
+ // … your own AppError / normalizeError path
2087
+ }
2088
+ ```
2089
+
2090
+
2091
+ ==============================================================================
2092
+ # Guide: Observability (docs/guide/observability.md)
2093
+ ==============================================================================
2094
+
2095
+ # Observability
2096
+
2097
+ A request reaches your app through one of two surfaces — an HTTP route or a
2098
+ tool call (MCP / agent). Observability is the same question on both: *what
2099
+ happened, how long did it take, who made it, did it fail.*
2100
+
2101
+ stitchkit answers this at two levels.
2102
+
2103
+ - **The raw hooks** — `LifecycleHooks` and `ToolCallHooks`. Every request and
2104
+ every tool call passes through a point you can observe. The lowest level;
2105
+ always available. [Jump ↓](#the-raw-hooks)
2106
+ - **`stitchkit/observability`** — the audit layer built on those hooks: W3C
2107
+ trace context, an `AsyncLocalStorage` request context, payload sanitisation,
2108
+ and `createAuditHook` to wire it all into one sink. Your logging becomes a
2109
+ table plus a `write` function. [Start here ↓](#the-observability-module)
2110
+
2111
+ stitchkit still ships no logger and no audit store — those are the app's choice.
2112
+ What it ships is the machinery that turns a completed call into a clean,
2113
+ normalised record.
2114
+
2115
+ ## The observability module
2116
+
2117
+ `stitchkit/observability` is server-only. It has three parts — a trace context,
2118
+ a request context, and the audit hook — and you usually touch only the last.
2119
+
2120
+ ### createAuditHook
2121
+
2122
+ `createAuditHook` is the whole module in one call. You give it a `write` sink;
2123
+ it gives you back a wrapper for each surface. Every completed call — HTTP
2124
+ request, MCP tool call, agent tool call — is normalised into one `RequestEvent`
2125
+ and handed to `write`.
2126
+
2127
+ ```ts
2128
+ import { createAuditHook } from 'stitchkit/observability'
2129
+
2130
+ export const audit = createAuditHook({
2131
+ // The only thing the app supplies — persist one row.
2132
+ write: (event) => {
2133
+ db.auditLog.create({ data: {
2134
+ traceId: event.traceId,
2135
+ source: event.source, // 'http' | 'mcp' | 'agent'
2136
+ method: event.method, // verb, or 'TOOL'
2137
+ path: event.path,
2138
+ ok: event.ok,
2139
+ statusCode: event.statusCode,
2140
+ durationMs: event.durationMs,
2141
+ userId: event.userId,
2142
+ payload: event.payload, // already sanitised
2143
+ }})
2144
+ },
2145
+ // Optional — keep only the events you care about.
2146
+ filter: (event) => event.source !== 'http' || event.method !== 'GET',
2147
+ })
2148
+ ```
2149
+
2150
+ It returns an [`AuditHook`](#audithook) — `{ http, toolCall }`:
2151
+
2152
+ ```ts
2153
+ // HTTP — wrap the fetch handler, inside wrapInRequestContext.
2154
+ Bun.serve({ fetch: wrapInRequestContext(audit.http(handler)) })
2155
+
2156
+ // MCP & agents — pass as the tool-call hooks.
2157
+ createMcpHandler({ /* … */ hooks: audit.toolCall })
2158
+ mountAgent(service, { hooks: audit.toolCall })
2159
+ ```
2160
+
2161
+ One `createAuditHook`, one sink, every surface. The sink runs
2162
+ fire-and-forget and its errors are swallowed — a slow or failing audit write
2163
+ can never block or break the request it observes.
2164
+
2165
+ ### RequestEvent
2166
+
2167
+ Every surface produces the same shape — so a single audit table stays
2168
+ queryable across all three:
2169
+
2170
+ | Field | Notes |
2171
+ |-------|-------|
2172
+ | `source` | `http` \| `mcp` \| `agent` |
2173
+ | `method` / `path` | the verb + path, or `TOOL` + `/{source}/{tool}` |
2174
+ | `toolName` | tool calls only |
2175
+ | `traceId` / `spanId` / `parentSpanId` | [W3C trace context](#trace-context) |
2176
+ | `ok` / `statusCode` | outcome — real HTTP status, or `200`/`400` for a tool |
2177
+ | `durationMs` / `startedAt` | timing |
2178
+ | `errorCode` / `errorMessage` | failures only |
2179
+ | `payload` | the request body / tool arguments — sanitised |
2180
+ | `resultSize` / `responseBytes` | result item count + serialised size |
2181
+ | `userId` / `ipAddress` / `userAgent` | identity |
2182
+
2183
+ ### Request context
2184
+
2185
+ `createAuditHook`'s `http` wrapper reads a request context — trace ids, timing,
2186
+ identity — from `AsyncLocalStorage`. `wrapInRequestContext` establishes it, and
2187
+ must be the **outermost** wrapper of your fetch handler:
2188
+
2189
+ ```ts
2190
+ import { getTraceId, wrapInRequestContext } from 'stitchkit/observability'
2191
+
2192
+ Bun.serve({
2193
+ fetch: wrapInRequestContext(audit.http(handler)),
2194
+ })
2195
+ ```
2196
+
2197
+ Two fields are filled in late — the resolved user, and the error outcome. Set
2198
+ them from the hooks that know:
2199
+
2200
+ ```ts
2201
+ import { setRequestError, setRequestUser } from 'stitchkit/observability'
2202
+
2203
+ createAuthHook({ /* … */ inject: (ctx, user) => user && setRequestUser(user.id) })
2204
+ // in your onError hook:
2205
+ setRequestError({ code: err.code, message: err.message })
2206
+ ```
2207
+
2208
+ Make the framework router share this trace id — so request logs and your
2209
+ application logs carry one id — by passing `getTraceId` as the resolver:
2210
+
2211
+ ```ts
2212
+ createHandler({ /* … */ traceId: getTraceId })
2213
+ ```
2214
+
2215
+ `getRequestContext()` / `getTraceId()` then return the active values from
2216
+ anywhere in the call — stamp `getTraceId()` onto every line your logger writes.
2217
+
2218
+ ### Trace context
2219
+
2220
+ stitchkit speaks [W3C Trace Context](https://www.w3.org/TR/trace-context/). An
2221
+ inbound `traceparent` header is continued — its trace id is kept, its span
2222
+ becomes the parent — so a trace spans the front-end call, your HTTP handler and
2223
+ every tool call underneath it. With no inbound header a fresh root trace is
2224
+ minted. Each tool call opens a [`childSpan`](#trace-context) of the request it
2225
+ runs in.
2226
+
2227
+ You rarely call the trace functions directly — `wrapInRequestContext` and
2228
+ `createAuditHook` use them for you. They are exported (`resolveTraceContext`,
2229
+ `parseTraceparent`, `formatTraceparent`, `childSpan`) for when you need to
2230
+ propagate a `traceparent` onward to another service.
2231
+
2232
+ > **Span ids live in the request context, not on `ctx`.** The handler `ctx`
2233
+ > carries a single `traceId`; the full `{ traceId, spanId, parentSpanId }` is on
2234
+ > the observability request context. To stamp `spanId` / `parentSpanId` into an
2235
+ > audit row read `getRequestContext()?.trace`, not `ctx.spanId`:
2236
+ >
2237
+ > ```ts
2238
+ > const trace = getRequestContext()?.trace
2239
+ > audit({ traceId: trace?.traceId, spanId: trace?.spanId, parentSpanId: trace?.parentSpanId })
2240
+ > ```
2241
+
2242
+ ### Sanitisation
2243
+
2244
+ A payload goes into an audit row only after `sanitizePayload`:
2245
+
2246
+ - **secret-named keys are masked** — `password`, `token`, `apiKey`, `secret`,
2247
+ `authorization`, `cookie`, … (value → `[redacted]`);
2248
+ - **binary blobs** (`Uint8Array`, `Blob`, `FormData`) collapse to metadata —
2249
+ never the bytes;
2250
+ - the result is **capped** — anything over the byte limit becomes a preview.
2251
+
2252
+ `createAuditHook` runs it on every event; tune it through `sanitize`:
2253
+
2254
+ ```ts
2255
+ createAuditHook({
2256
+ write,
2257
+ sanitize: { maxBytes: 8_000, sensitiveKeys: /password|token|pin/i },
2258
+ })
2259
+ ```
2260
+
2261
+ `redact`, `truncatePreview` and `measureSize` are exported on their own if you
2262
+ need to sanitise something outside the audit path.
2263
+
2264
+ ## The raw hooks
2265
+
2266
+ `createAuditHook` is built on hooks you can also use directly — for a one-off
2267
+ metric, a custom log line, anything that is not a full audit row.
2268
+
2269
+ | Surface | Hook | Fires |
2270
+ |---------|------|-------|
2271
+ | HTTP | `LifecycleHooks.afterHandle` / `onError` | after each HTTP request |
2272
+ | MCP & agent tools | `ToolCallHooks.afterToolCall` | after each tool call |
2273
+
2274
+ `afterHandle(ctx, result, endpoint)` runs after a handler returns;
2275
+ `onError(ctx, error, endpoint)` when one throws. `afterToolCall(toolName, args,
2276
+ result, durationMs, context)` runs after every tool call — success and error
2277
+ alike — carrying the tool name, the arguments, the result, the duration and the
2278
+ call context.
2279
+
2280
+ ```ts
2281
+ createMcpHandler({
2282
+ serverInfo, auth, services,
2283
+ hooks: {
2284
+ afterToolCall: (toolName, _args, result, durationMs, context) => {
2285
+ metrics.timing(`tool.${toolName}`, durationMs, { ok: String(result.ok) })
2286
+ },
2287
+ },
2288
+ })
2289
+ ```
2290
+
2291
+ Log **after** completion — a record is of a *finished* call; you need the
2292
+ outcome and the duration, neither of which exists before the handler runs.
2293
+
2294
+ ### Keying a row on (service, action)
2295
+
2296
+ For a per-endpoint audit row keyed by **service** and **action**, read the
2297
+ endpoint identity off the `MethodDef` the hook receives — `endpoint.serviceName`
2298
+ (the contract prefix) and `endpoint.key` (the endpoint key, e.g. `updatePartial`).
2299
+ They are stable and always present (→ ADR 0022); the action is not in the URL and
2300
+ `toolName` is absent on HTTP-only endpoints, so this is the only reliable pair.
2301
+ `afterHandle` also gives you the handler `result` — so it, not `createAuditHook`'s
2302
+ HTTP wrapper (which never sees the response body), is the home for a rich mutation
2303
+ audit that records output:
2304
+
2305
+ ```ts
2306
+ hooks: {
2307
+ afterHandle: (ctx, result, endpoint) => {
2308
+ void logMutation({
2309
+ service: endpoint.serviceName,
2310
+ action: endpoint.key,
2311
+ input: ctx.input,
2312
+ output: result,
2313
+ userId: ctx.userId,
2314
+ traceId: ctx.traceId,
2315
+ })
2316
+ return result // don't transform
2317
+ },
2318
+ }
2319
+ ```
2320
+
2321
+ > **Why the HTTP audit is a wrapper, not a lifecycle hook.** `LifecycleHooks`
2322
+ > has a single `onError` — an audit built on it would compete with the app's own
2323
+ > error handler. `createAuditHook`'s `http` wrapper sees the final `Response`
2324
+ > instead, success and error alike, and never contends for a hook. The raw
2325
+ > lifecycle hooks remain yours for everything else.
2326
+
2327
+ Keep any sink **asynchronous and self-contained**: a slow or failing write must
2328
+ never block or break the request. Swallow the sink's own errors.
2329
+
2330
+
2331
+ ==============================================================================
2332
+ # Guide: Testing & deployment (docs/guide/testing-and-deployment.md)
2333
+ ==============================================================================
2334
+
2335
+ # Testing & deployment
2336
+
2337
+ ## Testing
2338
+
2339
+ stitchkit's own test suite runs on `bun:test`. The contract makes most of an
2340
+ API testable without a live socket.
2341
+
2342
+ ### Test handlers in process
2343
+
2344
+ `createHandler` is the router as a plain `(req) => Promise<Response>` function —
2345
+ no `Bun.serve`, no port. Drive it with a `Request`:
2346
+
2347
+ ```ts
2348
+ import { test, expect } from 'bun:test'
2349
+ import { createHandler } from 'stitchkit/server'
2350
+ import { implement } from 'stitchkit/server'
2351
+ import { notes } from '../shared/contracts'
2352
+
2353
+ const handler = createHandler({
2354
+ services: [implement(notes, {
2355
+ list: () => [],
2356
+ create: (ctx) => ({ id: '1', text: ctx.input.text }),
2357
+ get: (ctx) => ({ id: ctx.params.id, text: 'x' }),
2358
+ })],
2359
+ })
2360
+
2361
+ test('create returns the note', async () => {
2362
+ const res = await handler(new Request('http://test/notes', {
2363
+ method: 'POST',
2364
+ headers: { 'content-type': 'application/json' },
2365
+ body: JSON.stringify({ text: 'hi' }),
2366
+ }))
2367
+ expect(res.status).toBe(200)
2368
+ expect(await res.json()).toEqual({ id: '1', text: 'hi' })
2369
+ })
2370
+ ```
2371
+
2372
+ This exercises the full pipeline — routing, schema parsing, hooks, the error
2373
+ envelope — with no network.
2374
+
2375
+ ### Test handlers directly
2376
+
2377
+ A handler is a plain function of `ctx`. For pure handler logic, call it with a
2378
+ context object directly — no HTTP at all. The contract's types keep the test
2379
+ `ctx` honest.
2380
+
2381
+ ### Validation and errors
2382
+
2383
+ A bad request body comes back as `400 VALIDATION_ERROR`; a thrown `AppError`
2384
+ comes back with its `code` and `status`. Assert on the envelope:
2385
+
2386
+ ```ts
2387
+ const res = await handler(new Request('http://test/notes/missing'))
2388
+ expect(res.status).toBe(404)
2389
+ expect(await res.json()).toEqual({ error: { code: 'NOT_FOUND', message: 'Note not found' } })
2390
+ ```
2391
+
2392
+ stitchkit's own suite — 143 tests in `packages/core/tests` — is the working
2393
+ reference for testing each piece.
2394
+
2395
+ ## Deployment
2396
+
2397
+ ### Build
2398
+
2399
+ A stitchkit app is a Bun program — there is no framework build step. Bundle it
2400
+ however the project already does (`bun build`, or run the entry file directly).
2401
+ The `stitchkit` package itself ships pre-built; you consume `dist/`, not `src/`.
2402
+
2403
+ ### Runtime
2404
+
2405
+ The recommended target is **Bun ≥ 1.2** — `createServer` is `Bun.serve`. **Node
2406
+ ≥ 22** is also supported via `stitchkit/node` (see below).
2407
+
2408
+ ```ts
2409
+ createServer({
2410
+ services,
2411
+ port: Number(process.env.PORT ?? 3000),
2412
+ hostname: '0.0.0.0',
2413
+ })
2414
+ ```
2415
+
2416
+ `createServer` returns the `Bun.serve` instance — keep the reference if you need
2417
+ `.stop()` for a graceful shutdown.
2418
+
2419
+ ### Deploy on Node
2420
+
2421
+ The contract, `implement`, hooks, auth and the client are runtime-agnostic. Only
2422
+ the listener differs: replace `createServer` with **`serveNode`** (from
2423
+ `stitchkit/node`, built on `srvx`) — same `HandlerConfig`:
2424
+
2425
+ ```ts
2426
+ import { serveNode } from 'stitchkit/node'
2427
+
2428
+ serveNode({
2429
+ services,
2430
+ port: Number(process.env.PORT ?? 3000),
2431
+ })
2432
+ ```
2433
+
2434
+ Notes for a Node host:
2435
+
2436
+ - Add **`@types/bun`** as a dev dependency — it is an optional peer that types the
2437
+ shared `stitchkit/server` surface (without it `tsc` reports a missing `Bun`
2438
+ namespace).
2439
+ - **Socket.IO** attaches to the Node HTTP server via `serveNode({ socket })`, and
2440
+ on Node the default transport is `['websocket']` — set the client to match
2441
+ (`transports: ['websocket']`). See [realtime](./realtime.md).
2442
+ - **Bun-only helpers** do not run on Node: `serveFile` (uses `Bun.file`) and the
2443
+ raw WebSocket lane. `staticRoute` is runtime-neutral (`node:fs`) and works on
2444
+ both, but in production prefer a CDN / the static front-end below.
2445
+
2446
+ ### Production checklist
2447
+
2448
+ - **CORS** — set `cors.origin` to your real front-end origin(s). Do not ship
2449
+ `origin: '*'` with credentials.
2450
+ - **Logging** — `logging: true` for built-in request logs, or pass a
2451
+ `StitchLogger` to route them into your logging stack.
2452
+ - **Trace ids** — override `traceId` to reuse an id your platform already
2453
+ assigns, so request logs and application logs share one id.
2454
+ - **Rate limiting** — `createRateLimiter` in `onRequest` for a global limit;
2455
+ per-route limits belong in `beforeHandle`.
2456
+ - **Auth** — a `createAuthHook` `beforeHandle` guards every transport at once;
2457
+ do not re-check auth per handler.
2458
+ - **Errors** — handlers throw `AppError`; let the standard envelope render them.
2459
+ Add an `onError` hook only to integrate an error tracker.
2460
+ - **Secrets** — read them from the environment; never commit them.
2461
+
2462
+ ### The static front-end
2463
+
2464
+ stitchkit serves the API. A SPA front-end is built and hosted separately — a
2465
+ static host or CDN in production, its own dev server in development. The backend
2466
+ does not serve static files (`staticRoute` exists for the occasional asset, not
2467
+ a whole app). See [`packages/starter`](../../packages/starter) for the split.
2468
+
2469
+ ### MCP
2470
+
2471
+ If the app exposes MCP tools, `createMcpHandler` is mounted as a raw route
2472
+ (`/mcp`). It needs the `@modelcontextprotocol/sdk` peer installed in production —
2473
+ it is optional only for apps that do not use MCP.
2474
+
2475
+
2476
+ ==============================================================================
2477
+ # Guide: Multi-tenant (docs/guide/multi-tenant.md)
2478
+ ==============================================================================
2479
+
2480
+ # Multi-tenant / resource-scoped paths
2481
+
2482
+ A walkthrough of one mainstream scenario end-to-end: a SaaS API scoped by a path
2483
+ segment — `/tenants/:tenantId/widgets/...` — where the same `tenantId` gates
2484
+ access in auth and is injected into tool calls (one API key → many tenants).
2485
+
2486
+ Every piece below already exists; this recipe wires them into one flow. Each step
2487
+ links to its reference section.
2488
+
2489
+ ## 1. The contract — tenant-agnostic
2490
+
2491
+ Endpoints know nothing about the tenant; the path segment lives on the group, not
2492
+ the endpoint:
2493
+
2494
+ ```ts
2495
+ export const widgets = defineContract(
2496
+ { prefix: 'widgets', scope: 'tenant' },
2497
+ {
2498
+ list: { method: 'GET', path: '/', desc: 'List widgets', output: WidgetList },
2499
+ create: { method: 'POST', path: '/', desc: 'Create a widget', input: NewWidget, output: Widget },
2500
+ },
2501
+ )
2502
+ ```
2503
+
2504
+ ## 2. The server — a param prefix
2505
+
2506
+ Mount the service under a group whose `pathPrefix` carries `:tenantId`. The
2507
+ matched value is on the context root as `ctx.tenantId`
2508
+ ([details](./server.md#param-prefixes-resource-scoped-paths)):
2509
+
2510
+ ```ts
2511
+ createServer({
2512
+ groups: [
2513
+ { pathPrefix: '/tenants/:tenantId', services: [widgetsService], hooks: { beforeHandle: authHook } },
2514
+ ],
2515
+ })
2516
+ // → /tenants/:tenantId/widgets
2517
+ ```
2518
+
2519
+ With more than one scope, let **`scopePrefixes`** map `scope → prefix` and mount
2520
+ the flat `services` list — no hand-partitioning, the mapping lives in one place
2521
+ ([details](./server.md#scope-driven-mounting-scopeprefixes), → ADR 0024):
2522
+
2523
+ ```ts
2524
+ createServer({
2525
+ services, // mixed scopes, listed once
2526
+ scopePrefixes: { tenant: 'tenants/:tenantId', project: 'projects/:projectId' },
2527
+ hooks: { beforeHandle: authHook },
2528
+ })
2529
+ // `tenant`-scoped → /tenants/:tenantId/..., `project` → /projects/:projectId/..., the rest flat
2530
+ ```
2531
+
2532
+ Handlers read `ctx.tenantId` (a raw `string` — narrow it). To get it typed inside
2533
+ `ctx.params`, add `tenantId` to the endpoint's `params` schema (a `z.strictObject`
2534
+ that omits it will reject the request). When each scope guarantees different
2535
+ injected fields, call `createImplement<TenantCtx>()` / `createImplement<BaseCtx>()`
2536
+ **once per scope** and implement each contract with the matching factory — every
2537
+ handler is typed to its scope, with no superset context that lies about a
2538
+ `tenantId` a `public` handler never has.
2539
+
2540
+ ## 3. Auth — gate the tenant in the path
2541
+
2542
+ The `tenant` scope rule reads the prefix param and checks access; `inject` puts
2543
+ the identity and derived facts on `ctx`
2544
+ ([details](./auth-and-errors.md#resource-scoped-rule--reading-a-pathprefix-param)):
2545
+
2546
+ ```ts
2547
+ const authHook = createAuthHook<User>({
2548
+ resolve: sessionResolver,
2549
+ rules: {
2550
+ public: 'public',
2551
+ tenant: (user, ctx) => userCanAccessTenant(user.id, String(ctx.tenantId)),
2552
+ } satisfies Record<Scope, AuthRule<User>>,
2553
+ inject: (ctx, user) => { ctx.user = user },
2554
+ })
2555
+ ```
2556
+
2557
+ ## 4. The client — a per-tenant `pathPrefix`
2558
+
2559
+ `createClient`'s third argument prepends the tenant segment; `stripPrefixKeys`
2560
+ keeps `tenantId` out of the body/query
2561
+ ([details](./client.md#contractclientconfig--per-tenant--resource-scoped-clients)):
2562
+
2563
+ ```ts
2564
+ const widgetsApi = createClient(widgets, http, {
2565
+ pathPrefix: (args) => `tenants/${args.tenantId}/`,
2566
+ stripPrefixKeys: ['tenantId'],
2567
+ })
2568
+
2569
+ widgetsApi.list({ tenantId: 't_123' }) // GET /tenants/t_123/widgets
2570
+ ```
2571
+
2572
+ `stripPrefixKeys` (a `const` tuple) also makes the consumed keys **typed,
2573
+ required args** on every method — `tenantId` above is type-checked, not a runtime
2574
+ surprise — so no hand-written scoped-client wrapper is needed (→ ADR 0025).
2575
+
2576
+ ## 5. The AI surface — `extend` injects the tenant
2577
+
2578
+ One API key serves every tenant: the model passes `tenantId` per call, `resolve`
2579
+ validates it and puts it on `ctx` — so the *same* handler that reads `ctx.tenantId`
2580
+ over HTTP serves the tool call
2581
+ ([details](./mcp-and-agents.md#adding-tool-only-args--extend)):
2582
+
2583
+ ```ts
2584
+ createMcpHandler({
2585
+ serverInfo, auth,
2586
+ services: [widgetsService],
2587
+ lifecycle: { beforeHandle: authHook },
2588
+ extend: {
2589
+ schema: { tenantId: z.string().describe('Tenant to act on') },
2590
+ resolve: async ({ tenantId }) => ({ tenantId: String(tenantId) }),
2591
+ filter: (_s, m) => m.scope === 'tenant',
2592
+ },
2593
+ })
2594
+ ```
2595
+
2596
+ ## The through-line
2597
+
2598
+ `ctx.tenantId` is the single join point: the **prefix param** puts it there on
2599
+ HTTP, `extend`'s **resolve** puts it there on a tool call. Handlers and the auth
2600
+ rule read it the same way on both surfaces — one contract, every surface, no
2601
+ per-transport tenant plumbing.
2602
+
2603
+
2604
+ ==============================================================================
2605
+ # Guide: Upgrading (docs/guide/upgrading.md)
2606
+ ==============================================================================
2607
+
2608
+ # Upgrading stitchkit
2609
+
2610
+ How to move a consuming project from one stitchkit version to another — including
2611
+ across many versions at once (a project frozen on an old version, then jumped
2612
+ forward). The process is mechanical: stitchkit marks every breaking change in one
2613
+ place and one format, so you can recover the full migration from the version diff.
2614
+
2615
+ ## The one rule that makes this work
2616
+
2617
+ A release that breaks a public API leads its `CHANGELOG.md` entry with a
2618
+ **`### ⚠️ Breaking changes`** section (exact heading), each item carrying a
2619
+ **before → after** snippet. A version with **no** such section is **purely
2620
+ additive** — adopting it changes nothing in your code. (See
2621
+ [`AGENTS.md` → Breaking changes](../../AGENTS.md).)
2622
+
2623
+ So upgrading is: read the `### ⚠️ Breaking changes` of every version *above* your
2624
+ current one *up to* your target, and apply each snippet.
2625
+
2626
+ ## Flow (agent or human)
2627
+
2628
+ 1. **Find the current version.** In the consumer: the resolved `stitchkit` in
2629
+ `bun.lock` (authoritative), or `node_modules/stitchkit/package.json`. The range
2630
+ in `package.json` (`^0.6.0`) is intent, not the installed truth.
2631
+ > A `file:` link (`"stitchkit": "file:…"`) means the consumer tracks a **local
2632
+ > checkout**, not a published version — its effective version is whatever that
2633
+ > checkout's `package.json` says, and a plain `install` will not relink it after
2634
+ > the local version moves (`bun install --force` does). Prefer a real
2635
+ > `^x.y.z` range for reproducibility.
2636
+
2637
+ 2. **Pick the target.** Latest published (`bun pm view stitchkit version`) or a
2638
+ specific `x.y.z`.
2639
+
2640
+ 3. **Read the breaking sections in range.** In stitchkit's
2641
+ [`CHANGELOG.md`](../../CHANGELOG.md), for every version `> current` and
2642
+ `<= target`, read its `### ⚠️ Breaking changes`. Versions without that section
2643
+ are additive — skip them. (Fast scan: `grep -n "Breaking changes" CHANGELOG.md`.)
2644
+
2645
+ 4. **Apply each migration** — the before → after snippet tells you exactly what to
2646
+ change at each call site. There are no deprecation shims to lean on; the old
2647
+ shape is gone, so every site must move.
2648
+
2649
+ 5. **Bump and install.** `bun add stitchkit@<target>` (or update the range), then
2650
+ `bun install`. Note the caret: `^0.7.0` is `< 0.8.0`, so crossing a breaking
2651
+ minor is always an explicit version bump, never automatic.
2652
+
2653
+ 6. **Verify.** `bun run check` (or per-package typecheck) — TypeScript catches the
2654
+ removed/renamed/retyped surfaces. Then a **runtime smoke** (typecheck ≠
2655
+ runtime): bootstrap the server, one HTTP request, and any feature you rely on
2656
+ (Socket.IO connect, an MCP tool call, a multipart upload, …).
2657
+
2658
+ ## Worked example — frozen on 0.3, jumping to 0.7
2659
+
2660
+ 1. `bun.lock` → consumer resolves `stitchkit@0.3.x`.
2661
+ 2. Target: `0.7.0`.
2662
+ 3. Scan CHANGELOG `### ⚠️ Breaking changes` for 0.4.0 … 0.7.0 → **none** (every
2663
+ release was additive — new exports, an extra hook argument, opt-in fields).
2664
+ 4. Nothing to migrate.
2665
+ 5. `bun add stitchkit@^0.7.0`, `bun install`.
2666
+ 6. `bun run check` green → runtime smoke → done. New surfaces
2667
+ (`STITCH_ERROR_STATUS`, `serveFile`, `scopePrefixes`, `afterToolCall`'s
2668
+ `MethodDef`, `maxUploadBytes`) are available to adopt, not required.
2669
+
2670
+ ## When you author a breaking change in stitchkit
2671
+
2672
+ You are on the other side of this flow — see
2673
+ [`AGENTS.md` → Breaking changes & migration](../../AGENTS.md). In short: it is
2674
+ allowed; write the `### ⚠️ Breaking changes` block with a before → after snippet,
2675
+ bump the minor (pre-1.0), and migrate the controlled consumers in the same pass.
2676
+
2677
+
2678
+ ==============================================================================
2679
+ # API reference (docs/api/reference.md)
2680
+ ==============================================================================
2681
+
2682
+ # API reference
2683
+
2684
+ Every public export of stitchkit, grouped by entrypoint. Each entry links to the
2685
+ guide page that explains it in context. Types are marked _type_; everything else
2686
+ is a value (a function, a class, a constant).
2687
+
2688
+ The root `stitchkit` entrypoint is browser-safe; `stitchkit/server` and
2689
+ `stitchkit/tools` are server-only. See
2690
+ [Getting started → entrypoints](../guide/getting-started.md#entrypoints).
2691
+
2692
+ ---
2693
+
2694
+ ## `stitchkit`
2695
+
2696
+ The browser-and-server entrypoint. Re-exports everything from
2697
+ [`stitchkit/contract`](#stitchkitcontract), plus the client and browser realtime.
2698
+
2699
+ ### Client
2700
+
2701
+ | Export | Kind | Summary |
2702
+ |--------|------|---------|
2703
+ | `createClient` | function | build a typed client from a contract — [guide](../guide/client.md#createclient) |
2704
+ | `createClients` | function | build one typed client per contract from a registry |
2705
+ | `ClientConfig` | _type_ | config for `createClient`'s bare-fetch mode (2nd arg, no `HttpClient`) |
2706
+ | `ContractClientConfig` | _type_ | per-tenant / resource-scoped client config — dynamic `pathPrefix` + `stripPrefixKeys` ([guide](../guide/client.md#contractclientconfig--per-tenant--resource-scoped-clients)) |
2707
+ | `createHttpClient` | function | the Ky-based HTTP transport — [guide](../guide/client.md#createhttpclient) |
2708
+ | `ApiError` | class | a non-2xx response, with `code` / `status` / `details` / `hint` |
2709
+ | `HttpClient` | _type_ | the transport interface `createClient` builds on |
2710
+ | `HttpClientConfig` | _type_ | config for `createHttpClient` |
2711
+ | `RequestOptions` | _type_ | per-call options — params, timeout, response type |
2712
+ | `HeaderProvider` | _type_ | static or per-request headers |
2713
+ | `ApiEvent` | _type_ | a client event — `unauthorized` / `network_error` / `logout` |
2714
+ | `ApiEventListener` | _type_ | an `ApiEvent` handler |
2715
+
2716
+ ### Realtime (client) & streaming
2717
+
2718
+ | Export | Kind | Summary |
2719
+ |--------|------|---------|
2720
+ | `createSocketIOClient` | function | the typed Socket.IO client — [guide](../guide/realtime.md#client--createsocketioclient) |
2721
+ | `parseSSE` | function | parse an SSE `Response` into an async generator — [guide](../guide/client.md#sse) |
2722
+ | `SocketIOClient` | _type_ | the client handle |
2723
+ | `SocketIOClientConfig` | _type_ | config for `createSocketIOClient` |
2724
+ | `SocketEventMap` | _type_ | the shape of an event map |
2725
+ | `ParseSSEOptions` | _type_ | options for `parseSSE` |
2726
+
2727
+ ---
2728
+
2729
+ ## `stitchkit/contract`
2730
+
2731
+ The contract layer alone — browser-and-server safe. All of this is also exported
2732
+ from the root `stitchkit`.
2733
+
2734
+ ### Contract
2735
+
2736
+ | Export | Kind | Summary |
2737
+ |--------|------|---------|
2738
+ | `defineContract` | function | declare a contract — [guide](../guide/contracts.md#definecontract) |
2739
+ | `ALL_TRANSPORTS` | constant | `['HTTP', 'MCP', 'AGENT', 'CLI']` |
2740
+ | `ContractDef` | _type_ | a defined contract |
2741
+ | `ContractMeta` | _type_ | a contract's `prefix` + optional `scope` |
2742
+ | `EndpointDef` | _type_ | a single endpoint definition |
2743
+ | `HttpMethod` | _type_ | `GET \| POST \| PUT \| PATCH \| DELETE` |
2744
+ | `Transport` | _type_ | `HTTP \| MCP \| AGENT \| CLI` |
2745
+ | `TransportSource` | _type_ | `http \| mcp \| agent \| cli` — the value of `ctx.source` |
2746
+ | `RuntimeContext` | _type_ | the loose context seen by transport and hooks |
2747
+ | `HandlerContext` | _type_ | the typed context seen by a handler |
2748
+ | `EndpointFn` | _type_ | the call signature of one client method |
2749
+ | `TypedClient` | _type_ | the full typed client for a contract |
2750
+ | `TypedHttpClient` | _type_ | the typed client, HTTP endpoints only (`= ScopedHttpClient<C, unknown>`) |
2751
+ | `ScopedHttpClient` | _type_ | a client whose `stripPrefixKeys` become required args ([guide](../guide/multi-tenant.md)) |
2752
+ | `ScopedEndpointFn` | _type_ | one method's signature with the consumed keys folded in |
2753
+
2754
+ ### Errors
2755
+
2756
+ | Export | Kind | Summary |
2757
+ |--------|------|---------|
2758
+ | `AppError` | class | the framework error — `code` / `status` / `details` / `hint` |
2759
+ | `ErrorEnvelope` | _type_ | the JSON shape of an error response |
2760
+ | `notFound` | function | throw `404 NOT_FOUND` — [guide](../guide/auth-and-errors.md#throwing-errors) |
2761
+ | `badRequest` | function | throw `400 BAD_REQUEST` |
2762
+ | `unauthorized` | function | throw `401 UNAUTHORIZED` |
2763
+ | `forbidden` | function | throw `403 FORBIDDEN` |
2764
+ | `conflict` | function | throw `409 CONFLICT` |
2765
+ | `rateLimited` | function | throw `429 RATE_LIMITED` |
2766
+ | `appError` | function | throw an `AppError` for any code |
2767
+ | `STITCH_ERROR_STATUS` | const | `code → HTTP status` map for stitchkit's own error codes — [guide](../guide/auth-and-errors.md#stitch-codes-vs-your-codes) |
2768
+ | `StitchErrorCode` | _type_ | a code stitchkit itself emits (`keyof STITCH_ERROR_STATUS`) |
2769
+ | `isStitchErrorCode` | function | type guard — is a code one of stitchkit's own? |
2770
+
2771
+ ### Pagination
2772
+
2773
+ | Export | Kind | Summary |
2774
+ |--------|------|---------|
2775
+ | `paginatedSchema` | function | the `{ items, nextCursor }` Zod schema — [guide](../guide/contracts.md#pagination) |
2776
+ | `Paginated` | _type_ | the cursor-pagination envelope |
2777
+ | `encodeCursor` | function | encode a keyset value into an opaque `nextCursor` string (base64url, UTF-8-safe) |
2778
+ | `decodeCursor` | function | decode + Zod-validate a cursor back to its value (`null` if missing/invalid) |
2779
+
2780
+ ---
2781
+
2782
+ ## `stitchkit/server`
2783
+
2784
+ Server-only. Builds and runs the HTTP server, and carries the server primitives.
2785
+ Also re-exports the error helpers from `stitchkit/contract`.
2786
+
2787
+ ### Server & handlers
2788
+
2789
+ | Export | Kind | Summary |
2790
+ |--------|------|---------|
2791
+ | `createServer` | function | build the router and start `Bun.serve` — [guide](../guide/server.md#createserver) |
2792
+ | `createHandler` | function | the router as a bare `(req) => Response` — [guide](../guide/server.md#createserver) |
2793
+ | `implement` | function | bind a contract to typed handlers — [guide](../guide/server.md#implement) |
2794
+ | `createImplement` | function | fix the handler context type once |
2795
+ | `staticRoute` | function | a raw route that serves a directory |
2796
+ | `serveFile` | function | serve a file with `Range` / `304` / `HEAD` — [guide](../guide/server.md#serving-files--range-requests) |
2797
+ | `parseByteRange` | function | parse a single `Range` header → range / `unsatisfiable` / `null` |
2798
+ | `weakETag` | function | a weak `ETag` from size + mtime |
2799
+ | `ServeFileOptions` | _type_ | options for `serveFile` |
2800
+ | `ByteRange` | _type_ | an inclusive `{ start, end }` byte range |
2801
+ | `respondJson` | function | a raw route's JSON response (`204` for null/undefined) |
2802
+ | `errorResponse` | function | any thrown value → the framework error envelope + `x-request-id` |
2803
+ | `parseBody` | function | parse + Zod-validate a JSON body → `data` or `null` (no throw) |
2804
+ | `HandlerConfig` | _type_ | config for `createHandler` (runtime-agnostic) |
2805
+ | `BunServerConfig` | _type_ | config for `createServer` (Bun) |
2806
+ | `ServiceDef` | _type_ | the result of `implement` |
2807
+ | `MethodDef` | _type_ | one resolved endpoint inside a service |
2808
+ | `Handlers` | _type_ | the typed handler map `implement` expects |
2809
+ | `LifecycleHooks` | _type_ | `onRequest` / `beforeHandle` / `afterHandle` / `onError` |
2810
+ | `RouteGroup` | _type_ | a prefixed group of services with its own hooks |
2811
+ | `RawRoute` | _type_ | a non-contract `Request → Response` route |
2812
+ | `RawRouteContext` | _type_ | the routing context a raw handler receives |
2813
+ | `BunServer` | _type_ | the `Bun.serve` instance type |
2814
+ | `ServerPassthrough` | _type_ | extra `Bun.serve` options |
2815
+ | `StitchLogger` | _type_ | the custom-logger interface |
2816
+
2817
+ ### Auth
2818
+
2819
+ | Export | Kind | Summary |
2820
+ |--------|------|---------|
2821
+ | `createAuthHook` | function | a scope-enforcing `beforeHandle` hook — [guide](../guide/auth-and-errors.md#createauthhook) |
2822
+ | `createBearerResolver` | function | a bearer-token identity resolver |
2823
+ | `verifyJwt` | function | verify an HS256 JWT |
2824
+ | `extractToken` | function | read a bearer token from header or cookie |
2825
+ | `AuthHook` | _type_ | the hook `createAuthHook` returns |
2826
+ | `AuthHookConfig` | _type_ | config for `createAuthHook` |
2827
+ | `AuthRule` | _type_ | `'public' \| 'authenticated' \| predicate` |
2828
+ | `BearerResolverConfig` | _type_ | config for `createBearerResolver` |
2829
+ | `JwtPayload` | _type_ | a decoded JWT payload |
2830
+
2831
+ ### Cookies & CORS
2832
+
2833
+ | Export | Kind | Summary |
2834
+ |--------|------|---------|
2835
+ | `defineCookie` | function | a typed cookie `get` / `set` / `clear` handle — [guide](../guide/auth-and-errors.md#cookies) |
2836
+ | `parseCookies` | function | parse a `Cookie` header to a record |
2837
+ | `serializeCookie` | function | build a `Set-Cookie` value |
2838
+ | `corsHeaders` | function | compute CORS response headers |
2839
+ | `corsPreflightResponse` | function | build a preflight `Response` |
2840
+ | `CookieDef` | _type_ | the `defineCookie` handle |
2841
+ | `CookieOptions` | _type_ | cookie attributes |
2842
+ | `CorsConfig` | _type_ | CORS policy |
2843
+
2844
+ ### Realtime (server)
2845
+
2846
+ | Export | Kind | Summary |
2847
+ |--------|------|---------|
2848
+ | `createSocketIOServer` | function | the typed Socket.IO server — [guide](../guide/realtime.md#server--createsocketioserver) |
2849
+ | `SocketIOServerConfig` | _type_ | config for `createSocketIOServer` |
2850
+ | `SocketIOServerHandle` | _type_ | the `{ io, websocket, route }` handle |
2851
+ | `composeWebSocketHandlers` | function | compose one Bun `websocket` from N lanes — a raw binary lane beside Socket.IO ([guide](../guide/realtime.md#raw-binary-lane-bun)) |
2852
+ | `webSocketLane` | function | a typed, cast-free lane for `composeWebSocketHandlers` |
2853
+ | `socketIoLane` | function | the Socket.IO catch-all lane for `composeWebSocketHandlers` |
2854
+ | `ComposedLane` | _type_ | a lane bridged to the loose data type |
2855
+ | `WebSocketLane` | _type_ | a typed lane (`{ match, handlers }`) |
2856
+ | `WebSocketComposeConfig` | _type_ | server-wide tuning for the composed handler |
2857
+
2858
+ ### Primitives
2859
+
2860
+ | Export | Kind | Summary |
2861
+ |--------|------|---------|
2862
+ | `streamSSE` | function | an async generator → SSE `Response` — [guide](../guide/server.md#sse-streaming) |
2863
+ | `parseSSE` | function | parse an SSE `Response` (also on the root entrypoint) |
2864
+ | `parseMultipart` | function | parse a `multipart/form-data` request — [guide](../guide/server.md#multipart) |
2865
+ | `createRateLimiter` | function | token-bucket rate limiting — [guide](../guide/server.md#rate-limiting) |
2866
+ | `createCache` | function | an in-memory TTL cache |
2867
+ | `cacheHeaders` | function | build a `Cache-Control` header |
2868
+ | `createEventBus` | function | typed in-process pub/sub — [guide](../guide/server.md#event-bus) |
2869
+ | `generateTraceId` | function | a fresh trace id |
2870
+ | `resolveTraceId` | function | the default per-request trace-id resolver |
2871
+ | `extractIp` | function | the caller IP from a request |
2872
+ | `getClientInfo` | function | caller IP + user-agent |
2873
+ | `EventBus` | _type_ | the `createEventBus` handle |
2874
+ | `RateLimitConfig` | _type_ | config for `createRateLimiter` |
2875
+ | `ParseSSEOptions` | _type_ | options for `parseSSE` |
2876
+
2877
+ ### OpenAPI
2878
+
2879
+ | Export | Kind | Summary |
2880
+ |--------|------|---------|
2881
+ | `generateOpenApiDocument` | function | an OpenAPI 3.1 document from contract services — [ADR 0018](../decisions/0018-openapi-generation.md) |
2882
+ | `openApiRoute` | function | a `RawRoute` that serves the document as JSON |
2883
+ | `OpenApiConfig` | _type_ | config for `generateOpenApiDocument` |
2884
+ | `OpenApiDocument` | _type_ | the generated document |
2885
+ | `OpenApiInfo` | _type_ | the spec `info` block |
2886
+ | `OpenApiServer` | _type_ | a spec `servers` entry |
2887
+
2888
+ ---
2889
+
2890
+ ## `stitchkit/observability`
2891
+
2892
+ Server-only. The audit layer one level above the raw hooks — W3C trace context,
2893
+ an `AsyncLocalStorage` request context, payload sanitisation and a normalised
2894
+ audit event. See the [Observability guide](../guide/observability.md).
2895
+
2896
+ ### Audit
2897
+
2898
+ | Export | Kind | Summary |
2899
+ |--------|------|---------|
2900
+ | `createAuditHook` | function | wire both surfaces into one sink — [guide](../guide/observability.md#createaudithook) |
2901
+ | `RequestEvent` | _type_ | the normalised audit event handed to the sink |
2902
+ | `AuditConfig` | _type_ | config for `createAuditHook` |
2903
+ | `AuditHook` | _type_ | the `{ http, toolCall }` the hook returns |
2904
+
2905
+ ### Request context
2906
+
2907
+ | Export | Kind | Summary |
2908
+ |--------|------|---------|
2909
+ | `wrapInRequestContext` | function | run a fetch handler inside a request context — [guide](../guide/observability.md#request-context) |
2910
+ | `getRequestContext` | function | the active request context |
2911
+ | `getTraceId` | function | the active trace id — pass as `traceId` to `createServer` |
2912
+ | `getUserId` | function | the active user id, once auth has resolved it |
2913
+ | `setRequestUser` | function | attach the resolved user to the active context |
2914
+ | `setRequestError` | function | record the error outcome on the active context |
2915
+ | `runWithRequestContext` | function | run a function inside a given context |
2916
+ | `RequestContext` | _type_ | the per-request record |
2917
+
2918
+ ### Trace context
2919
+
2920
+ | Export | Kind | Summary |
2921
+ |--------|------|---------|
2922
+ | `resolveTraceContext` | function | the trace for a request — `traceparent` continued or fresh |
2923
+ | `parseTraceparent` | function | parse a `traceparent` header |
2924
+ | `formatTraceparent` | function | render a `traceparent` header value |
2925
+ | `createTraceContext` | function | a fresh root trace |
2926
+ | `childSpan` | function | a child span of a parent trace |
2927
+ | `TraceContext` | _type_ | `{ traceId, spanId, parentSpanId? }` |
2928
+
2929
+ ### Sanitisation
2930
+
2931
+ | Export | Kind | Summary |
2932
+ |--------|------|---------|
2933
+ | `sanitizePayload` | function | redact secrets and cap size — [guide](../guide/observability.md#sanitisation) |
2934
+ | `redact` | function | mask secret-named keys, drop binary blobs |
2935
+ | `truncatePreview` | function | cap a value by serialised size |
2936
+ | `measureSize` | function | item count + byte size of a result |
2937
+ | `JsonValue` | _type_ | a JSON-serialisable value |
2938
+ | `SanitizeOptions` | _type_ | tuning for `redact` / `sanitizePayload` |
2939
+ | `SizeMeasure` | _type_ | the result of `measureSize` |
2940
+
2941
+ ---
2942
+
2943
+ ## `stitchkit/tools`
2944
+
2945
+ Server-only. Turns contracts into MCP and AI-agent tools. Needs the
2946
+ `@modelcontextprotocol/sdk` peer (for MCP) and the `ai` peer (for agents).
2947
+
2948
+ | Export | Kind | Summary |
2949
+ |--------|------|---------|
2950
+ | `createMcpHandler` | function | a complete Streamable-HTTP MCP server — [guide](../guide/mcp-and-agents.md#mcp--createmcphandler) |
2951
+ | `createStdioMcpServer` | function | a complete stdio MCP server — [guide](../guide/mcp-and-agents.md#mcp-over-stdio--createstdiomcpserver) |
2952
+ | `buildMcpServer` | function | build an `McpServer` from contracts — the transport-neutral core |
2953
+ | `mountMcp` | function | add contract tools to an existing `McpServer` — [guide](../guide/mcp-and-agents.md#mountmcp) |
2954
+ | `implementRemote` | function | bind a contract to a remote HTTP API — [guide](../guide/mcp-and-agents.md#proxying-a-remote-api--implementremote) |
2955
+ | `mountAgent` | function | a Vercel AI SDK `ToolSet` from a service — [guide](../guide/mcp-and-agents.md#ai-agents--mountagent) |
2956
+ | `createCli` | function | a command-line program from contracts — [guide](../guide/cli.md) (also on `stitchkit/cli`) |
2957
+ | `createToolkit` | function | context-typed tool mounts — [guide](../guide/cli.md#typed-context) |
2958
+ | `mountViewFile` | function | a native multimodal "view file" MCP tool |
2959
+ | `resolveMedia` | function | resolve a media reference for a tool result |
2960
+ | `validateMcpSchemas` | function | assert every tool schema is JSON Schema-compatible — [guide](../guide/mcp-and-agents.md#incompatible-schemas--onincompatibleschema) |
2961
+ | `McpHandlerConfig` | _type_ | config for `createMcpHandler` |
2962
+ | `StdioMcpServerConfig` | _type_ | config for `createStdioMcpServer` |
2963
+ | `McpServerBuildConfig` | _type_ | shared config for `buildMcpServer` |
2964
+ | `ImplementRemoteOptions` | _type_ | options for `implementRemote` |
2965
+ | `McpMountConfig` | _type_ | config for `mountMcp` |
2966
+ | `AgentMountConfig` | _type_ | config for `mountAgent` |
2967
+ | `AgentContext` | _type_ | the context merged into agent tool handlers |
2968
+ | `CliConfig` | _type_ | config for `createCli` |
2969
+ | `CliWaitConfig` | _type_ | `--wait` polling config |
2970
+ | `ExitCodeMap` | _type_ | `ToolResult.code` → process exit code |
2971
+ | `Toolkit` | _type_ | the context-pinned tool surface from `createToolkit` |
2972
+ | `ToolExtend` | _type_ | extra-args extension for `mountMcp` / `mountAgent` |
2973
+ | `ToolLifecycle` | _type_ | `beforeHandle` / `afterHandle` gate for tool calls — [guide](../guide/mcp-and-agents.md#guarding-tools--lifecycle) |
2974
+ | `ToolCallHooks` | _type_ | `beforeToolCall` / `afterToolCall` observability hooks |
2975
+ | `ToolResult` | _type_ | the result of one tool call |
2976
+ | `IncompatibleSchemaPolicy` | _type_ | `'throw' \| 'skip' \| 'warn'` |
2977
+ | `McpMediaContent` | _type_ | a multimodal MCP content item |
2978
+
2979
+ ---
2980
+
2981
+ ## `stitchkit/cli`
2982
+
2983
+ Server-only. Turns contracts into a command-line program — the fourth transport.
2984
+ Light by design: needs neither the MCP SDK nor the `ai` peer.
2985
+
2986
+ | Export | Kind | Summary |
2987
+ |--------|------|---------|
2988
+ | `createCli` | function | build and run a CLI from contracts — [guide](../guide/cli.md) |
2989
+ | `parseCliArgs` | function | argv → typed tool args against a schema (advanced) |
2990
+ | `pollUntilDone` | function | the generic `--wait` poller (advanced) |
2991
+ | `emitResult` | function | write a `ToolResult` to stdout/stderr + exit code (advanced) |
2992
+ | `DEFAULT_EXIT_CODES` | const | the default `ToolResult.code` → exit-code map |
2993
+ | `CliConfig` | _type_ | config for `createCli` |
2994
+ | `CliRunOptions` | _type_ | parsed global flags (`--json`, `--wait`, …) |
2995
+ | `ParsedCliArgs` | _type_ | result of `parseCliArgs` |
2996
+ | `CliWaitConfig` | _type_ | per-command `--wait` polling config |
2997
+ | `ExitCodeMap` | _type_ | `ToolResult.code` → process exit code |
2998
+ | `PollParams` | _type_ | params for `pollUntilDone` |
2999
+ | `CliWriters` | _type_ | stdout/stderr sinks for `emitResult` |
3000
+ | `EmitOptions` | _type_ | options for `emitResult` |
3001
+
3002
+ ---
3003
+
3004
+ ## `stitchkit/react`
3005
+
3006
+ Browser-only. The React data-layer helpers. Needs the `@tanstack/react-query`
3007
+ and `react-query-kit` peers.
3008
+
3009
+ | Export | Kind | Summary |
3010
+ |--------|------|---------|
3011
+ | `createCursorQuery` | function | a cursor-paginated infinite query — [guide](../guide/client.md#cursor-pagination) |
3012
+ | `createCacheBridge` | function | sync socket events into the Query cache — [guide](../guide/realtime.md#cache-bridge) |
3013
+ | `CursorQueryConfig` | _type_ | config for `createCursorQuery` |
3014
+ | `CacheBridge` | _type_ | the `createCacheBridge` handle |
3015
+ | `CacheBridgeConfig` | _type_ | config for `createCacheBridge` |
3016
+ | `CacheBridgeContext` | _type_ | the `ctx` a bridge handler receives |
3017
+ | `CacheBridgeHandler` | _type_ | one event-to-cache handler |
3018
+ | `CacheBridgeHandlers` | _type_ | the handler map |
3019
+ | `CacheBridgeSocket` | _type_ | the minimal emitter a bridge accepts |
3020
+
3021
+ ---
3022
+
3023
+ For the rationale behind these APIs — why `Bun.serve` and not a framework, why
3024
+ two context types, why thin wrappers — see the
3025
+ [Architecture Decisions](../decisions/).