Skip to content

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.

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:

RoutesThe 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_deviceseverything
GET /api/v1/live, /api/v1/live/settings, /api/v1/live_conversations and its member routeseverything, 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_messageslist, show, edit, delete, reorder, interrupt — not create: the app queues through follow_up
/api/v1/sessions/:id/logs, /api/v1/sessions/:id/subagent_transcriptslist and show only
DELETE /api/v1/sessions/:id/uncle_links/:uncle_iddetach an “also senior” link
/api/v1/notificationslist, show, badge, mark read, dismiss — not push
/api/v1/triggerseverything 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/recordsread — 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 actionsthe 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_itemslist, 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/:idread the ledger — not create, a gate’s write
GET /api/v1/outcome_analyses, GET /api/v1/outcome_analyses/:id, GET /api/v1/goal_checksread 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_cacheeverything the /clis page offers
/api/v1/inference and its routesread 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/respondlist 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_entriesread the catalogs
GET /api/v1/me, /api/v1/oauth_grants, /api/v1/api_keys, POST /api/v1/sign_out_everywhereread 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_settingsread, 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.

Every snippet below assumes these two:

Terminal window
API_KEY="your_api_key"
BASE_URL="https://your-zimmer-host/api/v1"
Terminal window
# Create a session on a configured agent root
curl -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 instead
curl -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 transcript
curl "$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" }),
});

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 }
}

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.

: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.

