Runtimes
A session runs one headless coding-agent CLI, and there are three to pick from. Which one a
session gets is the sessions.agent_runtime column, and the choice is per session.
claude_code | codex | pi | |
|---|---|---|---|
| CLI | claude | codex exec | pi -p |
| Vendor | Anthropic | OpenAI | Pi coding agent (@earendil-works/pi-coding-agent, pinned to 0.84.4 in Dockerfile.base) |
| Credential | pooled OAuth subscription account | pooled OAuth subscription account | OPENROUTER_API_KEY, put in Pi’s process environment at spawn |
| Default model | opus | gpt-5.6-terra | openrouter/anthropic/claude-opus-4.6 |
| MCP, hooks, plugins | all three native | MCP native; no hook lifecycle for AIR to translate into | none native — all three come from pinned Pi extensions |
RuntimeRegistry::BUNDLES is the list, and RuntimeRegistry::LABELS is what the UI renders
(“Claude Code”, “Codex”, “Pi”). Registering a runtime there is what makes it selectable —
AgentRootsConfig.available_runtimes returns the whole registry, so any agent root can be
launched under any of the three regardless of what it declares as its own default.
For how a fourth would be added, see Adding an agent harness. This page is about the three that ship.
Picking one
Section titled “Picking one”Three places set it, most specific first:
- The session’s own
agent_runtime, passed at creation (the new-session form’s runtime selector, or theagent_runtimeparam onPOST /api/v1/sessionsandstart_session). - The agent root’s
default_runtimeinroots.json. - Settings → Default runtime, which is
AppSetting#default_runtime.
Below all three is the database column default, claude_code.
Every spawn surface honors the chain in full, through one implementation:
Sessions::ResolveSpawnDefaults runs on every POST /api/v1/sessions, every MCP start_session,
every new-session form submit, and every Session.create_from_agent_root! (the quick prompt, the
chat bubble, triggers), whether or not an agent_root was named. With no root there is simply no
root tier and the chain falls through to Settings → Default runtime. Until
#265 that was true of REST only — the MCP tool could
not spawn without an agent_root at all — and until #454
the form and create_from_agent_root! carried their own copies, which is why all four now share the
code.
Models
Section titled “Models”ModelCatalog is the authoritative per-runtime list, and it is what the new-session form, the
Quick Router’s Advanced accordion, the detail-page model editor, the Settings defaults, the REST
API and start_session all validate against. It has two parts: the built-in models in ModelCatalog::MODELS, which ship with Zimmer,
and the models an operator added while it runs, which follow them. The built-in models are:
| Runtime | Model ids |
|---|---|
claude_code | opus (default), sonnet, haiku, fable |
codex | gpt-5.6-terra (default), gpt-5.6-sol, gpt-5.6-luna, gpt-5.5, gpt-5.4, gpt-5.4-mini, and two deprecated *-codex ids |
pi | openrouter/anthropic/claude-opus-4.6 (default), …/claude-sonnet-4.6, …/claude-haiku-4.5, openrouter/openai/gpt-5.4, …/gpt-5.4-mini, openrouter/google/gemini-3.5-flash, plus direct anthropic/* and openai/* ids |
Pi’s ids are provider-qualified, and the other two runtimes’ are not. pi --model takes a
bare id or a provider/id pattern, so the catalog stores the qualified form and
PiRuntimeAdapter passes it through verbatim — no separate --provider flag. OpenRouter is
itself a provider, so its ids carry the vendor after it as well:
openrouter/anthropic/claude-opus-4.6 is three segments, not two.
The anthropic/* and openai/* entries at the end of Pi’s list go direct to the vendor and need
ANTHROPIC_API_KEY or OPENAI_API_KEY. They are kept in the catalog so a session pinned to one
still validates; this deployment feeds neither key, so the openrouter/* ids are the ones that
run.
All three runtimes have a refresh discipline written into ModelCatalog, and Pi’s has a
trap in it: pi --list-models only prints providers whose credential currently resolves, so
running it without OPENROUTER_API_KEY set silently omits every openrouter row and makes the
catalog look far smaller than it is.
requires_oauth marks the Codex models that only work with a ChatGPT login. No Claude Code or Pi
model sets it true.
effort_levels and default_effort are the reasoning-effort levels a session on that model may set
in config.effort (passed to Claude Code as --effort) and the level it runs at when it sets none.
Only opus, sonnet and fable carry them — all five levels, defaulting to medium, medium and
high. haiku, every Codex and Pi model, and any added model carry none, so an effort on them is
refused. They are a copy of the model table in Claude Code CLI 2.1.289, which updates itself daily,
so they are re-checked whenever a new model lands behind an alias. See
What gets spawned.
messages_api_id is the model’s id on Anthropic’s POST /v1/messages, and only haiku sets it
(claude-haiku-4-5). The quota probe is the one caller that hits that endpoint directly
(QuotaCheckService::PROBE_MODEL, via ModelCatalog.messages_api_id_for; see
Agent harness credentials). The endpoint answers the bare CLI alias haiku with a
400, so the CLI id cannot double as the API id. The field holds the endpoint’s floating alias, never a
dated snapshot.
In the app’s Ruby, the catalog is the only place a versioned Claude model id is written.
ModelCatalogTest fails on a dated snapshot anywhere in the catalog, on a claude_code id that
ClaudeModelConfigurationAudit would call a version pin, and on a Claude model version inside any
string, symbol or backtick literal under app/, config/ or lib/ outside model_catalog.rb,
including one embedded in a tool description or a command line. So a model id added somewhere else
fails CI until it moves here or is looked up from here. Comments, ERB, YAML and JavaScript are not
scanned.
Adding a model without a deploy
Section titled “Adding a model without a deploy”Settings → Models (/settings/models) adds a model to a runtime’s catalog, and so do
POST /api/v1/model_catalog_entries (REST) and the manage_models MCP
tool. All three write through ModelCatalogEntry.add, and the model is offered everywhere the
built-in ones are from the next request on. Adding a runtime is a MODELS entry and a deploy.
Adding a model installs nothing. The CLI already in the image has to run it, and Pi’s and Codex’s
pinned CLIs only know the models their release bundled. So add checks what it can before it saves:
- The id’s shape. No whitespace and nothing that starts like a flag. No dated snapshot on any
runtime. No Claude version pin in the
claude_codecatalog, which is the same ruleClaudeModelConfigurationAuditapplies, since the bare aliases already follow new releases. Pi ids must beprovider/…. An id that is already built in is refused. - Whether the installed CLI lists the id (
ModelCatalogCliCheck). Codex answers fromcodex debug models --bundledand Pi frompi --offline --list-models, both offline and without refreshing their lists. Pi only lists a provider whose key resolves, so the check sets a placeholder key for the provider in the id. The network is never called, so the placeholder never leaves the box. Claude Code has no list, so its ids are saved unchecked against the CLI. The two calls are bounded at 5s and 10s, because they run inside the request that adds the model.
An id the CLI does not list is refused unless the caller says to add it anyway
(allow_unlisted). That is a warning, not proof the model is broken. Codex sends an unlisted slug to
OpenAI and Pi uses it as a custom model id, so the provider accepts or refuses it on the session’s
first turn. Adding a model released after the CLI in the image is exactly that case. The check’s
answer is stored on the row (cli_listed, cli_version, cli_note) and shown beside the model on
Settings → Models, in manage_models list, in get_configs and in GET /api/v1/configs. A CLI
that is missing or prints something unreadable leaves the model unchecked rather than blocking it.
An added model never becomes a runtime’s fallback default and never supplies messages_api_id.
Both are read into constants at boot and stay in MODELS. To make an added model the default for
new sessions, pick it under Session Defaults on the Settings page.
Removing an added model stops new sessions picking it. A session already on it keeps it. Removal is
refused while the Settings page’s session default names the model, because AppSetting
re-validates it on every save. If a later deploy makes an added id built-in,
the built-in entry wins and Settings → Models lists the redundant row for removal.
Choosing a harness and a model on the Quick Router
Section titled “Choosing a harness and a model on the Quick Router”Every Quick Router surface has an Advanced accordion, collapsed by default, holding a
Harness picker, a Model picker and the Run as spot checkbox. One partial
(app/views/sessions/_quick_router_advanced.html.erb) renders all three copies, so they cannot
drift:
| Surface | Where |
|---|---|
The dashboard’s inline prompt row, at md: and wider | Under the attach buttons |
| The dashboard’s full-screen prompt overlay, on phones | Above Submit |
| The chat-bubble panel (Cmd/Ctrl+K), on every page | Under the prompt, above Submit / Submit & Open |
Harness offers every runtime RuntimeRegistry has registered — Claude Code, Codex, Pi — and is
the per-session agent_runtime override, the same one start_session takes. Model offers the
models that harness accepts. A model id belongs to exactly one runtime’s catalog, so picking a
harness rebuilds the model list from that runtime’s models and drops whatever was selected;
quick_router_advanced_controller.js does that client-side, and QuickRouterOptions validates the
pair again server-side whatever the client posted.
Both pickers lead with a blank option that names the fallback rather than selecting it —
Default (Claude Code), Default (opus). Leaving them there posts nothing, so
Sessions::ResolveSpawnDefaults resolves both at create time: the router agent root’s
default_runtime and default_model, then Settings → Default runtime / Default model, then
the runtime’s catalog default, stamped into agent_runtime and config["model"] exactly as for a
trigger fire. The form never posts that default itself: a tab left open across a Settings change or
a roots.json edit would otherwise submit yesterday’s default as if someone had chosen it, and the
resolution would live in two places. Picking a harness whose catalog does not hold the root’s
declared model is fine — ResolveSpawnDefaults self-heals to that runtime’s own default.
What happens to a value neither picker could have produced differs by surface, and the difference
is the surface rather than the policy. quick_prompt (the two dashboard forms) redirects, so
rejecting would throw away the prompt someone just typed — it ignores the value, falls back to the
default and says so in the flash on the new session. That can happen honestly: an operator removes
an added model, or a runtime leaves the registry, between the page rendering and the click.
chat_bubble loses nothing on a rejection — its panel stays open with the draft in the textarea —
so it answers 422 with the reason and the panel shows it.
All three choices are per submission, not sticky preferences: closing the phone overlay or the chat bubble resets both pickers and the checkbox and re-collapses the accordion.
Credentials
Section titled “Credentials”Claude Code and Codex share a shape: ClaudeAccount rows, one marked current per runtime, tokens
written to a host-global file, and rotation when one hits a quota wall. That is
Agent harness credentials.
Pi is not in that pool, and nothing about it rotates. Pi resolves a provider credential per
request from its own process environment, so PiAuthProvider pools nothing and every one of its
pooling methods is a documented no-op — #accounts returns an always-empty relation rather than nil
precisely because callers chain .available.exists? onto it. There is no Pi identity to mark
current, nothing to refresh, and no Pi entry in RuntimeAuthProvider::RUNTIMES (the constant that
drives the token-refresh sweep and the auth warm-up fan-out).
The key is OPENROUTER_API_KEY, set in the Parameter Store through the secrets console the
Inference page’s Pi tab links (Zimmer does not write it; see Secrets Zimmer itself depends
on). The tab shows whether it is
set, which link of the chain holds it, and its path. See How the key reaches a Pi
session. It reaches the
process through PiRuntimeAdapter#apply_provider_key rather than through the session .env
writer, because that writer reads Rails-encrypted mcp_secrets only and never consults
SecretProviders. A value already in the clone’s .env wins, and a store Zimmer cannot reach is
logged rather than fatal: the session spawns and Pi reports its own not_ready.
CliStatusService is the observable answer to “can a Pi session run” — it shells
pi auth check --provider openrouter, which prints ready and exits 0 when the key resolves.
It is served by GET /api/v1/clis/status and get_system_health(include_cli_status: true), so a session
can learn whether the key is set without any surface being able to tell it what the key is.
What differs at spawn
Section titled “What differs at spawn”| Claude Code | Codex | Pi | |
|---|---|---|---|
| Session id | Zimmer generates it, --session-id | Codex mints its own; Zimmer reads it off codex exec --json | Zimmer’s id is Pi’s id, --session-id (it creates the session when missing) |
| MCP config | --mcp-config <path> | ~/.codex/config.toml (no flag) | .mcp.json in the working directory, read by the pi-mcp-adapter extension (no flag) |
| System prompt | --append-system-prompt | written into AGENTS.md below a marker | --append-system-prompt <file> |
| Resume | --resume UUID | codex exec resume UUID | re-run with the same --session-id — no resume subcommand |
| Transcript | ~/.claude/projects/…/*.jsonl | ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl{,.zst} | <clone>/.pi/sessions/*.jsonl |
| Config dir | ~/.claude | CODEX_HOME (~/.codex) | PI_CODING_AGENT_DIR (~/.pi/agent) |
| Stderr log | claude_stderr.log | codex_stderr.log | pi_stderr.log |
Pi’s system prompt goes through a file because the orchestrator prompt runs to many kilobytes and
Linux caps a single argv entry at 128 KiB; passing it inline would fail the spawn with E2BIG
instead of running. It is rewritten on every spawn so a resumed turn never appends a stale prompt.
Pi also reads AGENTS.md and CLAUDE.md from the working directory on its own, which is why
PiRuntimePromptContribution#delivered_via_file? is false: writing the orchestrator prompt into
a project file as well would put it in the model’s context twice.
Two flags on the Pi command line have no analogue on the other two. --approve trusts
project-local files for the run — without it Pi treats the .pi/skills/ and .mcp.json Zimmer
just wrote as untrusted third-party content and, having no TTY in -p mode to ask about them,
silently ignores everything. -e <path> loads each Pi extension, once per extension.
Pi brings no MCP, hooks or plugins of its own
Section titled “Pi brings no MCP, hooks or plugins of its own”Pi ships a skills mechanism and stops there. Everything else arrives as a Pi extension, installed
into /opt/pi-extensions at image-build time and passed explicitly with pi -e:
| Extension | Version | What it supplies |
|---|---|---|
pi-mcp-adapter | 2.32.1 | MCP servers, read from the .mcp.json in the clone |
@tadasant/pi-hooks | 0.2.0 | AIR lifecycle hooks |
@tadasant/pi-plugins | 0.2.0 | AIR plugins — a manifest’s skills, hooks and MCP servers |
PiExtensions is the registry, and both @tadasant packages live in
tadasant/pi-extensions. The 0.2.0 floor is not
cosmetic: below it a portable AIR hook loads, matches, spawns, and has everything it wrote thrown
away, with logs indistinguishable from a hook that worked.
Two consequences follow for the reader rather than the implementer.
MCP tools are not individually callable on Pi. pi-mcp-adapter exposes one mcp proxy tool
that the agent searches and calls through, so a dozen servers do not consume the context window
before the session starts. PiRuntimePromptContribution tells the agent so, because one that expects
mcp__server__tool to exist will otherwise conclude its servers are missing. It also means a Pi
transcript names MCP tools differently from a Claude Code one — see Transcript
hooks.
A Pi session’s MCP servers connect lazily. At spawn every server reports disconnected, and
that is the healthy resting state. pending on a Pi session means “not connected yet”, not
“broken”. The full evidence table is What actually works on the Pi
runtime.
The implementation — why PiMcpConfigPostProcessor writes the server table rather than
adjusting one, and what PiAirBridge generates so the hook and plugin extensions run the
session’s selection and not the cloned repo’s — is in Pi is the runtime that supplies
nothing.
Where Pi is partial today
Section titled “Where Pi is partial today”Some of these follow from the runtime itself; others are Zimmer wiring that has not been done. Either way each is worth knowing before you route real work to Pi.
- No subagents. Claude Code has
Task/Agentand Codex hasspawn_agent. Pi has neither, so the orchestrator prompt points a Pi session’s self-review at the/code-reviewskill instead.PiTranscriptNormalizer’s subagent extractors always return[]. - A transient provider failure is retried, and a quota wall parks; auth and context length are terminal.
pi -pexits 0 even when the model call returned 401, 429, 500 or a context-length 400 — it recordsstopReason: "error"in the transcript and writes nothing to stderr.PiTranscriptSourceanswersTranscriptSource#records_turn_errors?, soApiErrorRetryServicereadsPiTurnErrorthrough the sameRecordedTurnErrorseam Codex uses: a 5xx, a 429, a 408, a dropped stream or a refused connection gets the same six-attempt backoff a Claude session gets — classified by the HTTP status Pi records rather than by the provider’s error body, since production Pi runs on OpenRouter and the characterization ran against an OpenAI-dialect stub. A 402, or a 429 worded the way Pi itself declines to retry (insufficient_quota, “quota exceeded”, …), is a quota wall: the session parks and a timed re-check wakes it (ProviderQuotaWallPark— 15 minutes, doubling to every 8 hours, for up to seven days), spending no retry budget and paging nobody. A 401/403 and a 4xx rejection (context-window refusals among them) are terminal by design rather than unimplemented — Pi pools no credentials to rotate to, and it does not compact on a resume the way Codex does, so there is no recovery to route them into. They fail naming the provider’s own wording, without a page. See Known limitations. - Status summaries always take the cheap path.
SessionStatusSummaryGenerator#pool_exhausted?readsaccounts.available.none?, which is true for Pi by construction, so a Pi session’s summary is generated by a headless completion rather than by forking the session. See The Status summary. - Cost is priced on the Anthropic models only. Token counts are ingested for every Pi model;
Ratecarries no entry for the OpenAI and Google ids, so a Pi session on one of those lands with correct tokens and a zero cost. See Token spend. - A transcript hook cannot see a Pi session’s MCP tool calls.
pi-mcp-adapterroutes every call through one proxy tool rather than naming the server, so a Pi transcript carries nomcp__<server>__<tool>for a hook to key on — a PR opened through an MCPcreate_pull_requestgoes unrecorded. See Transcript hooks. - A Pi session’s MCP status pills go green or stay grey, never red.
PiMcpStatusDetectormines the transcript for proxy-tool signals, and an absent signal is indistinguishable from a server nothing called. See Known limitations. - A refreshed MCP OAuth token does not reach a Pi session that is already running. Pi ships its own MCP OAuth client and refreshes mid-session, and Zimmer adopts that rotation back — but at the next spawn or the next cron run, not into the live process, which memoizes the entry until its own provider rejects it. See MCP server OAuth and Known limitations.
- The spot concurrency ceiling does not count Pi, and never pauses it. That ceiling, and every
pause and preemption path, filter on
agent_runtime = 'claude_code', because they price a running fleet against an Anthropic quota window — a Pi session spends against an OpenRouter key instead. The top-up ceiling is the exception and counts every runtime, Pi included. See Spot and priority.
Where Pi is easier
Section titled “Where Pi is easier”Pi accepts --session-id, so Zimmer’s session id is Pi’s session id. mints_own_session_id? is
false and there is no window before a capture during which the transcript cannot be identified.
PiRuntimeAdapter also points --session-dir inside the clone, so each session’s transcripts
live in its own working directory. Two concurrent Codex sessions share one rollout tree and can
latch onto each other’s files, which is what CodexTranscriptSource#fallback_transcript exists to
defend against; two Pi sessions cannot collide, because a clone path is unique per session.
That same choice makes Pi restorable from a single deterministic file — Pi resolves --session-id
against the id inside a session file rather than its filename, so Zimmer writes the stored bytes
to one path and Pi continues appending to its leaf. Codex returns nil there and cannot.
The cost of putting the transcript in the clone is that it is reaped with the clone, which is why
PiTokenUsageIngestionService is the one usage ingestor that reads no file at all: it reads the
durable copy in sessions.transcript. See Token spend.