Skip to content

Zimmer's MCP server

Zimmer speaks MCP itself. POST /mcp is a streamable-HTTP MCP endpoint served by the Rails app, and it is how an agent session reaches back into the orchestrator that spawned it: to archive itself, to schedule its own wake-up, to spawn a downstream session, to tell you it is stuck.

There is no separate process. The tools call Zimmer’s models and services in-process — the same ones the REST API calls — so there is nothing to install, nothing to keep in version lockstep, and no HTTP hop back into the app.

The protocol itself is the official MCP Ruby SDK (the mcp gem): JSON-RPC framing, version negotiation, the streamable-HTTP transport, and argument validation against each tool’s schema. Zimmer supplies the two things the SDK cannot know — who may call (the API key) and what this connection may see (the scoped tool list).

Any MCP client that speaks streamable HTTP works. The whole configuration is a URL and an API key:

{
"mcpServers": {
"zimmer": {
"type": "http",
"url": "https://your-zimmer.example.com/mcp",
"headers": { "X-API-Key": "one-of-your-API-keys" }
}
}
}

That is Claude Code’s .mcp.json — and Pi’s, since pi-mcp-adapter reads the same file by convention. Codex’s config.toml wants the same two things under different keys:

[mcp_servers.zimmer]
url = "https://your-zimmer.example.com/mcp"
http_headers = { "X-API-Key" = "one-of-your-API-keys" }

Or drive it by hand:

Terminal window
curl -s https://your-zimmer.example.com/mcp \
-H "X-API-Key: $ZIMMER_API_KEY" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

The X-API-Key header, holding an API_KEYS entry or a key minted on /settings/api_keys — literally Api::BaseController, which McpController inherits. A key that works against /api/v1/sessions works against /mcp, a revoke refuses it on both from the next request on, and the log names the key behind each call. See the REST API’s auth.

MCP clients that only know how to send a bearer token can send the same key as Authorization: Bearer <key> instead. There is one credential either way.

The same endpoint serves several scoped variants, selected with a query parameter. This is how a session gets exactly the surface it should have and no more.

URLTools
/mcpThe default surface — 27 tools; the opt-in groups are not among them
/mcp?tool_groups=sessionsSession orchestration: spawn, search, inspect, act on other sessions
/mcp?tool_groups=self_sessionSelf-management: the 8 tools a session needs to run itself
/mcp?tool_groups=gate_decisionsThe gate decision ledger: search past ratings, read the human corrections, record one
/mcp?tool_groups=work_backlogThe work backlog: read the ranked queue, append a cleared issue, pull the top items into spot sessions, hold a stranded row for a human decision
/mcp?tool_groups=sessions_readonly,outcome_analysesOutcome analysis: start, batch and stop analyses, plus the session reads
/mcp?tool_groups=settingsThe Settings page’s global defaults: read and change the base runtime and model and the Experimental toggles
/mcp?tool_groups=external_appsZimmer plugins: register one, set the triggers it may invoke, mint and revoke its keys
/mcp?tool_groups=root_dataRoot data: the calling session’s agent root’s persistent records
/mcp?tool_groups=live_conversationsLive Mode: read conversations, start one, type into it, turn voice on or off, end it; read and change the Live settings and guidance
/mcp?tool_groups=triggers_readonly,health_readonlyAny combination; _readonly drops the write tools
/mcp?tool_groups=self_session&session_id=42Names the calling session, so self-management tools can default to it

The base groups are sessions, notifications, triggers and health; gate_decisions, work_backlog, outcome_analyses, settings, external_apps, root_data and live_conversations are opt-in groups, and self_session is a composite. Each domain group, opt-in included, has a _readonly variant. Omitting tool_groups enables the four base groups and nothing else — an opt-in group is valid and addressable but never handed out by default, so /mcp on its own does not carry record_gate_decision. An unknown group is dropped with a warning rather than failing the connection.

self_session is the important one. It is auto-injected into every session (see below) and carries get_session, get_session_provenance, get_configs, send_push_notification, wake_me_up_later, wake_me_up_when_session_changes_state, a restricted action_session — the same tool name, but its action enum is narrowed to update_notes, update_title, set_heartbeat, pause_into_spot_queue, rest_in_needs_input, message_parent, and archive — and a self-scoped get_costs, narrowed the same way to the calling session’s own spend. All but two of action_session’s actions are narrowings of the full surface; message_parent and rest_in_needs_input are on this surface and on no other. A session can manage itself; it cannot restart, fork, or re-configure anything. In particular the capability/config edits on the full surface — change_mcp_servers, change_model, change_effort, change_skills, change_hooks, change_plugins, change_goal, change_auto_compact_window, toggle_push_notifications — are deliberately absent here: a session must not rewrite its own capabilities or goal through the server injected into it. (The action is narrowed, not the target: every tool takes a session_id, and a session is trusted to pass its own. See the caution above.)

The injected entry’s URL also carries session_id=<id> — the session the config was written for. Nothing in an MCP request body identifies its caller (the API key is shared by the whole fleet and the transport is stateless), so this is the only place that knowledge exists. The wake-up tools use it to default their session_id argument, which is the difference between a session’s first wake call working and it failing on an argument the agent cannot see the value of. It is not a scope: tool_groups and allowed_agent_roots still decide everything the connection may reach, it grants no tool, and an explicit session_id always wins. RuntimeConfigPostProcessor stamps it onto every Zimmer entry in a session’s config, including catalog-provided ones, and leaves alone any entry that already names a session.

It is not purely a default either, and the one exception is worth knowing when you point an entry by hand. It is also how Zimmer answers “is this caller the session it is acting on”, which is what exempts a session archiving itself from the refusal that stops one session terminating another’s in-flight turn (see Archiving someone else’s running turn). A connection stamped with another session’s id therefore inherits that session’s exemption — which is why a fork’s config is prepared for the fork rather than for the session it was forked from.

Restricting what a connection may spawn: allowed_agent_roots

Section titled “Restricting what a connection may spawn: allowed_agent_roots”
/mcp?tool_groups=sessions&allowed_agent_roots=zimmer,docs

With allowed_agent_roots set, the connection is locked to those agent roots:

  • start_session requires an agent_root, it must be in the list, and its mcp_servers must exactly match that root’s default_mcp_servers — no additions, no removals. That includes []: on an unrestricted connection an explicit empty array is a valid request for no servers (omitted vs []), but here it is a removal and is rejected unless the root has no defaults to begin with.

  • start_session’s git_root, branch and subdirectory are rejected outright — with or without an allowed agent_root beside them. What this restriction fences is which repositories the connection may spawn into, and those three name a repository and a tree within it directly, so a restricted session’s repository coordinates come from its agent root’s catalog entry exactly. That closes the rootless path to a restricted connection entirely. A root that should spawn against a different repository or branch is a root to configure that way, not a per-call override.

  • start_session’s plugins is rejected outright, [] included, because a plugin bundles MCP servers of its own and those are added on top of mcp_servers — so naming one at launch reaches the servers the rule above locks out. Omit the parameter and the session takes the root’s default_plugins. skills and hooks carry no such server expansion and stay narrowable.

  • action_trigger may only create, update, delete, toggle, or invoke triggers on an allowed root, and search_triggers only shows those. Its catalog_plugins is refused for the reason above, but its mcp_servers is not held to the root’s defaults the way start_session’s is — see Limitations.

  • action_session’s change_mcp_servers — and change_plugins, since plugins can bundle MCP servers — are refused outright.

  • action_session’s fork, regenerate_status_summary and restart are refused unless the session they name belongs to an allowed root. A fork copies the source session’s repository, branch and MCP servers onto a new session, so it is a spawn by another name; regenerating a status summary is that same fork under another verb; and a restart runs that session’s agent in its own repository. Without this, a connection fenced out of a repository could reach it through any session id, and session ids are sequential integers get_session hands out for the asking. The session is placed by the agent root its row names and by nothing else, so a row that names no root — or one the catalog has dropped — is refused as well: a fence that admits what it cannot place is not a fence. → Limitations

  • wake_me_up_when_session_changes_state refuses to watch a session outside the allowed roots. (A session waking itself is never restricted.)

  • quick_router is refused unless the router’s root (zimmer-orchestrator, or its zimmer-router alias) is on the list. The router can start work on any root, so on a fenced connection it would be a way around the fence; a connection that lists the router root could already spawn it with start_session, so allowing it there adds nothing.

  • get_configs hides the roots you may not use, so the model never sees them. Its filters work inside that view: a hidden root is neither matched nor counted.

    It hides unusable MCP servers the same way, on every connection: a server whose ${VAR} does not resolve or whose OAuth credential is missing or dead is left out of the server list and named in a trailing Unavailable roster instead — enough for an agent to tell a broken server from an absent one without offering it as a choice. → Availability, and what an agent is offered

