HTTP API¶
The daemon exposes HTTP routes for the dashboard, hooks, CLI helpers, and external integrations. The stable public client surfaces are the CLI and MCP tools; raw HTTP routes may move faster.
Primary route groups¶
/healthand status routes for daemon checks./peersfor peer registration, listing, lookup, and lifecycle operations. Peer records include optionalmodel, the last observed runtime model reported by the backend, and flattened provenance:source(hook,codex-app-server,unknown),initiator(user,agent,system, or absent),parent_runtime_id,parent_peer_id(resolved at read time,nullwhen the parent is not registered),ephemeral,addressable, andaddressable_reason.GET /peersis a complete inventory;?source=,?addressable=true|false, and?listed=true|false(the routing view's rule: addressable, notsystem, parent live if any) narrow it.POST /peersaccepts an optionalprovenanceobject as a fresh classification (omitting it preserves what the daemon knows, and only an unknownsourceis inferred frommetadata.transport/hook_version).POST /peers/{id}/provenancereplaces a live peer's provenance and emits apeer_updatedevent; the codex bridge uses it to demote a thread whose direct input the App Server denied. A peer withaddressable=falseis inbound-only:POST /askandPOST /notifyto it return 409{"error"|"reason": "peer_not_addressable", "peer_id", "parent_peer_id" (null when the parent is not registered), "hint"}before anything is registered or queued, broadcasts skip it, and scheduled asks fail the same way. It can still ack, reply, notify, and broadcast. When a live peer loses addressability the daemon closes its open inbound asks with reasonpeer_not_addressable, drops its queued deliveries, notifies each asker, and records onepeer_not_addressableevent with the counts. Durable replay obeys the same gate: the WebSocket flush-on-connect andGET /deliveries/pendingcheck the live verdict, replay nothing to a non-addressable peer, drop whatever is still queued (dropped_not_addressablein the drain response), and record the drop, so a row queued before demotion can never land after it. IncludesGET /peers/{id}/doctor(read-only diagnostic report with contradiction detection) andPOST /peers/{id}/rehook(non-destructive inbound ws-hook recovery, same-host, dry-run by default).POST /peers/{id}/restart({fresh, dry_run, message, wait, circle, from_peer};waitdefaults to true) stops and relaunches the peer with resume and always answers 200 with a typed result (outcome,failed.stage,would_run, ...); only an unknown or ambiguous peer or missing spawn wiring is an HTTP error.GET /peers/{id}/restartreturns the latest result;DELETE /peers/{id}/restartcancels an in-flight one. Every stage is journaled with itsrestart_id(peer_restart_started,peer_restart_launched,peer_restart_restarted,peer_restart_failed,peer_restart_offline_ignored); commands in results and the journal redact every value except the daemon-supplied resume argument, listed permission words, and model ids from the daemon's spawn config.POST /peers/{id}/offlineaccepts optionalpane_idandagent_pid; a post whose pane and pid both match an execution a restart replaced is ignored (in memory, until the daemon restarts).GET /peers/{id}includes arestartblock (restartability preview and last result).POST /peers/{id}/offlineaccepts an optional body{reason, source, detail, terminal}recording the truthful cause in the event log (e.g.session_end,agent_exited,pane_takeover).terminal: trueretires the peer: its websocket is severed and reconnects claiming that peer_id are rejected (HTTP 409 / ws errorpeer_retired) unless they carry a liveagent_pid. An empty body keeps the legacy plain-offline behavior.POST /peers/identity/validatechecks a runtime birth certificate and rehydrates its peer;POST /peers/identity/renew({birth_certificate, backend, path}) exchanges a matching certificate that names a runtime session, expired or not, for a fresh one only while its peer is connected on that same session (409 otherwise). The Codex bridge renews long-lived threads this way.GET /panes/orphanslists local tmux panes not bound to any registered peer (with a display-only backend hint);POST /panes/{pane_id}/linkadopts one — registers the peer AND establishes its inbound ws-hook, rolling the registration back if no live transport connects (fail-closed against ghosts). Driven byrepowire link./ask,/ack,/decline, and/asks/pendingfor ask lifecycle.GET /asks/history?pane_id=|peer_id=&since=RFC3339lists the closed ask threads a peer took part in (as asker or recipient) aftersince, read from the durable trace ledger rather than the in-memory tracker, plus the peer'srepowire_session_id. Backs the git-trailer hook andrepowire why.POST /declinecloses an unhandled ask withclose_reason="declined"and returns the reason to its asker.POST /asks/{correlation_id}/waitblocks (bounded, ~50s per call) until the ask is answered or acked, returning a resolution snapshot{status, reply, outcome, close_reason, ...}or{status: "pending"}on timeout with nothing recorded. First wait switches the ask to pull reply delivery so the reply is retained on the ask instead of injected at the asker. Backs the MCPwait_on_acktool./answerrecords a typed answer to a structured question;POST /questions/ask-blockingregisters a blocking structured question (tool permission) and holds the connection open until it is answered or the daemon's wait cap elapses, then returns the typed answer (fail-closed: a tool-permission question denies on timeout). Used by blocking transports such as the ACP broker and the Claude CodePreToolUseapproval hook./queryis a legacy blocking compatibility route. It opens a structured text ask through the ask lifecycle, waits for the recipient's answer/ack, and returns the old{text, error, status}response shape for existing clients./inboxroutes read and answer the human inbox./traces/{trace_id}returns the recorded delivery stages for an ask (correlation_id) or notify (delivery_id) from the local delivery trace ledger./messagesand WebSocket routes for live delivery./schedulesfor one-shot and recurring scheduled messages./jobs/ work routes for durable tracked work./spawncreates tmux-backed peers (an optionalmessageis delivered as a notify fromfrom_peeronce the peer registers on its pane;seed_statein the response reportsawaiting_registrationorundeliverable);GET /spawn/configreports whether spawning is enabled, configured commands/profiles, allowed paths, and the activecircle_boundary(sessionorwindow). In window mode the CLI supplies its current pane internally so the daemon can split the peer into that window.POST /peers/{id}/fork-backendnon-destructively starts an agent-role sibling on another configured backend in the source peer's project and circle. The source stays live and its backend-native conversation history is not copied./attachmentsfor upload and download./dashboardfor the static dashboard bundle.
Jobs Execution Policy¶
POST /jobs accepts process_scope and continuity for path/backend durable
jobs. Unassigned path/backend jobs default to process_scope=per_fire, so each
run uses a daemon-spawned or backend-resumed executor process that is released
after terminal completion. Reused persistent executors and explicitly assigned
peers are not killed by per-fire cleanup. One-shot jobs default to
continuity=fresh; recurring jobs default to continuity=resume, so the next
fire resumes backend-native runtime context when a runtime session id is
available. Use continuity=fresh to avoid backend resume.
Jobs State Semantics¶
Fire completion is structural: dispatch asks come from the built-in @jobs
service peer, the daemon arms the fire when the executor's prompt turn is
observed (running / phase turn_started), and the executor's next assistant
turn completes it with the final message as result_summary. Executor death
(terminal peer offline or daemon-restart reconcile) fails in-flight fires with
executor_died. Only the runner's dispatching phase is lease-bounded —
delivered fires are never reaped by a wall clock. Every state transition
emits a job_state_changed event, and terminal states best-effort notify the
owner (owner → creator → circle orchestrator). PATCH /jobs/{id} remains
available for progress enrichment and the explicit hold-open escape hatch.
Jobs List Views¶
GET /jobs returns the full durable-work list by default. Dashboard-style clients that only need row data can use:
The summary view keeps the same { "work": [...], "recurring": [...] } envelope and preserves ids, state, timestamps, ownership/routing fields, result summaries, and trimmed execution target/delivery metadata. It omits heavier detail fields such as full requests, provenance, runner state, prompt bodies, and progress history. Fetch GET /jobs/{id}/status for the selected job or recurring cal-* template when full detail is needed.
Auth¶
When daemon.auth_token is configured, clients send:
The dashboard served from the daemon's localhost origin is the exception: its same-origin browser requests are accepted without exposing the token to JavaScript.
Peer lifecycle¶
DELETE /peers/{name-or-id} is the explicit operator-close route used by the
dashboard's Unregister peer action and the CLI. It resolves the target to an
immutable peer_id, records a hard retirement tombstone, and removes it from the
registry. A still-running bridge cannot reclaim that closed identity.
POST /peer/unregister is intentionally non-terminal. Transport and service
clients use it to drop a registration before reconnecting the same session.
Related¶
Human inbox¶
Messages to the human peer (its name, telegram, or slack) are stored in the human inbox. POST /notify to it adds inbox_item_id and surfaces to the response; POST /ask to it adds inbox: {item_id, surfaces}. POST /ask accepts an optional origin: {surface, ref} naming the surface message an ask from the human was typed in, so the reply threads under it.
| Route | Purpose |
|---|---|
GET /inbox?unread=1&limit=N |
Inbound items, newest first, with per-surface pushes (surface, status, surface_ref). Returns {human, human_peer_id, items}. surface=S keeps items successfully pushed to S, listed in S's delivery order (newest rendered first, by numeric ref) rather than creation order; with it, through_ref=N keeps those whose first message on S has a numeric ref at or below N (Telegram message ids, which grow with every message in a chat). |
GET /inbox/{id} |
One item. |
POST /inbox/{id}/read |
{surface}. Marks the item read for every surface (idempotent). |
POST /inbox/{id}/reply |
{surface, text, option_id?, ref?}. On an open ask: acks it with text (or answers a structured question). Otherwise opens a new ask from the human to the item's sender. Returns {action: acked\|answered\|asked\|read, correlation_id}. An empty text on a non-ask only marks it read. |
POST /inbox/reply |
Same, finding the item by {surface, surface_ref} (for example a Telegram reply-to). 404 when no item was rendered as that ref. |
All routes return 503 when no human peer is registered.
Inbox surfaces connect over /ws with capabilities: ["inbox_surface"]. The daemon pushes {type: "inbox_item", delivery_id, item, reply_to_ref}. The surface answers with {type: "delivery_ack", delivery_id, status: "pushed", surface_ref, surface_refs} or {..., status: "failed", error_class}. surface_ref is the first message rendered and anchors later replies on the thread; surface_refs lists every message rendered (a long item is split), and a failed push still lists what was sent, so a reply to any of them finds the item. A bridge without the capability gets the item as a plain notify/ask frame, which proves only the socket write (verified: false).
The dashboard is a surface without a socket of its own. GET /events/stream?surface=dashboard marks the stream as the dashboard: the dashboard is live while one such stream is open, and a push to it is receipted (surface_pushed, surface: dashboard) once a stream has flushed the item's inbox_item event. The event stream carries inbox_item (item_id, kind, from, from_peer_id, correlation_id) and inbox_updated (item_id, read, replied, surface), never the text; the dashboard reads the item from GET /inbox. Relay share streams never carry inbox events.
Pull-delivery asks¶
POST /ask accepts optional reply_delivery: "pull" (default "push"). Pull
mode is set before delivery so an immediate recipient reply is retained for
POST /asks/{correlation_id}/wait. Callers without a registered peer may list
their outstanding asks with GET /asks/pending?peer_id=<sender>&direction=outbound.
The wait endpoint still requires the original sender identity.
GET /health advertises capabilities.ask_pull_delivery when ask handling is wired.
Targetless asks and routing¶
to_peer names the recipient. Under the default routing.strategy: static, an ask must name one. If the to_peer key is left out, the ask is refused with 400 {"error": "no_target", "hint"}, and nothing is registered or recorded.
When routing.strategy names an arm, an ask with no to_peer key is routed: the arm picks the recipient, and the pick is authorized like a named target. Optional fallback_to names the peer used if the arm fails or its pick is refused. A strategy never delivers to a service- or human-role peer unless routing.allow_service_targets is set. A fallback_to naming one returns 403 {"error": "service_excluded", "peer", "role", "hint"}. The response adds routing: {arm, routed_to, routed_to_peer_id, confidence, fallback, fallback_reason}. A routing failure with no fallback_to returns 503 {"error": "router_unavailable", "arm", "reason", "hint"}, and nothing is registered or delivered.
Only an absent key routes. "to_peer": "", whitespace and "to_peer": null are named asks to nobody. They return 404 Unknown peer: … under every strategy, as before. Keys match case-insensitively, as JSON decoding always has, so TO_PEER names a target. A key that only resembles to_peer (for example toPeer or to-peer) is rejected with 400, so a misspelled target never routes. A named ask is never rerouted, and responses to named asks carry no routing key. @route is an ordinary name. See routing.
The hosted relay has a separate OAuth-authenticated MCP surface;
it does not tunnel the daemon's administrative /mcp endpoint.