Skip to content

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_codecodexpi
CLIclaudecodex execpi -p
VendorAnthropicOpenAIPi coding agent (@earendil-works/pi-coding-agent, pinned to 0.84.4 in Dockerfile.base)
Credentialpooled OAuth subscription accountpooled OAuth subscription accountOPENROUTER_API_KEY, put in Pi’s process environment at spawn
Default modelopusgpt-5.6-terraopenrouter/anthropic/claude-opus-4.6
MCP, hooks, pluginsall three nativeMCP native; no hook lifecycle for AIR to translate intonone 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.

Three places set it, most specific first:

  1. The session’s own agent_runtime, passed at creation (the new-session form’s runtime selector, or the agent_runtime param on POST /api/v1/sessions and start_session).
  2. The agent root’s default_runtime in roots.json.
  3. 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.

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:

RuntimeModel ids
claude_codeopus (default), sonnet, haiku, fable
codexgpt-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
piopenrouter/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.

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_code catalog, which is the same rule ClaudeModelConfigurationAudit applies, since the bare aliases already follow new releases. Pi ids must be provider/…. An id that is already built in is refused.
  • Whether the installed CLI lists the id (ModelCatalogCliCheck). Codex answers from codex debug models --bundled and Pi from pi --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:

SurfaceWhere
The dashboard’s inline prompt row, at md: and widerUnder the attach buttons
The dashboard’s full-screen prompt overlay, on phonesAbove Submit
The chat-bubble panel (Cmd/Ctrl+K), on every pageUnder 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.

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.

Claude CodeCodexPi
Session idZimmer generates it, --session-idCodex mints its own; Zimmer reads it off codex exec --jsonZimmer’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-promptwritten into AGENTS.md below a marker--append-system-prompt <file>
Resume--resume UUIDcodex exec resume UUIDre-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~/.claudeCODEX_HOME (~/.codex)PI_CODING_AGENT_DIR (~/.pi/agent)
Stderr logclaude_stderr.logcodex_stderr.logpi_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:

ExtensionVersionWhat it supplies
pi-mcp-adapter2.32.1MCP servers, read from the .mcp.json in the clone
@tadasant/pi-hooks0.2.0AIR lifecycle hooks
@tadasant/pi-plugins0.2.0AIR 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.

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/Agent and Codex has spawn_agent. Pi has neither, so the orchestrator prompt points a Pi session’s self-review at the /code-review skill 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 -p exits 0 even when the model call returned 401, 429, 500 or a context-length 400 — it records stopReason: "error" in the transcript and writes nothing to stderr. PiTranscriptSource answers TranscriptSource#records_turn_errors?, so ApiErrorRetryService reads PiTurnError through the same RecordedTurnError seam 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? reads accounts.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; Rate carries 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-adapter routes every call through one proxy tool rather than naming the server, so a Pi transcript carries no mcp__<server>__<tool> for a hook to key on — a PR opened through an MCP create_pull_request goes unrecorded. See Transcript hooks.
  • A Pi session’s MCP status pills go green or stay grey, never red. PiMcpStatusDetector mines 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.

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.