SelfSessionInjector + the runtime config post-processors write these entries into a session’s .mcp.json / config.toml at prepare time. They are not catalog entries — Zimmer synthesizes them, pointed at the instance that is running the session:

EntryWhenURL
zimmer-self-sessionEvery session, unless something already covers the surface<instance>/mcp?tool_groups=self_session
zimmerRoots that declare default_subagent_roots<instance>/mcp?allowed_agent_roots=<those roots>

The zimmer entry is full-surface, which is why it does cover the self-session surface — a parent root gets one server, not two. A catalog entry you select yourself (zimmer, zimmer-sessions, zimmer-self-session in mcp.json) that is full-surface suppresses the injection the same way.

Both injections are defensive about a name collision. If the catalog already supplies a zimmer entry, the subagent injection leaves it alone rather than overwriting it — the two are the same URL differentiated only by query param, so writing the root-restricted allowed_agent_roots over a catalog-provided full-surface entry would silently narrow what the session may spawn. The catalog’s entry (retargeted) wins, and start_session keeps its full root surface.

Outside production, every zimmer* entry is retargeted at the instance preparing the session: the origin is rewritten and the API key replaced, while the query string (the scoping) is preserved. A staging session orchestrates staging, not production — even though the catalog’s URLs say production.

47 tools, eleven domains — 27 of them on the unscoped surface.

GroupTools
sessionsquick_search_sessions, get_session, get_session_provenance, get_configs, get_transcript_archive, get_user_view, quick_router, start_session, action_session, manage_enqueued_messages, manage_models, reorder_user_view, respond_to_elicitation, get_outcome_analysis, save_outcome_analysis
notificationsget_notifications, send_push_notification, action_notification
triggerssearch_triggers, action_trigger, wake_me_up_later, wake_me_up_when_session_changes_state
healthget_system_health, action_health, get_spot_policy, action_spot_policy, get_costs (self-scoped variant on self_session)
gate_decisions (opt-in)search_gate_decisions, get_gate_decision_feedback, record_gate_decision
work_backlog (opt-in)get_work_backlog, append_work_backlog_item, pull_work_backlog_items, hold_work_backlog_item_for_decision
outcome_analyses (opt-in)action_outcome_analysis (its read, get_outcome_analysis, is in sessions)
settings (opt-in)get_app_settings, action_app_settings
external_apps (opt-in)search_external_apps, action_external_app — register Zimmer plugins and mint their keys
root_data (opt-in)list_root_collections, search_root_records, get_root_record, put_root_record, append_root_record, delete_root_record — the calling root’s persistent data
live_conversations (opt-in)get_live_conversations, action_live_conversation, get_live_settings, action_live_settings — Live Mode

get_live_conversations and action_live_conversation read and drive Live Mode conversations: whether Live Mode can start, the current conversation and whether voice is on, a conversation’s thread with its tool calls, and start / send a typed message / set_mode (voice on or off) / end. The voice call’s audio relay and the OpenAI key are deliberately not tools — the first needs a client holding the call, the second is a secret value. set_mode to voice only says the conversation is spoken; a client still has to open the call. get_live_settings and action_live_settings (update, reset, restore_guidance) read and change the Live settings and the Live mode guidance, through the same checks as the settings page; a change made here is recorded as an agent’s. The live model does not need them: its own get_live_settings / update_live_settings functions are always on its bridge.

live_conversations is a group of its own, and opt-in, because of what a live conversation can do. Its model can switch on any catalog MCP server and call it with the credentials Rails resolves, while a session reaches only the servers it was given. send_message hands that model an instruction, so in sessions every session carrying zimmer-sessions (or the unscoped zimmer server) would reach through it to the whole catalog. A connection names live_conversations to get all four tools, and live_conversations_readonly the reads alone. No catalog entry names either, so no root carries them, and neither does the live agent’s own gateway, which is built from the groups its zimmer entries name. The usual caveat holds: this decides what a connection is offered, not what the fleet’s shared API key could compose. The REST routes that start and steer a conversation do not take an API key at all; see the REST API.

get_user_view and reorder_user_view are the read and write halves of the dashboard’s User view — the human’s decision board. The read returns the board in the order it is drawn, with the three facts a decision on it turns on that a session listing does not carry: the agent root, the cached status-summary blurb, and the session’s most recent PR with its lifecycle state and CI verdict. The write takes a whole ordering as one argument and applies it in one transaction.

They are a pair, and neither is a convenience over quick_search_sessions + action_session. Assembling the board out of those means a get_session per row, and reordering it with change_precedence means a call per row and an absolute rank computed against a board that has moved since it was read. reorder_user_view is what the dashboard’s Reprioritize button’s session uses.

quick_search_sessions narrows by the dashboard’s filters, combined: status (one or an array), agent_runtime, agent_root (a catalog name, matched as the dashboard’s Advanced filter matches it), priority_class, genesis, visibility and show_archived. It matches session titles plus the metadata and custom_metadata JSON by default, and search_contents: true widens it to the transcript — this is the MCP route to finding a session by something said mid-conversation, and it exists so nobody has to curl the REST API for it. That scan is bounded rather than best-effort (the transcript column has no index a substring match can use), so it walks candidates newest-first, stops at per_page matches or a wall-clock budget, orders results newest-first regardless of order, and reports no total count. When it stops early it says so and returns a scan_cursor; pass that back with the same query and filters to continue from exactly where it stopped. The query is matched as one case-insensitive substring, so a multi-word query is a phrase — the words must be adjacent and in order, which is what makes a search precise enough to confirm a session said something rather than shortlist the ones that mentioned each word somewhere. An empty result that says “scan incomplete” means “not found yet”, not “not there”. get_transcript_archive is a bulk export, not the search — it is hundreds of megabytes and up to ten minutes stale.

The JSON columns are matched in Postgres’s canonical spelling, so "key":"value" and "key": "value" find the same sessions and neither depends on which of Zimmer’s two metadata writers last touched the row — the false negatives of #930. One caveat comes with that: canonical form orders an object’s keys by length rather than by how they were written, so search a single key/value pair or a bare value rather than a fragment spanning two keys. The REST API page has the detail.

Two defaults matter when the question is “does this work already have a session?”. show_archived defaults to false, and a session that finished a piece of work has archived itself — so a duplicate check has to pass show_archived: true or name archived in status, or it misses exactly the sessions it is looking for. And each result’s prompt line — which only a verbose: true row carries at all — is a preview, the first 100 characters, so an issue URL named later in a prompt is not visible in the listing; query does not read the prompt column, so search for the identifier a router put in custom_metadata instead.

Rows are compact by default: they carry status, runtime, pause, board visibility, genesis and scheduling class, precedence and both timestamps, and omit five per-session fields — slug, repository, branch, the prompt preview and the MCP server list. That is what makes the advertised per_page: 100 a page you actually get back. With the full row a hundred results came to 54,034 characters and the runtime refused the tool result outright, so the real ceiling was 35–40 and the schema’s maximum: 100 was a promise the tool did not keep. The omission is never silent: every response that has a compact row carries a line naming all six fields and the two calls that return them — verbose: true for the full rows, get_session for one session in full. See Payload budgets.

