Skip to content

The REST API

Base URL /api/v1. Authentication is the X-API-Key header, compared against ENV["API_KEYS"] (comma-separated) with a constant-time comparison.

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. GET /categories is the exception — it ignores both and returns every category.

{
"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 rootzimmer, 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: status, agent_runtime, priority_class, genesis, show_archived, visibility, page, per_page. visibility (on_board / off_board) is unset by default — see Board visibility. 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 / priority_class / genesis / show_archived / visibility filters as /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, session_hierarchy, human_messages and human_message_capture_gaps beside session; include_transcript=true adds the raw transcript
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
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
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. 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. The from-scratch 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/forkmessage_index required → 201
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
POST/sessions/refresh_all{message, refreshed, restarted, continued, errors}. Max 50 restarts/continues. Sessions in a frozen category are parked and excluded
POST/sessions/bulk_archivesession_ids[]archived_count and any errors. A session with a queued message lands in errors and is left alone; force: true applies 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
PATCH/sessions/:id/notessession_notes ≤ 50,000; empty string clears
PATCH/sessions/:id/heartbeatenabled and/or interval_seconds (30–86,400, default 60); omit either to leave it unchanged
PATCH/sessions/:id/set_categorycategory_id; blank or omitted clears. Unknown id → 404
POST/sessions/reorderids — one dashboard section’s cards, top to bottom — plus category_id (omit, null, or "uncategorized" for the Uncategorized bucket) and an optional session_id (id or slug) naming the one card that moved. → {category_id, session_ids}, the section’s full order. Unknown category or session → 404. See Card order
POST/sessions/:id/toggle_favoritefavorited sessions sort to the top of the dashboard
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=texttext/plain, else {transcript_text}

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 json column, which stores whatever bytes the writer produced. Zimmer has two writers for it — an ordinary attribute write, which serialises {"agent_root_key":"zimmer"}, and the atomic merge_metadata! UPDATE, which serialises {"agent_root_key": "zimmer"} — so until #930 a query spanning a key’s colon found a session or did not depending on which writer had touched the row last. The same query, seconds apart, returned different sets. Both columns are now read through ::jsonb::text, which renders one canonical form whoever wrote the row, and the query is tried in both spellings: "key":"value" and "key": "value" find the same sessions. A zero result is no longer a coin flip on which writer touched the row last.

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.

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

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.

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.

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) is rejected with a 422 before anything is delivered, on every path.

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. 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_sessionremove_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, 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, scheduling_class, priority_class, category_id, category{}, 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.

Every response with a session key renders it through the same serializer (ApiSessionSerialization), including POST /enqueued_messages/:id/interruptsession means one shape everywhere on the surface.

GET /sessions/:id returns three 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.
  • 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 four 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.
  • category is a four-key summary, not the category resource below: {id, name, position, is_frozen}, or null when the session is Uncategorized.
  • metadata is Zimmer’s own bookkeepingclone_path, exit_status, agent_root_key, and friends. custom_metadata is the one you own.

heartbeat_enabled defaults to false.

GET /triggers (filters condition_type, status) · GET /triggers/:id (+ recent_sessions, limit 10) · POST · PATCH · DELETE · POST /triggers/:id/toggle · POST /triggers/:id/invoke · GET /triggers/channels (Slack; 503 when Slack is unconfigured).

Conditions are nested via trigger_conditions_attributes.

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

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

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

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.

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.

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/respondaction_typeaccept | 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.

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

Organizational buckets for the dashboard. Full CRUD at /categories[/:id] plus POST /categories/reorder.

A category is {id, name, description, position, is_frozen, session_count, created_at, updated_at}. name is required, unique case-insensitively, ≤100 chars, and whitespace-stripped; description is ≤1000 chars and stored as null when blank. New categories append to the end of the stack. Deleting one nullifies its sessions’ category_id — the sessions themselves survive as Uncategorized.

A frozen category (is_frozen: true) is a parked bucket: its sessions are excluded from refresh-all and from recovery.

PATCH /categories/:id is a genuine partial update — sending only is_frozen leaves name and description alone.

POST /categories/reorder takes ids, an ordered array top to bottom; each listed category’s position becomes its index, and any category you leave out keeps the position it had. The string sentinel "uncategorized" positions the Uncategorized section, which is stored on AppSetting rather than as a row — so it is not in the category list the call returns.

Terminal window
curl -X POST "$BASE_URL/categories/reorder" \
-H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \
-d '{"ids": [5, "uncategorized", 3, 8]}'

POST /sessions/reorder is the other half: where each card sits inside one section. It is what the dashboard’s drag-and-drop posts, and it writes sessions.sort_order. See Card order is yours to set for the model.

ids is one section’s cards, top to bottom — normally one page of it, since sections paginate at 50 — and category_id names the section (omit it, send null, or send "uncategorized" for the Uncategorized bucket; an unknown id → 404). Ids that are not in that section are ignored, and an index in ids is never read as a position, so a card you do not name never moves.

What the call does depends on session_id:

  • With session_id (an id or slug; unknown → 404), only that card moves. It goes immediately above the card after it in ids, or immediately below the card before it when it is last — the rest of ids is context. If the card is in another category it is moved into this one first, in the same transaction, so one call persists the category change and the placement. This is what a drag sends.
  • Without it, the cards named in ids are rearranged among the slots they already hold, in the order given.

The response is {category_id, session_ids} — the section’s full order after the write, not just the page you sent.

Terminal window
curl -X POST "$BASE_URL/sessions/reorder" \
-H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \
-d '{"ids": [41, 17, 92], "category_id": 5, "session_id": 17}'

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. The four member actions after show are the human-only operations and deliberately have no MCP counterpart.

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

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.

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

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; levelinfo · error · debug · warning · verbose, and doubles as the index filter
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; statuspending · processing · sent · undelivered; the read payload also carries origincaller · 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, and requires_oauth · GET /mcp_servers{name, title, description, unavailable, unavailable_reason} · GET /skills

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.

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 [REDACTED:<LABEL>] rather than the value. Content posted to /sessions/:session_id/subagent_transcripts is redacted on the way in as well.

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) · 422 · 429 (health cooldown) · 500 · 503 (Slack unconfigured; health maintenance refused because no usable cache store is configured).

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.