The REST API
Base URL /api/v1. Authentication is the X-API-Key header, which must hold an API_KEYS entry
(comma-separated, from the environment) or a key minted on /settings/api_keys. See
the REST API’s auth.
The iOS app’s bearer token
Section titled “The iOS app’s bearer token”Zimmer’s iOS app holds no API key. It signs in through
the OAuth authorization server as the
built-in zimmer-ios client and sends Authorization: Bearer <access token>. The REST API takes
that token only on the actions a controller names with accepts_native_app_tokens (every action
when it names none), and only when the token’s grant belongs to the built-in client:
| Routes | The app’s token may |
|---|---|
/api/v1/sessions and its member routes (not the nested resources below) | everything an API key may, plus authorize_merge, which only the app may call, and only on a grant that acts as you |
POST /api/v1/quick_router, /api/v1/apns_devices | everything |
GET /api/v1/live, /api/v1/live/settings, /api/v1/live_conversations and its member routes | everything, and only the app may start, message, finish or relay for a conversation, or change the Live settings, on a grant that acts as you |
/api/v1/sessions/:id/enqueued_messages | list, show, edit, delete, reorder, interrupt — not create: the app queues through follow_up |
/api/v1/sessions/:id/logs, /api/v1/sessions/:id/subagent_transcripts | list and show only |
DELETE /api/v1/sessions/:id/uncle_links/:uncle_id | detach an “also senior” link |
/api/v1/notifications | list, show, badge, mark read, dismiss — not push |
/api/v1/triggers | everything the triggers pages offer: list, show, create, edit, delete, toggle, the two re-use switches, invoke, and the Slack channel and WhatsApp chat pickers the form fetches — except the two re-use switches, which have no API route until #1318 |
GET /api/v1/costs, GET /api/v1/costs/records | read — not backfill, the one action there that costs the box a sweep of every transcript; the cron runs it on its own |
/api/v1/health and its actions | the read and every operator action the web health page offers: cleanup, retries, archiving, queue recovery, the queued-job levers, re-arming the post-deploy tasks |
/api/v1/work_backlog_items | list, show, hold; start_now, pin, unpin and remove — the human-only levers — only on a grant that acts as you (403 otherwise) — not create or pull, which are the gate’s and the groomer’s |
GET /api/v1/gate_decisions, GET /api/v1/gate_decisions/:id | read the ledger — not create, a gate’s write |
GET /api/v1/outcome_analyses, GET /api/v1/outcome_analyses/:id, GET /api/v1/goal_checks | read the Outcomes ledger, one analysis with its tree, and the goal-check tally — not create, an analyzer session’s write |
/api/v1/clis/status, /clis/refresh, /clis/clear_cache | everything the /clis page offers |
/api/v1/inference and its routes | read the Inference page (an in-flight login’s link and code only on a grant that acts as you); every write and the login poll — refresh, activate, delete or add an account, drive a CLI login, change the spot, top-up or genesis policy — only on a grant that acts as you (403 otherwise, and for an API key) |
GET /api/v1/sessions/:id/elicitations, PATCH /api/v1/elicitations/:id/respond | list a session’s open prompts; answer one only on a grant that acts as you (403 otherwise) — not create or poll |
GET /api/v1/configs, /mcp_servers, /skills, /hooks, /plugins, /model_catalog_entries | read the catalogs |
GET /api/v1/me, /api/v1/oauth_grants, /api/v1/api_keys, POST /api/v1/sign_out_everywhere | read who it is and both lists on any grant; revoke or re-level a connection, mint, revoke or restore a key, and sign out everywhere only on a grant that acts as you — an API key is refused every write (the auth boundary) |
/api/v1/app_settings | read, and write the session defaults and the experimental toggles; the MCP Apps switch and its allowlist only on a grant that acts as you (403 otherwise) |
A token issued to any other OAuth client, the app’s token on an action its controller does not name,
or on any other controller, gets the same 401 as a bad key. The
same expiry, revocation and audience checks as /mcp apply, and revoking the connection on
Settings → API keys refuses the phone on its next request. X-API-Key is unchanged beside it.
A follow-up the app sends is recorded the way any OAuth client’s is
(the assistant channel): as the
approver’s message, on channel assistant with entry point ios_app.follow_up, and only when the
app’s connection acts on their behalf. Editing a queued message from the app is recorded the same
way, as ios_app.enqueued_message_edited, the counterpart of the web UI’s
web_ui.enqueued_message_edited, and so is the prompt of a session the app creates with
POST /sessions, as ios_app.new_session (the web UI’s web_ui.new_session). A relay-only connection, an approver with no roster row, or a
follow-up over an API key records nothing.
The app also sends X-Zimmer-Client: ios/<version> on every request, and CarPlay’s voice loop sends
carplay/<version> with X-Zimmer-Input-Modality: voice_dictation. Any REST caller may send
X-Zimmer-Input-Modality: text or voice_dictation. These headers only shape the
turn-source note the agent sees. They can narrow ios to carplay but
cannot turn an API key into the app. They record no human and authorize nothing. A missing or
off-list value becomes unknown, and the request is never refused because of it.
On a deployment that serves the app from its own hostname behind an access proxy, the app also sends
cf-access-token, the edge’s credential, which Rails ignores. It gets that credential from
GET /native/access-handoff, which sits outside /api/v1. See
the iOS app.
Quick start
Section titled “Quick start”Every snippet below assumes these two:
API_KEY="your_api_key"BASE_URL="https://your-zimmer-host/api/v1"# Create a session on a configured agent rootcurl -X POST "$BASE_URL/sessions" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"agent_root": "zimmer", "prompt": "Fix the auth bug"}'
# Create one on an arbitrary repo insteadcurl -X POST "$BASE_URL/sessions" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"prompt": "Fix the auth bug", "git_root": "https://github.com/example/repo.git"}'
# List running sessions, search across transcripts, read one with its transcriptcurl "$BASE_URL/sessions?status=running" -H "X-API-Key: $API_KEY"curl "$BASE_URL/sessions/search?q=authentication&search_contents=true" -H "X-API-Key: $API_KEY"curl "$BASE_URL/sessions/1?include_transcript=true" -H "X-API-Key: $API_KEY"
# Follow up, then archive (moves to trash)curl -X POST "$BASE_URL/sessions/1/follow_up" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"prompt": "Now add tests"}'curl -X POST "$BASE_URL/sessions/1/archive" -H "X-API-Key: $API_KEY"Spawn and poll, in Python:
import requests, time
API_KEY = "your_api_key"BASE_URL = "https://your-zimmer-host/api/v1"headers = {"X-API-Key": API_KEY}
resp = requests.post(f"{BASE_URL}/sessions", headers=headers, json={ "agent_root": "zimmer", "prompt": "Fix the auth bug",})session = resp.json()["session"]
while True: resp = requests.get(f"{BASE_URL}/sessions/{session['id']}", headers=headers) if resp.json()["session"]["status"] in ("needs_input", "failed", "archived"): break time.sleep(5)And in JavaScript:
const API_KEY = "your_api_key";const BASE_URL = "https://your-zimmer-host/api/v1";const headers = { "X-API-Key": API_KEY, "Content-Type": "application/json" };
const resp = await fetch(`${BASE_URL}/sessions`, { method: "POST", headers, body: JSON.stringify({ agent_root: "zimmer", prompt: "Fix the auth bug" }),});const { session } = await resp.json();
await fetch(`${BASE_URL}/sessions/${session.id}/follow_up`, { method: "POST", headers, body: JSON.stringify({ prompt: "Now add tests" }),});Pagination
Section titled “Pagination”Most list endpoints take page and per_page (default 25, max 100) and answer with a pagination
object alongside the collection.
{ "pagination": { "page": 1, "per_page": 25, "total_count": 312, "total_pages": 13 }}Terminology
Section titled “Terminology”Archived is “trash” in the UI, archived on the wire. The status enum value is archived and
the column is archived_at; filters and status values never take trash. Only the prose moves —
archiving answers "Session moved to trash" and a trash_after timestamp saying when what the
archive retained gets cleaned up — preserved clone artifacts, the scratch directory, prompt
attachments. A clean clone is deleted well before that, at the end of the undo window.
git_root is a string; agent_root is a catalog name. git_root is a free-form repository URL
or local path stored on the session. agent_root names a preconfigured agent
root — zimmer, say — that resolves to a git_root plus defaults for branch,
subdirectory, mcp_servers, catalog_skills, catalog_hooks, catalog_plugins, and model.
Passing agent_root is the recommended way to spawn on a configured root.
Sessions
Section titled “Sessions”:id is a numeric id or a slug, resolved by Session.locate — the single implementation the
web UI, this API and the MCP tools all share. An all-digit :id is
always an id, never retried as a slug; anything else is a slug. That guard is load-bearing:
slugs are title.parameterize plus a -YYYYMMDD-HHMM stamp, so a session titled after an issue
number gets a slug like 728-fix-the-poller-20260830-1102, and parsing it as a number would
resolve it to session #728. A slug that matches nothing is a 404, never the session its
leading digits spell. For the same reason a slug may not be all digits — it would be unreachable —
and the model refuses to write one, answering 422.
| Method | Path | Notes |
|---|---|---|
GET | /sessions | filters, all combinable as the dashboard’s are: status (one or several — status[]=needs_input&status[]=failed or status=needs_input,failed — of waiting, running, needs_input, failed, archived; naming archived includes archived sessions without show_archived, and with no status archived sessions stay out unless show_archived=true (where the dashboard reads ticking nothing as every status); an unknown status is a 422 naming the valid ones), agent_runtime, agent_root (a catalog name, matched as the dashboard’s Advanced filter matches it), priority_class, genesis (a key from geneses in GET /configs, each with the label the dashboard shows), show_archived, visibility, page, per_page. visibility (on_board / off_board) is unset by default — see Board visibility. view orders the rows as the dashboard’s board views do: created_desc (the default, newest first) and last_touched (the last time a person acted on the session, else when it was created) page as usual; user and ranked are the User view’s order — priority above spot, then precedence descending, then oldest first — which is not a single ORDER BY, so they answer the top 500 in one page (asking for page 2 is a 422), with truncated: true when more matched. Any other view is a 422 naming the four. include=board_row adds board_rows, keyed by session id: what a dashboard row shows that session does not carry — agent_root (the catalog name, or null) and status_summary (the shape GET /sessions/:id returns, or null) — read in one query for the page. Zimmer’s own status-summary forks are never listed |
GET | /sessions/search | q (or query) required (≤1000 chars), search_contents (true or 1), scan_cursor, plus the same status / agent_runtime / agent_root / priority_class / genesis / show_archived / visibility filters as /sessions, combined the same way, and include=board_row as on /sessions. Missing/oversized query → 400 (the only 400 in the API). Status-summary forks are never listed. See Searching transcript contents for what search_contents changes about the response |
GET | /sessions/:id | always returns top-level status_summary, waiting, session_hierarchy, human_messages and human_message_capture_gaps beside session; include_transcript=true adds the raw transcript. viewed=true, from the iOS app’s sign-in only, is a person opening the session, as the dashboard’s page is: its unread notifications are marked read and it is touched as last touched. From an API key it does nothing — the key is the fleet’s, and an agent reading a session must not clear a person’s inbox |
POST | /sessions | → 201, or 200 with idempotent_replay: true when idempotency_key matches an earlier create. A 201 can carry a warnings array when one of the session’s MCP servers cannot start. See below. |
PATCH | /sessions/:id | permits only title, slug, goal, is_autonomous, scheduling_class, precedence, place, custom_metadata. Promoting a waiting session to priority also starts it now, which is what makes “moved to priority and started” true rather than aspirational — the deferred re-check it was carrying can be an hour out. Only the transition into priority does it, so a PATCH that touches the title cannot restart a session. When it acts, the response carries a start object (outcome: started / refused, plus a message); it is absent when the promotion started nothing. A title is a rename, with the same rules as the web UI and the update_title MCP action: stripped, refused with 422 Validation failed when blank, not a string, or over 100 characters, a no-op when it equals the current title, and otherwise logged on the session and never overwritten afterwards by automatic titling. A refused title refuses the whole PATCH |
DELETE | /sessions/:id | → 204. Hard delete, not archive: the row and its associations go, and so do the session’s scratch directory and prompt attachments |
POST | /sessions/:id/archive | from waiting, running, needs_input, or failed → {session, message, trash_after}. 422 while any message is still queued for the session, since archiving discards it; force: true overrides deliberately and the discarded messages are retired to undelivered — see lifecycle. 422 too while an agent turn is in flight (error: "A turn is in flight"; the queue is checked first). force: true lets it through; so does end_turn: true, which lets the turn go and still refuses a queue — the web UI’s Trash, and what the iOS app sends. An archive over a live turn writes a warning on the session’s timeline |
POST | /sessions/:id/unarchive | → {session, clone_restored, message}. Recreates the clone directory and restores the transcript when they are gone, so the harness resumes where it left off. A session that never ran has neither, so it is returned to needs_input to start fresh (clone_restored: false) rather than refused |
POST | /sessions/:id/follow_up | prompt (≤500,000), goal (≤50,000), force_immediate, acting_session_id, attachments (at most 20, [{filename, media_type, data}] with data base64: a PNG, JPEG, GIF or WebP (by its bytes) goes to the agent as an image, anything else as a file it is told to read, as the web composer’s two pickers do; they go with the message on every path, queued included, and one that will not store is a 422 that sends nothing; a body over 110 MB is a 413; see Prompt attachments). 202 if a turn is already underway — executing, or ready in the agents queue with a worker coming for it — in which case the prompt is queued; 200 otherwise. Read through Sessions::LiveTurn.underway? rather than status == running, because a handed-over turn reads waiting. goal takes effect on every path — see below |
POST | /sessions/:id/message_parent | message (required), reason (required: wrong_scope | missing_tools | other), force_immediate, unarchive_parent. :id is the CHILD — the target is read from parent_session_id and is never a parameter. 202 if the parent already has a turn underway (queued); 200 otherwise; 409 if the parent is archived or failed, or on a queue position conflict; 422 if the session has no parent or is its own parent. The enqueued_message key is present only on the queued branch — an interrupt consumes the row it staged. See Reporting back to the parent that started you |
POST | /sessions/:id/pause | running only → needs_input |
POST | /sessions/:id/sleep | needs_input → sleeps; running → sets pending_sleep. Marks the sleep deliberate (deliberate_sleep_at), which is what keeps StrandedSleepSweepJob from treating a session slept with nothing armed as one whose wake was lost. Any resume or restart clears the marker |
POST | /sessions/:id/restart | clears stale retry metadata and re-queues the job; re-runs the whole setup pipeline when there is no conversation to prompt into — setup never finished (a failed clone, say), or the session never ran at all — and the replacement first turn carries the session’s stored images and files. 422 Session has no session_id is reserved for the session that has a transcript but no id to resume it under. Either branch answers 503 rather than 500 when a dropped database connection survives its retries — the write is retryable, so the caller should retry it |
POST | /sessions/:id/start_now | the Ranked view’s Start now, with MCP start_now’s answers: take a waiting session’s next turn now rather than when the scheduler gets to it → {session, outcome, message}. It moves when the turn is asked for, not whether the spot gate allows it. 422 when Sessions::StartNow refuses (asleep on a wake, say) and when the session has no turn queued — send it a follow-up, or restart it |
POST | /sessions/:id/force_start | the queued-for-a-worker banner’s Force, with MCP force_start’s semantics: stop the turn that most recently took one of the agents lane’s threads, put that turn straight back in the queue, and give the thread to this session’s queued turn → {session, message}. expected_victim_id is required — the session waiting’s queued_turn banner named; 422 Missing parameter without it — and a pick that differs (the named turn ended, or somebody forced it first) is refused rather than stopping a turn nobody confirmed. An agent passing acting_session_id is never the victim, as with MCP. 422 Cannot force with Sessions::ForceTurnStart’s own sentence when the session has no turn queued, a thread is already free, nothing can be stopped, or the victim changed. A spot turn still meets the spot gate once it has the thread |
PATCH | /sessions/:id/reorder_precedence | the Ranked view’s drag-and-drop: above_id and/or below_id, the spot-queue rows it was dropped between (omit one at an end; a row no longer in the spot queue counts as omitted). The server derives the precedence, nudging a neighbour aside when there is no gap → {session, changes: [{id, precedence}]}, every row whose value moved. 422 with the reason on a refusal |
POST | /sessions/:id/fork | message_index required → 201. It counts the transcript’s events (one per line, blank and unparseable lines skipped), not its user and assistant messages; take it from a message’s transcript_index in /conversation |
POST | /sessions/:id/regenerate_status_summary | → 202. Queues a forced rewrite of the Status summary; it forks the session and spends an agent turn, so it is asynchronous and must not be polled. 422 with the reason, rather than a 202 for work that cannot run, when there is nothing to summarize: no transcript, or a session that is itself a summary fork. An archived session is a normal candidate however long ago it was archived — the fork answers from the conversation, and gets an empty working directory when Zimmer has already reclaimed the clone |
POST | /sessions/:id/refresh | re-read transcript from disk. A shorter filesystem transcript never overwrites a longer stored one — that happens when the clone was recreated at a new path, and the stored history wins. The write is retried on a dropped database connection, and 503 if it stays down |
POST | /sessions/:id/authorize_merge | the User view’s Merge, from the iOS app only: a person’s sign-off that the session may merge its PR (the Merge button). → {outcome, message, pr_url}, outcome sent or already_sent; 422 (error: "Cannot merge", with outcome not_mergeable or undeliverable) when the PR is not open and green (the message says which) or the session cannot take the message. 403 before the lookup, so an unknown id is refused the same way. Recorded as your message. 403 for an API key and for a relay-only app grant: an API key is the fleet’s, and an agent authorizing its own merge is what the button exists to prevent |
POST | /sessions/reprioritize | the User view’s Reprioritize: hand the board to the deployment’s reprioritizing session. → {outcome: "fired", message, session}; 422 (error: "Not reprioritized", with outcome) and no session for any other outcome (pressed again too soon, a session still pending). It is an invoke of that session’s trigger, which an API key can already reach, so it takes an API key. A session it creates is recorded as the API’s (genesis: api) |
POST | /sessions/refresh_all | → {message, refreshed, restarted, continued, errors}. Max 50 restarts/continues. A byte-identical transcript is not counted as refreshed; a transcript write the database keeps refusing counts under errors |
POST | /sessions/bulk_archive | session_ids[] → archived_count and any errors. A session with a queued message lands in errors and is left alone. A session mid-turn lands there too, unless end_turn: true. force: true and end_turn: true apply to the whole batch, not one member of it |
PATCH | /sessions/:id/mcp_servers | max 50, validated against the catalog. Replaces the set; [] clears it and is recorded as deliberate, so the backfill does not restore the root’s defaults. Takes effect on the session’s next prepare — see Changing a session’s artifacts takes effect on its next prepare |
PATCH | /sessions/:id/catalog_skills · /catalog_hooks · /catalog_plugins | max 100 / 100 / 50, validated against the catalog. Replaces the set; same next-prepare rule |
PATCH | /sessions/:id/model | validated against ModelCatalog for the session’s runtime. Refused when the session has an effort the new model does not take. Same rules as the web model editor and the change_model MCP action, all through Sessions::UpdateModel |
PATCH | /sessions/:id/effort | effort: low · medium · high · xhigh · max, a level the session’s model takes, or default (or null/"") to clear it so the model’s default applies. Missing or unsupported → 422 naming the valid levels. Takes effect from the next turn. Same rules as the web effort editor and the change_effort MCP action, all through Sessions::UpdateEffort |
PATCH | /sessions/:id/notes | session_notes ≤ 50,000 characters; blank or absent clears. Over the cap → 422 Too long; a non-string → 422 Validation failed. Same rules as the web notes panel and the update_notes MCP action (which requires the parameter), all through Sessions::UpdateNotes |
PATCH | /sessions/:id/heartbeat | enabled and/or interval_seconds (30–86,400, default 60); omit either to leave it unchanged |
POST | /sessions/:id/toggle_favorite | favorited sessions sort to the top of the dashboard. Same flip as the web star and the toggle_favorite MCP action, all through Sessions::ToggleFavorite, which reads the star under a row lock so concurrent toggles both land |
PATCH | /sessions/:id/visibility | visibility (visible | hidden | snoozed), plus snoozed_until and timezone for a snooze. Board visibility only — see Board visibility. It changes what the dashboard draws and nothing else: no session is started, stopped, slept, woken or reordered. Unknown value, missing or past-dated snoozed_until → 422 |
GET | /sessions/:id/transcript | format=text → text/plain, else {transcript_text} |
GET | /sessions/:id/conversation | the transcript’s user and assistant messages as data, oldest first: {messages: [{role, content, timestamp, has_tool_use, has_tool_result, transcript_index}], total, first_position, truncated}. limit (default 50, max 200) is how many, ending just before before (a position, 0 for the first message) or at the newest when before is omitted. first_position is where the page starts — the before of the page above it — and truncated says there is one. Each content is cut at 4,000 characters; with full_content=true, at 100,000, which an agent should rarely want: one long tool result can be most of a context window. Positions count the messages in this list, not transcript_index, which is the message_index that fork takes to fork at that message. What the iOS app renders |
force_immediate: true on follow_up interrupts a running session and delivers the prompt now,
through the same race-free interrupt backend as the web UI’s “Send Now” — exactly-once and
FIFO-ordered. When the interrupt can’t be dispatched the staged message is discarded rather than
left half-queued, and the call answers 404, 409, 422, or 500.
goal on follow_up lands on every path — see
Following up, and the goal that rides along below.
Changing a session’s artifacts takes effect on its next prepare
Section titled “Changing a session’s artifacts takes effect on its next prepare”The four artifact endpoints — PATCH /sessions/:id/mcp_servers, /catalog_skills,
/catalog_hooks, /catalog_plugins — persist the new list and stop there. They do not rewrite
the session’s runtime config in its clone. The change lands the next time that config is prepared:
the session’s next turn, a restart, or an unarchive, each of which re-runs AIR against the clone.
One rule covers all four endpoints, the same four editors in the web UI, and action_session’s
change_mcp_servers / change_skills / change_hooks / change_plugins on the MCP
server. The same request means the same thing whichever door it comes through.
Regenerating on the write buys nothing. A running agent has already launched its MCP servers, so
rewriting .mcp.json underneath it changes nothing about that process, and an idle session
re-prepares before its next turn anyway — while the regeneration itself is a shell-out inside the
request.
So a change does not reconfigure a live process. To make one take effect immediately, restart the session.
mcp_servers and catalog_plugins are the two lists that can bring in an MCP server, so those two
responses also carry oauth_required and oauth_required_servers. When a newly selected server has
no usable token, Zimmer parks the session (failure_reason: "oauth_required") so the Authorize
buttons appear on its page — those two fields are how a REST caller knows that is why the status
moved. A session that is mid-turn is never parked this way; its already-running process cannot see
the change either way.
Board visibility
Section titled “Board visibility”A session has a second field beside status, and the two have nothing to do with each other.
status is where the session is in its lifecycle. visibility is whether its card is on the
operator’s dashboard: visible, hidden, or snoozed until snoozed_until.
It is a visual-organization device with no effect on scheduling or execution. No scheduler,
queue, gate or state machine reads it. A snoozed session starts, runs, ranks and finishes exactly as
it would have if nobody had touched it — the human simply is not looking at its card. If you want to
defer work, POST /sessions/:id/sleep and PATCH /sessions/:id (precedence) are the endpoints
that do that.
A snooze expires by being read. Once snoozed_until passes, the session is back on the board
with no request having been made and no background job having written to the row. That is why the
serializer reports two fields:
visibility— the stored choice, unchanged until somebody changes it. A session whose snooze ran out last week still reads"snoozed"here.effective_visibility— that choice with an expired snooze already resolved to"visible". This is what every human-facing board reads, and what a consumer deciding whether to draw something wants.
GET /sessions and GET /sessions/search take an optional visibility filter: on_board
(what a tidied dashboard shows) or off_board (the hidden and snoozed ones). They are unfiltered
on this axis by default, deliberately. Agents read these endpoints to check whether a piece of work
already has a session; a session a human tidied off their board is still that session, and hiding it
from a duplicate check would produce duplicate work with no visible cause. Pass the filter only when
the question you are asking is genuinely about the board.
# Snooze a card until 9am on the 5th, Pacific time. Nothing about the session's# scheduling changes — only whether it is drawn.curl -X PATCH "$ZIMMER/api/v1/sessions/4242/visibility" \ -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"visibility":"snoozed","snoozed_until":"2026-09-05T09:00:00","timezone":"America/Los_Angeles"}'
# Put it back by hand (it would have come back on its own at 9am).curl -X PATCH "$ZIMMER/api/v1/sessions/4242/visibility" \ -H "X-API-Key: $KEY" -H "Content-Type: application/json" -d '{"visibility":"visible"}'snoozed_until is a naive wall-clock ISO-8601 datetime read in timezone (an IANA name,
default UTC) — send the time the human means plus their zone rather than converting to UTC
yourself. A value carrying its own offset is rejected, as is one in the past. The MCP counterpart is
action_session’s set_visibility action, with the same parameters and the same rules.
Searching transcript contents
Section titled “Searching transcript contents”GET /sessions/search matches session titles plus the metadata and custom_metadata JSON by
default. search_contents widens it to the stored transcript — the whole conversation.
Two things about the query string, both of which used to bite callers who guessed:
- The search term is
q, andqueryis accepted as an alias. The MCP tool and the dashboard both call itquery, and a caller who copied that name used to get400 Missing parameter. search_contentsacceptstrueand1. The dashboard’s checkbox posts1; the API used to compare against"true"only, so a URL copied out of the browser silently ran a title-only search and answered 200.
The query is one substring, so several words are a phrase. q=YC interview matches a
transcript that says YC interview; it does not match one that says the interview is at YC. The
words have to be adjacent and in that order — there is no tokenising, no OR between words, no
stemming — and % and _ are literal characters rather than wildcards. Search a distinctive
phrase when you want to confirm a session said something, and one word when you want a wider net.
Both the dashboard and quick_search_sessions match the same way, on the title and metadata columns
as well as on the transcript.
The JSON columns are matched in Postgres’s canonical spelling, and both spellings of a query
work. metadata is a jsonb column, so Postgres stores one normalised form of the document and
renders it back the same way, however the writer spelled it. It was json until
#847, which kept whatever bytes the writer produced:
an ordinary attribute write serialised {"agent_root_key":"zimmer"} and the atomic
merge_metadata! UPDATE serialised {"agent_root_key": "zimmer"}, so a query spanning a key’s
colon found a session or did not depending on which writer had touched the row last
(#930). Both columns are read through
::jsonb::text, which renders that canonical form, and the query is tried in both spellings:
"key":"value" and "key": "value" find the same sessions.
One thing canonical form costs you: jsonb orders an object’s keys by length then bytewise, not in the
order they were written, so a fragment spanning the comma between two keys only matches if you spelled
them in Postgres’s order. Search one key/value pair, or a value — both are unaffected. See
Limitations.
search_contents matches transcript::text — the stored JSON, not the rendered conversation. A
phrase broken across a line break is \n in that text and does not match, and a hit can land in a
tool argument or a file path rather than in anything anybody said. Keep the phrase short and inside
one line, and read a miss as “not found” rather than “never said”.
The content search is bounded, and says so. transcript is a json column with no index a
substring match can use, so a single unbounded ILIKE over the corpus raced the proxy’s 30-second
timeout and returned a 504 as often as results. Instead the search walks candidate sessions
newest-first in chunks, stops at per_page matches or a wall-clock budget, and always returns.
That changes the response in three ways, only on this path:
{ "query": "the kestrel manoeuvre", "search_contents": true, "sessions": [ /* newest first */ ], "pagination": { "page": 1, "per_page": 25, "total_count": null, "total_pages": null }, "content_scan": { "complete": false, // false ⇒ older sessions were NOT ruled out "timed_out": false, // true when the wall-clock budget ran out "scanned_sessions": 25, "candidate_sessions": 3677, "next_cursor": "2026-08-30T11:02:19.482913Z|10884" }}total_countisnull. Counting matches across the transcript corpus costs exactly the full scan the bound exists to avoid, so no number is offered rather than a wrong or expensive one.pagedoes not page it. Passcontent_scan.next_cursorback asscan_cursor, with the same query and filters, to continue from where the scan stopped. The cursor is a keyset position on(created_at, id), so sessions created between calls never shift it.complete: falseis a real answer. An empty result withcomplete: falsemeans “not found yet”, not “not there”. Narrowing withstatus,genesisoragent_runtimemakes each call reach further back.
The budget defaults to 20 seconds and the chunk to 100 sessions; both are overridable per
deployment with ZIMMER_CONTENT_SEARCH_BUDGET_SECONDS and ZIMMER_CONTENT_SEARCH_CHUNK_SIZE.
The MCP tool quick_search_sessions takes the same search_contents and scan_cursor arguments
and runs the same scan — see the MCP server.
Creating a session
Section titled “Creating a session”Permitted params: agent_root, agent_runtime, prompt, git_root, branch, subdirectory,
title, slug, goal, is_autonomous, parent_session_id, auto_compact_window,
scheduling_class, precedence, place, idempotency_key, mcp_servers[], catalog_skills[],
catalog_hooks[], catalog_plugins[], config{}, custom_metadata{}.
branch defaults to the root’s default_branch, or main. show_archived and search_contents
default to false wherever they appear.
clone_only: true is the web form’s Clone Only: the session is created needs_input with the
clone-only job queued (AgentSessionJob.enqueue_for_clone_only, a clone and no turn), and the
first prompt arrives through follow_up. Nothing is recorded as a human message, since nobody has
said anything yet. A prompt alongside it is a 422 rather than dropped. Without it, a create with
no prompt makes a session that queues nothing and stays waiting — a row, not a clone.
goal is a goal id from GET /configs or a free-text sentence. A single word that is not a known
id is refused with a 422 that lists the known ids. The same rule applies on PATCH, on
follow_up and on enqueued messages. See where a goal comes from.
priority_class accepts spot or priority; genesis accepts one of web_ui, slack,
github_issue, github_label, schedule, ao_event, api, unknown. A session that carries no
scheduling_class of its own is classified from its genesis on read, so moving a genesis on Inference
moves those sessions between the two priority_class values immediately — and the five trigger-backed
kinds are not movable that way at all (their class is set per trigger). An unrecognised value for
either filter is ignored rather than erroring.
genesis is not a permitted param on create and is never caller-supplied. A create that passes
parent_session_id inherits that parent’s genesis; one that does not is recorded as api, which
classifies spot.
scheduling_class is caller-supplied, and takes precedence over whatever the genesis would give
the session. Send spot or priority; omit it and the session inherits its parent’s explicit class
if it has one, and otherwise derives from its genesis — except from the iOS app’s token, where an
omitted class is written as priority: a person is waiting on that session as they are on the web
form’s, whose web_ui genesis is priority, and the app’s api genesis would make it wait for quota.
The Quick Router ingest classes a native start the same way. A class the app sends stands. An unknown value is a 422 (unlike the
priority_class/genesis filters above, which ignore what they do not recognise — a filter that
matches nothing is not the same kind of mistake as a session created in the wrong class). It is also
permitted on PATCH /sessions/:id, which is how a spot session already held behind the quota gate is
moved to priority without touching the trigger that spawned it; send null to go back to derived.
precedence is caller-supplied too, on both POST /sessions and PATCH /sessions/:id. It ranks the
session within the spot queue — higher is handled sooner, on an absolute scale, so 100000 comes before
50 — and it is carried on every session, priority ones included. Omit it on create and the session
lands one point above the session named in parent_session_id, or at 0 with no parent.
place is the symbolic form of the same field, on both of those endpoints. Instead of naming a
number you name a position — top_of_spot is the one placement today — and the server resolves it
against the queue as it stands at the moment of the write. It is there so that heading the queue is
one request rather than two: reading the current top and then writing a few points above it can be
overtaken between the two calls, landing the session second in the queue it meant to head, and it
makes the caller re-derive a rule the server owns (the live maximum over non-archived spot
sessions — a caller that forgets the archived exclusion inflates the whole scale). Resolution goes
through the same helper the Ranked view’s demote button and the MCP tools
start_session / action_session use, so the surfaces cannot drift apart on
what “the top of the queue” means.
curl -X PATCH https://zimmer.example.com/api/v1/sessions/123 \ -H "X-API-Key: $ZIMMER_API_KEY" -H "Content-Type: application/json" \ -d '{"place": "top_of_spot"}'place and precedence are mutually exclusive — they are two answers to the same question, so
sending both is a 422 rather than a silent preference for one. An unknown placement is a 422 too.
Sending neither leaves the ranking alone: a PATCH that touches only the title does not renumber the
session, and a create falls through to the ordinary inheritance described above. On an update the
placement is resolved with the session itself excluded from the population and floored at the rank it
already holds, so re-placing the session already on top is a no-op rather than a ratchet, and “put
this first” never lowers a rank.
See Spot and priority. Every session object carries genesis,
scheduling_class (the explicit choice, usually null), priority_class (the resolved answer) and
precedence.
agent_root is not a Session column — it names a catalog entry that expands into git_root,
branch, subdirectory and the catalog defaults, and is recorded as metadata.agent_root_key. An
invalid one → 422 {"error": "Invalid agent_root"}.
An artifact list you send replaces the root’s defaults. For mcp_servers, catalog_skills,
catalog_hooks, and catalog_plugins, leaving the key out takes the agent root’s defaults for that
list, sending an explicit [] creates the session with none of that artifact, and sending a
non-empty list creates it with exactly that list — every default not named is dropped. All three are
different requests — see what a list
replaces for the full rule
and for why an explicit [] survives to job start rather than being restored from the root’s
defaults. Note that a form-encoded body cannot express an empty array; send JSON to request none.
Zimmer’s own injected servers (zimmer-self-session) are added separately and still arrive.
execution_provider is not a parameter, and session_json does not carry it. It named a choice
that never existed — one legal value, local_filesystem, and no code path that read it — and was
removed in #172 along with the unwired execution
layer behind it. A create that still sends the key has it dropped by the permitted-params filter
rather than rejected, so an older client keeps working. Every agent runs on the Zimmer host itself,
unsandboxed — see
Agents run unsandboxed on the app host.
The AgentSessionJob is enqueued only if prompt is present.
warnings — an MCP server the session cannot start
Section titled “warnings — an MCP server the session cannot start”A 201 can carry a warnings array beside session. Today it holds exactly one kind of entry: one
or more of the session’s MCP servers cannot start right now — a required ${VAR} does not resolve,
an OAuth flow was never completed, or the catalog declares the entry dead. The session is created and
the job is queued regardless; the warning names the servers, says what it costs this session (the
three cases differ — an unresolved variable fails preparation outright, a missing OAuth credential
parks the session for authorization, a catalog-declared entry only loses its tools) and points at
/connectors.
{ "session": { "id": 4821, "status": "waiting" }, "warnings": [ "Zimmer cannot start MCP server strad-secrets-staging-rw (STRAD_STAGING_API_KEY unresolved) right now. …" ]}The key is absent when there is nothing to say, and it rides only on the 201 — an
idempotent_replay hands back a session an earlier call made, and nothing about it changed here. The
same sentence is written to the session’s log at warning level, and GET /api/v1/mcp_servers
carries the per-server unavailable / unavailable_reason this is derived from. Why this warns rather than
rejecting — including what it means for a connection restricted by allowed_agent_roots — is in
Creating a session with a server that cannot start.
idempotency_key — making the create safe to retry
Section titled “idempotency_key — making the create safe to retry”idempotency_key (≤255 chars) names this create attempt, so that repeating it is not the same as
asking for a second session. Send a string unique to the unit of work you are spawning; repeat the
call with the same key and you get back the session the first call made — 200 with
"idempotent_replay": true beside the session object, instead of 201 — and no second
AgentSessionJob is queued.
This exists because a create can fail after it succeeds. The reverse proxy in front of Zimmer returns its own 504 page when a request outruns its timeout, and that page arrives long after the row has committed: the session exists, is healthy, and runs to completion, while the caller holds an error carrying no session id and nothing to distinguish “the create never landed” from “the create landed and only the response was lost”. Retrying — the obvious move — spawns a second clone, a second agent holding a Claude quota slot, and eventually two branches and two PRs for one task.
So: with a key, retry on any error, including a timeout. Without one, do not retry — search
for the session by title first (GET /sessions/search) and see whether it is already there.
One thing a replay does not promise: that the session it hands back is running. The winning request
queues the agent job a moment after the row commits, so a worker killed in between leaves a session
that is real, carries a prompt, and never started. A replay reports the session as it is — check
job_id on the returned object, and POST /sessions/:id/restart if it is null.
Uniqueness is enforced by a unique index on the column, not only by a model validation, so two concurrent creates racing on the same key resolve to one session rather than two: the loser is handed the winner’s session. The key is not a fingerprint of the request — a repeat returns the session that key created whatever arguments came with the repeat, so use a fresh key for each new unit of work.
Use a fresh UUID, and do not derive the key from the task. Keys share one global namespace, so
two callers that independently build issue-577-fix from the same issue collide, and the second
one’s session is silently never created — which is a worse outcome than the duplicate this closes,
because a duplicate is visible and a no-op is not. An empty string is treated as no key at all, so a
client that always serializes the field is not quietly locked to one session.
Every session object carries idempotency_key (null on the sessions that were created without one),
so a caller reading a list can tell which session its key made.
Which runtime and model you get
Section titled “Which runtime and model you get”One chain, whether or not you name an agent_root:
request param → agent root's declared value → AppSetting (the Settings page) → hardcoded defaultWith no agent_root there is simply no root tier and the chain falls through to AppSetting, so a
rootless API spawn honors the global defaults the Settings page presents. A model that isn’t valid
for the resolved runtime — a root pinning opus on a codex spawn, say — self-heals to the global
default for that runtime rather than persisting something the harness can’t run. config.model is
always explicitly set on the created session.
agent_runtime must name a registered runtime; an unregistered value → 422.
Valid models are a property of the runtime, not the root (ModelCatalog). The built-in ones are
below; models added through /model_catalog_entries are valid too, and GET /configs
lists both:
| Runtime | Models |
|---|---|
claude_code | opus (default) · sonnet · haiku · fable |
codex | gpt-5.6-sol (needs a ChatGPT login) · gpt-5.6-terra (default, needs a ChatGPT login) · gpt-5.6-luna (needs a ChatGPT login) · gpt-5.5 (needs a ChatGPT login) · gpt-5.4 (retires from ChatGPT sign-in August 31, 2026) · gpt-5.4-mini (retires from ChatGPT sign-in August 31, 2026) · gpt-5.3-codex (deprecated) · gpt-5.2-codex (deprecated) |
PATCH /sessions/:id/model validates against the list for the session’s own runtime; anything else
→ 422 {"error": "Invalid model"} with a message naming the valid ones.
config.effort sets the session’s reasoning-effort level, which reaches Claude Code as --effort.
Omit it and the model’s own recommended default applies; every session carries an effort object
saying which one it got: {level, source, default, levels}, where source is explicit or
default. Each entry in runtime_models from GET /configs carries the model’s effort_levels
and default_effort:
| Model | Levels | Default |
|---|---|---|
opus | low · medium · high · xhigh · max | medium |
sonnet | low · medium · high · xhigh · max | medium |
fable | low · medium · high · xhigh · max | high |
haiku, every Codex and Pi model, any added model | none | — |
A level the resolved model does not take → 422 naming the valid ones, and no session is created.
Following up, and the goal that rides along
Section titled “Following up, and the goal that rides along”POST /sessions/:id/follow_up has three delivery paths, and which one a request takes depends on
the session’s status at the moment it arrives:
| Session status | What happens | Response |
|---|---|---|
waiting / needs_input | prompt is written to the session and AgentSessionJob is enqueued | 200 |
running | prompt becomes a pending EnqueuedMessage, delivered when the turn ends | 202 |
any of the three, with force_immediate: true | staged as an EnqueuedMessage and delivered through Sessions::InterruptService, terminating the running turn | 200 |
On the direct path the prompt is also recorded on the session itself, not only handed to the job. That is what makes a 200 mean delivery: a worker shutdown between the response and the turn discards the job, and without the record the session would be resumed on a generic recovery nudge with the prompt gone and nothing to say so — see a follow-up the session is still holding. Nothing about the request or the response shape changes; the record is internal.
Each path adds a one-line turn-source note to the turn the agent receives.
The note says where the prompt came from. The direct path adds it before the prompt is stamped and
enqueued. The queued and interrupted paths record it on the EnqueuedMessage (source_context, also
in the enqueued_message JSON) and add it when the message is delivered.
goal behaves identically on all three: a non-blank goal is applied to the session, a blank or
omitted one leaves the session’s existing goal alone. The queued and interrupted paths carry it on
the EnqueuedMessage and EnqueuedMessageProcessorService applies it when it claims the message;
the direct path writes it alongside the prompt. A goal over GOAL_MAX_LENGTH (50,000), or a single
word that is not a known goal id, is rejected with a 422 before anything is delivered, on every path.
Repeating the session’s own goal is never refused, even when that goal is an id since retired from
the catalog.
pending_wake — the session was asleep on a wake-up of its own, and still is. When the target
had an armed one-time wake at the moment the follow-up arrived, all three responses carry an extra
key:
{ "pending_wake": { "at": "2026-09-05T08:06:00Z", "preserved": true } }The key is absent, not null, when there is no such wake, so a client can test for it. at is
null when the wake is a session-scoped watcher with no wall-clock time. preserved: true is the
statement that matters: a follow-up does not cancel the target’s wake, so sending one does not make
you responsible for waking that session — it wakes itself, on its own schedule, after the turn you
just gave it. On the queued and interrupted branches the resume happens later, at the drain, and
preserves it there. When at is set, the target also goes back to sleep on that wake once it
has answered, so poll for waiting, not needs_input; with at null (watchers only) it rests in
needs_input. See A follow-up does not cancel a
wake.
Reporting back to the parent that started you
Section titled “Reporting back to the parent that started you”POST /sessions/:id/message_parent is follow_up run the other way down the hierarchy, and it
exists because that direction had no route at all. A parent can reach a child whenever it likes; a
child’s only channel back was its final message, which the parent reads only if it happens to be
polling get_session. A parent that has archived, or one asleep on a wake that will not fire, never
learned — so a session handed work it could not do (the wrong agent root, a missing MCP server or
credential) had nowhere to report that except a GitHub issue.
:id names the child, and there is no parameter naming the target. Zimmer reads
parent_session_id itself. That is the whole design: the same capability is on the filtered
self_session MCP surface a session carries, where an argument naming an arbitrary target would be a
general session-to-session messaging primitive rather than a report to one’s own parent. follow_up
above is that general form, and it stays on the full surface.
| Parameter | Meaning |
|---|---|
message | What the child is telling its parent. Delivered quoted — every line reaches the parent behind a > prefix, so a body carrying its own separator or bracketed header cannot end the quotation early and continue in Zimmer’s voice. Bounded so that it plus the framing fits inside PROMPT_MAX_LENGTH; the 422 names the room left |
reason | wrong_scope (the work belongs to a different agent root), missing_tools (an MCP server, credential or privilege it was not given), or other. Required, and a closed list, because it is what a parent branches on |
force_immediate | Interrupt a running parent instead of queuing. Off by default — see below |
unarchive_parent | Restore an archived parent out of the trash and deliver to it |
Delivery is the ordinary follow-up routing, not a second path: a running parent takes the report
on its EnqueuedMessage queue (202), and a waiting or needs_input one takes it now (200) —
including a parent asleep on a wake, which is woken, since that is exactly the parent that would
otherwise never learn. Queuing is the default rather than interrupting: the parent of a stuck
child is usually a router mid-delegation, and ending that turn to say “child #N cannot do this”
costs the other delegations in flight while the news itself keeps a few minutes.
Because it is the ordinary queue, it is inside the ordinary accounting. The row’s origin is
caller, so the parent cannot archive over an unread report without being refused, and a forced
archive retires it to undelivered and raises the strand alert. A report cannot be accepted and then
silently vanish.
Four refusals, each with something the child can do about it:
| Situation | Response |
|---|---|
| The session has no parent — it was started by a human or a trigger | 422. Nothing is upstream; report to the human instead |
| The session is recorded as its own parent | 422. parent_session_id is client-supplied and the model checks existence rather than identity, so this is reachable; it is a defect in how the session was created |
| The parent is archived | 409, naming unarchive_parent. Nothing delivers a message to a session in the trash, so accepting one would throw it away |
| The parent has failed | 409. A human has to restart it first |
A fifth response is not a refusal but a retry: a 409 naming a message position conflict. The unique constraint on (session_id, position) is deferred to COMMIT, and the parent’s queue has several other writers, so a report can lose a race with one of them. Send it again.
unarchive_parent: true runs the same restore as POST /sessions/:id/unarchive — clone, transcript
and all — and then delivers. It is deliberately opt-in rather than automatic: it interrupts a session
that considered its work finished, so it is for work that still has to happen, not for filing a note.
The caller is self-declared in the same sense as acting_session_id below — the shared API key
identifies a caller, not a session, so :id is a claim. What is not self-declared is the target:
the parent comes from the database. The MCP surface, which does know which session a connection was
written for, refuses a report sent on another session’s behalf; this endpoint cannot check.
acting_session_id: declaring yourself as the caller
Section titled “acting_session_id: declaring yourself as the caller”follow_up, and the enqueued-message create and interrupt endpoints, take an optional
acting_session_id. If you are an agent session driving another session, set it to your own id and
Zimmer records an “uncle” lineage edge marking you as a senior of the target — which widens that
session’s hierarchy to include yours, so the human messages recorded in your hierarchy reach it as
elsewhere context.
It is self-declared and unverified, because the API key is shared by the whole fleet and identifies a caller but not a session. Omit it and nothing is recorded; that is the right answer for a script or a person with a curl command. The rules — including what happens when a junior calls back into its senior — are in Hierarchy and human messages, and the provenance consequences are in Limitations.
Removing an uncle edge
Section titled “Removing an uncle edge”DELETE /api/v1/sessions/:id/uncle_links/:uncle_idDetaches :uncle_id as an additional senior of :id. Both ends are in the path because direction is
the whole content of an uncle edge: :id is the junior — the session whose hierarchy grew when
the edge was written — and :uncle_id the senior. Either may be an id or a slug.
Optional body param acting_session_id is recorded on both timelines as the actor, self-declared for
the same reason it is on follow_up. Omitting it logs “an undeclared REST API caller”.
204 No Content— the edge is gone, and both sessions’ timelines record when it was written, what entry point wrote it, and who detached it.404 Not Found— there is no such edge. Never a 204: a caller told an edge is gone while it is still widening two sessions’ context has been misinformed. When the pair is joined the other way round, the message says so and names how to remove that one — the edge the caller did not name is left alone.
There is no POST, GET or PATCH on this resource. Edges are written by
Sessions::RecordUncleEdge, as a side effect of a queue or interrupt, which is where the acyclicity
invariant lives — a create here would be a way round it. The same removal is on
action_session → remove_uncle and on the session-detail hierarchy panel.
There is no way to clear a goal through follow_up — a blank one means “leave it”, not “remove
it”. Use PATCH /sessions/:id with goal: "" for that. (The HTML endpoint behind the web follow-up
form reads a blank goal as a clear and an absent one as “leave it”, a distinction the JSON API does
not draw. Nothing in the shipped UI sends the key either way; see
the limitations page.)
The MCP action_session tool’s follow_up action takes the same goal parameter with the same
semantics.
session_json
Section titled “session_json”id, slug, title, status, agent_runtime, prompt, git_root, branch, subdirectory,
goal, goal_check, mcp_servers, all_mcp_servers, injected_mcp_servers, catalog_skills, catalog_hooks,
catalog_plugins, config, metadata, custom_metadata,
is_autonomous, heartbeat_enabled, heartbeat_interval_seconds, auto_compact_window,
genesis, source_context, last_turn_source, scheduling_class, priority_class, session_id, job_id,
running_job_id, archived_at, trash_after, created_at, updated_at, session_notes,
session_notes_updated_at, favorited, visibility, effective_visibility, snoozed_until.
source_context and last_turn_source give the turn source of the
initial prompt and of the latest tagged follow-up, as {surface, modality, client_version?, live_conversation_id?, label}. Each is null when none was recorded. They are style hints, not
authorization.
goal_check is null for a free-text goal or no goal. For a catalog goal with checks it is the
goal check:
{goal_id, goal_name, verdict, provisional, observed_at, delegated_session_ids, criteria: [{key, label, status, detail}], note}.
verdict is met, unmet or pending, and each criterion’s status is met, unmet,
pending or unknown. provisional is true while the session is running or waiting, which is
before it has come to rest. observed_at is when the poll pass last read the PRs judged.
delegated_session_ids lists the spawned sessions whose PRs were judged because this session recorded
none of its own, and is empty otherwise. It is advisory: nothing in Zimmer acts on it. It is computed
from custom_metadata, plus the spawned sessions’ PR state for a PR goal with no PR of its own.
GET /sessions and GET /sessions/search read that for the whole page in at most three queries.
Every response with a session key renders it through the same serializer
(ApiSessionSerialization), including POST /enqueued_messages/:id/interrupt — session means one
shape everywhere on the surface.
GET /sessions/:id returns more top-level keys alongside session, never inside it —
precisely so the one-shape rule above keeps holding, since they cost queries the index would pay once
per card:
status_summary— the cached “where things stand” blurb, ornullwhen the session has never had one requested.summaryandmessages_since_generatedarenulluntil text exists;state(idle/pending/ready/failed),generatinganderrorare always present, so a caller that asked for a regeneration can tell “still running” from “failed” without polling for text that may never arrive.messages_since_generatedcounts transcript events since the blurb was written —0means current. Reading it never generates one; see The Status summary.waiting— the session page’s “why is it waiting” and recovery banners, in the page’s own words, so a client shows them without re-deriving any of it.reasons[]isSessionWaitingReason’s ranking —mechanism(spot_hold,spot_pause,auth_outage_park,turn_queued),label,at,current(exactly one is) anddemoted(an overdue hold nothing will re-arm).banners[]come in the order the page stacks them, each{key, tone, headline, body, details, action}:toneisinfoorwarning,bodyis paragraphs of plain text,detailsthe structured facts behind them (times in ISO 8601), andactionisnullor the banner’s one control. A banner whose inputs could not be read (theagentsqueue, the account pool, the trigger table) is left out and logged rather than failing the request. The keys:quota_limit_paused,auth_outage,spot_hold(holds and pauses alike, with the superseded-reason lines when another mechanism owns the wait),starvation_admission,queued_turn,mcp_oauth_reconnect,mcp_oauth_required,lost_elicitationandpending_wake— the last the one banner the web page does not draw, shown only on awaitingsession. The action kinds name the call to make:update_scheduling_class(PATCH /sessions/:idwith itsscheduling_class),force_start(POST /sessions/:id/force_startwith itsexpected_victim_id;confirmis the web page’s confirmation text),follow_up(POST /sessions/:id/follow_upwith itsprompt) andopen_in_browser(pathon the web app — MCP OAuth is a browser round-trip). Both arrays are empty when there is nothing to say.session_hierarchy— the lineage graph this session belongs to:origin_session_id,root_session_ids,truncated,truncation_reason, andnodes[]each withid,title,agent_root,status,depth,parent_session_id,render_parent_session_id,spawn_edge,uncle_session_ids,current,genesisandpriority_class.parent_session_idis the spawn edge and means “spawned”, NOT “most recently talked to”.render_parent_session_idis the node this one is drawn under — whatdepthplus the array order expresses — andspawn_edgeisfalseon the rare node no spawn edge placed: one the walk could only reach through an uncle, or the requested session appended so it stays visible after the node ceiling cut its branch. Rebuild the tree fromrender_parent_session_id, never fromdepthalone:nodes[]is ordered depth-first so each node follows the one it hangs from, but a consumer that infers the parent from indentation is the bug that made every child of a sibling batch read as a child of the last sibling.spawn_edge: truedoes not implyrender_parent_session_id == parent_session_id. A session can record its spawner in bothparent_session_idand the legacycustom_metadata.router_session_id, andrender_parent_session_idis whichever of the two the walk reached it through — both are spawn claims, so the edge is still a spawn edge.uncle_session_idsare the sessions that queued or interrupted this one and are therefore treated as additional seniors — self-declared by the caller, so a claim rather than a fact.origin_session_idis the spawn origin and stays single-valued;root_session_idsis every root the graph is drawn from, which uncle edges can make more than one. See Hierarchy and human messages.human_messages— the messages Zimmer knows a named human authored anywhere in that tree, each withorigin(here— a human spoke to this session — orelsewhere— a human spoke to another session in the hierarchy),author,author_display_name,channel,channel_label,authored_in_session_id,authored_in,entry_point,contentandoccurred_at.human_message_capture_gaps— the input channels this hierarchy came in through that capture cannot write for, each withchannel,channel_label,genesis,session_ids,reasonandremedy. Normally empty.
All five are unconditional on the show action. An empty human_messages means no human authored
anything in this hierarchy — but only when human_message_capture_gaps is also empty. A
non-empty gap array means a channel this hierarchy arrived over had nothing listening on it (chiefly
Slack, whose user IDs are a per-deployment row edit at /supervisor/users), so the empty
human_messages is “the check could not be established” rather than “no human spoke”. Read them
together; see Hierarchy and human
messages
for why absence is the point and when it is not an answer.
Five of those fields are easy to misread:
all_mcp_serversis the effective set — selected + plugin-bundled + auto-injected. Read this one to learn what a session actually has wired.injected_mcp_serversis only what Zimmer adds itself (zimmer-self-session;zimmerfor subagent roots). A strict subset, and not evidence of what is available.is_autonomousgoverns whether the session fires broadcast (unscoped) event triggers. It defaults to true; set it false for user-driven sessions that shouldn’t trip global automation.metadatais Zimmer’s own bookkeeping —clone_path,exit_status,agent_root_key, and friends.custom_metadatais the one you own.
heartbeat_enabled defaults to false.
The Quick Router ingest
Section titled “The Quick Router ingest”POST /api/v1/quick_router is the browser extension’s way in: the
Quick Router bubble from any page on the web. It is the narrowest endpoint in the API, on purpose.
It takes a different key. The X-API-Key header must hold a key minted on /settings/api_keys
with Quick Router only chosen — an ApiKey whose grant is quick_router. A full-API key, an
API_KEYS entry, no key and a revoked key all get the same 401 here; and the Quick Router key gets
that 401 from every other endpoint on this page and from POST /mcp. Both directions are one
comparison in ApiKey.authenticate, so the key that sits in a browser’s extension storage can
start Quick Router sessions and read nothing back. There is no CSRF token to present — the request
comes from the extension’s service worker — and no CORS, because an extension’s own fetch to a host
it holds a permission for is exempt from it.
Zimmer’s iOS app uses it too, with its bearer token instead of a
key. The session is started the same way, with metadata.source set to ios_app. It has api
genesis at priority, as MCP’s quick_router does for an OAuth caller, because it arrives over an
OAuth grant rather than from a browser — unless scheduling_class is spot. The prompt is recorded
the same way as the app’s follow-ups (ios_app.quick_router). The rate limit applies to both.
The route also takes what the in-app bubble’s Advanced accordion and attach buttons send, so the app can offer the same: a harness, a model, the spot opt-in, a parent session and attachments.
curl -X POST https://zimmer.example.com/api/v1/quick_router \ -H "X-API-Key: zmr_…" -H "Content-Type: application/json" \ -d '{ "prompt": "This retry comment is wrong — 502 from GitHub is not transient here.", "page_url": "https://github.com/tadasant/zimmer/pull/1150", "page_title": "refactor(catalog): … · Pull Request #1150", "page_context": "# refactor(catalog): serve the snapshot everywhere\n\n…", "pin": { "x": 840, "y": 2310, "viewport_width": 1440, "viewport_height": 900, "selector": "#discussion_bucket > div.comment:nth-of-type(3) > p", "tag": "p", "text": "We should retry on 502 here.", "excerpt": "**tadasant** commented\n\nWe should retry on 502 here.\n\nOtherwise the poller gives up." } }'| Field | Required | What it is, and its cap |
|---|---|---|
prompt | yes | The human’s words. ≤ 500,000 characters, like every prompt |
page_url | no | Cut to 2,048 characters |
page_title | no | Cut to 300 |
page_context | no | The page, as markdown. Cut to 50,000 — the same cap as the in-app bubble’s; the extension sends at most 20,000 |
pin | no | An object. x, y (page coordinates), viewport_width, viewport_height are integers; selector (≤ 500), tag (≤ 32), text (≤ 1,000) and excerpt (≤ 4,000) are strings. Anything else in it is dropped; a pin that is not an object is ignored |
agent_runtime | no | A registered harness (claude_code, codex, …). Blank leaves it to the router root. One the registry does not carry is a 422, “That harness is not available.” — refused, not replaced, as the bubble refuses it |
model | no | A model the effective harness offers (runtime_models in GET /api/v1/configs). Blank leaves it to the ordinary chain (the router root’s, then Settings, then the catalog’s default). One the harness does not offer is a 422 |
scheduling_class | no | spot to wait for quota room instead of starting now; it lands at the top of the spot queue, as the bubble’s does. Anything else leaves the class alone |
parent_session_id | no | The session the request was made from, so the new one threads under it (the bubble on a session page sends this). An id that names no session is a 422 |
attachments | no | Inline, as follow_up carries them: a list of { filename, media_type, data }, data base64. At most 20, 75 MB together; the body may be 110 MB. One bad attachment refuses the whole request before any session exists |
The pin’s caps are separate from the page’s, so a long page never truncates away the thing that
was pinned. Everything is composed into one prompt — the same
<context-about-user's-current-view> block the chat bubble writes, with a <pinned-element>
section inside it when there is a pin, and the human’s words last and unchanged — and a session is
created on the router root with genesis web_ui, metadata.source browser_extension,
metadata.original_prompt holding the words as typed, and metadata.pin holding the pin as
normalized. The words are also recorded as a human message
with entry point browser_extension.quick_router: this is the one API route that does, because
its key is a browser’s and not the fleet’s.
// 201{ "session_id": 4123, "session_url": "https://zimmer.example.com/sessions/4123" }That is the whole response, and there is no GET. Errors: 401 for the key, 422 for a blank
prompt, a prompt over the cap, prompt and page together over the cap, or a router root the catalog
cannot resolve, and 429 (with retry_after, in seconds) past 10 sessions a minute from one
client address — the ceiling on what a leaked key can spend from one place, not something a person
dropping pins meets. The session_url is built on the instance’s configured base URL
(ZIMMER_PROD_BASE_URL), not on the host the request came in on, and is left out while that base
URL is still the zimmer.example.com placeholder.
Zimmer plugins
Section titled “Zimmer plugins”A Zimmer plugin’s key opens two routes and nothing else. Every other
route on this page answers it 401, and these two answer every other key 401:
GET /api/v1/external_app/triggers— the triggers this key may invoke:{ "external_app": {id, name, description}, "triggers": [{id, name, variables, max_sessions_per_minute}] }.POST /api/v1/external_app/triggers/:id/invokewith{ "variables": { "text": "…" } }—{ outcome, fired, message, trigger: {id, name}, session: {id, status, url} | null }.201when it fired;429forburst_notice/burst_suppressed,409forpending_session,404for a trigger not on the allowlist,422for bad variables and the rest. A disabled plugin gets403.
The full contract, with the MCP equivalent, is on Zimmer plugins.
Plugins are registered on Settings → Zimmer plugins or with the external_apps MCP tool group.
There is no REST route for managing them.
Triggers
Section titled “Triggers”GET /triggers (filters condition_type, status) · GET /triggers/:id (+ recent_sessions,
limit 10) · POST · PATCH · DELETE · POST /triggers/:id/toggle ·
POST /triggers/:id/toggle_enqueue_messages · POST /triggers/:id/toggle_resuscitate_archived ·
POST /triggers/:id/invoke · GET /triggers/channels (Slack; 503 when Slack is unconfigured) ·
GET /triggers/whatsapp_chats (the chats the WhatsApp bridge’s account is in, with the chat_id a
whatsapp condition takes; 503 when WHATSAPP_MCP_URL is unset or
the bridge cannot be reached).
Conditions are nested via trigger_conditions_attributes.
toggle_enqueue_messages and toggle_resuscitate_archived flip the two switches on the trigger
page and return {trigger}. Both flags act only on the session a trigger re-uses, so while
reuse_session is off the flip is refused with 422 (error: "Validation failed", message: "Enqueue messages can only be enabled when re-use session is enabled", and the same for “Resuscitate
archived”), carrying the unchanged trigger. To set either flag outright, or together with
reuse_session, PATCH the trigger.
A trigger payload carries unresolved_catalog_references, read-only: which of the four artifact
lists name something the catalog can no longer resolve, and when each name was first seen that way
({"mcp_servers": {"slack-workspace": "2026-09-03T15:35:11Z"}}). The heal keeps such a name on the
trigger and filters it out of the sessions it spawns rather than deleting it (see Stale catalog
references), so this field is how a
caller tells a configured artifact from a broken one. It reflects what fires have found: a trigger
that has not fired since the rename reports {}.
A trigger payload also carries agent_root_missing_from_catalog, read-only: true when the
catalog has no agent root under this trigger’s agent_root_name. Unlike the four artifact lists,
agent_root_name is not rejected at save when the catalog does not know it — naming a root
before the catalog entry that defines it exists is a legitimate ordering, so the write succeeds and
POST/PATCH come back 201/200 carrying a warnings array instead:
{ "trigger": { "id": 12, "agent_root_name": "root-that-lands-tomorrow", "agent_root_missing_from_catalog": true, "status": "enabled" }, "warnings": ["Agent root 'root-that-lands-tomorrow' is not in this deployment's catalog. …"]}warnings is omitted entirely when there is nothing to say, so its presence is the signal.
Both fields report false/absent when the catalog itself could not be read, because then nothing is
known about any name. See An agent root the catalog does not carry
yet.
POST /triggers/:id/invoke fires the trigger now, without waiting for one of its conditions to
match — the same fire the Invoke button on the trigger page performs, through the same code path. The
session is linked to the trigger, counts toward sessions_created_count, and a reuse trigger follows
up its target session rather than spawning a new one. status is not consulted: a disabled trigger
can still be invoked, which is how you test one before enabling it — status governs whether the
trigger’s own conditions fire it, not whether a caller may.
Send variables — an object keyed by the prompt template’s placeholders (link, text, author,
channel, event, repo, number, title, labels, channel_id, message_ts, thread_ts,
author_id) — to fill them in. Any other key is ignored, a placeholder the template names but the
request omits interpolates as an empty string, and {{time}}/{{date}} fill themselves in. All of
them take a string; labels also takes an array, which is joined with commas. The four Slack IDs
interpolate as an empty string unless they are in Slack’s ID format — see
Prompt template variables.
curl -X POST https://zimmer.example.com/api/v1/triggers/12/invoke \ -H "X-API-Key: $ZIMMER_API_KEY" -H "Content-Type: application/json" \ -d '{"variables": {"link": "https://example.com/msg/1", "channel": "eng-alerts"}}'201 Created returns {trigger, session, burst_notice, message} — session is the compact
{id, slug, title, status, created_at} shape recent_sessions uses, and trigger is the full
payload with its counters already updated. Two outcomes are not ordinary successes:
burst_notice: true(still 201) — the trigger blew its burst cap, sosessionis the burst-notice session it spawned instead of the one you asked for.- 429 Too Many Requests (
error: "Burst suppressed") — the trigger is inside a burst it has already announced, so nothing at all was created. - 409 Conflict (
error: "No session created") — the trigger hasskip_if_pending_sessionon and a session it already spawned is stillwaitingorrunning.sessioncarries that pending session, so you can go and look at it instead of firing again.
A one-time reuse trigger whose target session is gone or is no longer reusable returns 422
(error: "No session created"), with session carrying that target when the row still exists and
null when it does not. An agent root that cannot be resolved returns 422
(error: "Invalid agent_root").
The MCP equivalent is action_trigger with action: "invoke".
skip_if_pending_session (boolean, default false) makes the trigger spawn nothing while a session
it already created is still pending —
waiting or running. It bounds the backlog of duplicate-intent sessions, where
max_sessions_per_minute bounds the rate. needs_input, archived and failed predecessors never
block a fire.
max_sessions_per_minute (integer, nullable) sets the trigger’s burst
cap; null — the default — means unbounded. The trigger payload
also reports bursting, true while the trigger is inside a burst and spawning nothing.
It guards the spawn path only, so it does nothing on a fire into a reused session. The payload
reports skip_if_pending_session_inert (boolean), true while the trigger has a live session to reuse,
rather than leaving a caller to infer that the flag it just set will not be read on those fires — it
applies again on a fire that has to spawn.
coalesce_window_seconds (integer, nullable) sets the trigger’s Slack coalescing
window: messages that land in the same
channel, thread or DM within that many seconds of each other are one event and spawn one session,
with the rest folded into its prompt. null — the default — inherits 60 seconds; 0 turns
coalescing off so every message spawns its own session. The payload reports the stored value beside
effective_coalesce_window_seconds (the one actually in force), so a caller can tell “inherits 60s”
from “set to 60s”, and coalesce_window_inert (boolean), true when a window is stored on a trigger
with no Slack condition to read it.
missed_fire_count (integer) and first_missed_fire_at (ISO 8601, nullable) report consecutive
scheduled runs that did not happen because the reused session had not consumed the previous
prompt — see coalescing a repeated fire.
last_triggered_at advances on a coalesced fire exactly as on a delivered one, so it cannot be used
to tell the two apart; this pair can. Both reset when a fire lands.
scheduling_class (spot / priority / null) sets the spot or
priority class of the sessions this trigger spawns; null — the
default — derives it from the trigger’s condition type. The payload reports both scheduling_class
(what was chosen, usually null) and effective_scheduling_class (what its sessions actually get).
Changing it applies to sessions the trigger spawns from then on and to that trigger’s own
already-spawned sessions still in waiting — see what a class change
reaches. A PATCH that changed the class
carries reclassified_waiting_sessions: how many of those sessions actually moved between classes.
The key is absent when the request did not touch the class, so its absence means “not asked” rather
than “nothing moved”.
precedence (an integer, or null) predefines the rank the trigger’s sessions land on in the spot
queue. Same absolute scale, same “applies to sessions spawned from now on” rule as the class.
catalog_skills, catalog_hooks and catalog_plugins (arrays of AIR catalog ids) are the skills,
hooks and plugins stamped onto every session this trigger spawns, and the payload reports all three
so a caller can read a trigger’s configuration and not only overwrite it. Every id is validated
against the catalog: an unknown one fails the write with 422 rather than being persisted and
breaking the next spawn. Unlike mcp_servers, an omitted key means “leave the list alone”.
[] does not mean “no skills”. A session spawned from a trigger resolves each list as
catalog_skills.presence || agent_root.default_skills, and a fire into a re-used session leaves
its artifacts alone when the trigger’s list is empty. So an empty list means the trigger says
nothing about that artifact and its sessions take the agent root’s defaults — sending [] resets
the trigger to that state rather than stripping anything.
enqueue_messages and resuscitate_archived (booleans, both default false) only apply to a
trigger with reuse_session on: the first queues a fire’s prompt for a target session that is still
running instead of dropping it, the second
unarchives a target session that has been trashed. Sent without reuse_session they are cleared
by the model rather than rejected, and the response payload reports the false that was stored — so
read it back rather than assuming the request took. The MCP equivalent, action_trigger, refuses
that combination outright.
status is one of enabled, disabled, or failed, and all three work as ?status= filters.
failed is Zimmer’s to set: a one-shot fire raised and the trigger was
parked rather than destroyed — either a one-time
schedule or a session-scoped ao_event wake. The payload carries
failed_at and last_error alongside it, both null on a healthy trigger — any write that moves
the status off failed, including POST /triggers/:id/toggle, clears them.
Live conversations
Section titled “Live conversations”Live Mode: one continuous conversation with a fast OpenAI model, typed or with
voice on, switching between the two mid-thread. The same rules as the /live pages
(LiveConversations::Actions).
An API key may read: GET /live, GET /live_conversations and GET /live_conversations/:id.
Every POST below — start, switch, message, finish, and the voice relay — is 403 for an API key
and for a relay-only app grant, like authorize_merge. It takes the iOS app’s
sign-in acting as you. A live model can enable any catalog MCP server, and tool_calls runs one
directly, so an API key, which is the fleet’s, must not reach the whole catalog through here. The web
pages are the other way in, behind the sign-in wall.
GET /live → {label, agent_root, available, key_configured, root_present, reasons, modes: [{mode, label, model, agent_root, available, key_configured, root_present, reasons}]} — whether Live Mode
can start, and in words why not (no LIVE_OPENAI_API_KEY, or no live-mode root in the catalog),
with each mode’s model. Typing and voice share the prerequisites, so every modes entry repeats them.
GET /live_conversations → {live_conversations: [...], current_id, current: {<mode>: id}}, the
newest 20 and the open conversation’s id (or null). current is the same conversation keyed by the
mode it is in right now, empty when there is none ·
POST /live_conversations (mode, text by default or voice) → 201, a preparing conversation
that ends the open one; 503 with the reasons when Live Mode is off, 422 on an unknown mode ·
GET /live_conversations/:id (after_turn_id for only newer turns) → the conversation with turns ·
POST /live_conversations/:id/mode (mode text|voice) → the conversation, switched. A no-op when it
is in that mode already; 409 once it has ended, 422 on an unknown mode ·
POST /live_conversations/:id/finish → the ended conversation.
A conversation is {id, mode (text|voice — right now), voice, label, mode_label, status (preparing|active|ended|failed), title, agent_root, model, instructions_source, mode_styles: {text, voice: agent_root|zimmer_default}, mcp_servers, responding, error_message, created_at, updated_at, last_activity_at, ended_at}. model follows mode. A turn is {id, role (user|assistant|tool), source (typed|spoken), content, tool_name, call_id, tool_arguments, tool_output, tool_error, created_at}.
Typed: POST /live_conversations/:id/messages (content, at most 8,000 characters) → 201, the
user turn. The reply is written by a job; poll GET /live_conversations/:id?after_turn_id=. A message
sent while the conversation is still preparing is accepted and answered once it is active. 422
while voice is on.
Voice — the relay a client holding the audio connection makes:
POST /live_conversations/:id/client_secret→{client_secret, expires_at, model, calls_url, websocket_url, tools}, plussettings_warningwhen OpenAI refused the Live settings and the call runs on the defaults. A ten-minute OpenAI Realtime secret whose session config (instructions for the spoken mode with a recap of the thread, tools, and the Live settings) Zimmer chose. Open WebRTC by posting an SDP offer tocalls_url, or a WebSocket towebsocket_url, withAuthorization: Bearer <client_secret>. 409 while the conversation is stillpreparingor voice is off; 502 when OpenAI refuses.POST /live_conversations/:id/tool_calls(name,argumentsas the model’s JSON string,call_id) →{output, error, tools_changed, turn_id}, plustoolswhentools_changed, andsessionwhen the call changed a Live setting an open call can take. Sendsessionas asession.updatewhen it is there, otherwise sendtoolsin one. Sendoutputback as afunction_call_outputitem.POST /live_conversations/:id/session_update→{session}: thesession.updatepayload that brings an open call up to the current Live settings and guidance. Send it after the settings changed somewhere else, such as the app’s own settings screen.POST /live_conversations/:id/turns(roleuser|assistant,content) → 201 with the turn andrespond: true. A finished transcript line, so the thread carries what was said. Withlistening_modeson a call whose Listening is gated, a user line is also where Zimmer decides whether the call answers it. Kept, it is 201 withrespond: true: sendresponse.create. Dropped, it is 200{respond: false, dropped: true}and nothing is written: sendconversation.item.deletefor the line’s item.
client_secret, session_update and tool_calls take listening_modes too. It says the client
asks for its own replies when Listening is gated, and client_secret then answers listening
(always, wake_word or dynamic). Without it, the call is built to answer everything.
Every one answers 409 on a conversation that is not active. tool_calls, session_update and turns do not need
voice to be on, so a call still connected when it is switched off elsewhere finishes cleanly.
Live settings
Section titled “Live settings”The Live settings and the Live mode guidance. An API key may read
them. The writes take the app’s sign-in acting as you, and are recorded as a person’s; an agent
changes them through MCP (action_live_settings), where the change is recorded as an agent’s.
GET /live/settings→{settings: {key: value in force}, overrides: [keys set away from their default], guidance, updated_at, updated_by, fields: [{key, path (text|voice), type (enum|float|integer), label, help, options: [{value, label}], min, max, default, mid_call}], guidance_max_length, history: [{id, actor_kind (human|agent), actor, live_conversation_id, changes: {key: {before, after}}, created_at}]}, the newest 30 changes.PATCH /live/settings(any setting key,guidanceto replace it,append_guidanceto add a line) → the same, plus{changed, summary}. Blank or"default"puts a setting back. 422 with every problem, and nothing saved, on an unknown key or a value not among itsoptions. A number outsidemin–maxis clamped, andsummarysays so.POST /live/settings/reset→ every setting back to its default; the guidance is kept.POST /live/settings/changes/:id/restore_guidance→ the guidance as it was before that change. 422 when the change did not touch the guidance.
Notifications
Section titled “Notifications”GET /notifications (status=read|unread) · GET /notifications/:id ·
GET /notifications/badge → {pending_count} · PATCH /notifications/:id/mark_read ·
PATCH /notifications/mark_all_read · DELETE /notifications/:id/dismiss (422 if unread) ·
DELETE /notifications/dismiss_all_read · POST /notifications/push (session_id + message).
POST /apns_devices (token hex, environment sandbox|production, device_name, app_version)
→ 201 {apns_device} · DELETE /apns_devices/:token → 204. How the iOS app
registers a phone for push. It is an upsert on the token, and one made with the app’s bearer token is
tied to its grant. Both take the app’s token or an API key. DELETE reaches only the caller’s own
registrations: those under its grant for the app’s token, and grant-less ones for an API key. A token
outside that scope is left alone and still answers 204.
The auth boundary: who you are, connections and keys
Section titled “The auth boundary: who you are, connections and keys”What the Settings page’s Sign-in card, its Deployment Information and Settings → API keys show,
for the iOS app. The reads take the app’s token on any grant, or an API key.
Every write takes the app’s token on a grant that acts as you, and nothing else: an API key is
the fleet’s, not a person’s, and one that could mint keys would issue itself credentials. An API key
or a relay-only grant gets the same 403 as authorize_merge (the message says which it was), before
any lookup.
GET /me → {me, web_sign_in, deployment}. me is the credential the request came in on:
authenticated_as native_app with email, privilege (act_as_human or relay_only),
acts_as_you and grant_id — the caller’s own row in /oauth_grants — or api_key with the key’s
{id, name, source, grant} and no person. web_sign_in is the Sign-in card for that email:
{email, name, last_signed_in_at, totp_required, totp_enrolled_at, recovery_codes_remaining}, the
last two nil until an authenticator stands; the whole thing nil when
web sign-in is not configured, when nobody with that email has signed in to
the web UI, over an API key, or on a relay-only grant, which is not that person’s sign-in. deployment is {git: {commit_sha, commit_short, branch, commit_date}, environment: {rails_env, ruby_version, rails_version}}, read once at boot.
GET /oauth_grants → {oauth_grants: [...]}, the page’s MCP connections: active first, most
recently used first, at most 50. Each row is {id, client_name, client_label, client_id, first_party, registration (published|self_registered), publisher_host, user_email, privilege, acts_as_you, status (active|revoked), current, created_at, last_used_at, scope_changed_at, scope_change_reason, revoked_at, revocation_reason}. current is true on the caller’s own
connection, so the app can say “this phone”. The list, and every write in this section, is the
deployment’s — every connection and every key, whoever approved or minted it — as the page is;
Zimmer is one circle of trust. Only sign_out_everywhere is scoped to the caller’s email.
PATCH /oauth_grants/:id (privilege: act_as_human | relay_only) → {oauth_grant, message}.
The page’s Make relay only / Let it act on my behalf, applied from the connection’s next
request and recorded on the grant as app_upgrade / app_downgrade. 422 for any other value and
for a revoked connection. The caller may lower its own connection; its writes here are then refused
until a person raises it again on the page.
DELETE /oauth_grants/:id → {oauth_grant, signed_out, message}. Revokes the connection — the
caller’s own included, which is how the app signs out: signed_out is true and the next request on
that token is a 401. A relay-only grant cannot revoke even itself here; it signs out through
POST /oauth/revoke, like any OAuth client. Revoking an already-revoked connection is a 200 that
changes nothing.
GET /api_keys → {api_keys: [...]}, every key the page lists — active first, newest first — as
{id, name, status (active|revoked|retired), source (env|minted), grant, fingerprint, external_app, agent_sessions_use_it, last_used_at, created_at, revoked_at}. fingerprint is the first eight
characters of the key’s SHA-256; the key itself is never in a list. agent_sessions_use_it marks the
key this deployment hands its own agent sessions, which the page warns about before a revoke.
POST /api_keys (name, grant api | quick_router, default api; also as api_key[name],
api_key[grant] as the page’s form sends them) → 201 {api_key, token}. token is the only copy
of the key there will ever be; the response is Cache-Control: no-store. 422 with the page’s
messages (Name has already been taken, the 100-character limit, control characters, a grant a
person may not mint). DELETE /api_keys/:id revokes (→ {api_key, message}; the row stays) and
POST /api_keys/:id/restore undoes that, the page’s Restore.
POST /sign_out_everywhere → {signed_out_everywhere, web_sign_ins_ended, this_connection_revoked, message}. What the Sign-in card’s Sign out everywhere does: every browser signed in to the web
UI as the caller’s email is signed out at its next request, its trusted devices are forgotten, and
its live-update connections are closed. The web does that to the browser pressing the button too,
so this revokes the caller’s own connection as well — this_connection_revoked is always true,
and the app has to sign in again. Other phones signed in as the same person keep their connections;
revoke those from /oauth_grants. With web sign-in off there is no web session to end
(web_sign_ins_ended 0) and the call still revokes the caller.
Every write is logged at WARN naming the actor — the grant’s email and that it was the iOS app — the
way the page’s lines name “the settings page”. A revoked connection’s revocation_reason says the
same.
Health
Section titled “Health”GET /health → {health_report, timestamp, rails_env, ruby_version} ·
POST /health/cleanup_processes · POST /health/retry_sessions ·
POST /health/archive_old (days, clamped 1–365, default 7).
GET /health/queue_recovery_mode · POST /health/enter_queue_recovery_mode (reason,
ttl_minutes, clamped 5–240, default 60) · POST /health/exit_queue_recovery_mode — the job-queue
escape hatch. Entering halts execution on pollers, triggers, inference, maintenance and default and leaves agents
running, so a session started to investigate the backlog still runs. GET /health carries the same
state under a top-level queue_recovery_mode key. These three are deliberately not behind the
cooldown described below: an overloaded instance is exactly when the cache is least trustworthy, and
the cooldown fails closed. Entering answers 503 Queue recovery mode unavailable when
config.good_job.enable_pauses is off, rather than reporting a halt GoodJob would ignore. See
Queue recovery mode.
GET /health/queued_jobs (job_class and/or queue_name) →
{scope, matched, by_job_class, by_queue, over_cap, max_per_call} ·
POST /health/discard_queued_jobs · POST /health/reschedule_queued_jobs (delay_minutes,
clamped 0–10080) — the third cleanup lever for a runaway queue. All three require a scope
(job_class, queue_name, or both) and the two POSTs require expected_count; a mismatch, an
unscoped call, a scope over the 2,000-row cap, or the protected agents queue answers
422 {"error": "Refused"} with a message naming the real count and the per-class breakdown, having
changed nothing. A discard is not recoverable; a reschedule is its reversible sibling, and the
response says which with a recoverable boolean. Only unfinished, unstarted, unclaimed rows are
eligible. Not behind the cooldown, for the same reason as the three above. See
Queued job maintenance.
Two health endpoints sit outside this API — no /api/v1 prefix, no API key, because a load
balancer and a deploy gate have neither: GET /up (200 if the process booted) and GET /up/deep
(200 only if the database, the cache and Redis each answered a real round trip; 503 with a
failed list naming the one that did not). They report; they change nothing, and they are not rate
limited. See Deploying.
Inference
Section titled “Inference”The Inference page as data, for the iOS app: the
account roster with each account’s quota windows, the pool’s capacity, the rotation log, the Pi
tab’s OpenRouter key state, and the fleet-scheduling policy the MCP tools get_spot_policy /
action_spot_policy read and write. Every figure comes off the objects the page and the tool
render from (ClaudeAccountPool, SpotGateService, SpotHoldExplanation, FleetTopUpStatus,
SessionGenesis), so the three surfaces cannot disagree.
GET /inference (runtime: claude_code (default), codex or pi, the page’s tabs) →
{runtime, runtimes, accounts, pool, rotation_log, openrouter_key, spot_policy, fleet_top_up, genesis_classes}. Any key or grant may read. Opening it converges a stale quota_exceeded
column against the reading on file, as opening the page does.
accounts— each account as its card renders it:email,priority,is_current,status(the sticky column) andeffective_status(the badge, which the latest reading can outrank),quota_hit_count,api_key_account,credentials(stateone ofnone,unverified,verified,rejected;stored,refused,rejected_at,rejection_reason,verified_at),quota(nullfor Codex, which has no probe:taken_at,trigger,subscription_type,rate_limit_tier,five_hourandseven_dayeach{utilization, effective_utilization, status, reset_at, reset_passed}—statusis dropped once the window has rolled over, as the badge is — andfive_hour_headroom_unusable), andlogin(available,active_attempt,last_attempt). No OAuth token, refresh token or API key is ever in it, and an in-flight login’sverification_urlandverification_codearenullunless the caller is a person (the app’s token on a grant that acts as you): with the device code, a holder of the fleet’s key could finish the login with an account of its own and have it captured under your email.pool— the Account Pool card: the four counts,five_hour_utilizationandweekly_utilization(the averages the spot gate decides on), the worst account on each,read_count,servable_count,weekly_spent_count,capacity_now,next_capacity_at,next_weekly_reset, the dollarwindows, anddry({reasons, breakdown, recovery}) when no account can serve.rotation_log— the runtime’s 50 most recent rotations:at,fromandto({email, deleted}),reason,source,triggered_by, andsession_idwhen a session triggered it.openrouter_key— onruntime=pionly, where the page shows it, andnullelsewhere: the key’s state and never the key —present,provider_badge,fingerprint(a truncated SHA-256),path,last_write,writable,deletable,write_reason,missing_permissions. There is no write for it here; the key is set on the page.spot_policy— the six knobs (gating_enabled,five_hour_reserve_pct,weekly_reserve_pct,max_concurrent_sessions,preemption_enabled,starvation_age_ceiling_hours),worker_slotsand whether the ceiling is out of its reach, the livedecision(allowed,reason,ceiling,detail, sessions on a worker and awaiting one, burn rates, both windows,pool_capacity),hold(why it is held and until when, when it is), the four dormant counts (paused_count,preempted_count,held_count,overdue_hold_count) with their sentences, andstarvation.fleet_top_up— the four knobs (max_running_sessions,idle_minutes,min_fire_interval_minutes,quiet_backlog_interval_minutes), the monitor’sstate, the running / awaiting / asleep split,headroom,under_ceiling_since,last_fired_at,next_fire_at, thecadencephrases and the card’ssentence.genesis_classes— theoverridesand everykindwith itsclass,default_class,overridden,settable(the three kinds no trigger starts; the rest take their class from the trigger) andlive_sessions.
The writes — the page’s buttons and forms — take only the iOS app’s token on a grant that acts as
you; an API key and a relay-only grant get 403 having changed nothing. Each names you on the
record the web writes: a rotation event’s triggered_by and AppSetting’s policy audit line read
<your email> via the iOS app.
| Method | Path | Notes |
|---|---|---|
POST | /inference/accounts | the Add account form: runtime, email (required, unique per runtime), priority, api_key (Codex only). → 201 {account, message}; the OAuth account is created with no credentials and status needs_reauth until a login captures them. 422 for a blank or duplicate email |
POST | /inference/accounts/:id/refresh | re-probe one account’s quota → {account, pool} with the fresh reading; when the probe fails, account.probe_error says why and the reading on file stays |
POST | /inference/accounts/:id/activate | the Switch button: make it the current account, through the same activation path an automatic rotation takes → {account, message}. 422 with the page’s sentence when it cannot be admitted — no credentials, no refresh token, a token the vendor refuses |
DELETE | /inference/accounts/:id | → {message, current_account}; deleting the current account activates the next one whose credentials validate, or leaves the runtime with none. Its snapshots, logins and rotations survive it |
POST | /inference/logins | the Authenticate button: account_id. Starts the login CLI in the worker, superseding any in-flight attempt → 201 {login} in starting. 422 for a Codex API-key account, which carries its credential statically |
GET | /inference/logins/:id | poll it → {login, account}. login carries status, verification_url and (Codex) the one-time verification_code once the CLI prints them, awaiting_code when the Claude flow wants the code pasted back, code_received, error_message, expires_at and terminal. 404 once the attempt is pruned or its account deleted — stop polling. A person only, like the writes: the link and code are what completes the login |
POST | /inference/logins/:id/code | the Claude flow: code, the authorization code the page shows. 422 for a blank code or a finished attempt. The code is handed to the CLI and never echoed |
POST | /inference/logins/:id/cancel | → {login}; a finished attempt is left as it finished |
PATCH | /inference/spot_policy | enabled, five_hour_reserve_pct, weekly_reserve_pct (0–100), max_concurrent_sessions (1–100), preemption_enabled, starvation_age_ceiling_hours (0–720; 0 turns the lane off) — the parameters action_spot_policy set_gating takes, any subset. → {changed, spot_policy}. 422 Nothing to change with none, Invalid spot policy out of range, nothing persisted |
PATCH | /inference/fleet_top_up | max_running_sessions (1–100), idle_minutes (1–1440), min_fire_interval_minutes, quiet_backlog_interval_minutes (1–10080) — action_spot_policy set_top_up’s. → {changed, fleet_top_up}; the same two 422s |
PATCH | /inference/genesis/:genesis | priority_class: spot or priority — the Make spot / Make priority buttons, promote_genesis / demote_genesis on the tool. Reclassifies every session deriving its class from that kind → {message, genesis_classes}. 422 for an unknown genesis, an unknown class, or a trigger-backed kind, which takes its class from the trigger |
DELETE | /inference/genesis | Reset to defaults → {message, genesis_classes} |
Not here: Refresh All (the page streams it; the app refreshes accounts one at a time) and the OpenRouter key’s write and delete, which stay on the page.
GET /api/v1/costs → rollups over a window: totals, cost_breakdown (by kind of token),
by_day, by_agent_root, by_model, by_thread_kind, by_adhoc_source, top_sessions, and
unpriced_models. Window is days (default 7, clamped 1–365) or explicit from/to.
On the whole fleet the response also carries burn_rates: the Costs page’s
burn rate card as
[{harness, model, usd_per_minute, usd_per_hour, sample_session_count, sample_minutes, computed_at}],
dearest first, fresh rates only, [] before any have been computed. They are the scheduler’s current
rates over a fixed sample of recent Claude Code sessions, not a rollup of the window, and they are
fleet rates, so a request narrowed by agent_root or session_id omits the key, as the page omits
the card.
Narrow every rollup with agent_root or session_id — the same two arguments get_costs takes and
the Costs page’s Only this links carry, so all three surfaces answer the same question. session_id
wins when both are given, and the response echoes what it applied under scope. An agent-root scope
excludes ad hoc spend, which belongs to no root; a session scope keeps the ad hoc calls made about
that session. See Narrowing the whole page.
GET /api/v1/costs/records → the underlying rows, paginated. kind selects the table
(session, the default, or adhoc); filter with session_id, agent_root, agent_runtime,
model, subagent, or source. This is the export path for cost-versus-performance analysis —
the app deliberately does not try to do that analysis itself.
Session rows carry agent_runtime, and it is worth filtering on rather than ignoring: the ledger
holds three billing relationships. A claude_code row is subscription spend against an Anthropic
quota window; a pi row is a metered OpenRouter invoice; a codex row draws on a ChatGPT plan and
carries tokens with no dollars, because TokenPricing has no OpenAI rates yet. Reconciling against
any one provider’s bill means subtracting the others’ rows. See
Token spend.
POST /api/v1/costs/backfill → queue a sweep of the ledger’s whole history — every Claude Code
transcript on disk, and every other runtime’s corpus in whatever form it takes (for Pi, the stored
transcripts of every Pi session; for Codex, every rollout under ~/.codex/sessions). This is an ops action with an endpoint rather than a shell:
getting history into the ledger must not require SSH onto the production box. Idempotent — it returns the run already in flight rather than
starting a second one, and ingestion upserts on request_id, so a re-read directory writes no
duplicate rows. The same sweep starts itself after a deploy; this is for a re-scan.
GET /api/v1/costs also carries ledger_coverage: whether the one-time historical sweep has
finished, how far it has got, and covers_since — the oldest call actually stored. A total whose
coverage is unknown is not interpretable, which is why it travels with the figures.
Both rollup and record responses carry a pricing object: the per-MTok rates and cache multipliers used to produce
every dollar figure in that response. Volumes are stored, prices are applied on read, so a figure
without its rate table is not reproducible — see Token spend.
Dollar amounts are list price, not a bill. These accounts are subscription-billed; the figures
are a comparable unit across models. A model with no configured rate contributes zero and is named
in unpriced_models rather than being silently folded into the total.
Elicitations
Section titled “Elicitations”The fallback-elicitation protocol that an MCP server speaks. These two routes take a session token in the path instead of an API key, because the MCP child process has no key and the client sends no auth header:
POST /elicitations/session/:token— creates an elicitation on the session the token names. Requires_meta["com.pulsemcp/request-id"]andmessage. → 201, with_meta["com.pulsemcp/poll-url"]pointing at the token route below. 401 when the token does not verify. 403 when_meta["com.pulsemcp/session-id"]names a different session; a blank one is accepted. 422 whenrequest-idormessageis missing, or therequest-idis already taken.GET /elicitations/session/:token/:request_id— polls one of that session’s elicitations. Auto-expires pastexpires_at. 401 when the token does not verify. 404 when therequest_iddoes not exist or belongs to another session.
Nothing hands out a token URL except the ELICITATION_REQUEST_URL and ELICITATION_POLL_URL that
Zimmer puts in a session’s MCP server environment. See
who may raise a prompt.
With an API key:
POST /elicitations— the session comes from_meta["com.pulsemcp/session-id"](id or slug). → 201. 401 without a valid key, 404 when the session-id names no session, 422 as above.GET /elicitations/:request_id— polls any elicitation. 401 without a valid key.PATCH /elicitations/:id/respond—action_type∈accept | decline | cancel, optionalcontent(kept only foraccept;cancelis the protocol’s “dismissed without answering”).:idis either therequest_idor the numeric primary key, so the identifier you already hold — from a poll response or from the web UI’s own/elicitations/:id/respondroute — works here too. A session token does not reach it.GET /sessions/:session_id/elicitations— the session’s elicitations still waiting on an answer, oldest first, as{elicitations: [{id, request_id, mode, message, requested_schema, field_order, tool_name, context, created_at, expires_at}]}: what the web UI draws a banner for.field_orderlistsrequested_schema’s properties in the order the banner draws them, because a JSON object’s key order does not survive most decoders. One past its deadline is left out; the read does not expire it, the five-minute sweep does.
The iOS app’s token reaches that list and respond, nothing else here.
It answers only on a connection that acts on your behalf;
a relay-only one gets a 403, because an answer approves or refuses what the MCP server does next.
Both polls stay request_id-only on purpose: the protocol speaks nothing else, and accepting a
primary key would turn a poll into a sequential-id enumeration.
Note the parameter is action_type, not action — action is a Rails reserved param.
respond is the authenticated counterpart to a human clicking the button in the web UI: it lets a
script or agent resolve the request, which unblocks the owning session (needs_input → running).
It answers 200 with the poll response — action, content, and a _meta carrying
com.pulsemcp/request-id and com.pulsemcp/responded-at — or 404 when nothing matches the
identifier, 422 when the elicitation is already resolved or action_type is not one of the three.
App settings
Section titled “App settings”The Settings page’s row: the global session defaults, every Settings → Experimental toggle, and
the MCP Apps switch with its per-server allowlist. The page’s forms, the get_app_settings and
action_app_settings MCP tools and these two endpoints all write the same AppSetting; a change
applies to sessions created or spawned from then on. The spot gate and the backlog top-up share the
row but are the fleet’s quota posture, read with get_spot_policy.
| Endpoint | |
|---|---|
GET /app_settings | {app_settings: {session_defaults, runtimes, experimental, mcp_apps}} |
PATCH /app_settings | Change any subset; only the keys sent are touched. Answers as GET does, after the save |
session_defaults is {runtime, model, runtime_overridden, model_overridden, built_in: {runtime, model}}: the effective pair, whether each half is an operator override, and the shipped pair an
empty value falls back to. runtimes lists each runtime with label, default_model and its
models. experimental is one {key, title, description, enabled, default_enabled, extension} per
toggle, in the page’s order. mcp_apps is {enabled, allowed_servers, eligible_servers: [{name, title}]}; the allowlist can only name an eligible server, and anything else sent is dropped rather
than refused, as the page drops it.
The PATCH body is {app_settings: {default_runtime, default_model, experimental: {"<key>": bool}, mcp_apps_enabled, mcp_apps_allowed_servers: [names]}}, every key optional. An empty
default_runtime or default_model clears that override; a null leaves it alone. A runtime +
model pair the catalog does not allow is a 422 (error: "Settings not saved"), as is an
experimental that is not an object, or an experimental key the registry does not list
(error: "Unknown experimental setting"); nothing changes on any of them.
mcp_apps_enabled and mcp_apps_allowed_servers are a person’s to change, as on the page: the
iOS app’s token on a grant that acts as you. An API key, or a relay-only grant, gets a 403 before
anything is read. The audit line names the writer: ios-app:/settings or api:/app_settings,
beside the page’s web:/settings.
curl -X PATCH "$BASE_URL/app_settings" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"app_settings": {"experimental": {"mcp_tool_search": false}}}'Models
Section titled “Models”Models added to a runtime’s catalog without a deploy. The built-in models are read-only and are
listed with the added ones under runtime_models in GET /configs. Settings → Models and the
manage_models MCP tool write through the same path; see
Adding a model without a deploy.
| Endpoint | |
|---|---|
GET /model_catalog_entries | {model_catalog_entries: [...]}, every added model |
POST /model_catalog_entries | Add one. runtime and model_id are required; label, requires_oauth and allow_unlisted are optional. 201 with {model_catalog_entry} |
DELETE /model_catalog_entries/:id | Remove one. 204 |
An entry is {id, runtime, model_id, label, requires_oauth, cli_listed, cli_version, cli_note, shadowed_by_built_in, added_via, created_at}. label falls back to model_id. cli_listed is
what the installed CLI said when the model was added: true, false, or null when the runtime
has no model list (Claude Code) or the check could not run. shadowed_by_built_in is true once a
deploy has made the id built-in, and then the row does nothing. added_via is web_ui, api or
mcp.
A POST is refused with 422 in two ways. error: "Validation failed" is the id’s shape: whitespace,
a leading -, more than 100 characters, a dated snapshot, a Claude version pin under claude_code, a Pi id with no provider,
a duplicate, or an id that is already built in. error: "Model not listed by CLI" means the CLI’s
model list does not name the id. That response also carries cli_listed, cli_version and
cli_note, and resending with allow_unlisted: true adds it anyway.
A DELETE is refused with 422 and error: "Model in use" while the Settings page’s session default
names the model.
curl -X POST "$BASE_URL/model_catalog_entries" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"runtime": "codex", "model_id": "gpt-5.7", "allow_unlisted": true}'Work backlog
Section titled “Work backlog”The work backlog: the ranked queue of issues the issue work gate has
cleared and nobody has started. index, create and pull mirror the get_work_backlog,
append_work_backlog_item and pull_work_backlog_items MCP tools through the same filter, append
and pull objects, so the two surfaces cannot disagree. start_now, pin, unpin and remove are
the human-only operations and deliberately have no MCP counterpart. hold is not one of them: it
mirrors hold_work_backlog_item_for_decision through WorkBacklog::Hold.
| Endpoint | What it does |
|---|---|
GET /work_backlog_items | The queue in rank order, each item with its whole-queue position, plus counts and the ranking bands. Filters: status (default queued; all for history), surface, repo, scope_direction, kind, estimated_cost, pinned, key, added_by, plus page/per_page |
GET /work_backlog_items/:id | One item. :id is the row id or the item’s key (zimmer#498) — the queued row for that key, else the latest |
POST /work_backlog_items | Append one. Body: key, issue_url, repo, surface, title, kind, scope_direction, estimated_cost, plus ratings, gate_verdict, gate_session, decided_at, notes, optional added_by (default human) and writing_session_id (self-declared; acting_session_id is accepted as an alias). Any other key rides into payload, as over MCP. A human may hand-place on create with pinned: true and a precedence, and may add an issueless item with a prompt. Idempotent on key among queued items: 201 with created: true, or 200 with the existing item and created: false |
POST /work_backlog_items/pull | The groomer’s pull. Body: count (top N) or keys (specific queued items, in order), dead ([{key, reason}] with a mechanical reason), acting_session_id. Spawns a spot zimmer-orchestrator session per item and marks it started |
POST /work_backlog_items/:id/start_now | Spawn a priority session for a queued item right now. The “promote” button’s server half |
PATCH /work_backlog_items/:id/pin | Body: precedence. Hand-place the item and pin it there |
PATCH /work_backlog_items/:id/unpin | Release the pin; the item is re-ranked into its cost band |
POST /work_backlog_items/:id/remove | Body: reason (free text), optional removed_by (default human). Off the queue; the row stays as history |
POST /work_backlog_items/:id/hold | Body: reason (required), acting_session_id (self-declared). Hold a stranded row for a human decision, as hold_work_backlog_item_for_decision does: out of stranded and the alert until the hold lapses (14 days) or its evidence changes. 422 for a row that is not stranded, has no liveness evidence yet, or is already held |
A filter or an enum outside the vocabulary is a 422 rather than an empty result — an empty queue
must never be a typo. pin, unpin, remove and start_now are 422 on an item that is not
queued.
Those four are also on the browser surface, at POST /issues/backlog/:id/promote, POST and
DELETE /issues/backlog/:id/pin, and POST /issues/backlog/:id/remove — session cookie and CSRF,
not an API key, and :id is the row id only. That is not a convenience: the API key this namespace
authenticates is shared by the whole agent fleet, so the endpoints above establish a caller but not
a person, and the Issues view is where a
human reaches these levers without one. The iOS app’s token reaches
them too, but only on a sign-in that acts as you: a relay-only grant gets a 403 before the lookup,
with the same sentence the app shows. hold, which an agent may do as well, takes any app sign-in.
curl "$BASE_URL/work_backlog_items?estimated_cost=small&per_page=5" -H "X-API-Key: $API_KEY"
curl -X POST "$BASE_URL/work_backlog_items/zimmer%23498/start_now" -H "X-API-Key: $API_KEY"Outcome analyses
Section titled “Outcome analyses”The write half of the Outcomes view: a Transcript Segment tree saved against
an archived session. POST /outcome_analyses mirrors the save_outcome_analysis MCP tool exactly —
both go through the same validator, so they cannot disagree about what a well-formed tree is.
| Endpoint | What it does |
|---|---|
POST /outcome_analyses | Save one. Body: session_id (id or slug), analyzer_session_id, schema_version, root, notes |
GET /outcome_analyses | Current analyses, newest first, without their trees. Filters: from, to, agent_root, agent_runtime, model, outcome |
GET /outcome_analyses/:id | One analysis with its tree. :id is the analyzed session’s id or slug, not the analysis row’s |
GET /goal_checks | How the goal check read on sessions at rest, the same GoalCheckTally /outcomes/goal_checks renders. Filters: from, to (on created-at; a missing from is 7 days before to, or before today), agent_root, agent_runtime, model |
GET /goal_checks returns {filters, window, resting_sessions, checked_sessions, verdicts, delegated_sessions, criteria, unmet_reasons, pending_reasons, by_goal, by_agent_root, unmet_on_own_pull_request: {sessions, listed}}. unmet_reasons and pending_reasons are
[{criteria, sessions, sample_session_ids}], one entry per set of criteria that kept sessions from
met. listed holds at most 50 {session_id, title, status, unmet_criteria}. Unlike MCP, the REST
filters drop an unparseable value rather than refusing it, the same as GET /outcome_analyses.
The whole tree is validated before anything is stored — id scheme, enum values, explanation presence
and its 140-character cap, nesting. A malformed tree is a 422 naming every problem, and nothing is
written. A session that is not archived is a 422 too: only finished transcripts have outcomes.
Saving twice supersedes rather than duplicating — the earlier reading is kept and stamped
superseded_at, and superseded_previous in the response says whether that happened.
curl -X POST "$BASE_URL/outcome_analyses" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"session_id": 4021, "schema_version": "1", "root": {"id": "S0", "trigger": {"kind": "New", "source": "user"}, "goal": {"text": "Fix the login redirect loop", "kind": "Action"}, "outcome": {"kind": "Success", "explanation": "Landed after one failed patch."}, "meta": {}, "children": []}}'Gate decisions
Section titled “Gate decisions”The read/append surface of the gate decision ledger — every rating the
PR-merge and issue-work gates have made. Mirrors the search_gate_decisions and
record_gate_decision MCP tools, through the same filter object, so the two surfaces cannot answer
the same question differently.
| Endpoint | What it does |
|---|---|
GET /gate_decisions | Newest first, summaries only. Filters: gate, surface, decision, artifact_url (exact), artifact_query (substring over the artifact URL), query (full text over the entry), with_human_feedback, from, to, plus page/per_page |
GET /gate_decisions/:id | One decision with its payload and its human feedback |
POST /gate_decisions | Record one. Body: gate, surface, entry (the decision in the gate’s own schema), optional writing_session_id |
There is no update, no destroy, and no feedback action here — and the absence is the feature.
Decisions are append-only, so a correction is another POST on the same artifact. human_feedback
is dropped from entry if you send it: the only way to write one is
POST /gate_decisions/:id/feedbacks in the browser, because the API key this namespace
authenticates is shared by the whole agent fleet and so establishes a caller but not a person. A
filter it cannot honour — an unknown gate, an unparseable date — is a 422 rather than an empty
result set, since “no precedent” is the wrong thing for a gate to conclude from a typo.
curl "$BASE_URL/gate_decisions?gate=pr_merge&surface=zimmer&decision=hold&per_page=10" \ -H "X-API-Key: $API_KEY"The rest
Section titled “The rest”| Resource | Endpoints |
|---|---|
| Logs | Full CRUD at /sessions/:session_id/logs[/:id]. content and level are required on create; level ∈ info · error · debug · warning · verbose, and doubles as the index filter; exclude_level leaves one level out (exclude_level=verbose is the web UI’s Show Logs) |
| Subagent transcripts | Full CRUD at /sessions/:session_id/subagent_transcripts[/:id]. agent_id required on create; PATCH takes every field but id and session_id; index filters on status and subagent_type; include_transcript=true on show returns the full JSONL |
| Enqueued messages | CRUD + PATCH :id/reorder (position ≥ 1) + POST :id/interrupt (pauses a running session first). content ≤ 500,000 chars, optional goal; status ∈ pending · processing · sent · undelivered; the read payload also carries origin ∈ caller · automated_pr_merged · automated_merge_conflict · automated_recovery_nudge, which records who wrote the row. No request can set it: every create site names its attributes literally and no permit list mentions it. Zimmer assigns it, and on one internal path (SpotSessionHold, for a refused turn it is re-queueing) derives it from the prompt body. Archiving a session is refused (422) while any row is pending, since the archive would discard it; force: true on the archive overrides that and retires the rows to undelivered — see lifecycle. Deleting one re-numbers the positions behind it |
| CLIs | GET /clis/status · POST /clis/refresh · POST /clis/clear_cache |
| Transcript archive | GET /transcript_archive/download (zip) · /status. status returns {state, generated_at, session_count, file_size_bytes, stale, stale_reason, complete, deferred_count, incomplete_reason} where state is present. complete is false while the job is still draining a backlog — a partial archive is freshly written, so stale is false and complete is the only thing that distinguishes it; a 404 carries state never_built or missing plus the archive_path it looked at, so “no archive” is a fact you can check rather than a promise to wait. The download’s X-Archive-* headers carry the same generated-at, session count and staleness. Not the way to search conversations — use /sessions/search?search_contents=true; the zip is hundreds of megabytes and up to ten minutes stale |
| Config (read-only) | GET /configs → {mcp_servers, agent_roots, runtime_models, goals}, where each root is the full AgentRootsConfig::Root#to_h (see Agent roots) and runtime_models is grouped by runtime with each model’s id, label, default, requires_oauth, effort_levels, default_effort and source (built_in or added; an added model also carries cli_listed, cli_version and cli_note, see Models) · each goal is {id, name, description, checks}, where checks names the goal check criteria · GET /mcp_servers → {name, title, scope, description, unavailable, unavailable_reason, startup_timeout_sec} · GET /skills · GET /hooks → {id, name, qualified_name, title, description} · GET /plugins → {id, qualified_name, title, description, version, skills, mcp_servers, hooks, keywords} |
Every artifact also says which AIR catalog it came from. A server carries scope, and a root
and a skill carry qualified_name, so two artifacts that a composed catalog contributes under the
same short id can be told apart — name is then the fully-qualified @owner/repo/<id> for one of
them, and that is the string to send back. On a deployment with a single catalog scope is null
on every server and no name is qualified. See
Artifact names.
Both server lists say whether Zimmer can start each entry. unavailable is a boolean and
unavailable_reason a short string that is null exactly when unavailable is false — the same
ConnectorStatusProbe computation
the Connectors page and get_configs read. It is worth checking before you attach one: an
unresolved ${VAR} raises at prepare time and fails the whole session, not just that server.
Neither list is filtered. An unavailable server is flagged and kept, because “it exists and is broken” and “it does not exist” call for different actions — the second invites registering a duplicate. A server whose readiness could not be determined (the secret store did not answer) reports as available: that state is transient and hits every server at once.
One endpoint lives outside /api/v1: GET /api/secrets/keys → {secrets: [{name, description}]},
the secret-name autocomplete. It returns names and descriptions, never values, and it sits behind
the same X-API-Key gate as everything else.
Zimmer’s OAuth authorization server for /mcp is also outside /api/v1, and none of it takes an
X-API-Key: GET /.well-known/oauth-protected-resource[/mcp],
GET /.well-known/oauth-authorization-server[/mcp], POST /oauth/register,
GET/POST /oauth/authorize, POST /oauth/token and POST /oauth/revoke. The access tokens it
issues open POST /mcp and are refused everywhere under /api/v1. See
Connecting to /mcp over OAuth.
POST /webhooks/slack and POST /webhooks/github are not part of this API and do not take an
X-API-Key. They are Slack’s Events API request URL and a GitHub webhook’s payload URL, each
authenticated by the provider’s own signature over the request body, and each answers 404 unless that
provider’s delivery is switched on — see
Slack Events API delivery and
GitHub webhook delivery.
Transcript content is redacted
Section titled “Transcript content is redacted”Every endpoint that returns or accepts transcript content serves the redacted copy —
GET /sessions/:id?include_transcript=true, GET /sessions/:id/transcript,
POST /sessions/:id/refresh, the subagent-transcript endpoints, and the transcript archive. Zimmer
redacts on write, as bytes come off disk, so a credential an agent printed reads back as a
[REDACTED:…] marker rather than the value. Content posted to
/sessions/:session_id/subagent_transcripts is redacted on the way in as well.
The marker says which tier fired and how much text it stands in for — [REDACTED:ENV:SLACK_BOT_TOKEN:56ch]
for an exact match against a known value, [REDACTED:MATCH:GITHUB_TOKEN:40ch] for a shape that merely
matched. See the marker vocabulary for the full set;
a consumer parsing markers should treat the label as opaque and match on the [REDACTED: prefix.
No request parameter, response field, or status code changes because of this — only the bytes inside
transcript. Two consequences worth planning around: a consumer diffing a transcript against the
file on disk will see the markers, and redaction is irreversible, so the plaintext is not recoverable
through the API. It is defense in depth, not a guarantee — see
Transcripts for what it does and does not catch, and keep treating a
downloaded transcript as secret material.
The plain-text transcript
Section titled “The plain-text transcript”transcript_text is a rendered reading copy, not the raw transcript — for that, use
GET /sessions/:id?include_transcript=true.
Every entry is rendered. Entries the renderer has no special layout for (system, result,
summary, anything a future harness emits) are labeled and dumped rather than dropped. Content that
arrives as an array of blocks is rendered block by block — text, thinking, image, tool_use,
tool_result, and pretty JSON for anything else. Tool results are truncated to 500 characters.
Errors
Section titled “Errors”One shape, everywhere:
{ "error": "Validation failed", // short label "message": "Title can't be blank, Slug is invalid", // always a String "messages": ["Title can't be blank", "Slug is invalid"] // always an Array of String}message is messages.join(", "). Read whichever suits you; neither needs a type check. A handful
of responses carry an extra top-level key alongside these — retry_after on the health 429.
Status codes in use: 200 · 201 · 202 (follow-up queued) · 204 · 400 (search only) · 401 · 404 · 409 (follow-up position collision, interrupt races; a live conversation that has ended or is still preparing) · 422 · 429 (health cooldown; the Quick Router ingest’s per-address limit) · 500 · 502 (OpenAI refused a live conversation’s client secret) · 503 (Slack unconfigured; health maintenance refused because no usable cache store is configured; a live mode that is off).
Keeping this page honest
Section titled “Keeping this page honest”There is no second copy of this reference and no generated OpenAPI spec. If you change a route,
change this page in the same PR — app/controllers/api/AGENTS.md says so, and this is the one page
to change.