action_session’s set_visibility action writes a session’s board visibility — whether its card is on the human’s dashboard, visible / hidden / snoozed until a time. It is a visual-organization device and nothing else: no scheduler reads the field, and a snoozed session runs exactly when it would have run anyway. Reach for pause, pause_into_spot_queue or change_precedence when the intent is to defer work. The field is reported by quick_search_sessions and get_session (as a **Visibility:** line, only when a session is tucked away) and is an optional visibility filter on the search — unset by default, deliberately, because a session a human snoozed off their board is still a session a duplicate check has to find. It is not in the self_session group: a session tidying its own card off a human’s board is not self-management.

get_session always includes a ### Session Hierarchy section (the spawn tree this session belongs to — an edge means “spawned”, not “most recently talked to”) and a ### Human Messages section (the messages Zimmer knows a named human authored anywhere in that tree, with author, channel, timestamp, content and the session each was said in). Neither is behind an include_ flag, because the most important reading of the message record is the empty one: a caller asking “did a human authorize this?” must be able to tell “no human turns” from “I forgot the flag.” Entries are marked here (a human spoke to this session) or elsewhere (a human spoke to another session in the hierarchy). On this tool the message record is rendered as a summary — see Payload budgets — while get_session_provenance returns it whole. An empty record is only an affirmative “no human spoke” when capture was configured for the channel the work arrived over; when it was not, the section says so in its own **Capture is NOT configured for …** bullet and reads “the check could not be established” instead. See Hierarchy and human messages.

When the session’s goal is a catalog goal with checks, get_session includes a ### Goal Check (advisory) section: the verdict, how many criteria are met, and one - [status] label — detail line per criterion. It reads the same GoalCheck as the web page’s Goal check panel and the REST goal_check field, and nothing acts on it. A free-text goal gets no section, because an empty one would read as “checked”. See How a goal is checked.

get_session also always includes a ### Queued Messages section: how many messages are pending for that session, and a one-line, hard-truncated preview of the first few by position. It is there because get_session is the dump a caller reads before it decides what to do with a session, and without it a queue is invisible — four orchestrator sessions once each correctly declined to spawn a duplicate and then each appended a follow-up to the same queue none of them could see, three of them restating the message above. The section is deliberately a count plus a summary rather than the messages: this output already runs long on a big session, so previews are cut at 120 characters, five messages are shown and the rest are counted. manage_enqueued_messages is the tool that reads the queue in full — and its update, delete and reorder actions are how a caller consolidates a queue instead of growing it. Only pending counts: a delivered message’s row is destroyed, an undelivered one is terminal, and a processing one has already been handed over, so none of the three is something a caller can still get ahead of. The two surfaces that add to a queue report the same number from the other side — action_session’s follow_up and manage_enqueued_messages’ create, so a caller that just became fourth in line finds out at the moment it happens. When the call delivered the message rather than queuing it (an idle session, or force_immediate) the line says what is still pending behind it instead; a message’s position in the queue is not the depth, since a retired undelivered row holds a position too.

gate_decisions is a group of its own rather than three more tools in sessions, and it is opt-in rather than base. Folded into sessions, every session carrying zimmer-sessions would be handed the ability to write gate ratings; left in the base set, so would every session holding the full zimmer server — and a ledger every session has a pen for is not evidence of anything. The group is meant for the two gate roots, whose scoped zimmer-gate-decisions server is the one catalog entry that names it; those roots live in a deployment’s own catalog rather than in Zimmer’s, so nothing in Zimmer’s own catalog reaches the group. Like every tool group this is a scoping boundary rather than an authorization one — it decides what a session is offered, not what a shared API key can reach. Nothing in this group or any other can write human_feedback: the key is scrubbed recursively out of every entry, record_gate_decision says so in its receipt, and the only write path is the browser. See the gate decision ledger. search_gate_decisions is the read that replaces loading a 3.4 MB JSON file to calibrate one rating, and its description is as much of the feature as its code, since a gate that does not know it can ask for “the last 10 holds on this surface” will go on reading everything.

work_backlog is opt-in for the same reason and one more: the work backlog is read by a job that spawns sessions from it with no human in the loop, so an entry on it becomes an unattended implementing session. get_work_backlog returns the queue in rank order with each item’s whole-queue position; append_work_backlog_item places the item by the band rules, stamps the writing session and its agent root from the connection, and refuses prompt, precedence, pinned and added_by outright; pull_work_backlog_items spawns a spot zimmer-orchestrator session per item as a child of the caller and records it on the row; hold_work_backlog_item_for_decision records that a stranded row is waiting on a human decision, which takes it out of the stranded alert until the hold lapses or its evidence changes, and moves nothing. What is deliberately absent, on this group or any other: no tool pins an item, hand-places it, removes it by judgement, or starts it as a priority session — those are the human’s levers over what the fleet works on next and exist only on the REST controller. The one removal an agent may make is on a pull, with a reason from a fixed vocabulary of observed facts. zimmer-work-backlog (mcp.json) is the one catalog entry that names the group.

outcome_analyses is opt-in because every outcome analysis is a full spot session, and action_outcome_analysis’s analyze_all turns one call into a batch of them. It is the only opt-in group that holds a write and not its read. get_outcome_analysis starts nothing, so it sits in sessions beside get_session, and outcome_analyses_readonly is empty. The write stays out of sessions because analysis sessions are spawned with zimmer-sessions, and there it would hand every analysis session the tool directly. Once a caller has the tool, it is still held to limits the web form does not have: at most 3 analyses in flight per batch, one MCP-started batch running at a time (and none while a stopped one’s analyses are still in flight), and an expected_count that must match what the filters would queue. zimmer-outcome-analyses (mcp.json, ?tool_groups=sessions_readonly,outcome_analyses) is the one catalog entry that names the group.

settings is the Settings page, and it is opt-in because of what it writes. get_app_settings reports the global base runtime and model and every Settings → Experimental toggle — MCP tool search, warm clones for every active root, the warm Quick Router process, and any registered experimental extension — each marked as the shipped default or an operator override. action_app_settings changes them: set_session_defaults takes a runtime and/or model (an empty string clears one, as the form’s blank input does) and saves through the same model validation the form uses, so a pair the page refuses is refused here with the same message; set_experimental_setting takes a key from ExperimentalSettingsRegistry, the list the page renders, so an unregistered extension has no key and cannot be written. Those values are what every later session is created under, so a session holding the write can change the harness its successors run in. That is why the group is not in health beside the spot policy that shares its row, not in the base set, and never in self_session: a connection has to name settings to get the write, and settings_readonly gets the read alone. zimmer-settings and zimmer-settings-readonly (mcp.json) are the catalog entries that name them, and no root carries either by default. The usual caveat holds — a scoping boundary, not an authorization one — so the property that does not depend on the caller is the record: every change, from this tool or from /settings, leaves an [AppSettings] WARN line naming the surface (for the tool, also the action and the calling session) and every value that moved. A call that sets a value to what it already is changes nothing, writes no line, and says so.

get_session_provenance returns those same two sections on their own, for one session_id, with every entry it lists rendered in full — it exists to serve that record and nothing else, so it has no content budget to spend. It lists the newest 25 entries, as it always has, and always includes every entry get_session’s summary listed; a record longer than 25 says how many older ones no MCP call returns. Zimmer injects neither into a session’s turns, so this is the tool a session calls to read its own provenance — and its description, not a block in the prompt, is where the caveats that record has to be read with are stated. Like get_session it is in self_session as well as sessions, because the auto-injected self-session server is the only surface every session carries.

Note the corollary for anything calling action_session with follow_up: a follow-up issued over this API is machine-authored and records nothing, which is deliberate — pass parent_session_id to start_session so the session you spawn can see the human context you were given. The one exception is an OAuth connection its approver let act on their behalf: what it sends is recorded as theirs, on the assistant channel.

For a session that already exists, acting_session_id is the equivalent. Set it on follow_up, or on manage_enqueued_messages create / send_now / interrupt, to your own session id: Zimmer records an “uncle” lineage edge marking you as a senior of the target, and that widens the target’s hierarchy to include yours, so your hierarchy’s human messages reach it as elsewhere context. Like parent_session_id it is self-declared and unverified — the API key identifies a caller, not a session — so omitting it records nothing, and a recorded edge is a claim of seniority rather than proof of one. The rules, including what happens when a junior calls back into its senior, are in Hierarchy and human messages.

