==> read the decisions
ADR-0021: An API is described once, and its OpenAPI document is a build product
Status: Accepted (2026-10-15). Extends ADR-0014 (the repo layout is the contract) and ADR-0009 (a release). Implemented 2026-10-15 (the platform's API, the shared library, the release build).
Context
Section titled “Context”A route is described in several places today: its handler, the input checks inside it
(@cs/input's str() and int()), a comment, and whatever a reader reverse-engineers from the
code. There is no machine-readable description of the platform's API or of a user's project API,
so neither can be documented, tried, or handed to a program, without writing it again by hand --
and a second copy drifts.
Decision
Section titled “Decision”- A route carries its own description. In a user's service (
defineUserService), a route gains optional fields beside its handler:summary,description,input(body, query and path schemas),output,errors. The platform API's Hono routes carry the same, through a smalldescribed()helper over the shared module -- nothono-openapi: Zod 4 implements the Standard JSON Schema interface, so one dependency-free module validates and describes for both sides. - Schemas are Zod 4, which converts to JSON Schema natively, through the Standard Schema
interface. The shared host validates
inputbefore the handler runs, so a handler receives typed, checked data;@cs/inputbecomes a thin helper and then retires. - One shared module (
@cs/openapi) sets the conventions both sides use: the error shape{ error, code? }, the bearer security scheme, the server URLs, and how uploads (anapplication/octet-streambody with the route's size limit) are written. - WebSockets, which OpenAPI cannot describe, carry
messages: { in, out }schemas and appear in anx-cs-socketsextension. - The document is generated at build time.
mkRepowritesprojects/<slug>/openapi.json, and a Markdown rendering for language models, into the release beside the UI and migrations; the shared check validates it, so a broken description refuses the push. The platform's build does the same for its own API. - A project's overview is an optional
projects/<slug>/README.md, part of the layout contract. - Where the documents are served:
api.<domain>/openapi.json(the platform's, public) andapi.<domain>/p/<slug>/openapi.json(a project's, to its owner only), read from the live release by the reconciler like the UI path. - The frontend's client is typed from the same definitions:
projectApi()checks a call against the route it names, with no code generation.
Consequences
Section titled “Consequences”- Validation, types, the OpenAPI document, the documentation pages (ADR-0022) and
llms.txtcome from one definition; nothing about a route is written twice. - A project's description is versioned with its release and available when its service is down; the API never runs user code to describe it.
- Every user repo moves to schema-described routes; the shared library keeps the old form working until hasel and nikita are migrated.
Alternatives rejected
Section titled “Alternatives rejected”- Generating the document at runtime from the running service. It would mean asking user code to describe itself on request, and nothing would describe a service that is down.
- A hand-written OpenAPI file per project. The second copy that drifts.