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.
Quick start
Section titled “Quick start”Every snippet below assumes these two:
API_KEY="your_api_key"BASE_URL="https://your-zimmer-host/api/v1"# Create a session on a configured agent rootcurl -X POST "$BASE_URL/sessions" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"agent_root": "zimmer", "prompt": "Fix the auth bug"}'
# Create one on an arbitrary repo insteadcurl -X POST "$BASE_URL/sessions" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"prompt": "Fix the auth bug", "git_root": "https://github.com/example/repo.git"}'
# List running sessions, search across transcripts, read one with its transcriptcurl "$BASE_URL/sessions?status=running" -H "X-API-Key: $API_KEY"curl "$BASE_URL/sessions/search?q=authentication&search_contents=true" -H "X-API-Key: $API_KEY"curl "$BASE_URL/sessions/1?include_transcript=true" -H "X-API-Key: $API_KEY"
# Follow up, then archive (moves to trash)curl -X POST "$BASE_URL/sessions/1/follow_up" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"prompt": "Now add tests"}'curl -X POST "$BASE_URL/sessions/1/archive" -H "X-API-Key: $API_KEY"Spawn and poll, in Python:
import requests, time
API_KEY = "your_api_key"BASE_URL = "https://your-zimmer-host/api/v1"headers = {"X-API-Key": API_KEY}
resp = requests.post(f"{BASE_URL}/sessions", headers=headers, json={ "agent_root": "zimmer", "prompt": "Fix the auth bug",})session = resp.json()["session"]
while True: resp = requests.get(f"{BASE_URL}/sessions/{session['id']}", headers=headers) if resp.json()["session"]["status"] in ("needs_input", "failed", "archived"): break time.sleep(5)And in JavaScript:
const API_KEY = "your_api_key";const BASE_URL = "https://your-zimmer-host/api/v1";const headers = { "X-API-Key": API_KEY, "Content-Type": "application/json" };
const resp = await fetch(`${BASE_URL}/sessions`, { method: "POST", headers, body: JSON.stringify({ agent_root: "zimmer", prompt: "Fix the auth bug" }),});const { session } = await resp.json();
await fetch(`${BASE_URL}/sessions/${session.id}/follow_up`, { method: "POST", headers, body: JSON.stringify({ prompt: "Now add tests" }),});Pagination
Section titled “Pagination”Most list endpoints take page and per_page (default 25, max 100) and answer with a pagination
object alongside the collection. GET /categories is the exception — it ignores both and returns
every category.
{ "pagination": { "page": 1, "per_page": 25, "total_count": 312, "total_pages": 13 }}Terminology
Section titled “Terminology”Archived is “trash” in the UI, archived on the wire. The status enum value is archived and
the column is archived_at; filters and status values never take trash. Only the prose moves —
archiving answers "Session moved to trash" and a trash_after timestamp saying when what the
archive retained gets cleaned up — preserved clone artifacts, the scratch directory, prompt
attachments. A clean clone is deleted well before that, at the end of the undo window.
git_root is a string; agent_root is a catalog name. git_root is a free-form repository URL
or local path stored on the session. agent_root names a preconfigured agent
root — zimmer, say — that resolves to a git_root plus defaults for branch,
subdirectory, mcp_servers, catalog_skills, catalog_hooks, catalog_plugins, and model.
Passing agent_root is the recommended way to spawn on a configured root.
Sessions
Section titled “Sessions”:id is a numeric id or a slug, resolved by Session.locate — the single implementation the
web UI, this API and the MCP tools all share. An all-digit :id is
always an id, never retried as a slug; anything else is a slug. That guard is load-bearing:
slugs are title.parameterize plus a -YYYYMMDD-HHMM stamp, so a session titled after an issue
number gets a slug like 728-fix-the-poller-20260830-1102, and parsing it as a number would
resolve it to session #728. A slug that matches nothing is a 404, never the session its
leading digits spell. For the same reason a slug may not be all digits — it would be unreachable —
and the model refuses to write one, answering 422.
| Method | Path | Notes |
|---|---|---|
GET | /sessions | filters: 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/search | q (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/:id | always 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/:id | permits only title, slug, goal, is_autonomous, scheduling_class, precedence, place, custom_metadata. Promoting a waiting session to priority also starts it now, which is what makes “moved to priority and started” true rather than aspirational — the deferred re-check it was carrying can be an hour out. Only the transition into priority does it, so a PATCH that touches the title cannot restart a session. When it acts, the response carries a start object (outcome: started / refused, plus a message); it is absent when the promotion started nothing |
DELETE | /sessions/:id | → 204. Hard delete, not archive: the row and its associations go, and so do the session’s scratch directory and prompt attachments |
POST | /sessions/:id/archive | from waiting, running, needs_input, or failed → {session, message, trash_after}. 422 while any message is still queued for the session, since archiving discards it; force: true overrides deliberately and the discarded messages are retired to undelivered — see lifecycle |
POST | /sessions/:id/unarchive | → {session, clone_restored, message}. Recreates the clone directory and restores the transcript when they are gone, so the harness resumes where it left off. A session that never ran has neither, so it is returned to needs_input to start fresh (clone_restored: false) rather than refused |
POST | /sessions/:id/follow_up | prompt (≤500,000), goal (≤50,000), force_immediate, acting_session_id. 202 if a turn is already underway — executing, or ready in the agents queue with a worker coming for it — in which case the prompt is queued; 200 otherwise. Read through Sessions::LiveTurn.underway? rather than status == running, because a handed-over turn reads waiting. goal takes effect on every path — see below |
POST | /sessions/:id/message_parent | message (required), reason (required: wrong_scope | missing_tools | other), force_immediate, unarchive_parent. :id is the CHILD — the target is read from parent_session_id and is never a parameter. 202 if the parent already has a turn underway (queued); 200 otherwise; 409 if the parent is archived or failed, or on a queue position conflict; 422 if the session has no parent or is its own parent. The enqueued_message key is present only on the queued branch — an interrupt consumes the row it staged. See Reporting back to the parent that started you |
POST | /sessions/:id/pause | running only → needs_input |
POST | /sessions/:id/sleep | needs_input → sleeps; running → sets pending_sleep. Marks the sleep deliberate (deliberate_sleep_at), which is what keeps StrandedSleepSweepJob from treating a session slept with nothing armed as one whose wake was lost. Any resume or restart clears the marker |
POST | /sessions/:id/restart | clears stale retry metadata and re-queues the job; re-runs the whole setup pipeline when there is no conversation to prompt into — setup never finished (a failed clone, say), or the session never ran at all — and the replacement first turn carries the session’s stored images and files. 422 Session has no session_id is reserved for the session that has a transcript but no id to resume it under. 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/fork | message_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/refresh | re-read transcript from disk. A shorter filesystem transcript never overwrites a longer stored one — that happens when the clone was recreated at a new path, and the stored history wins |
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_archive | session_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_servers | max 50, validated against the catalog. Replaces the set; [] clears it and is recorded as deliberate, so the backfill does not restore the root’s defaults. Takes effect on the session’s next prepare — see Changing a session’s artifacts takes effect on its next prepare |
PATCH | /sessions/:id/catalog_skills · /catalog_hooks · /catalog_plugins | max 100 / 100 / 50, validated against the catalog. Replaces the set; same next-prepare rule |
PATCH | /sessions/:id/model | validated against ModelCatalog for the session’s runtime |
PATCH | /sessions/:id/notes | session_notes ≤ 50,000; empty string clears |
PATCH | /sessions/:id/heartbeat | enabled and/or interval_seconds (30–86,400, default 60); omit either to leave it unchanged |
PATCH | /sessions/:id/set_category | category_id; blank or omitted clears. Unknown id → 404 |
POST | /sessions/reorder | ids — 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_favorite | favorited sessions sort to the top of the dashboard |
PATCH | /sessions/:id/visibility | visibility (visible | hidden | snoozed), plus snoozed_until and timezone for a snooze. Board visibility only — see Board visibility. It changes what the dashboard draws and nothing else: no session is started, stopped, slept, woken or reordered. Unknown value, missing or past-dated snoozed_until → 422 |
GET | /sessions/:id/transcript | format=text → text/plain, else {transcript_text} |
force_immediate: true on follow_up interrupts a running session and delivers the prompt now,
through the same race-free interrupt backend as the web UI’s “Send Now” — exactly-once and
FIFO-ordered. When the interrupt can’t be dispatched the staged message is discarded rather than
left half-queued, and the call answers 404, 409, 422, or 500.
goal on follow_up lands on every path — see
Following up, and the goal that rides along below.
Changing a session’s artifacts takes effect on its next prepare
Section titled “Changing a session’s artifacts takes effect on its next prepare”The four artifact endpoints — PATCH /sessions/:id/mcp_servers, /catalog_skills,
/catalog_hooks, /catalog_plugins — persist the new list and stop there. They do not rewrite
the session’s runtime config in its clone. The change lands the next time that config is prepared:
the session’s next turn, a restart, or an unarchive, each of which re-runs AIR against the clone.
One rule covers all four endpoints, the same four editors in the web UI, and action_session’s
change_mcp_servers / change_skills / change_hooks / change_plugins on the MCP
server. The same request means the same thing whichever door it comes through.
Regenerating on the write buys nothing. A running agent has already launched its MCP servers, so
rewriting .mcp.json underneath it changes nothing about that process, and an idle session
re-prepares before its next turn anyway — while the regeneration itself is a shell-out inside the
request.
So a change does not reconfigure a live process. To make one take effect immediately, restart the session.
mcp_servers and catalog_plugins are the two lists that can bring in an MCP server, so those two
responses also carry oauth_required and oauth_required_servers. When a newly selected server has
no usable token, Zimmer parks the session (failure_reason: "oauth_required") so the Authorize
buttons appear on its page — those two fields are how a REST caller knows that is why the status
moved. A session that is mid-turn is never parked this way; its already-running process cannot see
the change either way.
Board visibility
Section titled “Board visibility”A session has a second field beside status, and the two have nothing to do with each other.
status is where the session is in its lifecycle. visibility is whether its card is on the
operator’s dashboard: visible, hidden, or snoozed until snoozed_until.
It is a visual-organization device with no effect on scheduling or execution. No scheduler,
queue, gate or state machine reads it. A snoozed session starts, runs, ranks and finishes exactly as
it would have if nobody had touched it — the human simply is not looking at its card. If you want to
defer work, POST /sessions/:id/sleep and PATCH /sessions/:id (precedence) are the endpoints
that do that.
A snooze expires by being read. Once snoozed_until passes, the session is back on the board
with no request having been made and no background job having written to the row. That is why the
serializer reports two fields:
visibility— the stored choice, unchanged until somebody changes it. A session whose snooze ran out last week still reads"snoozed"here.effective_visibility— that choice with an expired snooze already resolved to"visible". This is what every human-facing board reads, and what a consumer deciding whether to draw something wants.
GET /sessions and GET /sessions/search take an optional visibility filter: on_board
(what a tidied dashboard shows) or off_board (the hidden and snoozed ones). They are unfiltered
on this axis by default, deliberately. Agents read these endpoints to check whether a piece of work
already has a session; a session a human tidied off their board is still that session, and hiding it
from a duplicate check would produce duplicate work with no visible cause. Pass the filter only when
the question you are asking is genuinely about the board.
# Snooze a card until 9am on the 5th, Pacific time. Nothing about the session's# scheduling changes — only whether it is drawn.curl -X PATCH "$ZIMMER/api/v1/sessions/4242/visibility" \ -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"visibility":"snoozed","snoozed_until":"2026-09-05T09:00:00","timezone":"America/Los_Angeles"}'
# Put it back by hand (it would have come back on its own at 9am).curl -X PATCH "$ZIMMER/api/v1/sessions/4242/visibility" \ -H "X-API-Key: $KEY" -H "Content-Type: application/json" -d '{"visibility":"visible"}'snoozed_until is a naive wall-clock ISO-8601 datetime read in timezone (an IANA name,
default UTC) — send the time the human means plus their zone rather than converting to UTC
yourself. A value carrying its own offset is rejected, as is one in the past. The MCP counterpart is
action_session’s set_visibility action, with the same parameters and the same rules.
Searching transcript contents
Section titled “Searching transcript contents”GET /sessions/search matches session titles plus the metadata and custom_metadata JSON by
default. search_contents widens it to the stored transcript — the whole conversation.
Two things about the query string, both of which used to bite callers who guessed:
- The search term is
q, andqueryis accepted as an alias. The MCP tool and the dashboard both call itquery, and a caller who copied that name used to get400 Missing parameter. search_contentsacceptstrueand1. The dashboard’s checkbox posts1; the API used to compare against"true"only, so a URL copied out of the browser silently ran a title-only search and answered 200.
The query is one substring, so several words are a phrase. q=YC interview matches a
transcript that says YC interview; it does not match one that says the interview is at YC. The
words have to be adjacent and in that order — there is no tokenising, no OR between words, no
stemming — and % and _ are literal characters rather than wildcards. Search a distinctive
phrase when you want to confirm a session said something, and one word when you want a wider net.
Both the dashboard and quick_search_sessions match the same way, on the title and metadata columns
as well as on the transcript.
The JSON columns are matched in Postgres’s canonical spelling, and both spellings of a query
work. metadata is a 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" }}total_countisnull. Counting matches across the transcript corpus costs exactly the full scan the bound exists to avoid, so no number is offered rather than a wrong or expensive one.pagedoes not page it. Passcontent_scan.next_cursorback asscan_cursor, with the same query and filters, to continue from where the scan stopped. The cursor is a keyset position on(created_at, id), so sessions created between calls never shift it.complete: falseis a real answer. An empty result withcomplete: falsemeans “not found yet”, not “not there”. Narrowing withstatus,genesisoragent_runtimemakes each call reach further back.
The budget defaults to 20 seconds and the chunk to 100 sessions; both are overridable per
deployment with ZIMMER_CONTENT_SEARCH_BUDGET_SECONDS and ZIMMER_CONTENT_SEARCH_CHUNK_SIZE.
The MCP tool quick_search_sessions takes the same search_contents and scan_cursor arguments
and runs the same scan — see the MCP server.
Creating a session
Section titled “Creating a session”Permitted params: agent_root, agent_runtime, prompt, git_root, branch, subdirectory,
title, slug, goal, is_autonomous, parent_session_id, auto_compact_window,
scheduling_class, precedence, place, idempotency_key, mcp_servers[], catalog_skills[],
catalog_hooks[], catalog_plugins[], config{}, custom_metadata{}.
branch defaults to the root’s default_branch, or main. show_archived and search_contents
default to false wherever they appear.
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.
curl -X PATCH https://zimmer.example.com/api/v1/sessions/123 \ -H "X-API-Key: $ZIMMER_API_KEY" -H "Content-Type: application/json" \ -d '{"place": "top_of_spot"}'place and precedence are mutually exclusive — they are two answers to the same question, so
sending both is a 422 rather than a silent preference for one. An unknown placement is a 422 too.
Sending neither leaves the ranking alone: a PATCH that touches only the title does not renumber the
session, and a create falls through to the ordinary inheritance described above. On an update the
placement is resolved with the session itself excluded from the population and floored at the rank it
already holds, so re-placing the session already on top is a no-op rather than a ratchet, and “put
this first” never lowers a rank.
See Spot and priority. Every session object carries genesis,
scheduling_class (the explicit choice, usually null), priority_class (the resolved answer) and
precedence.
agent_root is not a Session column — it names a catalog entry that expands into git_root,
branch, subdirectory and the catalog defaults, and is recorded as metadata.agent_root_key. An
invalid one → 422 {"error": "Invalid agent_root"}.
An artifact list you send replaces the root’s defaults. For mcp_servers, catalog_skills,
catalog_hooks, and catalog_plugins, leaving the key out takes the agent root’s defaults for that
list, sending an explicit [] creates the session with none of that artifact, and sending a
non-empty list creates it with exactly that list — every default not named is dropped. All three are
different requests — see what a list
replaces for the full rule
and for why an explicit [] survives to job start rather than being restored from the root’s
defaults. Note that a form-encoded body cannot express an empty array; send JSON to request none.
Zimmer’s own injected servers (zimmer-self-session) are added separately and still arrive.
execution_provider is not a parameter, and session_json does not carry it. It named a choice
that never existed — one legal value, local_filesystem, and no code path that read it — and was
removed in #172 along with the unwired execution
layer behind it. A create that still sends the key has it dropped by the permitted-params filter
rather than rejected, so an older client keeps working. Every agent runs on the Zimmer host itself,
unsandboxed — see
Agents run unsandboxed on the app host.
The AgentSessionJob is enqueued only if prompt is present.
warnings — an MCP server the session cannot start
Section titled “warnings — an MCP server the session cannot start”A 201 can carry a warnings array beside session. Today it holds exactly one kind of entry: one
or more of the session’s MCP servers cannot start right now — a required ${VAR} does not resolve,
an OAuth flow was never completed, or the catalog declares the entry dead. The session is created and
the job is queued regardless; the warning names the servers, says what it costs this session (the
three cases differ — an unresolved variable fails preparation outright, a missing OAuth credential
parks the session for authorization, a catalog-declared entry only loses its tools) and points at
/connectors.
{ "session": { "id": 4821, "status": "waiting" }, "warnings": [ "Zimmer cannot start MCP server strad-secrets-staging-rw (STRAD_STAGING_API_KEY unresolved) right now. …" ]}The key is absent when there is nothing to say, and it rides only on the 201 — an
idempotent_replay hands back a session an earlier call made, and nothing about it changed here. The
same sentence is written to the session’s log at warning level, and GET /api/v1/mcp_servers
carries the per-server unavailable / unavailable_reason this is derived from. Why this warns rather than
rejecting — including what it means for a connection restricted by allowed_agent_roots — is in
Creating a session with a server that cannot start.
idempotency_key — making the create safe to retry
Section titled “idempotency_key — making the create safe to retry”idempotency_key (≤255 chars) names this create attempt, so that repeating it is not the same as
asking for a second session. Send a string unique to the unit of work you are spawning; repeat the
call with the same key and you get back the session the first call made — 200 with
"idempotent_replay": true beside the session object, instead of 201 — and no second
AgentSessionJob is queued.
This exists because a create can fail after it succeeds. The reverse proxy in front of Zimmer returns its own 504 page when a request outruns its timeout, and that page arrives long after the row has committed: the session exists, is healthy, and runs to completion, while the caller holds an error carrying no session id and nothing to distinguish “the create never landed” from “the create landed and only the response was lost”. Retrying — the obvious move — spawns a second clone, a second agent holding a Claude quota slot, and eventually two branches and two PRs for one task.
So: with a key, retry on any error, including a timeout. Without one, do not retry — search
for the session by title first (GET /sessions/search) and see whether it is already there.
One thing a replay does not promise: that the session it hands back is running. The winning request
queues the agent job a moment after the row commits, so a worker killed in between leaves a session
that is real, carries a prompt, and never started. A replay reports the session as it is — check
job_id on the returned object, and POST /sessions/:id/restart if it is null.
Uniqueness is enforced by a unique index on the column, not only by a model validation, so two concurrent creates racing on the same key resolve to one session rather than two: the loser is handed the winner’s session. The key is not a fingerprint of the request — a repeat returns the session that key created whatever arguments came with the repeat, so use a fresh key for each new unit of work.
Use a fresh UUID, and do not derive the key from the task. Keys share one global namespace, so
two callers that independently build issue-577-fix from the same issue collide, and the second
one’s session is silently never created — which is a worse outcome than the duplicate this closes,
because a duplicate is visible and a no-op is not. An empty string is treated as no key at all, so a
client that always serializes the field is not quietly locked to one session.
Every session object carries idempotency_key (null on the sessions that were created without one),
so a caller reading a list can tell which session its key made.
Which runtime and model you get
Section titled “Which runtime and model you get”One chain, whether or not you name an agent_root:
request param → agent root's declared value → AppSetting (the Settings page) → hardcoded defaultWith no agent_root there is simply no root tier and the chain falls through to AppSetting, so a
rootless API spawn honors the global defaults the Settings page presents. A model that isn’t valid
for the resolved runtime — a root pinning opus on a codex spawn, say — self-heals to the global
default for that runtime rather than persisting something the harness can’t run. config.model is
always explicitly set on the created session.
agent_runtime must name a registered runtime; an unregistered value → 422.
Valid models are a property of the runtime, not the root (ModelCatalog::MODELS):
| Runtime | Models |
|---|---|
claude_code | opus (default) · sonnet · haiku · fable |
codex | gpt-5.6-sol (needs a ChatGPT login) · gpt-5.6-terra (default, needs a ChatGPT login) · gpt-5.6-luna (needs a ChatGPT login) · gpt-5.5 (needs a ChatGPT login) · gpt-5.4 (retires from ChatGPT sign-in August 31, 2026) · gpt-5.4-mini (retires from ChatGPT sign-in August 31, 2026) · gpt-5.3-codex (deprecated) · gpt-5.2-codex (deprecated) |
PATCH /sessions/:id/model validates against the list for the session’s own runtime; anything else
→ 422 {"error": "Invalid model"} with a message naming the valid ones.
Following up, and the goal that rides along
Section titled “Following up, and the goal that rides along”POST /sessions/:id/follow_up has three delivery paths, and which one a request takes depends on
the session’s status at the moment it arrives:
| Session status | What happens | Response |
|---|---|---|
waiting / needs_input | prompt is written to the session and AgentSessionJob is enqueued | 200 |
running | prompt becomes a pending EnqueuedMessage, delivered when the turn ends | 202 |
any of the three, with force_immediate: true | staged as an EnqueuedMessage and delivered through Sessions::InterruptService, terminating the running turn | 200 |
On the direct path the prompt is also recorded on the session itself, not only handed to the job. That is what makes a 200 mean delivery: a worker shutdown between the response and the turn discards the job, and without the record the session would be resumed on a generic recovery nudge with the prompt gone and nothing to say so — see a follow-up the session is still holding. Nothing about the request or the response shape changes; the record is internal.
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.
| Parameter | Meaning |
|---|---|
message | What the child is telling its parent. Delivered quoted — every line reaches the parent behind a > prefix, so a body carrying its own separator or bracketed header cannot end the quotation early and continue in Zimmer’s voice. Bounded so that it plus the framing fits inside PROMPT_MAX_LENGTH; the 422 names the room left |
reason | wrong_scope (the work belongs to a different agent root), missing_tools (an MCP server, credential or privilege it was not given), or other. Required, and a closed list, because it is what a parent branches on |
force_immediate | Interrupt a running parent instead of queuing. Off by default — see below |
unarchive_parent | Restore an archived parent out of the trash and deliver to it |
Delivery is the ordinary follow-up routing, not a second path: a running parent takes the report
on its EnqueuedMessage queue (202), and a waiting or needs_input one takes it now (200) —
including a parent asleep on a wake, which is woken, since that is exactly the parent that would
otherwise never learn. Queuing is the default rather than interrupting: the parent of a stuck
child is usually a router mid-delegation, and ending that turn to say “child #N cannot do this”
costs the other delegations in flight while the news itself keeps a few minutes.
Because it is the ordinary queue, it is inside the ordinary accounting. The row’s origin is
caller, so the parent cannot archive over an unread report without being refused, and a forced
archive retires it to undelivered and raises the strand alert. A report cannot be accepted and then
silently vanish.
Four refusals, each with something the child can do about it:
| Situation | Response |
|---|---|
| The session has no parent — it was started by a human or a trigger | 422. Nothing is upstream; report to the human instead |
| The session is recorded as its own parent | 422. parent_session_id is client-supplied and the model checks existence rather than identity, so this is reachable; it is a defect in how the session was created |
| The parent is archived | 409, naming unarchive_parent. Nothing delivers a message to a session in the trash, so accepting one would throw it away |
| The parent has failed | 409. A human has to restart it first |
A fifth response is not a refusal but a retry: a 409 naming a message position conflict. The unique constraint on (session_id, position) is deferred to COMMIT, and the parent’s queue has several other writers, so a report can lose a race with one of them. Send it again.
unarchive_parent: true runs the same restore as POST /sessions/:id/unarchive — clone, transcript
and all — and then delivers. It is deliberately opt-in rather than automatic: it interrupts a session
that considered its work finished, so it is for work that still has to happen, not for filing a note.
The caller is self-declared in the same sense as acting_session_id below — the shared API key
identifies a caller, not a session, so :id is a claim. What is not self-declared is the target:
the parent comes from the database. The MCP surface, which does know which session a connection was
written for, refuses a report sent on another session’s behalf; this endpoint cannot check.
acting_session_id: declaring yourself as the caller
Section titled “acting_session_id: declaring yourself as the caller”follow_up, and the enqueued-message create and interrupt endpoints, take an optional
acting_session_id. If you are an agent session driving another session, set it to your own id and
Zimmer records an “uncle” lineage edge marking you as a senior of the target — which widens that
session’s hierarchy to include yours, so the human messages recorded in your hierarchy reach it as
elsewhere context.
It is self-declared and unverified, because the API key is shared by the whole fleet and identifies a caller but not a session. Omit it and nothing is recorded; that is the right answer for a script or a person with a curl command. The rules — including what happens when a junior calls back into its senior — are in Hierarchy and human messages, and the provenance consequences are in Limitations.
Removing an uncle edge
Section titled “Removing an uncle edge”DELETE /api/v1/sessions/:id/uncle_links/:uncle_idDetaches :uncle_id as an additional senior of :id. Both ends are in the path because direction is
the whole content of an uncle edge: :id is the junior — the session whose hierarchy grew when
the edge was written — and :uncle_id the senior. Either may be an id or a slug.
Optional body param acting_session_id is recorded on both timelines as the actor, self-declared for
the same reason it is on follow_up. Omitting it logs “an undeclared REST API caller”.
204 No Content— the edge is gone, and both sessions’ timelines record when it was written, what entry point wrote it, and who detached it.404 Not Found— there is no such edge. Never a 204: a caller told an edge is gone while it is still widening two sessions’ context has been misinformed. When the pair is joined the other way round, the message says so and names how to remove that one — the edge the caller did not name is left alone.
There is no POST, GET or PATCH on this resource. Edges are written by
Sessions::RecordUncleEdge, as a side effect of a queue or interrupt, which is where the acyclicity
invariant lives — a create here would be a way round it. The same removal is on
action_session → remove_uncle and on the session-detail hierarchy panel.
There is no way to clear a goal through follow_up — a blank one means “leave it”, not “remove
it”. Use PATCH /sessions/:id with goal: "" for that. (The HTML endpoint behind the web follow-up
form reads a blank goal as a clear and an absent one as “leave it”, a distinction the JSON API does
not draw. Nothing in the shipped UI sends the key either way; see
the limitations page.)
The MCP action_session tool’s follow_up action takes the same goal parameter with the same
semantics.
session_json
Section titled “session_json”id, slug, title, status, agent_runtime, prompt, git_root, branch, subdirectory,
goal, 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/interrupt — session 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, ornullwhen the session has never had one requested.summaryandmessages_since_generatedarenulluntil text exists;state(idle/pending/ready/failed),generatinganderrorare always present, so a caller that asked for a regeneration can tell “still running” from “failed” without polling for text that may never arrive.messages_since_generatedcounts transcript events since the blurb was written —0means current. Reading it never generates one; see The Status summary.session_hierarchy— the lineage graph this session belongs to:origin_session_id,root_session_ids,truncated,truncation_reason, andnodes[]each withid,title,agent_root,status,depth,parent_session_id,render_parent_session_id,spawn_edge,uncle_session_ids,current,genesisandpriority_class.parent_session_idis the spawn edge and means “spawned”, NOT “most recently talked to”.render_parent_session_idis the node this one is drawn under — whatdepthplus the array order expresses — andspawn_edgeisfalseon the rare node no spawn edge placed: one the walk could only reach through an uncle, or the requested session appended so it stays visible after the node ceiling cut its branch. Rebuild the tree fromrender_parent_session_id, never fromdepthalone:nodes[]is ordered depth-first so each node follows the one it hangs from, but a consumer that infers the parent from indentation is the bug that made every child of a sibling batch read as a child of the last sibling.spawn_edge: truedoes not implyrender_parent_session_id == parent_session_id. A session can record its spawner in bothparent_session_idand the legacycustom_metadata.router_session_id, andrender_parent_session_idis whichever of the two the walk reached it through — both are spawn claims, so the edge is still a spawn edge.uncle_session_idsare the sessions that queued or interrupted this one and are therefore treated as additional seniors — self-declared by the caller, so a claim rather than a fact.origin_session_idis the spawn origin and stays single-valued;root_session_idsis every root the graph is drawn from, which uncle edges can make more than one. See Hierarchy and human messages.human_messages— the messages Zimmer knows a named human authored anywhere in that tree, each withorigin(here— a human spoke to this session — orelsewhere— a human spoke to another session in the hierarchy),author,author_display_name,channel,channel_label,authored_in_session_id,authored_in,entry_point,contentandoccurred_at.human_message_capture_gaps— the input channels this hierarchy came in through that capture cannot write for, each withchannel,channel_label,genesis,session_ids,reasonandremedy. Normally empty.
All 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_serversis the effective set — selected + plugin-bundled + auto-injected. Read this one to learn what a session actually has wired.injected_mcp_serversis only what Zimmer adds itself (zimmer-self-session;zimmerfor subagent roots). A strict subset, and not evidence of what is available.is_autonomousgoverns whether the session fires broadcast (unscoped) event triggers. It defaults to true; set it false for user-driven sessions that shouldn’t trip global automation.categoryis a four-key summary, not the category resource below:{id, name, position, is_frozen}, ornullwhen the session is Uncategorized.metadatais Zimmer’s own bookkeeping —clone_path,exit_status,agent_root_key, and friends.custom_metadatais the one you own.
heartbeat_enabled defaults to false.
Triggers
Section titled “Triggers”GET /triggers (filters condition_type, status) · GET /triggers/:id (+ recent_sessions,
limit 10) · POST · PATCH · DELETE · POST /triggers/:id/toggle ·
POST /triggers/:id/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.
curl -X POST https://zimmer.example.com/api/v1/triggers/12/invoke \ -H "X-API-Key: $ZIMMER_API_KEY" -H "Content-Type: application/json" \ -d '{"variables": {"link": "https://example.com/msg/1", "channel": "eng-alerts"}}'201 Created returns {trigger, session, burst_notice, message} — session is the compact
{id, slug, title, status, created_at} shape recent_sessions uses, and trigger is the full
payload with its counters already updated. Two outcomes are not ordinary successes:
burst_notice: true(still 201) — the trigger blew its burst cap, sosessionis the burst-notice session it spawned instead of the one you asked for.- 429 Too Many Requests (
error: "Burst suppressed") — the trigger is inside a burst it has already announced, so nothing at all was created. - 409 Conflict (
error: "No session created") — the trigger hasskip_if_pending_sessionon and a session it already spawned is stillwaitingorrunning.sessioncarries that pending session, so you can go and look at it instead of firing again.
A one-time reuse trigger whose target session is gone or is no longer reusable returns 422
(error: "No session created"), with session carrying that target when the row still exists and
null when it does not. An agent root that cannot be resolved returns 422
(error: "Invalid agent_root").
The MCP equivalent is action_trigger with action: "invoke".
skip_if_pending_session (boolean, default false) makes the trigger spawn nothing while a session
it already created is still pending —
waiting or running. It bounds the backlog of duplicate-intent sessions, where
max_sessions_per_minute bounds the rate. needs_input, archived and failed predecessors never
block a fire.
max_sessions_per_minute (integer, nullable) sets the trigger’s burst
cap; null — the default — means unbounded. The trigger payload
also reports bursting, true while the trigger is inside a burst and spawning nothing.
It guards the spawn path only, so it does nothing on a fire into a reused session. The payload
reports skip_if_pending_session_inert (boolean), true while the trigger has a live session to reuse,
rather than leaving a caller to infer that the flag it just set will not be read on those fires — it
applies again on a fire that has to spawn.
coalesce_window_seconds (integer, nullable) sets the trigger’s Slack coalescing
window: messages that land in the same
channel, thread or DM within that many seconds of each other are one event and spawn one session,
with the rest folded into its prompt. null — the default — inherits 60 seconds; 0 turns
coalescing off so every message spawns its own session. The payload reports the stored value beside
effective_coalesce_window_seconds (the one actually in force), so a caller can tell “inherits 60s”
from “set to 60s”, and coalesce_window_inert (boolean), true when a window is stored on a trigger
with no Slack condition to read it.
missed_fire_count (integer) and first_missed_fire_at (ISO 8601, nullable) report consecutive
scheduled runs that did not happen because the reused session had not consumed the previous
prompt — see coalescing a repeated fire.
last_triggered_at advances on a coalesced fire exactly as on a delivered one, so it cannot be used
to tell the two apart; this pair can. Both reset when a fire lands.
scheduling_class (spot / priority / null) sets the spot or
priority class of the sessions this trigger spawns; null — the
default — derives it from the trigger’s condition type. The payload reports both scheduling_class
(what was chosen, usually null) and effective_scheduling_class (what its sessions actually get).
Changing it applies to sessions the trigger spawns from then on and to that trigger’s own
already-spawned sessions still in waiting — see what a class change
reaches. A PATCH that changed the class
carries reclassified_waiting_sessions: how many of those sessions actually moved between classes.
The key is absent when the request did not touch the class, so its absence means “not asked” rather
than “nothing moved”.
precedence (an integer, or null) predefines the rank the trigger’s sessions land on in the spot
queue. Same absolute scale, same “applies to sessions spawned from now on” rule as the class.
catalog_skills, catalog_hooks and catalog_plugins (arrays of AIR catalog ids) are the skills,
hooks and plugins stamped onto every session this trigger spawns, and the payload reports all three
so a caller can read a trigger’s configuration and not only overwrite it. Every id is validated
against the catalog: an unknown one fails the write with 422 rather than being persisted and
breaking the next spawn. Unlike mcp_servers, an omitted key means “leave the list alone”.
[] does not mean “no skills”. A session spawned from a trigger resolves each list as
catalog_skills.presence || agent_root.default_skills, and a fire into a re-used session leaves
its artifacts alone when the trigger’s list is empty. So an empty list means the trigger says
nothing about that artifact and its sessions take the agent root’s defaults — sending [] resets
the trigger to that state rather than stripping anything.
enqueue_messages and resuscitate_archived (booleans, both default false) only apply to a
trigger with reuse_session on: the first queues a fire’s prompt for a target session that is still
running instead of dropping it, the second
unarchives a target session that has been trashed. Sent without reuse_session they are cleared
by the model rather than rejected, and the response payload reports the false that was stored — so
read it back rather than assuming the request took. The MCP equivalent, action_trigger, refuses
that combination outright.
status is one of enabled, disabled, or failed, and all three work as ?status= filters.
failed is Zimmer’s to set: a one-shot fire raised and the trigger was
parked rather than destroyed — either a one-time
schedule or a session-scoped ao_event wake. The payload carries
failed_at and last_error alongside it, both null on a healthy trigger — any write that moves
the status off failed, including POST /triggers/:id/toggle, clears them.
Notifications
Section titled “Notifications”GET /notifications (status=read|unread) · GET /notifications/:id ·
GET /notifications/badge → {pending_count} · PATCH /notifications/:id/mark_read ·
PATCH /notifications/mark_all_read · DELETE /notifications/:id/dismiss (422 if unread) ·
DELETE /notifications/dismiss_all_read · POST /notifications/push (session_id + message).
Health
Section titled “Health”GET /health → {health_report, timestamp, rails_env, ruby_version} ·
POST /health/cleanup_processes · POST /health/retry_sessions ·
POST /health/archive_old (days, clamped 1–365, default 7).
GET /health/queue_recovery_mode · POST /health/enter_queue_recovery_mode (reason,
ttl_minutes, clamped 5–240, default 60) · POST /health/exit_queue_recovery_mode — the job-queue
escape hatch. Entering halts execution on pollers, triggers, inference, maintenance and default and leaves agents
running, so a session started to investigate the backlog still runs. GET /health carries the same
state under a top-level queue_recovery_mode key. These three are deliberately not behind the
cooldown described below: an overloaded instance is exactly when the cache is least trustworthy, and
the cooldown fails closed. Entering answers 503 Queue recovery mode unavailable when
config.good_job.enable_pauses is off, rather than reporting a halt GoodJob would ignore. See
Queue recovery mode.
GET /health/queued_jobs (job_class and/or queue_name) →
{scope, matched, by_job_class, by_queue, over_cap, max_per_call} ·
POST /health/discard_queued_jobs · POST /health/reschedule_queued_jobs (delay_minutes,
clamped 0–10080) — the third cleanup lever for a runaway queue. All three require a scope
(job_class, queue_name, or both) and the two POSTs require expected_count; a mismatch, an
unscoped call, a scope over the 2,000-row cap, or the protected agents queue answers
422 {"error": "Refused"} with a message naming the real count and the per-class breakdown, having
changed nothing. A discard is not recoverable; a reschedule is its reversible sibling, and the
response says which with a recoverable boolean. Only unfinished, unstarted, unclaimed rows are
eligible. Not behind the cooldown, for the same reason as the three above. See
Queued job maintenance.
Two health endpoints sit outside this API — no /api/v1 prefix, no API key, because a load
balancer and a deploy gate have neither: GET /up (200 if the process booted) and GET /up/deep
(200 only if the database, the cache and Redis each answered a real round trip; 503 with a
failed list naming the one that did not). They report; they change nothing, and they are not rate
limited. See Deploying.
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.
Elicitations
Section titled “Elicitations”The fallback-elicitation protocol that an MCP server speaks. These two routes take a session token in the path instead of an API key, because the MCP child process has no key and the client sends no auth header:
POST /elicitations/session/:token— creates an elicitation on the session the token names. Requires_meta["com.pulsemcp/request-id"]andmessage. → 201, with_meta["com.pulsemcp/poll-url"]pointing at the token route below. 401 when the token does not verify. 403 when_meta["com.pulsemcp/session-id"]names a different session; a blank one is accepted. 422 whenrequest-idormessageis missing, or therequest-idis already taken.GET /elicitations/session/:token/:request_id— polls one of that session’s elicitations. Auto-expires pastexpires_at. 401 when the token does not verify. 404 when therequest_iddoes not exist or belongs to another session.
Nothing hands out a token URL except the ELICITATION_REQUEST_URL and ELICITATION_POLL_URL that
Zimmer puts in a session’s MCP server environment. See
who may raise a prompt.
With an API key:
POST /elicitations— the session comes from_meta["com.pulsemcp/session-id"](id or slug). → 201. 401 without a valid key, 404 when the session-id names no session, 422 as above.GET /elicitations/:request_id— polls any elicitation. 401 without a valid key.PATCH /elicitations/:id/respond—action_type∈accept | decline | cancel, optionalcontent(kept only foraccept;cancelis the protocol’s “dismissed without answering”).:idis either therequest_idor the numeric primary key, so the identifier you already hold — from a poll response or from the web UI’s own/elicitations/:id/respondroute — works here too. A session token does not reach it.
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.
Categories
Section titled “Categories”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.
curl -X POST "$BASE_URL/categories/reorder" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"ids": [5, "uncategorized", 3, 8]}'Card order
Section titled “Card order”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 inids, or immediately below the card before it when it is last — the rest ofidsis 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
idsare 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.
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}'Work backlog
Section titled “Work backlog”The work backlog: the ranked queue of issues the issue work gate has
cleared and nobody has started. index, create and pull mirror the get_work_backlog,
append_work_backlog_item and pull_work_backlog_items MCP tools through the same filter, append
and pull objects, so the two surfaces cannot disagree. The four member actions after show are the
human-only operations and deliberately have no MCP counterpart.
| Endpoint | What it does |
|---|---|
GET /work_backlog_items | The queue in rank order, each item with its whole-queue position, plus counts and the ranking bands. Filters: status (default queued; all for history), surface, repo, scope_direction, kind, estimated_cost, pinned, key, added_by, plus page/per_page |
GET /work_backlog_items/:id | One item. :id is the row id or the item’s key (zimmer#498) — the queued row for that key, else the latest |
POST /work_backlog_items | Append one. Body: key, issue_url, repo, surface, title, kind, scope_direction, estimated_cost, plus ratings, gate_verdict, gate_session, decided_at, notes, optional added_by (default human) and writing_session_id (self-declared; acting_session_id is accepted as an alias). Any other key rides into payload, as over MCP. A human may hand-place on create with pinned: true and a precedence, and may add an issueless item with a prompt. Idempotent on key among queued items: 201 with created: true, or 200 with the existing item and created: false |
POST /work_backlog_items/pull | The groomer’s pull. Body: count (top N) or keys (specific queued items, in order), dead ([{key, reason}] with a mechanical reason), acting_session_id. Spawns a spot zimmer-orchestrator session per item and marks it started |
POST /work_backlog_items/:id/start_now | Spawn a priority session for a queued item right now. The “promote” button’s server half |
PATCH /work_backlog_items/:id/pin | Body: precedence. Hand-place the item and pin it there |
PATCH /work_backlog_items/:id/unpin | Release the pin; the item is re-ranked into its cost band |
POST /work_backlog_items/:id/remove | Body: reason (free text), optional removed_by (default human). Off the queue; the row stays as history |
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.
curl "$BASE_URL/work_backlog_items?estimated_cost=small&per_page=5" -H "X-API-Key: $API_KEY"
curl -X POST "$BASE_URL/work_backlog_items/zimmer%23498/start_now" -H "X-API-Key: $API_KEY"Outcome analyses
Section titled “Outcome analyses”The write half of the Outcomes view: a Transcript Segment tree saved against
an archived session. POST /outcome_analyses mirrors the save_outcome_analysis MCP tool exactly —
both go through the same validator, so they cannot disagree about what a well-formed tree is.
| Endpoint | What it does |
|---|---|
POST /outcome_analyses | Save one. Body: session_id (id or slug), analyzer_session_id, schema_version, root, notes |
GET /outcome_analyses | Current analyses, newest first, without their trees. Filters: from, to, agent_root, agent_runtime, model, outcome |
GET /outcome_analyses/:id | One analysis with its tree. :id is the analyzed session’s id or slug, not the analysis row’s |
The whole tree is validated before anything is stored — id scheme, enum values, explanation presence
and its 140-character cap, nesting. A malformed tree is a 422 naming every problem, and nothing is
written. A session that is not archived is a 422 too: only finished transcripts have outcomes.
Saving twice supersedes rather than duplicating — the earlier reading is kept and stamped
superseded_at, and superseded_previous in the response says whether that happened.
curl -X POST "$BASE_URL/outcome_analyses" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"session_id": 4021, "schema_version": "1", "root": {"id": "S0", "trigger": {"kind": "New", "source": "user"}, "goal": {"text": "Fix the login redirect loop", "kind": "Action"}, "outcome": {"kind": "Success", "explanation": "Landed after one failed patch."}, "meta": {}, "children": []}}'Gate decisions
Section titled “Gate decisions”The read/append surface of the gate decision ledger — every rating the
PR-merge and issue-work gates have made. Mirrors the search_gate_decisions and
record_gate_decision MCP tools, through the same filter object, so the two surfaces cannot answer
the same question differently.
| Endpoint | What it does |
|---|---|
GET /gate_decisions | Newest first, summaries only. Filters: gate, surface, decision, artifact_url (exact), artifact_query (substring over the artifact URL), query (full text over the entry), with_human_feedback, from, to, plus page/per_page |
GET /gate_decisions/:id | One decision with its payload and its human feedback |
POST /gate_decisions | Record one. Body: gate, surface, entry (the decision in the gate’s own schema), optional writing_session_id |
There is no update, no destroy, and no feedback action here — and the absence is the feature.
Decisions are append-only, so a correction is another POST on the same artifact. human_feedback
is dropped from entry if you send it: the only way to write one is
POST /gate_decisions/:id/feedbacks in the browser, because the API key this namespace
authenticates is shared by the whole agent fleet and so establishes a caller but not a person. A
filter it cannot honour — an unknown gate, an unparseable date — is a 422 rather than an empty
result set, since “no precedent” is the wrong thing for a gate to conclude from a typo.
curl "$BASE_URL/gate_decisions?gate=pr_merge&surface=zimmer&decision=hold&per_page=10" \ -H "X-API-Key: $API_KEY"The rest
Section titled “The rest”| Resource | Endpoints |
|---|---|
| Logs | Full CRUD at /sessions/:session_id/logs[/:id]. content and level are required on create; level ∈ info · error · debug · warning · verbose, and doubles as the index filter |
| Subagent transcripts | Full CRUD at /sessions/:session_id/subagent_transcripts[/:id]. agent_id required on create; PATCH takes every field but id and session_id; index filters on status and subagent_type; include_transcript=true on show returns the full JSONL |
| Enqueued messages | CRUD + PATCH :id/reorder (position ≥ 1) + POST :id/interrupt (pauses a running session first). content ≤ 500,000 chars, optional goal; status ∈ pending · processing · sent · undelivered; the read payload also carries origin ∈ caller · automated_pr_merged · automated_merge_conflict · automated_recovery_nudge, which records who wrote the row. No request can set it: every create site names its attributes literally and no permit list mentions it. Zimmer assigns it, and on one internal path (SpotSessionHold, for a refused turn it is re-queueing) derives it from the prompt body. Archiving a session is refused (422) while any row is pending, since the archive would discard it; force: true on the archive overrides that and retires the rows to undelivered — see lifecycle. Deleting one re-numbers the positions behind it |
| CLIs | GET /clis/status · POST /clis/refresh · POST /clis/clear_cache |
| Transcript archive | GET /transcript_archive/download (zip) · /status. status returns {state, generated_at, session_count, file_size_bytes, stale, stale_reason, complete, deferred_count, incomplete_reason} where state is present. complete is false while the job is still draining a backlog — a partial archive is freshly written, so stale is false and complete is the only thing that distinguishes it; a 404 carries state never_built or missing plus the archive_path it looked at, so “no archive” is a fact you can check rather than a promise to wait. The download’s X-Archive-* headers carry the same generated-at, session count and staleness. Not the way to search conversations — use /sessions/search?search_contents=true; the zip is hundreds of megabytes and up to ten minutes stale |
| Config (read-only) | GET /configs → {mcp_servers, agent_roots, runtime_models, goals}, where each root is the full AgentRootsConfig::Root#to_h (see Agent roots) and runtime_models is grouped by runtime with each model’s id, label, default, 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.
Transcript content is redacted
Section titled “Transcript content is redacted”Every endpoint that returns or accepts transcript content serves the redacted copy —
GET /sessions/:id?include_transcript=true, GET /sessions/:id/transcript,
POST /sessions/:id/refresh, the subagent-transcript endpoints, and the transcript archive. Zimmer
redacts on write, as bytes come off disk, so a credential an agent printed reads back as
[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.
The plain-text transcript
Section titled “The plain-text transcript”transcript_text is a rendered reading copy, not the raw transcript — for that, use
GET /sessions/:id?include_transcript=true.
Every entry is rendered. Entries the renderer has no special layout for (system, result,
summary, anything a future harness emits) are labeled and dumped rather than dropped. Content that
arrives as an array of blocks is rendered block by block — text, thinking, image, tool_use,
tool_result, and pretty JSON for anything else. Tool results are truncated to 500 characters.
Errors
Section titled “Errors”One shape, everywhere:
{ "error": "Validation failed", // short label "message": "Title can't be blank, Slug is invalid", // always a String "messages": ["Title can't be blank", "Slug is invalid"] // always an Array of String}message is messages.join(", "). Read whichever suits you; neither needs a type check. A handful
of responses carry an extra top-level key alongside these — retry_after on the health 429.
Status codes in use: 200 · 201 · 202 (follow-up queued) · 204 · 400 (search only) · 401 · 404 · 409 (follow-up position collision, interrupt races) · 422 · 429 (health cooldown) · 500 · 503 (Slack unconfigured; health maintenance refused because no usable cache store is configured).
Keeping this page honest
Section titled “Keeping this page honest”There is no second copy of this reference and no generated OpenAPI spec. If you change a route,
change this page in the same PR — app/controllers/api/AGENTS.md says so, and this is the one page
to change.