archive takes the same argument for a different purpose: provenance, and no edge. It records you as the actor on the archived session’s own timeline, so a human reading that session later can tell an agent archiving it from a human clicking Trash. Set it whenever an agent drives an archive, including archiving itself; an archive that declares nothing is logged as one. See the archive line.

remove_uncle is the way back out of an edge recorded in error. Because an uncle edge is written as a side effect of a follow-up from an unverified acting_session_id, one stale or mistyped id attaches the wrong session as a senior and permanently widens what context both ends carry. The action takes session_id (the junior) and uncle_session_id (the senior), removes exactly that one edge, and records the removal on both timelines — acting_session_id names you as the remover, again as provenance only. Direction matters: naming the pair the wrong way round is refused with the direction that does exist, rather than deleting the opposite claim, since the inversion rule makes both directions ordinary. A non-existent edge is an error, not a silent success. It is not on the self_session surface — an uncle edge is a claim another session made about this one, and a session detaching its own seniors would be shedding context it was given. There is no companion that writes an edge: that belongs to the queue/interrupt path, which is where the acyclicity invariant lives. See Removing an edge recorded in error.

action_health’s three destructive actions (cleanup_processes, retry_sessions, archive_old) share a 30-second cooldown with Api::V1::HealthController and the /health web dashboard — the same HealthActionCooldown object, bucketed by a digest of the connection’s API key. Switching surfaces does not buy a second run, and one client’s cleanup does not throttle anyone else’s. If the cache cannot enforce the cooldown (a null store, or a Redis that is down), the tool refuses with Rate limiting unavailable rather than running unthrottled; cli_refresh and cli_clear_cache only enqueue a job and are never throttled. See the limitation.

action_health also carries the job-queue escape hatch: enter_queue_recovery_mode (reason, ttl_minutes) halts execution on pollers, triggers, inference, maintenance and default while leaving agents running, and exit_queue_recovery_mode resumes it. Both are outside that cooldown — the way out of a halt has to work on the first try. get_system_health states the mode up front when it is on, because a pending-job count means something completely different when the queues are deliberately frozen. An agent session only gets these if its connection was given the health tool group; the curated self_session set does not include it. See Queue recovery mode.

And the cleanup lever that goes with it: preview_queued_jobs counts the queued jobs a maintenance call would act on, and discard_queued_jobs / reschedule_queued_jobs act on them, scoped by job_class and/or queue_name. Before #335 this was the one queue-incident lever an agent could not drive — discarding the enqueued rows themselves meant GoodJob’s HTML dashboard at /jobs, which an agent session has no browser for. A discard is not recoverable; reschedule is its reversible sibling. Both require an expected_count taken from the preview, and a mismatch refuses with the real count and the per-class breakdown rather than acting. The agents queue and the two job classes that live on it are refused outright: an unfinished AgentSessionJob row is a live session, not a backlog entry. Neither is throttled, and the receipt reports what it did by class so the action is auditable in the transcript. See Queued job maintenance.

get_system_health also names the backlogged queues and job classes whenever ready work is waiting, plus each queue’s own head-of-line age and the single longest-waiting job’s lane and class, carrying the same split as the Queue backlog critical Slack page. This is the parity that matters for triage: the GoodJob dashboard at /jobs needs a browser session on the production host, which an agent session does not have. The per-queue ages are what let an agent triaging Zimmer GoodJob queue is not draining see past oldest_ready_age_seconds, which is a maximum across every queue at once and through which a two-thread lane in front of minute-long jobs reads exactly like a stalled worker. It is silent when nothing is waiting, and says so explicitly when the read itself fails rather than dropping the whole health report. See The page says which queue, of what, and how old there.

Those lines are all taken over ready work, though, and ready work cannot say why a lane has stopped draining. So the tool renders three more lines whenever the worker is holding anything: In flight by queue (each lane’s in-flight count beside its configured thread count), Oldest execution by queue, and Youngest execution by queue. Read together they separate three states that are identical from the ready side — a lane whose pool is full and whose youngest execution is already old is wedged; ready work with no claim at all is a lane the worker has stopped polling; an old oldest beside a fresh youngest is one slow job on an otherwise healthy lane. See When a lane is wedged.

The tool also renders a Secret Store section: which store ${VAR}s resolve from, the canonical Parameter Store namespace, any pre-rename namespaces still being read, and the sorted names still answering from them. Names only, never values — this response is read by other agent sessions, so a value here would be secret material handed to every caller. It exists because the namespace rename reads both namespaces across the move, which makes a half-done migration and a finished one identical from every other angle: nothing raises, nothing is missing. The session assigned “drop the pre-rename read path” has to check that precondition, and the only other surface that answers it is a web page.

It is reporting, not resolving, so it reads what the process already holds and never provokes a round trip to Google — a health report is the wrong place to inherit a store’s latency, and the caller most likely to ask is the one triaging a store that is not answering. It is stated in both directions, so “the migration is finished” is distinguishable from “this report doesn’t say”, and a namespace the process holds no snapshot of says unknown rather than reading as finished. See Telling a half-done migration from a finished one.

action_health also carries backfill_token_usage: queue a sweep of every transcript on disk into the token-spend ledger, so get_costs covers all of history rather than only spend since ingestion was deployed. It is the MCP half of an ops action that deliberately has no shell equivalent, it is not throttled, and it is idempotent — asking twice joins the run already in flight. See Token spend.

The action tools are verb-multiplexers: action_session takes an action enum (follow_up, pause, restart, archive, unarchive, fork, change_model, …), action_trigger takes create / update / delete / toggle / invoke, and so on. tools/list carries the full schema for each — ask the server rather than trusting this table.

action_trigger reaches parity with the conditions the triggers form and the REST API can express. A Trigger ORs its conditions, and a single trigger_type + configuration pair can only describe one of them — so a trigger that must fire on more than one thing (a Slack passive listener carrying both passive_listen_thread and passive_listen_channel, say) also accepts a conditions array of {trigger_type, configuration} objects. The flat pair still works unchanged for the single-condition case; sending both is rejected rather than guessed at.

On update, conditions upserts: an element with an id edits that condition, one without appends, and an existing condition the array does not mention is left alone. Deleting one is explicit ({"id": 123, "remove": true}). That asymmetry is deliberate — a Slack condition’s configuration holds the poller’s only copy of its cursors (TriggerCondition::SLACK_POLL_STATE_KEYS), which preserve_slack_poll_state keeps by merging back the keys an incoming configuration omits. Replace semantics would destroy the row and its cursors with it, silently re-baselining a live trigger. Fetching a trigger by id through search_triggers prints each condition’s id, which is what the array addresses.

search_triggers names each trigger’s MCP servers in list mode as well as by id, because the question a catalog rename asks is fleet-wide — which triggers reference server X? — and the list is the only view built for scanning many triggers. One listing answers it for a page of triggers rather than one by-id call per row.

The by-id view is the one that prints a condition’s configuration, and that hash holds the poller’s cursors as well as the settings a human typed. A Slack passive listener accumulates one entry per thread in thread_timestamps / participating_threads, rewritten every minute and growing without bound, so serialising it cost roughly 15k tokens for a single trigger. A configuration whose JSON is 2,000 characters or fewer — an ordinary schedule, ao_event or github_label one — is printed whole. Over that, a poller-owned key (TriggerCondition::SLACK_POLL_STATE_KEYS and GITHUB_POLL_STATE_KEYS, less the user-facing allowed_user_ids) holding more than ten entries is left out of the JSON and described under it instead:

- `thread_timestamps`: 312 entries, most recent 1788455311.688659
- `seen_items`: 312 entries, e.g. tadasant/zimmer#pull_request#42

Omitted rather than replaced in place, and that is the important half. The commonest way to misuse action_trigger is to read a configuration and send back what you believe is the desired final state; a summary sitting under its real key would be written straight over the live cursor map, while a key that is absent is merged back intact by preserve_slack_poll_state / preserve_github_poll_state. So the JSON this view prints is safe to echo.