MethodPathNotes
GET/sessionsfilters, 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/searchq (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/:idalways 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/:idpermits 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/archivefrom 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_upprompt (≤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_parentmessage (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/pauserunning only → needs_input
POST/sessions/:id/sleepneeds_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/restartclears 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_nowthe 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_startthe 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_precedencethe 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/forkmessage_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/refreshre-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_mergethe 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/reprioritizethe 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_archivesession_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_serversmax 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_pluginsmax 100 / 100 / 50, validated against the catalog. Replaces the set; same next-prepare rule
PATCH/sessions/:id/modelvalidated 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/efforteffort: 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/notessession_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/heartbeatenabled and/or interval_seconds (30–86,400, default 60); omit either to leave it unchanged
POST/sessions/:id/toggle_favoritefavorited 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/visibilityvisibility (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/transcriptformat=text → text/plain, else {transcript_text}
GET/sessions/:id/conversationthe 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.

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.

Terminal window
# 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.

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, and query is accepted as an alias. The MCP tool and the dashboard both call it query, and a caller who copied that name used to get 400 Missing parameter.
  • search_contents accepts true and 1. The dashboard’s checkbox posts 1; 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"
}
}
  1. total_count is null. 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.
  2. page does not page it. Pass content_scan.next_cursor back as scan_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.
  3. complete: false is a real answer. An empty result with complete: false means “not found yet”, not “not there”. Narrowing with status, genesis or agent_runtime makes 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.

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.

Terminal window
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.

One chain, whether or not you name an agent_root:

request param → agent root's declared value → AppSetting (the Settings page) → hardcoded default

With 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:

RuntimeModels
claude_codeopus (default) · sonnet · haiku · fable
codexgpt-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:

ModelLevelsDefault
opuslow · medium · high · xhigh · maxmedium
sonnetlow · medium · high · xhigh · maxmedium
fablelow · medium · high · xhigh · maxhigh
haiku, every Codex and Pi model, any added modelnone—

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 statusWhat happensResponse
waiting / needs_inputprompt is written to the session and AgentSessionJob is enqueued200
runningprompt becomes a pending EnqueuedMessage, delivered when the turn ends202
any of the three, with force_immediate: truestaged as an EnqueuedMessage and delivered through Sessions::InterruptService, terminating the running turn200

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.

ParameterMeaning
messageWhat 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
reasonwrong_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_immediateInterrupt a running parent instead of queuing. Off by default — see below
unarchive_parentRestore 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:

SituationResponse
The session has no parent — it was started by a human or a trigger422. Nothing is upstream; report to the human instead
The session is recorded as its own parent422. 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 archived409, naming unarchive_parent. Nothing delivers a message to a session in the trash, so accepting one would throw it away
The parent has failed409. 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.

DELETE /api/v1/sessions/:id/uncle_links/:uncle_id

Detaches :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.

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, or null when the session has never had one requested. summary and messages_since_generated are null until text exists; state (idle/pending/ready/failed), generating and error are 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_generated counts transcript events since the blurb was written — 0 means 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[] is SessionWaitingReason’s ranking — mechanism (spot_hold, spot_pause, auth_outage_park, turn_queued), label, at, current (exactly one is) and demoted (an overdue hold nothing will re-arm). banners[] come in the order the page stacks them, each {key, tone, headline, body, details, action}: tone is info or warning, body is paragraphs of plain text, details the structured facts behind them (times in ISO 8601), and action is null or the banner’s one control. A banner whose inputs could not be read (the agents queue, 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_elicitation and pending_wake — the last the one banner the web page does not draw, shown only on a waiting session. The action kinds name the call to make: update_scheduling_class (PATCH /sessions/:id with its scheduling_class), force_start (POST /sessions/:id/force_start with its expected_victim_id; confirm is the web page’s confirmation text), follow_up (POST /sessions/:id/follow_up with its prompt) and open_in_browser (path on 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, and nodes[] each with id, title, agent_root, status, depth, parent_session_id, render_parent_session_id, spawn_edge, uncle_session_ids, current, genesis and priority_class. parent_session_id is the spawn edge and means “spawned”, NOT “most recently talked to”. render_parent_session_id is the node this one is drawn under — what depth plus the array order expresses — and spawn_edge is false on 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 from render_parent_session_id, never from depth alone: 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: true does not imply render_parent_session_id == parent_session_id. A session can record its spawner in both parent_session_id and the legacy custom_metadata.router_session_id, and render_parent_session_id is whichever of the two the walk reached it through — both are spawn claims, so the edge is still a spawn edge. uncle_session_ids are 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_id is the spawn origin and stays single-valued; root_session_ids is 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 with origin (here — a human spoke to this session — or elsewhere — a human spoke to another session in the hierarchy), author, author_display_name, channel, channel_label, authored_in_session_id, authored_in, entry_point, content and occurred_at.
  • human_message_capture_gaps — the input channels this hierarchy came in through that capture cannot write for, each with channel, channel_label, genesis, session_ids, reason and remedy. 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_servers is the effective set — selected + plugin-bundled + auto-injected. Read this one to learn what a session actually has wired.
  • injected_mcp_servers is only what Zimmer adds itself (zimmer-self-session; zimmer for subagent roots). A strict subset, and not evidence of what is available.
  • is_autonomous governs 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.
  • metadata is Zimmer’s own bookkeeping — clone_path, exit_status, agent_root_key, and friends. custom_metadata is the one you own.

heartbeat_enabled defaults to false.

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.

Terminal window
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."
}
}'
FieldRequiredWhat it is, and its cap
promptyesThe human’s words. ≤ 500,000 characters, like every prompt
page_urlnoCut to 2,048 characters
page_titlenoCut to 300
page_contextnoThe page, as markdown. Cut to 50,000 — the same cap as the in-app bubble’s; the extension sends at most 20,000
pinnoAn 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_runtimenoA 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
modelnoA 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_classnospot 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_idnoThe 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
attachmentsnoInline, 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.

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/invoke with { "variables": { "text": "…" } } — { outcome, fired, message, trigger: {id, name}, session: {id, status, url} | null }. 201 when it fired; 429 for burst_notice / burst_suppressed, 409 for pending_session, 404 for a trigger not on the allowlist, 422 for bad variables and the rest. A disabled plugin gets 403.

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.

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.

Terminal window
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, so session is 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 has skip_if_pending_session on and a session it already spawned is still waiting or running. session carries 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 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}, plus settings_warning when 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 to calls_url, or a WebSocket to websocket_url, with Authorization: Bearer <client_secret>. 409 while the conversation is still preparing or voice is off; 502 when OpenAI refuses.
  • POST /live_conversations/:id/tool_calls (name, arguments as the model’s JSON string, call_id) → {output, error, tools_changed, turn_id}, plus tools when tools_changed, and session when the call changed a Live setting an open call can take. Send session as a session.update when it is there, otherwise send tools in one. Send output back as a function_call_output item.
  • POST /live_conversations/:id/session_update → {session}: the session.update payload 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 (role user|assistant, content) → 201 with the turn and respond: true. A finished transcript line, so the thread carries what was said. With listening_modes on a call whose Listening is gated, a user line is also where Zimmer decides whether the call answers it. Kept, it is 201 with respond: true: send response.create. Dropped, it is 200 {respond: false, dropped: true} and nothing is written: send conversation.item.delete for 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.

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, guidance to replace it, append_guidance to 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 its options. A number outside min–max is clamped, and summary says 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.

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.

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.

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) and effective_status (the badge, which the latest reading can outrank), quota_hit_count, api_key_account, credentials (state one of none, unverified, verified, rejected; stored, refused, rejected_at, rejection_reason, verified_at), quota (null for Codex, which has no probe: taken_at, trigger, subscription_type, rate_limit_tier, five_hour and seven_day each {utilization, effective_utilization, status, reset_at, reset_passed} — status is dropped once the window has rolled over, as the badge is — and five_hour_headroom_unusable), and login (available, active_attempt, last_attempt). No OAuth token, refresh token or API key is ever in it, and an in-flight login’s verification_url and verification_code are null unless 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_utilization and weekly_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 dollar windows, and dry ({reasons, breakdown, recovery}) when no account can serve.
  • rotation_log — the runtime’s 50 most recent rotations: at, from and to ({email, deleted}), reason, source, triggered_by, and session_id when a session triggered it.
  • openrouter_key — on runtime=pi only, where the page shows it, and null elsewhere: 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_slots and whether the ceiling is out of its reach, the live decision (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, and starvation.
  • fleet_top_up — the four knobs (max_running_sessions, idle_minutes, min_fire_interval_minutes, quiet_backlog_interval_minutes), the monitor’s state, the running / awaiting / asleep split, headroom, under_ceiling_since, last_fired_at, next_fire_at, the cadence phrases and the card’s sentence.
  • genesis_classes — the overrides and every kind with its class, default_class, overridden, settable (the three kinds no trigger starts; the rest take their class from the trigger) and live_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.

MethodPathNotes
POST/inference/accountsthe 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/refreshre-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/activatethe 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/loginsthe 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/:idpoll 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/codethe 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_policyenabled, 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_upmax_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/:genesispriority_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/genesisReset 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.

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"] and message. → 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 when request-id or message is missing, or the request-id is already taken.
  • GET /elicitations/session/:token/:request_id — polls one of that session’s elicitations. Auto-expires past expires_at. 401 when the token does not verify. 404 when the request_id does 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, optional content (kept only for accept; cancel is the protocol’s “dismissed without answering”). :id is either the request_id or the numeric primary key, so the identifier you already hold — from a poll response or from the web UI’s own /elicitations/:id/respond route — 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_order lists requested_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.

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_settingsChange 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.

Terminal window
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 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_entriesAdd one. runtime and model_id are required; label, requires_oauth and allow_unlisted are optional. 201 with {model_catalog_entry}
DELETE /model_catalog_entries/:idRemove 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.

Terminal window
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}'

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.

