openapi
Every HTTP-serving daemon exposes its API at GET /openapi.json. The document is generated from the resource registry — components/schemas comes from resreg.Resource.RowType reflection, and the paths come from the resource's Endpoints, the same slice the daemon mounts its handlers from. Doc and handler read one declaration, so they move together.
What you get
OpenAPI 3.1 JSON. Public — no auth gate. Cached for the process lifetime; reflection runs once per daemon. Paste a URL into any conformant tool (Swagger UI, Redoc, Stoplight Elements, an SDK generator) and the daemon's surface is browsable, callable, and codegen-ready.
Endpoints by daemon
| Daemon | URL | Owned resources |
|---|---|---|
routd |
https://<host>/openapi.json via proxyd, or http://routd:8080/openapi.json intra-container |
routes, web_routes, acl, secrets (write-only), route_tokens, installed_packages (read-only), scheduled_tasks (at /v1/tasks), groups (read-only) |
proxyd |
http://proxyd:8080/openapi.json |
proxyd_routes |
onbod |
http://onbod:8080/openapi.json |
onboarding, onboarding_gates, invites |
webd |
http://webd:8080/openapi.json |
none — forwards proxyd_routes CRUD to proxyd; doc is informational |
dashd |
http://dashd:8080/openapi.json |
none — HTMX operator UI; cold-tier CRUD lives in routd |
timed |
http://timed:8080/openapi.json |
none — routd owns scheduled_tasks; timed drives the fire loop as a client of it |
authd |
http://authd:8080/openapi.json |
audit, signing_keys (read-only), sessions (read + revoke) — its own audit trail, signing-key metadata, and refresh-token families; the token endpoints themselves are hand-rolled and carry no row schema |
runed |
http://runed:8080/openapi.json |
audit (read-only) — its own audit trail; runs and holds stay hot-tier |
litellm |
http://litellm:4000/openapi.json |
none of arizuko’s — upstream litellm’s own FastAPI document for the model gateway package; not engine-generated, not gated by the resource registry |
Daemons that own no resources still emit a valid OpenAPI document so external tooling can introspect them uniformly. The schemas + paths blocks are empty but info, servers, and the standard components/responses (400, 401, 403, 404, 409, 500) are present.
routd's own /openapi.json lists the nine resources above — sixteen paths — and only those, because it advertises only what it actually mounts. An endpoint in the document is an endpoint you can call; there is no separate list to forget to update. Four of the eight are narrower than full CRUD, and deliberately so:
secretsis write-only — it declares a create and a key-delete and no read op at all, so a sealed value cannot leak through a conventionGET.installed_packagesis read-only — twoGETs and nothing else. Installing a package writes host files and restarts sidecars; that pipeline is thearizuko packagesCLI, and a REST create would be a second path that drifts from it.groupsis read-only — oneGET /v1/groups. The agent gets aregister_grouptool, but creating a group also writes a route and a git-init'd directory on disk, so that stays dashd's job and no daemon servesPOST /v1/groups.auditis read-only, and more strictly so than the rest — oneGET /v1/audit, no read-one. The log is append-only and each row is written inside the transaction of the act it records, so a create or delete would let a caller forge or erase the record of something that did or did not happen. There is no/v1/audit/{id}becauseidis a per-database counter: the same number names a different row on each daemon. This is also the one resource three daemons serve — routd, runed and authd each own a table and each publish theirs, so/v1/auditalways means "this daemon's log".signing_keysandsessionsare authd's, and narrower than they look.signing_keysis oneGETand nothing else: rotation rebuilds the running signer's in-memory key set, so a write straight at the table would leave the daemon disagreeing with its own database.sessionsadds exactly one mutation,DELETE /v1/sessions/{family_id}, the operator revoke. Neither schema carries a private column — the key PEMs and the token hash are named in no query, no row struct and no document, so the strongest thing either can say is that a key or a session exists. Neither has an MCP face either: agents are scoped to a folder, and a signing key is instance-wide while a session is keyed by account, so there is no boundary that could contain an agent's reach over them.scheduled_tasksdeclares list, read, update and delete, but not schedule, pause or resume — those three are agent tools with no operator endpoint behind them (see the MCP reference). An operator pauses a task byPATCHing its status, or from the dashboard.
/v1/<name>: scheduled_tasks is served at /v1/tasks. timed already calls GET /v1/tasks and its fire loop shares the prefix (/v1/tasks/due, /v1/tasks/runlog, /v1/tasks/{id}/reschedule), all from a separate container that restarts on its own schedule — renaming half of a live control surface is the drift, not the fix. So this one is a deliberate exception, written down here so nobody files it as a bug. The name is still the identity everywhere else: the MCP tool prefix, the operationId, and the resource= column in the audit log all read scheduled_tasks. A new resource does not get the exception — it is served at /v1/<name>.Three more resources live in the same registry and drive the arizuko export/apply YAML surface (see CLI reference) without appearing in routd's REST document. network_rules is agent-MCP-only by design, so it has no REST face to advertise; acl_membership is dashd-FS-managed; proxyd_routes lives in routd's database but is served by proxyd, which advertises it in its own document.
How it works
Each cold-tier resource is one Go struct with db:, json:, and yaml: tags — the registry lives in routd. resreg.OpenAPI(daemon, baseURL, resources) walks the registered resources and emits:
- One
components.schemas.<Name>perRowType. Property name from thejson:tag; type inferred from the Go kind (string,integer,boolean, array, object).omitemptyremoves the field fromrequired. - One operation per declared
Endpoint— its real mounted verb and path. That is what lets a resource be write-only or read-only, or carry a custom verb likePOST /v1/route_tokens/chat, and still be described honestly. - A resource that declares no
Endpointsfalls back to the five-operation CRUD convention:GET /v1/<name>(list),GET /v1/<name>/{pk}(read one),POST /v1/<name>(create),PATCH /v1/<name>/{pk}(update),DELETE /v1/<name>/{pk}(delete). Composite PKs collapse to one URL parameter; the description flags the encoding so clients know to URL-encode separators. - Standard error responses (400, 401, 403, 404, 409, 500) defined once in
components.responsesand$ref-d from every operation. - One
servers[]entry frombaseURL.
Spec: specs/5/17-openapi-mcp.md, which owns OpenAPI emission. The row-schema half of resreg.Resource — RowType, Table, PKFields, Scope — belongs to specs/5/8-yaml-manifests.md.
The MCP twin: x-mcp-when
Each of these operations has a second face. The agent reaches the very same handler as an MCP tool named <resource>.<action> over its per-folder unix socket, with arguments reflected from the same RowType. One handler, one transaction, one audit row — two ways in.
What ties them together in the document is x-mcp-when: a vendor extension carrying the one-line description of when an agent should reach for this action. The same line is written twice — once as the operation's description, so a tool that has never heard of arizuko reads a normal OpenAPI 3.1 doc, and once as x-mcp-when, so an agent or a tool browser can pick out the agent-facing operations mechanically, without a second hand-written tool list to keep in sync.
curl -s https://<host>/openapi.json \
| jq '.paths | to_entries[] | .value | to_entries[]
| select(.value["x-mcp-when"]) | .value.operationId'
The rule is strict on purpose: an action with no agent-facing description gets neither an MCP tool nor an x-mcp-when annotation. That is how an operation is made REST-only — by saying nothing, rather than by maintaining an exclusion list. The reverse also holds: the hot-tier agent actions (reply, send, inspect_*) are MCP-only and appear in no OpenAPI document, because there is no operator endpoint to mirror — nobody administers a chat reply.
Browsing the spec
The JSON is a regular text file — curl + jq works fine for most lookups:
curl -s https://<host>/openapi.json | jq '.paths | keys'
curl -s https://<host>/openapi.json | jq '.components.schemas.Routes'
For an interactive renderer, point Swagger UI or Redoc at the URL. Neither is bundled with arizuko; pick the tool your operator workflow already uses.
SDK generation
Any OpenAPI 3.1 codegen tool (openapi-generator, swagger-codegen, oapi-codegen) can consume the spec to produce a typed client. Run the generator against the live daemon's URL or save the JSON and run offline. Re-run on every release — the spec moves with the struct, so clients stay current by re-generating.
Caveats
- The endpoint advertises the shape of the surface, not whether a given caller has permission to use it. Authorization happens in
auth.Authorizeper grants; the OpenAPI doc doesn't reflect per-caller scope. - Descriptions are minimal in v1 — types and paths are correct, but the prose is sparse. Iterate as needed.
- Composite-PK URL parameters collapse to one segment; the description explains the encoding. If your client library can't handle this, fall back to
POSTwith a body that names the PK fields.