Everything else is untouched: a poller key under ten entries stays in place, nothing outside those two constants is ever summarised (repos, labels, exclude_labels and allowed_user_ids included), and a large configuration with no high-cardinality poller state is still printed in full. GET /api/v1/triggers/:id serves every cursor exactly for a caller that needs the values.

action_trigger also sets what a trigger’s sessions get, not only when it fires: catalog_skills, catalog_hooks and catalog_plugins are the AIR-catalog lists stamped onto every session it spawns, and each id is validated against the catalog exactly as action_session’s change_skills / change_hooks / change_plugins validate theirs — an unknown id is rejected with the valid ones listed, rather than persisted and left to break the next spawn. A key you do not send is left alone.

An empty list is not “none”. Session.create_from_agent_root! counts only a non-empty list as named, so an empty one takes the agent root’s defaults, and a fire onto a re-used session skips the sync entirely (Trigger#sync_session_artifact! refuses to let an empty trigger list strip a live session’s artifacts — the session-9563 incident). So a trigger with catalog_skills: [] spawns sessions carrying the agent root’s default skills, not sessions carrying none. Sending [] resets the trigger to saying nothing about that artifact; there is no way to spell “spawn with no skills at all” from here. search_triggers and action_trigger both render an empty list as (agent root defaults) for that reason.

agent_root_name is the one reference that is warned about rather than rejected. Naming a root the catalog does not carry yet is a legitimate ordering — the trigger and the catalog entry that defines its root land through different systems — so create and update store it and the reply carries a ⚠️ line saying the name does not resolve and that every fire which has to spawn will fail until it does. Nothing is said when the catalog itself could not be read. See An agent root the catalog does not carry yet.

enqueue_messages and resuscitate_archived both require reuse_session, and the tool rejects them without it rather than accepting a value the model then clears (clear_enqueue_messages_without_reuse_session runs before the paired validation, so a save with either one set and reuse off succeeds silently). That silence is what the rejection is for: with enqueue_messages off, a fire that lands on a still-running session is dropped, which is exactly the failure a session is usually sent to action_trigger to fix. Send reuse_session: true in the same call, or drop the flag.

action_trigger’s invoke fires a trigger now, without waiting for a condition to match — the MCP half of POST /api/v1/triggers/:id/invoke, and the same fire the Run Now button on the trigger page performs. Pass variables to fill in the template’s placeholders. The session is linked to the trigger and counts toward its fire counter, a disabled trigger can still be invoked (and is not re-armed by it), and the trigger’s burst cap still applies — over it the tool returns the burst-notice session, or reports that nothing was created. See firing a trigger by hand.

action_session’s pause_into_spot_queue is the counterpart of wake_me_up_later for a session with no time worth naming: it sleeps the session with no trigger at all and leaves it for the spot scheduler, which resumes it when a Claude Code account is under both quota targets and a slot is free. It is on the self_session surface too, because a session waiting on quota rather than on an event is exactly the caller for it. See Spot and priority.

On a running session the park lets the turn finish, because its commonest caller is a session parking itself and a session that halted itself would kill the process waiting for the reply. Pass "halt": true to stop the turn where it stands when you are driving somebody else’s running session. The self_session variant does not expose the option, and strips it from the arguments if it is passed anyway.

Sleeping a session is an MCP-only capability. wake_me_up_later and pause_into_spot_queue are the only ways to put a session to sleep until a time or a quota opening — the web UI has no control that does it, and neither does the REST API (POST /api/v1/sessions/:id/sleep sleeps a session with no wake and no queue record, which is a different thing).

A human’s levers on a sleeping session are narrower than they look, and worth stating exactly. Start now (the Ranked view’s ⋮) resumes a session parked in the spot queue, which arms nothing — but it refuses one asleep on a wall-clock wake, because Sessions::StartNow treats an armed wake as outranking the queue. For that session a human has two routes and they do different things. A follow-up from its session page is delivered and the session takes a turn, but it leaves the wake armed — the message adds to the wait rather than ending it, so the session sleeps again on its own schedule afterwards. Genuinely taking the session over means cancelling the wake at /triggers, where it is listed as Wake session #<id> at <time>. The Restart button is neither — it is offered only for a failed or needs_input session, and one asleep on a wake is waiting.

start_session and action_session also take the web UI’s symbolic queue placement, not just the integer behind it: place: "top_of_spot" is the same server-side resolution the Ranked view’s Demote to spot button and the Quick Router’s Run as spot checkbox perform, mutually exclusive with precedence. It narrows the placement to one request rather than locking the queue. See Placing something at the head of the queue.

action_session reaches full parity with the fields the web UI’s session-detail editors expose. Its config-editing actions — change_mcp_servers, change_model, change_effort, change_skills, change_hooks, change_plugins, change_goal, change_auto_compact_window, toggle_push_notifications — mirror the inline editors on the session page. List-valued fields (mcp_servers, skills, hooks, plugins) use replace, not merge semantics, and every id is validated against its catalog, so an unknown skill/hook/plugin id is rejected with the valid options listed rather than persisted (a bad value would otherwise fail AIR prepare on the next unarchive).

All four persist to the session and take effect the next time its runtime config is prepared — the session’s next turn, a restart, or an unarchive. They do not hot-reconfigure a running process, and neither do the web UI’s editors or the REST endpoints: one rule, whichever door the request comes through. Rewriting .mcp.json under a live agent would change nothing anyway — the CLI launches its MCP servers when the process starts. To make a change take effect immediately, restart the session.

change_mcp_servers and change_plugins can move the target session’s status, and that is deliberate. Those two are the lists that can bring in an MCP server, so both probe the newly selected set for one nobody has authorized yet. When there is one, the answer names it under Needs authorization and the session is moved to failed with failure_reason: "oauth_required" — which is what puts the Authorize buttons on its page for a human. A session that is currently running is never moved this way: its already-spawned process cannot see the change either way, and killing a live turn over a config edit would be worse than waiting. Read a failed status after one of these calls as “a human has to authorize something”, not as a crash.

An empty array is a value, not an absence, on both sides of a session’s life: change_mcp_servers with [] clears the list, and start_session with [] launches with none — the same request means the same thing at launch time and at change time. start_session names the same four lists (mcp_servers, skills, plugins, hooks), so a hook that is noise for the task is dropped at launch rather than corrected by a follow-up change_hooks — which, for a clone-only session, would race the job start.

Every other action on the self_session action_session is a subset of the full one, apart from rest_in_needs_input, which is a statement about the caller’s own turn. message_parent is on this surface alone because it cannot be defined anywhere else: it takes no target. The caller names itself, and Zimmer reads parent_session_id to find who to deliver to.

That is the whole safety property. A “message any session” action on the server injected into every session would be a real privilege grant; “report to whoever started me” is not, because the edge it travels already exists and Zimmer, not the caller, decides where it goes. The general form is follow_up on the full sessions surface, and it stays there.

It closes an asymmetry rather than adding a channel. A parent has always been able to reach a child (action_session → follow_up, recording an uncle edge for the lineage). A child had no route back at all: its final message reaches its parent only if that parent happens to be polling get_session, and a parent that archived, or one asleep on a wake it will not get, never learns. So a session handed a goal it could not accomplish — the wrong agent root, or a missing MCP server or credential — had nowhere to report that but a GitHub issue.

{
"action": "message_parent",
"session_id": 4211, // your own id
"message": "The deploy scripts are in the infra root; I only have the app repo.",
"reason": "wrong_scope" // or "missing_tools", or "other"
}

reason is a closed list because a parent branches on it: wrong_scope (the work belongs to a different agent root), missing_tools (an MCP server, credential or privilege the session was not given), other. What the parent reads is the child’s own words wrapped in Zimmer’s framing — the child’s id, title, URL and reason code — so a report is never mistaken for a human speaking.

Delivery is the ordinary follow-up routing rather than a second path: a running parent takes the report on its queue and reads it at the end of its turn, and a waiting or needs_input parent takes it now. Queuing is the default, and force_immediate is opt-in, because the parent of a stuck child is usually a router mid-delegation: ending that turn costs the other delegations in flight, while the news itself keeps a few minutes. Waking a sleeping parent consumes its one-time wake triggers, exactly as any other follow-up does.

Because it is the ordinary EnqueuedMessage queue with origin: caller, the report is inside the ordinary accounting: the parent cannot archive over an unread one without being refused, and a forced archive retires it to undelivered and pages. It cannot be accepted and then silently lost.

An archived parent is refused rather than delivered to, since nothing delivers a message to a session in the trash — the error names unarchive_parent: true, which restores that session and then delivers. That is opt-in on purpose: it interrupts a session that considered its work finished. A failed parent, a session with no parent at all, and a session recorded as its own parent (parent_session_id is client-supplied, and the model checks existence rather than identity) are refused outright, and each error says what to do instead. One more error is a retry rather than a refusal: a queue position conflict, which means another writer of the parent’s queue won a race deferred to COMMIT. Send it again.

What the parent reads is the child’s message quoted — every line behind a > prefix — inside Zimmer’s framing. That is what lets the envelope claim the quoted lines are the child’s words and the rest is Zimmer’s: a body carrying its own --- and its own bracketed header cannot close the quotation early and continue in that voice. session_id must be the calling session’s own: a connection stamped with a session_id refuses to send another session’s report, which is the one place this surface enforces its aim rather than only its actions.

The same capability is POST /api/v1/sessions/:id/message_parent — see the REST API.

quick_router: the door for clients that do not know Zimmer

Section titled “quick_router: the door for clients that do not know Zimmer”

quick_router takes a request in plain language and starts a Quick Router session — the same one the chat bubble and the browser extension start. The router reads the request, picks the agent root, MCP servers and goal, and starts whatever follows. A client such as Claude.ai, which connects over OAuth and knows nothing of Zimmer’s roots, can get anything done with one call instead of reading get_configs and composing start_session by hand.

Its description says so, and start_session and get_configs point back to it. The server’s instructions lead with it only on a connection that names no calling session and has no allowed_agent_roots — a client from outside the fleet. A session’s own entries always name the session, and a router told to prefer quick_router would hand its work to a second router. For the same reason the tool refuses a call from a Quick Router session: a router routes with start_session.

Argument
promptRequired. The request.
context, context_urlOptional. What the person is looking at. Wrapped in the same data block the chat bubble uses for the page, so the router reads it as data, not instructions. context_url is used only with context.
idempotency_keyOptional. A retry with the same key returns the first call’s session instead of starting a second router, as on start_session.
scheduling_classOptional, priority or spot.
sync, sync_timeout_secondsOptional. Wait for the router’s answer and return it in this call — see sync: true.

Without sync, it returns the session’s id and URL at once. The caller follows up with get_session (the Status summary is the short answer once the session reaches needs_input or archived) and action_session follow_up.

Three things differ from the browser surfaces, because of who can be on the other end of /mcp:

  • A human message is recorded only on a connection that acts on its approver’s behalf. The chat bubble records the prompt because a person typed it. Here the calling model wrote it: one of the fleet’s agents on an API key, or a person’s assistant on an OAuth grant. A relay-only grant names who approved the client, not who wrote these words, so nothing is recorded. A grant its approver set to act on their behalf records the prompt (not the page context) as theirs, on the assistant channel. See What is captured, and what is not.
  • The scheduling class depends on the credential. An explicit scheduling_class wins. Otherwise an OAuth caller gets priority, since a person is waiting on that assistant, and an API-key caller gets what any agent spawn gets: the calling session’s lineage when the connection names one (?session_id=), the api genesis’s default (spot) when it does not.
  • A connection that names a session parents the router under it, the way the chat bubble parents under the page it is opened on.

It is in sessions, beside start_session, and not in self_session.

sync: true: one call, and the answer inline

Section titled “sync: true: one call, and the answer inline”

Three tools send a prompt into a session: quick_router, start_session with a prompt, and action_session → follow_up. Each takes sync (default false). Without it, nothing changes: the prompt is sent and the call returns at once. With it, the prompt is sent exactly the same way, and then the call waits until the session has answered that prompt. It returns:

  • the session’s reply: the last assistant text of the turn the prompt started;
  • any sessions it started during that turn, each with its status and URL;
  • its id, URL and resulting status, with a line saying how the turn ended.

This is for a caller that would otherwise poll get_session, such as Claude voice talking to quick_router. Inside a Zimmer session, use wake_me_up_when_session_changes_state instead. It holds no web thread while you wait, and follow_up refuses sync on the calling session itself.

Which turn is “the answer”. For a new session it is the first turn. For a follow-up to an idle session it is the turn the follow-up starts. A follow-up to a running session goes into its queue, and the wait runs through the turn already underway, then through the queued prompt’s own turn. With force_immediate the running turn is interrupted and the wait starts with the prompt’s turn. The pause that ended an earlier turn does not count as the answer, and neither does that turn’s reply. needs_input_count (bumped by every pause) marks where a turn ends, and the transcript says what it said. A turn handed straight to a queued message ends without a pause, so the start of the next turn in the transcript ends it instead. Zimmer’s own recovery nudges continue the turn rather than end it.

How the turn can end, each said in the result:

OutcomeMeaning
rests in needs_inputThe ordinary answer.
waiting, asleepIt armed a wake-up of its own and went to sleep; it resumes itself.
moved onIt finished this turn and has already started the next, from a queued message or a resumed turn. The reply is still this turn’s.
blocked on an approvalIt is paused mid-turn on an MCP elicitation. Answer it with respond_to_elicitation.
stood downZimmer parked the turn before the agent ran it, for example because no Claude account was usable. It is retried on its own.
archivedIt finished and archived itself, including straight from running.
failedget_session says why.
undeliveredThe queued prompt never reached the session: it was marked undelivered, or removed from the queue.

A Zimmer restart of a lost process (paused_by: "recovery") is not the end of the turn, and the wait continues. A Quick Router usually answers by starting a session for the real work. That session is listed and not waited on: its work can take an hour, and the router’s reply already says what it started.

Timeout. sync_timeout_seconds defaults to 55, under the 60-second request timeout the MCP TypeScript SDK and many clients default to: a client that gives up first never sees the session id it needs to fall back. It is capped at 90, the longest a request survives the proxies in front of production (How long a request may take). Ask for more only when your client waits that long. When it runs out, the result says the session is still working and gives its id, URL, status and anything it has said so far. The caller goes back to polling get_session; the session itself is not touched.

How it waits. Every status change saved through Session rings Sessions::TurnSignal, a stream on the existing solid_cable adapter. The waiting request subscribes to it and re-reads the session on each ring, with a 5-second backstop for the changes that do not ring. It holds no database connection while it waits. Only MCP_SYNC_MAX_CONCURRENT_WAITS calls (default 1) wait at once in the web process, because production’s Puma has three threads. A sync call over that cap still sends its prompt and returns at once, saying it was not waited on. See the limitation.

sync is MCP-only. The REST twins (POST /api/v1/quick_router, the sessions API) and the web UI do not take it. Their clients show the session itself: the browser extension opens its page, and the iOS app renders its conversation. A held request would only spend a web thread there.

With no arguments get_configs returns every MCP server, every agent root with its defaults, the runtime models and the goals. On a real deployment that is over 200,000 characters, which overflows agent tool-result caps and is far past what a voice client can load. Every argument is optional:

ArgumentEffect
sectionsAny of mcp_servers, agent_roots, models, goals. Only those are returned.
queryWords matched case-insensitively against each server’s, root’s and goal’s name, title and description (and a root’s git URL). An item has to contain every word. Runtime models are not filtered.
namesServer names, root names or goal ids — whole names, case-insensitive. Returns full detail for just those.
compactOne line per item, name and title, with no descriptions, defaults or usage notes.

“Is there a WhatsApp server?” is {"query": "whatsapp", "sections": ["mcp_servers"]}. A filtered listing opens with a line saying it is filtered, and the counts stay true totals (“Showing 1 of 17 usable servers”), so an empty match never reads as an empty catalog. An unfiltered compact listing keeps the Found N preconfigured repositories: header.

start_session names its repository with agent_root or git_root

Section titled “start_session names its repository with agent_root or git_root”

A session’s target repository comes from one of two arguments, and one of them is required:

  • agent_root — a catalog agent root. It carries the repository URL, branch and subdirectory and the root’s default_mcp_servers, default_skills, default_hooks and default_plugins. Prefer it whenever a root covers the repository.
  • git_root — a clone URL or local path, for a repository no root covers: a fork, a scratch repo, a one-off project. Optional branch and subdirectory go with it. This is the same rootless create POST /api/v1/sessions accepts. The new-session form is narrower than either: it offers the catalog’s agent roots and nothing else, so a rootless spawn is an API-and-MCP capability rather than something a human can do by clicking.

A git_root spawn inherits no catalog defaults: no MCP servers, no skills, no hooks, no plugins beyond what the call names, and an omitted mcp_servers means none rather than “fill these in later” — it is recorded as a deliberate empty, so the heal that restores an accidentally-empty column never quietly attaches the servers of a catalog root whose URL happens to match. POST /api/v1/sessions records it the same way on a rootless create, because both go through the same resolver. Its runtime and model resolve through the chain the whole app shares, minus the tier that isn’t there: argument → (no root) → the global defaults the Settings page presents → the hardcoded default. That chain is one implementation (Sessions::ResolveSpawnDefaults) shared with the REST endpoint, the new-session form and Session.create_from_agent_root!, so the spawn surfaces cannot drift.

Passing both is allowed and means “this root’s tooling against that repository”: the git_root wins over the root’s URL, and the root’s other defaults still apply. Passing neither is refused with an error naming both arguments — before #265 it fell through to the model’s validation and came back as Git root can't be blank, naming a field the tool’s schema did not have.

On a connection restricted to specific agent roots, git_root, branch and subdirectory are all rejected, so the rootless path is closed there.

start_session is only safe to retry if you name the attempt

Section titled “start_session is only safe to retry if you name the attempt”

start_session takes an optional idempotency_key. Generate a fresh UUID and pass it, and the create becomes safe to repeat: a second call with the same key returns the session the first one made — the result says so in its heading, Existing Session Returned (idempotency_key matched) — and queues no second agent job. The replayed result also reports whether an agent job is queued on that session, so a session that was created but never started (a worker killed between the commit and the enqueue) is visible rather than handed back as though it were running.

Without one, a failed start_session is genuinely ambiguous. The 504 a caller sees on a slow create comes from the reverse proxy, not from Zimmer, so it is an HTML page rather than a JSON-RPC error envelope, it carries no session id, and it arrives after the row has committed. Every observed occurrence had in fact created a healthy session that went on to run to completion. Retrying that — the obvious reading of an error — spawns a second clone, a second agent holding a Claude quota slot, and two agents opening two PRs for one task.

So: with a key, retry on any error including a timeout. Without one, do not retry — call quick_search_sessions with the title you passed and check whether the session is already there.

Two ways to get the key wrong, both of which end with a session that silently never exists. Do not derive it from the task, an issue number, or a date: keys share one global namespace, so two routers that independently build issue-577-fix collide. And it is not a fingerprint of the arguments — reusing a key returns the session it created the first time whatever you pass with the repeat. A fresh UUID per unit of work avoids both. See Creating a session for the REST equivalent and the concurrency guarantee behind both.

Two tools deliver a message to a session that already exists, and both let you choose whether to interrupt. action_session follow_up queues the prompt when the session is running and sends it straight through when the session is waiting or needs_input. force_immediate: true interrupts instead. A failed or archived session is rejected either way. manage_enqueued_messages makes the same choice by action name: create queues, send_now stages and interrupts in one step, interrupt promotes a message already in the queue. action_session follow_up takes an optional goal alongside the prompt, and it means the same thing on all three of its delivery paths: a non-blank goal becomes the session’s new definition of done, a blank or omitted one leaves the existing goal untouched (use change_goal to clear one). manage_enqueued_messages takes a goal too, but it is the message’s goal — update with a blank one clears it on the row, and neither it nor create/send_now length-check it before the insert. Every one of these, plus start_session and change_goal, refuses a single-word goal that is not a known goal id, naming the ids that exist. A free-text sentence is always accepted. All three interrupt paths run through Sessions::InterruptService, the same backend the web UI’s “Send Now” button uses, so they inherit its per-session advisory lock and exactly-once delivery. An interrupt jumps its own message to the front of the queue; the messages still pending keep their order behind it.

A queued message is not read until the current turn ends, so a message that would have redirected the agent arrives after the wasted work is done. What an interrupt costs is one turn: the in-flight process is terminated, an uncommitted tool call is lost, files already written stay written, and the next turn resumes the same conversation with the new message appended. Termination is not always instantaneous — a web process cannot signal a worker-spawned agent across container boundaries, so it records interrupt_terminate_pid and the worker’s monitoring loop ends that turn on its next pass. Queue the additive messages. Interrupt the ones that change the plan.

The two wake-up tools are the ones worth knowing by name. wake_me_up_later sleeps the calling session and creates a one-time trigger that resumes it at a wall-clock time; wake_me_up_when_session_changes_state resumes it when another session reaches needs_input, failed, or archived. Together they are how a session waits on CI, on a deploy, or on a session it spawned, without burning a process on sleep.

Two things about them are worth stating because they shape how much a wait costs:

  • session_id is optional on both. The injected entry names the session it was written for, so a session waking itself — the overwhelmingly common case — just omits it. Pass it only to schedule a wake for a different session, where it still wins over the connection’s own.
  • event_names takes the whole set at once. One call with ["session_archived", "session_needs_input", "session_failed"] creates one trigger with one condition per event, so a complete wait is two rows — that watcher plus a wake_me_up_later deadline backstop — rather than four. The singular event_name still works.

And session_needs_input no longer fires on a turn boundary the watched session leaves again at once. Zimmer settles it first, so a wake that reaches you is a session that actually came to rest → a turn boundary is not a rest.

A follow-up does not cancel either wake. If somebody prompts a sleeping session before its wake is due — a router’s follow_up, a human’s message, a queued message draining — the session takes that turn and the wake stays armed to fire afterwards. Only a takeover ends it: restart, a restart from scratch, or archiving the session. follow_up’s own result says so, with an Its own wake-up line naming the time, so a router redirecting a sleeper can see it does not have to wake it → A follow-up does not cancel a wake.

Reads Zimmer’s token-spend ledger: what inference cost, by agent root, model, session, and kind of token. Fleet-wide by default; pass agent_root or session_id to scope it, days — or from/to as YYYY-MM-DD — to set the window.

It lives in health rather than sessions for the same reason get_spot_policy does: this is the deployment’s posture, not one session’s business. A session has no reason to read the whole fleet’s bill — so what self_session carries is a composite override, Mcp::Tools::SelfSessionGetCosts, in exactly the shape action_session uses. Same tool name, same report body, one scope:

  • With no arguments it reports the calling session’s spend — the session named in the connection’s session_id — never the fleet’s. There is no argument that widens it.
  • agent_root is refused, with an error naming the health group and the Costs page as where the fleet report lives. The narrowed schema does not advertise the argument, and the refusal is what makes that a contract rather than a hint: no schema here sets additionalProperties: false.
  • An explicit session_id naming a different session is refused too, wherever the connection knows which session it belongs to. A connection that names none — a human client on ?tool_groups=self_session — has to pass one, and gets an error rather than a fleet report if it does not.

A connection holding health (or health_readonly) gets the unrestricted tool even alongside self_session: domain membership outranks a composite override, the same way it does for action_session.

Every fleet report states how complete the ledger is: a Partial history warning with the sweep’s progress while the one-time historical backfill is still running, and the covered window once it has finished. An agent comparing this month against last needs to know which of the two it is looking at.

The usual caution applies to anything it returns: the dollar figures are list price on subscription-billed accounts, so they are a comparable unit across models rather than money owed, and a model with no configured rate contributes zero and is named explicitly rather than being folded silently into a total. See Token spend.

Payload budgets: what a tool result may cost

Section titled “Payload budgets: what a tool result may cost”

A tool result the runtime refuses is worse than a small one. Two of these tools grew past that limit and were spilled to a file instead of returned (#652): quick_search_sessions at its own advertised per_page: 100, and get_session on an ordinary session — 77,258 characters with include_transcript: false, of which almost none was session state. It was the router’s brief in Current Prompt, that same brief again as active_follow_up_prompt inside the metadata JSON, and a hierarchy’s worth of them replayed in the message record. The fleet skills budget forty get_session reads a run to tell an outage-parked session from a paused one; at that size a run could afford two.

Both tools render less by default and take verbose: true to render everything. get_session’s default cuts exactly three things — nothing else in the dump moves:

BlockDefaultWhole thing
Current Promptfirst 1,000 charactersverbose: true
Long string values in System Metadata / Custom Metadatafirst 300 characters per value; keys and nesting untouchedverbose: true
Human Messagesnewest 5 here and newest 5 elsewhere entries, content cut to 300 charactersget_session_provenance, or verbose: true — both list the newest 25 in full

The one rule that governs all of it: a cut is only acceptable if the caller can see it happened. A response that quietly drops the tail of a value reads exactly like one where the value ended there, and the Human Messages block is the fallback both agent gates use to establish that a human asked for something — a gate that cannot tell a shortened record from an empty one is required to hold rather than guess. So every cut carries its own marker with the value’s real length; the block’s two counts are always of the whole record, never of what was rendered; the omitted entries are counted out loud next to the call that returns them; and when nothing needed cutting the block says **Complete:** every entry in this record is listed below, in full rather than staying silent.

The rule cuts both ways, and the second edge is easier to miss: a pointer that cannot deliver is the same failure arrived at from the other side. get_session_provenance renders every entry it lists in full, but it lists the newest 25 — a cap it has always had — so on a record longer than that, “fetch the rest there” would be a dead end. So the block computes what that call would return before it points at it: it names the newest-25 window explicitly, and it says how many entries fall outside it and are returned by no MCP call at all. The uncut rendering does not point at itself.

Two things make those pointers true rather than aspirational. The summary’s selection is a subset of the uncut rendering’s by construction — the uncut one lists the newest 25 plus the newest 5 of each origin — so every entry the summary shows is an entry get_session_provenance shows. And the here/elsewhere split of the entry budget applies to both: here is the half that answers “did a human ask this session for this?”, so a hierarchy with 25 recent elsewhere entries cannot push the here ones out of either rendering.

**Complete:** is qualified when the hierarchy walk itself was truncated — the block then says every entry the walk reached is listed, and points at the truncation note above it — because “complete” is the strongest word this block has and it must not be read as covering sessions nobody searched.

Everything a scheduler reads is short and is never cut: status, scheduling class, precedence, the waiting-reason lines, and the auth_outage_* / spot_hold_* / spot_pause_* metadata keys.

The transcript arrives in slices, under a cap that always applies

Section titled “The transcript arrives in slices, under a cap that always applies”

An ordinary session’s whole transcript runs to ~56k tokens, past a client’s 45k-token tool-result limit, which refuses the result outright. The file-path hint get_session returns when the transcript is not requested is no help to a caller on the far side of MCP, which cannot reach that disk. So get_session always returns the transcript as a bounded slice (Mcp::TranscriptSlice):

ParameterWhat it does
transcript_tail: N / transcript_head: NThe last / first N events.
transcript_from / transcript_toAn index range; transcript_to is exclusive.
transcript_max_charsThe character cap, 1,000–400,000 (≈4 characters a token). Defaults to 40,000 when not given.
transcript_conversation_only: trueHuman and assistant text only, each tool call collapsed to one line; tool output, thinking and bookkeeping are left out. Works the same for every runtime, because it renders the normalized events.
transcript_format: "text"Readable text with each event prefixed [#index], instead of raw JSONL.

Pass at most one of head, tail, or a range. Passing any transcript_* parameter implies include_transcript: true. A session with no transcript yet gets a section saying so, not an error.

An event is one stored JSONL line. Its 0-based index stays the same as the transcript grows, because the chunk store is append-only, and that stable index is what lets a caller page through it. A rewrite (a recovery merge, a carryover re-attachment) is the exception, and the limitations page says so. Every section states the total event count, the range requested, and the range returned. When the cap cuts the range short, the section says so twice: in a Truncated: line and in an in-place [… events #a–#b not shown …] marker, both naming the transcript_from / transcript_to values that fetch the rest. A tail, and the default with no selector, spends the cap from the newest end, because “what is it doing now” is the usual question. A head or a range spends it from the oldest end. A single event bigger than the whole cap is cut mid-event, with its own marker, so a page always makes progress.

The default cap is deliberate. include_transcript: true with no other parameter returns the newest ~40,000 characters rather than everything. Returning everything cannot work for any transcript a client would refuse, and a result the runtime refuses is worse than a short one that says how to get the rest.

The slice is read near the storage layer. Session#each_transcript_line picks chunks by their line_count and loads one chunk’s content at a time, from whichever end the walk starts, and stops when the budget runs out. A tail of a 34 MB transcript reads one or two 256 KiB chunks, not the whole document. Rows the chunk backfill has not reached fall back to splitting the legacy column.

get_transcript_archive takes no session and returns no transcript content, just a download URL for a zip of every transcript, so it has nothing to slice. Its description points a caller who wants one session’s transcript at get_session. The REST routes (GET /api/v1/sessions/:id?include_transcript=true and GET /api/v1/sessions/:id/transcript) still return the whole transcript.

The SDK’s StreamableHTTPTransport, run stateless with JSON responses: every POST carries one complete JSON-RPC message and gets one complete JSON response, so no Mcp-Session-Id is issued and any Puma worker can serve any request. Building the server per request is also what lets one endpoint serve every scoped variant. GET /mcp (server-initiated SSE) and DELETE /mcp (session termination) are 405 — there is no stream and no session to terminate. Batched bodies are rejected, as the spec requires since 2025-11-25.

The SDK owns version negotiation, the JSON-RPC error codes, and argument validation: a tools/call whose arguments don’t match the tool’s input_schema comes back as an error result the model can correct, before any Zimmer code runs.

McpController disables the SDK’s DNS-rebinding (Host/Origin) check. That check defends a locally bound server against a browser; Zimmer is a deployed Rails app whose config.hosts already validates Host, and whose credential is an explicit header rather than an ambient cookie.

A tool that raises Mcp::ToolError (bad arguments, missing record, forbidden by scoping) comes back as a tool result with isError: true and the message as text — the model reads it and can recover. A protocol-level problem (unknown method, a tool the connection never advertised) comes back as a JSON-RPC error, which the model never sees.

  1. Write app/services/mcp/tools/<name>.rb, subclass Mcp::Tool (which is an MCP::Tool from the SDK, plus Zimmer’s calling convention), declare tool_name, description, input_schema, and implement #call(args) (string keys). Return a String (sent as text) or a Hash/Array (sent as pretty JSON). Raise Mcp::ToolError for anything the model should see and act on. The schema is enforced for you — arguments are validated against it before #call runs.
  2. Call the models and services directly. If the logic already exists behind a service object, call it — the MCP layer validates arguments, calls, and formats; it does not own business logic.
  3. Register it in Mcp::Registry::ALL_TOOLS with its domain group and whether it is a write operation. If the group is new, decide whether it belongs in BASE_GROUPS (every unscoped connection gets it) or OPT_IN_GROUPS (a connection has to name it) — the second is for a group whose write tools should not ride along on /mcp. Add composite_groups: %w[self_session] if a session should be able to use it on itself, and a composite_overrides entry if it needs a narrower variant in that group (see action_session).
  4. Test it under test/services/mcp/tools/, and let test/controllers/mcp_controller_test.rb cover the wire shape.