EndpointWhat it does
GET /work_backlog_itemsThe 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/:idOne 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_itemsAppend 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/pullThe 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_nowSpawn a priority session for a queued item right now. The “promote” button’s server half
PATCH /work_backlog_items/:id/pinBody: precedence. Hand-place the item and pin it there
PATCH /work_backlog_items/:id/unpinRelease the pin; the item is re-ranked into its cost band
POST /work_backlog_items/:id/removeBody: reason (free text), optional removed_by (default human). Off the queue; the row stays as history
POST /work_backlog_items/:id/holdBody: 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.

Terminal window
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"

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.

EndpointWhat it does
POST /outcome_analysesSave one. Body: session_id (id or slug), analyzer_session_id, schema_version, root, notes
GET /outcome_analysesCurrent analyses, newest first, without their trees. Filters: from, to, agent_root, agent_runtime, model, outcome
GET /outcome_analyses/:idOne analysis with its tree. :id is the analyzed session’s id or slug, not the analysis row’s
GET /goal_checksHow 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.

Terminal window
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": []}}'

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.

EndpointWhat it does
GET /gate_decisionsNewest 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/:idOne decision with its payload and its human feedback
POST /gate_decisionsRecord 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.

Terminal window
curl "$BASE_URL/gate_decisions?gate=pr_merge&surface=zimmer&decision=hold&per_page=10" \
-H "X-API-Key: $API_KEY"
ResourceEndpoints
LogsFull 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 transcriptsFull 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 messagesCRUD + 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
CLIsGET /clis/status · POST /clis/refresh · POST /clis/clear_cache
Transcript archiveGET /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.

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.

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.

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).

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.