Skip to content

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

  • /health and status routes for daemon checks.
  • /peers for peer registration, listing, lookup, and lifecycle operations. Peer records include optional model, 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, null when the parent is not registered), ephemeral, addressable, and addressable_reason. GET /peers is a complete inventory; ?source=, ?addressable=true|false, and ?listed=true|false (the routing view's rule: addressable, not system, parent live if any) narrow it. POST /peers accepts an optional provenance object as a fresh classification (omitting it preserves what the daemon knows, and only an unknown source is inferred from metadata.transport / hook_version). POST /peers/{id}/provenance replaces a live peer's provenance and emits a peer_updated event; the codex bridge uses it to demote a thread whose direct input the App Server denied. A peer with addressable=false is inbound-only: POST /ask and POST /notify to 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 reason peer_not_addressable, drops its queued deliveries, notifies each asker, and records one peer_not_addressable event with the counts. Durable replay obeys the same gate: the WebSocket flush-on-connect and GET /deliveries/pending check the live verdict, replay nothing to a non-addressable peer, drop whatever is still queued (dropped_not_addressable in the drain response), and record the drop, so a row queued before demotion can never land after it. Includes GET /peers/{id}/doctor (read-only diagnostic report with contradiction detection) and POST /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}; wait defaults 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}/restart returns the latest result; DELETE /peers/{id}/restart cancels an in-flight one. Every stage is journaled with its restart_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}/offline accepts optional pane_id and agent_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 a restart block (restartability preview and last result).
  • POST /peers/{id}/offline accepts an optional body {reason, source, detail, terminal} recording the truthful cause in the event log (e.g. session_end, agent_exited, pane_takeover). terminal: true retires the peer: its websocket is severed and reconnects claiming that peer_id are rejected (HTTP 409 / ws error peer_retired) unless they carry a live agent_pid. An empty body keeps the legacy plain-offline behavior.
  • POST /peers/identity/validate checks 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/orphans lists local tmux panes not bound to any registered peer (with a display-only backend hint); POST /panes/{pane_id}/link adopts one — registers the peer AND establishes its inbound ws-hook, rolling the registration back if no live transport connects (fail-closed against ghosts). Driven by repowire link.
  • /ask, /ack, /decline, and /asks/pending for ask lifecycle. GET /asks/history?pane_id=|peer_id=&since=RFC3339 lists the closed ask threads a peer took part in (as asker or recipient) after since, read from the durable trace ledger rather than the in-memory tracker, plus the peer's repowire_session_id. Backs the git-trailer hook and repowire why. POST /decline closes an unhandled ask with close_reason="declined" and returns the reason to its asker.
  • POST /asks/{correlation_id}/wait blocks (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 MCP wait_on_ack tool.
  • /answer records a typed answer to a structured question; POST /questions/ask-blocking registers 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 Code PreToolUse approval hook.
  • /query is 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.
  • /inbox routes 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.
  • /messages and WebSocket routes for live delivery.
  • /schedules for one-shot and recurring scheduled messages.
  • /jobs / work routes for durable tracked work.
  • /spawn creates tmux-backed peers (an optional message is delivered as a notify from from_peer once the peer registers on its pane; seed_state in the response reports awaiting_registration or undeliverable); GET /spawn/config reports whether spawning is enabled, configured commands/profiles, allowed paths, and the active circle_boundary (session or window). In window mode the CLI supplies its current pane internally so the daemon can split the peer into that window. POST /peers/{id}/fork-backend non-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.
  • /attachments for upload and download.
  • /dashboard for 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:

GET /jobs?view=summary

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:

Authorization: Bearer <token>

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.

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.