Spec: @kaged/wire
- Status: Draft
- Last amended: 2026-06-13 (amendment 2: conditional handler ctx, query slot, status vocabulary, cookies capability, redirect handling)
- Constrained by: ADR-0039, ADR-0002, ADR-0003, ADR-0006
- Implements:
packages/wire/
Purpose
@kaged/wire is the single source of truth for all operator-facing wire shapes — HTTP request/response bodies, error envelopes, and WebSocket messages — between the kaged daemon and any consumer (operator UI, guest UI, CLI, external API clients).
This package is normative for:
- Every JSON request body the daemon accepts.
- Every JSON response body the daemon returns.
- Every WebSocket frame and event that flows over
kaged.v1. - The error envelope shape and error code vocabulary.
- The pagination envelope shape.
It is not normative for:
- Protocol-level semantics (auth modes, CSRF, versioning policy, WS ordering/reconnect) — that remains in
http-api.md. - Internal daemon types (handler contexts, storage rows, state machine states).
- Internal UI types (component props, store shapes, query keys).
Constraints (from ADRs)
| Constraint | Source |
|---|---|
| Wire shapes defined exactly once as Zod schemas; daemon and UI consume them | ADR-0039 |
Runtime dependency is zod only; no Bun/Node built-ins, no daemon/UI imports |
ADR-0039 |
Package must bundle for the browser unmodified; "sideEffects": false |
ADR-0039 |
Types are z.infer only — no hand-written wire types anywhere in the repo |
ADR-0039 |
Contract map keyed by "METHOD /api/v1/..." strings matching the daemon's route registry |
ADR-0039 |
Package structure
packages/wire/
├── package.json
├── tsconfig.json
└── src/
├── index.ts # Re-exports all public API
├── common.ts # Error envelope, pagination, shared scalars
├── ws.ts # WebSocket frame, channels, event/output types
├── sessions.ts # Session CRUD, messages, checkpoints, runs, compaction
├── projects.ts # Project CRUD, status, capabilities, DSL, files, prompts
├── local.ts # Preferences, providers, aliases, spend limits, model meta
├── plugins.ts # Plugin CRUD, config, knobs
├── tasks.ts # Task definitions, instances, run/stop/restart
├── workflows.ts # Workflow catalog, invocations, steps (operator + guest)
├── issues.ts # Issues, updates, todos, links (operator + guest)
├── users.ts # User management, grants, activity, account, auth
├── guests.ts # Guest identity, login, setup, project views
├── system.ts # Health, readiness, status, audit, logs, DSL validation
└── contract.ts # The contract map: route key → schema entry
Schema conventions
Naming
- Schema variables:
PascalCase+Schemasuffix — e.g.,SessionSummarySchema,ApiErrorSchema. - Inferred types:
PascalCasewithout suffix — e.g.,type SessionSummary = z.infer<typeof SessionSummarySchema>. - Both the schema and the inferred type are exported from the domain module.
Null vs undefined
- Wire fields that may be absent use
z.optional()(key omitted from JSON). - Wire fields that are present but valueless use
z.null()(key present, valuenull). - This matches the existing daemon convention in
http-api.md.
Timestamps
- All
*_atfields arez.number()(epoch milliseconds), per the wire conventions inhttp-api.md.
Contract map
The contract map is a plain object keyed by route strings in the format "METHOD /path". Each entry carries:
interface ContractEntry {
summary: string;
params?: z.ZodType; // path parameter validation (e.g. { id: Ulid })
query?: z.ZodType; // URL query parameters (e.g. { cursor, limit })
request?: z.ZodType; // request body; absent for GET/DELETE with no body
// Success response — exactly one of these two forms:
status?: 200 | 201 | 202 | 204; // single-status; default 200
response?: z.ZodType; // response body; absent for 204
// OR multi-status (mutually exclusive with status/response):
responses?: Partial<Record<200 | 201 | 202 | 204 | 302, z.ZodType | null>>;
// Capabilities — opt-in per route:
cookies?: true; // handler receives CookieJar in ctx
}
Field semantics:
params: Path parameter schema. See §Path parameter schemas.query: Query parameter schema. See §Query parameter schemas. Absence means the handler cannot access query parameters — the dispatcher does not pass the URL.request: Request body schema. Absence means no body parsing.status: HTTP success status code for single-status routes. Default200. A route withstatus: 204must not have aresponsefield — the handler returnsvoid.response: Response body schema for single-status routes. Absence with no explicitstatusmeans the handler returnsvoidand the dispatcher emits204 No Content.responses: Multi-status response map. Keys are HTTP status codes, values are body schemas (z.ZodType) ornullfor no-body statuses (204, 302). The handler returns{ status, body }as a discriminated union. Mutually exclusive withstatus/response.cookies: Whentrue, the handler receives a write-onlyCookieJaron its context. See §Cookie capability. Routes withoutcookies: truephysically cannot set cookies.
Form validation: A contract entry uses exactly one of two forms:
- Single-status (default): optional
status+ optionalresponse. The vast majority of routes. - Multi-status:
responsesmap. Used when one route returns different success shapes (e.g., 200 dry-run vs 202 accepted, or 200 JSON vs 302 redirect).
The contract map is exported as CONTRACT from contract.ts and re-exported from index.ts.
Path parameter schemas
Routes with path parameters (:id, :uid, :name, etc.) carry a params schema that validates the extracted URL segments before the handler runs. This turns malformed IDs into clean 400 bad_request errors at the routing edge instead of letting garbage values propagate to storage where they surface as misleading 404s.
Common param shapes live in common.ts:
// 26-char Crockford base32 ULID
export const UlidSchema = z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/i);
A route's params schema is a z.object() keyed by the parameter names from its path pattern:
"GET /api/v1/projects/:slug": {
summary: "Get project",
params: z.object({ id: UlidSchema }),
response: ProjectDetailSchema,
},
"GET /api/v1/sessions/:id/runs/:rid": {
summary: "Get run",
params: z.object({ id: UlidSchema, rid: UlidSchema }),
},
The router validates params immediately after segment matching. A failed parse short-circuits to 400 bad_request with the Zod issues in details. Handlers receive the parsed params object (typed per-route via ParamsOf<R>), not raw Record<string, string>.
Not all path parameters are ULIDs. Plugin names, alias names, file paths, and similar string-keyed params use appropriate schemas (z.string().min(1), etc.). The contract entry is the authority for what shape each param takes.
Query parameter schemas
Routes that read URL query parameters carry a query schema. The dispatcher parses URLSearchParams against it before the handler runs, so query access is contract-gated: a handler without a query schema physically cannot read the URL.
Coercion rules (query values are always strings on the wire):
- Numbers: Use
z.coerce.number().int()for pagination limits/offsets andz.coerce.number()for timestamps. The dispatcher passes the raw string to Zod; coercion handles the conversion. - Booleans: Use
z.enum(["true", "false"]).transform(v => v === "true"). Do not usez.coerce.boolean()— it treats any non-empty string astrue. - Optional params: Use
.optional()or.default(value). A query key absent from the URL isundefinedin the parsed object. - Repeated keys: Forbidden. The dispatcher takes the first value for each key. If an endpoint needs array-valued query params, use comma-separated syntax (e.g.,
?ids=a,b,c) and parse within the schema via.transform(v => v.split(",")).
Common query shapes (reusable across routes):
// Cursor-based pagination — used by ~15 list endpoints
export const PaginationQuerySchema = z.object({
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(500).optional(),
});
// Log query — used by 3 log endpoints
export const LogQuerySchema = PaginationQuerySchema.extend({
level: z.enum(["debug", "info", "warn", "error"]).optional(),
source: z.enum(["daemon", "plugin", "session", "subagent"]).optional(),
since: z.coerce.number().optional(),
until: z.coerce.number().optional(),
q: z.string().optional(),
});
Example contract entries with query schemas:
"GET /api/v1/projects/:slug/sessions": {
summary: "List sessions",
params: z.object({ id: UlidSchema }),
query: PaginationQuerySchema,
response: PaginatedResponseSchema(SessionSummarySchema),
},
"GET /api/v1/users/lookup": {
summary: "Lookup user identities by id",
query: z.object({
ids: z.string().transform(v => v.split(",").map(s => s.trim()).filter(s => s.length > 0)),
}),
response: PaginatedResponseSchema(UserLookupItemSchema),
},
Status code vocabulary
Success status codes are declared in the contract, not inferred by convention. The dispatcher reads the declared status and emits it — no handler involvement.
Single-status routes (the default):
status |
response |
Handler returns | Dispatcher emits |
|---|---|---|---|
| (absent) | present | z.infer<response> |
200 + JSON body |
| (absent) | absent | void |
204 No Content |
201 |
present | z.infer<response> |
201 + JSON body |
202 |
present | z.infer<response> |
202 + JSON body |
204 |
(must be absent) | void |
204 No Content |
Example:
"POST /api/v1/projects/:slug/sessions": {
summary: "Create session",
params: z.object({ id: UlidSchema }),
status: 201,
response: SessionSummarySchema,
},
"POST /api/v1/projects/:slug/tasks/run": {
summary: "Run task",
params: z.object({ id: UlidSchema }),
status: 202,
response: TaskInstanceSchema,
},
Multi-status routes (exception form):
Used when a single route returns different success shapes depending on runtime conditions. The responses map replaces status/response:
"POST /api/v1/sessions/:id/compact": {
summary: "Compact session context",
params: z.object({ id: UlidSchema }),
responses: {
200: PostCompactDryRunResponseSchema, // dry-run preview
202: PostCompactAcceptedResponseSchema, // compaction accepted
},
},
"GET /api/v1/launch": {
summary: "Loopback mode launch",
query: z.object({ token: z.string() }),
cookies: true,
responses: {
200: LaunchResponseSchema, // JSON mode (Accept: application/json)
302: null, // redirect mode (Location: /)
},
},
For multi-status routes, the handler returns { status, body }:
// handleLaunch
if (acceptsJson) return { status: 200, body: { ok: true, csrf_token: csrfToken } };
ctx.cookies.set("kaged_session", token, { httpOnly: true, ... });
return { status: 302, body: { location: "/" } };
The dispatcher inspects status to select the response schema for dev-mode validation and to emit the correct HTTP status code. For 302, the dispatcher reads body.location and emits a Location header with an empty body.
Cookie capability
Cookie setting is a declared capability, not a handler escape hatch. Only routes with cookies: true in their contract entry receive a CookieJar on their handler context. Routes without it physically cannot set cookies — the property does not exist on their typed context.
CookieJar is a write-only collector provided by the dispatcher:
interface CookieJar {
set(name: string, value: string, opts?: CookieOptions): void;
clear(name: string, opts?: CookieOptions): void;
}
interface CookieOptions {
httpOnly?: boolean;
secure?: boolean;
sameSite?: "strict" | "lax" | "none";
path?: string;
maxAge?: number;
}
The dispatcher merges the collected cookies into Set-Cookie headers on whatever Response it builds. The handler's return type is unaffected — body validation runs unconditionally.
Routes requiring cookies (exhaustive as of this amendment):
| Route | Cookies set |
|---|---|
GET /api/v1/me |
kaged_csrf |
GET /api/v1/launch |
kaged_session, kaged_csrf |
POST /api/v1/auth/sso |
kaged_user_session, kaged_csrf |
POST /api/v1/g/login |
kaged_user_session |
POST /api/v1/auth/logout |
clear kaged_user_session |
POST /api/v1/g/logout |
clear kaged_user_session |
POST /api/v1/account/password |
kaged_user_session (refresh) |
POST /api/v1/g/account/password |
kaged_user_session (refresh) |
Redirect handling
Redirects are a response kind, modeled in the responses map — not a handler escape hatch. A 302 entry in responses has a null schema (no body). The handler returns { status: 302, body: { location: string } }, and the dispatcher emits a Location header with an empty HTTP body.
This keeps redirects typed, visible in the contract, and present in generated documentation. The only route currently using redirects is GET /api/v1/launch (conditional on Accept header).
Error boundary
Error responses stay outside the per-route typing entirely. Errors are the one global envelope, produced only by ApiError → dispatcher mapping. The per-route errors field (deferred to contract enrichment) is documentation metadata for the generated reference, not typed handler returns.
The moment error shapes enter responses, every handler return becomes a union with its failure modes — checked exceptions with worse ergonomics. Success shapes are per-route; failure shape is protocol.
WebSocket protocol
The WS protocol schemas are exported from ws.ts:
WsFrameSchema— the envelope:{ channel, seq, type, payload }.WsChannel—"control" | "output" | "pty" | "events" | "system".- Per-channel discriminated unions for client→server and server→client message types.
WsCloseCode— the close reason vocabulary.- Constants:
WS_SUBPROTOCOL,WS_BUFFER_LIMITS,PTY_DISCRIMINATOR.
Testing strategy
Tests live in packages/wire/__tests__/. The package is tested with bun test.
Required tests
- Schema round-trip: Every exported schema
parse()s at least one valid example and rejects at least one invalid example. - Contract map completeness: Every route in the daemon's
routes.tshas a corresponding contract entry. Every contract entry has a matching route. This is a single generic test, not per-endpoint. - Type inference: Spot-check that
z.inferproduces the expected shape (compile-time, enforced bysatisfies). - No hand-written wire types: A grep-based test ensures no
packages/daemon/orpackages/ui/file declares types in the wire namespace (deferred to integration phase).
Daemon enforcement
Shared types are necessary but not sufficient. The daemon must enforce the contract at compile time so that mismatches between the contract map and handler implementations are caught before code ships — not at runtime, not by convention.
This section specifies four mechanisms that, together, make the contract load-bearing.
1. Raw routes
Not every daemon endpoint carries a JSON body contract. Protocol-level routes — health probes, WebSocket upgrades, SSE streams, binary responses — are explicitly outside the contract map.
Raw routes are enumerated in a separate, typed list exported from @kaged/wire:
export const RAW_ROUTES = [
"GET /healthz",
"GET /readyz",
"GET /api/v1/sessions/:id/socket",
"GET /api/v1/socket",
"GET /api/v1/projects/:slug/lsp/socket",
"GET /api/v1/projects/:slug/tasks/socket",
"GET /api/v1/projects/:slug/logs/stream",
"GET /api/v1/g/sessions/:id/socket",
"GET /api/v1/users/:uid/avatar",
] as const;
Raw routes have a params schema (validated identically to contract routes) but no request/response schemas. Their handlers receive Request and return Response directly — they are exempt from the dispatch() boundary.
The set of raw routes is closed: any /api/v1 endpoint not in RAW_ROUTES must appear in CONTRACT. A compile-time or test-time check enforces this — there is no third category.
A route belongs in RAW_ROUTES when it genuinely has no JSON contract shape — binary responses (avatar), protocol upgrades (WebSocket), streaming (SSE), or health probes. If you find yourself moving a route to raw because its response is awkward rather than because it isn't HTTP-JSON at all, that's the smell.
2. Conditional handler context
The handler's context type is built from what the contract entry declares. Absent slots don't exist — they are compile errors, not undefined values the handler must remember not to touch.
type BaseHandlerCtx = {
identity: OperatorIdentity;
resp: ResponseContext;
ready: boolean;
deploymentMode: "user" | "system";
authMode: "secure" | "insecure";
sandboxMode: "enabled" | "disabled";
warnings: WarningContext;
storage: StorageAdapter | null;
broker: PtyBroker | null;
localConfigPath: string | null;
daemonHome: string | null;
pluginSupervisor: PluginSupervisor | null;
// ... other daemon-internal fields
};
type HandlerCtx<K extends RouteKey> = BaseHandlerCtx
& (Contract[K] extends { params: infer P extends z.ZodType }
? { params: z.infer<P> } : {})
& (Contract[K] extends { query: infer Q extends z.ZodType }
? { query: z.infer<Q> } : {})
& (Contract[K] extends { request: infer B extends z.ZodType }
? { body: z.infer<B> } : {})
& (Contract[K] extends { cookies: true }
? { cookies: CookieJar } : {});
Consequences:
ctx.bodyon a GET route with norequestschema → compile error.ctx.queryon a route with noqueryschema → compile error.ctx.cookieson a route withoutcookies: true→ compile error.ctx.params.idon a route whoseparamsschema has noidfield → compile error.
The dispatcher is the only code that constructs HandlerCtx<K>. It populates each conditional slot if and only if the contract entry declares it.
3. Mapped-type handler registry
The daemon's handler object is a mapped type over the contract, not a Map<string, Handler>. The type system enforces that every contract route has exactly one handler, and that every handler's signature matches its contract entry:
// Single-status route handler — returns the body directly (or void)
type SingleStatusHandler<R extends RouteKey> =
(ctx: HandlerCtx<R>) => Promise<ReplyOf<R>> | ReplyOf<R>;
// Multi-status route handler — returns { status, body }
type MultiStatusHandler<R extends RouteKey> =
(ctx: HandlerCtx<R>) => Promise<MultiReplyOf<R>> | MultiReplyOf<R>;
// Per-route handler type — selected by contract form
type ContractHandler<R extends RouteKey> =
Contract[R] extends { responses: infer M }
? MultiStatusHandler<R>
: SingleStatusHandler<R>;
type ContractHandlers = {
[R in RouteKey]: ContractHandler<R>;
};
Where the utility types are:
ReplyOf<R>— for single-status routes:z.inferof the route'sresponseschema, orvoidif absent (204).MultiReplyOf<R>— for multi-status routes: a discriminated union{ status: S; body: z.infer<responses[S]> }for each status in theresponsesmap. Fornull-schema statuses (204, 302),bodycarries the appropriate metadata (e.g.,{ location: string }for 302).
The handler object is declared with satisfies ContractHandlers. Consequences:
- Deleting a contract entry breaks compilation — the handler object has an excess property.
- Adding a contract entry without a handler breaks compilation — the mapped type requires it.
- Changing a response schema breaks the handler if its return type no longer matches.
- Changing a params/query schema breaks the handler if it accesses a field that no longer exists or has changed type.
- Adding
cookies: trueto a contract entry makesctx.cookiesavailable — but does not break existing handlers (it's additive). - Removing
cookies: truebreaks any handler that referencesctx.cookies.
Handlers never touch Request or Response. They receive typed context, return typed values.
4. Single dispatch() boundary
A single dispatch() function owns the HTTP Request → Response conversion for all contract routes. No handler constructs a Response. The flow:
Request
→ router.match(method, pathname)
→ params schema parse (400 on failure)
→ query schema parse from URLSearchParams (400 on failure)
→ body schema parse for routes with `request` (400 on failure)
→ build HandlerCtx<K> with parsed slots + CookieJar if declared
→ handler(ctx)
→ for multi-status: inspect returned { status, body }
→ response schema parse in dev mode (500 on mismatch)
→ build Response:
- single-status: jsonResponse(result, entry.status ?? 200, ctx.resp)
- multi-status: select by returned status (302 → Location header, etc.)
- void/204: new Response(null, { status: 204 })
→ merge CookieJar entries into Set-Cookie headers
→ Response
dispatch() responsibilities:
- Param validation: Parse
CONTRACT[key].paramsagainst the matched path segments. Reject with400 bad_requeston failure, Zod issues indetails. - Query validation: Parse
CONTRACT[key].queryagainstURLSearchParamsfrom the request URL. Reject with400 bad_requeston failure. Skip if noqueryschema declared. - Body parsing: For routes with a
requestschema, parseawait req.json()against it. Reject with400 validation_failedon failure. - Context construction: Build
HandlerCtx<K>with the parsedparams,query,body(only the slots the contract declares), plusCookieJarifcookies: true. - Handler invocation: Call
handlers[key](ctx). The handler's return type is enforced by the mapped type. - Response serialization:
- Single-status: Wrap the handler's return value in
jsonResponse(result, entry.status ?? 200, ctx.resp).void→204 No Content. - Multi-status: Read the returned
{ status, body }. For302, emitLocationheader + empty body. For others,jsonResponse(body, status, ctx.resp). Fornull-schema statuses, emitnew Response(null, { status }).
- Single-status: Wrap the handler's return value in
- Cookie merging: Collect
Set-Cookieheaders from theCookieJarand append to the response. - Dev-mode response validation: In development, parse the handler's return value against the appropriate schema (selected by status for multi-status routes). A mismatch logs a loud error and returns
500 internal. - Error handling: Handlers throw
ApiError(or anyError).dispatch()catches and converts to the error envelope.
Raw-route handlers bypass dispatch() entirely. They receive Request and return Response — the protocol-level boundary is theirs to own.
5. Acceptance criteria
The enforcement is complete when all five conditions hold:
- Exhaustiveness: Deleting any
CONTRACTentry causes a compile error in the handler object. Adding aCONTRACTentry without a corresponding handler causes a compile error. - Shape correlation: Changing any route's
response,request,params, orqueryschema causes a compile error in its handler if the handler's types no longer match. - No Response in handlers:
grep -r 'instanceof Response\|new Response\|return.*Response' packages/daemon/src/runtime/*-handlers.tsreturns empty. Onlydispatch(), raw-route handlers, and infrastructure (respond.ts) touchResponse. - Closed route set: Every route in the daemon's route registry is either in
CONTRACTor inRAW_ROUTES. A test asserts the union is exact — no route exists in neither, no route exists in both. - Capability isolation:
grep -r 'Set-Cookie\|CookieJar\|\.cookies\.' packages/daemon/src/runtime/*-handlers.tsreturns matches only in handlers whose contract entry hascookies: true. No handler can set cookies without declaring the capability.
Future work (explicitly deferred)
- UI typed fetcher: Generating the UI's HTTP client from the contract map. Deferred to a follow-up PR.
- Doc generation: Deterministic reference generator from
@kaged/wireschemas. Deferred to a follow-up PR. - OpenAPI export:
z.toJSONSchema()for external consumers. Deferred until a consumer exists. - Contract enrichment: Adding
auth,errors,descriptionfields toContractEntryfor richer documentation and client generation. Deferred to a follow-up PR.
References
- ADR-0039 — the decision that creates this package
- ADR-0002 — HTTP+WS is the load-bearing transport
- ADR-0003 — doc-first; this spec precedes code
http-api.md— protocol semantics (auth, CSRF, versioning, WS reconnect)