openapi
Every HTTP-serving daemon exposes its API at GET /openapi.json. The resource registry generates the document. components/schemas comes from resreg.Resource.RowType reflection. 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.
Every HTTP daemon publishes an OpenAPI 3.1 document
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.
Each daemon owns its resources
| 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), chats, acl_membership, pending_actions, dead_letters, github_sources, gdrive_files, audit, network_rules (MCP only) |
proxyd |
http://proxyd:8080/openapi.json |
proxyd_routes |
onbod |
http://onbod:8080/openapi.json |
onboarding, onboarding_gates, invites, audit |
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 resources above and only those, because it advertises only what it mounts. An endpoint in the document is an endpoint you can call; there is no separate list to forget to update. Several 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. Syncing packages writes host files and generates compose; that pipeline is thearizuko packageCLI, and a REST create would be a second path that drifts from it.groupsis read-only —GET /v1/groupslists groups andGET /v1/groups/{folder}reads one group. The agent gets aregister_grouptool. Creating a group also writes a route and a directory, so 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. Four daemons serve this resource: routd, runed, authd and onbod each own a table and publish it, so/v1/auditalways means "the log of this daemon".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 dump/apply/plan 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 has one face, the pairing undo DELETE /v1/acl_membership (unpair for the agent); proxyd_routes lives in routd's database but is served by proxyd, which advertises it in its own document.
One handler serves two faces
Here is what sits behind every operation in the document. One declaration, two ways in, one handler, one audit row. Read the gates: the REST face checks the bearer, the agent face checks the folder’s grants against the actual target.
┌───────────────────────────────────────────────────────────────────────────┐
│ resreg.Resource, declared once in resreg/resources/<name>.go: │
│ Name, Table, RowType, PKFields, Endpoints, MCPDoc, MCPArgs │
│ the daemon takes it by resreg.Mount and sets Authz, Handler, Store, Gate │
└──────────────┬─────────────────────────────────────────────┬──────────────┘
▼ ▼
┌──────────────────────────────────┐ ┌──────────────────────────────────────┐
│ REST face: RegisterREST │ │ MCP face: MCPTools │
│ /v1/<name>, one op per Endpoint; │ │ deriveMCPTools: one tool per │
│ /openapi.json from the mounts │ │ Endpoint that has an MCPDoc, │
│ caller: the bearer's sub, scope │ │ <name>.<action> or MCPNames │
│ and folder claim │ │ caller: the turn's folder and sender │
│ Gate: a bearer-scope check, or │ │ Gate: AuthorizeTurn on the target; │
│ Authorize on <name>:<action> │ │ target-folder resources run it in │
│ target-folder resources add │ │ containFn behind agentAllowGate │
│ containFn, one per resource │ │ │
└──────────────┬───────────────────┘ └──────────────────┬───────────────────┘
└────────────────────┬───────────────────┘
▼
┌───────────────────────────────────────────────────────────────────────────┐
│ invoke: Authz ► Gate ► Handler ► one GuardedBatch with the handler's │
│ writes and one audit_log row; a successful read writes no row │
└───────────────────────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────────────────┐
│ beside it: a forwarder (Store nil: no gate, no row here) for secrets on │
│ routd and proxyd_routes on webd; the hot tier, MCP-only in ipc/ipc.go: │
│ reply, send, like, inspect_* │
└───────────────────────────────────────────────────────────────────────────┘
Reflection on the row type builds the document
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. - No operation is invented. A resource that declares no
Endpointscontributes its schema and no path — there is no CRUD convention to fall back on, so the document can never show a path nobody serves. - Each operation’s
operationIdis<action>_<name>, verb first with an underscore (list_routes), because client generators mangle a dotted id. The MCP twin of the same operation is<name>.<action>, or a flat name the resource declares (unpair). - Standard error responses (400, 401, 403, 404, 409, 500) defined once in
components.responsesand$ref-d from every operation. - One
servers[]entry frombaseURL.
The engine and its rules: resreg/README.md.
x-mcp-when marks the operations for agents
Each of these operations has a second face. The agent reaches the same handler as an MCP tool over its per-folder unix socket. The tool name is <name>.<action>, or the flat name that the MCPNames map of the resource declares, such as add_route, 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.
curl and jq read the document
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.
Standard generators build typed clients
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.
The document has three limits
- 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 does not reflect per-caller scope. - The descriptions are short. The types and the paths are correct.
- Composite-PK URL parameters collapse to one segment; the description explains the encoding. If your client library cannot handle this, fall back to
POSTwith a body that names the PK fields.