Skip to content
Like what we’re building? Star on GitHub

🤝 Backends and credential auto-detection ​

A backend is the executor iterion routes a node to — either the in-process provider client and Iterion-native tool loop, or a coding-agent CLI with its own tool loop — and one workflow can mix them per node. model: is an independent wire-model pin, not a request for a particular backend. Iterion ships seven: claw (in-process LLM SDK), claude_code (Claude Code CLI), pi (pi coding agent), kimi (Kimi Code CLI), grok (Grok Build CLI), opencode (OpenCode CLI) and codex (Codex CLI). It auto-detects whatever credentials you already have signed in: the default preference order is claude_code then claw; the other five require an explicit opt-in. This page documents the resolution chain, credentials, support boundaries, and overrides.

Cloud BYOK. The auto-detection below is the host/env path. In cloud mode, provider API keys are owned per-org and sealed in Mongo, resolved per-run with a precedence chain (per-webhook override → user default → org default → env fallback). See byok.md.

Backend status ​

Proven grades evidence, not ambition: 🟢 exercised in production · 🟠 supported, never run against the whole feature matrix · ⚪ not implemented. Supported is not proven — only claude_code and claw are battle-tested. The per-capability read, and what it would take to turn a 🟠 into a 🟢, live in state-of-the-art.md.

BackendProvenStatusSelection
claw🟢Recommended in-process backend for direct provider calls and native Iterion tools. anthropic + openai validated; bedrock/vertex/foundry ship but are untested.Automatic or explicit.
claude_code🟢Recommended CLI-agent backend for implementation work and Claude subscription/OAuth use.Automatic when Claude Code OAuth is detected, or explicit.
pi🟠Supported, with iterion's permission gate. Reaches ~36 providers and reports a provider-computed cost. Runs a long-lived --mode rpc session by default — tool events, native steering, authoritative accounting, pre-flight handshake (ITERION_PI_MODE=print rolls back). Permission gate, ask_user, board capabilities and workflow-declared MCP servers (all three transports — streamable http, legacy sse, stdio) work via an embedded extension, which loads on the rpc transport only: a node declaring permission: is refused under ITERION_PI_MODE=print rather than run ungated.Explicit only.
kimi🟠Supported through the generic CLI-agent protocol, with iterion's permission gate in deny only — an external PreToolUse hook can hard-block a call but cannot pause the run for ask, so ask is refused at compile time (C176). A gated node needs an unsandboxed run — sandbox: none on the workflow, or --sandbox none (C136 warns) — and session resume/fork is not wired.Explicit only.
grok🟠Same generic CLI-agent protocol, and the same deny-only gate, sandbox: none requirement and unwired session resume/fork.Explicit only.
opencode🟠Supported through the generic CLI-agent protocol (opencode run --format json [-m provider/model[#variant]], prompt on stdin). Multi-provider, reports a provider-computed cost, and carries a reasoning-effort dial. Cannot enforce iterion's permission gate at all — neither ask nor deny — so a gated node is refused at compile time (C176) and typed (CAPABILITY_UNSUPPORTED) at dispatch; interaction: async is refused by C267 and a synchronous interaction: warned as inert (C271); session resume/fork is wired (--session <id> / --fork=true) but session: persist and MCP forwarding are not; and a workspace carrying .opencode/plugin[s]/ is refused unless ITERION_OPENCODE_TRUST_PROJECT=1.Explicit only.
codex🟠Supported Codex CLI backend. Uses Codex's native tool loop and sandbox; see its capability boundaries below.Per-node/workflow opt-in, or explicit addition to ITERION_BACKEND_PREFERENCE.

Parity doctrine: claw ↔ claude_code ​

These two are not alternatives at different tiers — they are meant to be feature-paritary and interchangeable on the same node. claude_code is the more stable, mature harness today; claw is the in-process twin that reaches every provider the registry knows (including providers the CLI cannot speak to). The settled rules:

  • A claw gap, error or limitation found in real use is a claw-code-go backlog item, not a disqualification: fix the harness first, then re-judge the model that ran on it.
  • Engine capabilities (credentials, fingerprints/meters, permission gate, session resume, events) must land on both backends — or refuse with a typed diagnostic on the one that cannot honour them yet.
  • Run-level fallback and per-node overrides are the switching mechanism; every production switch doubles as a parity measurement, so switches stay observable (events carry the backend and the served model).

PDFs on read_file (claw) ​

read_file on claw extracts the text of a workspace PDF instead of returning mangled binary-as-text: a file carrying the %PDF magic is run through claw-code-go's BT/ET scraper and windowed with the same chunk semantics as any text file (continuation markers included). The extraction runs under a DOCUMENT-WIDE inflation budget equal to the read ceiling (64 MiB): zlib holds ~1000:1, so a compressed bomb — or many small streams summing to one — fails the read with an explicit ErrBudgetExceeded instead of allocating. claude_code reads PDFs natively; this is the same capability wired on claw (the parity doctrine above). The run's ${PROJECT_SCRATCH_DIR} is a READ root of claw's read_file/glob/grep/read_image beside the workspace (#2650 — a bot's agents must read the briefs the run itself wrote there, as claude_code's unbounded Read does); other attachment paths stay outside the read scope — reach them through bash.

What that root holds, measured: it is the PROJECT scratch dir — <iterion home>/projects/<encoded repo root>/scratch, the iterion home being $ITERION_HOME or ~/.iterion — and it is shared between the runs of one repository on purpose, because a sub-bot child writes its fan-in there from its own container and docs-refresh, sec-audit-deps and wiki-gen treat it as cross-run memory (pkg/runtime/scratch_sweep.go, applyScratchMount, which creates it 0700 when it binds it). A claw agent can therefore read what other runs OF THE SAME REPOSITORY left there, scanner output included — the breadth claude_code's unbounded Read already had. It reaches no further: another repository encodes another key, another operator resolves another iterion home, and a driver with no host filesystem (kubernetes — a pod rejects type=bind) gets a container-local scratch that the teardown banks per run (ADR-106), so no host directory is shared into the pod at all. Narrowing the root to the run's own subtree is a separate change, and it has to keep the fan-in and those caches readable.

Only reads take the extra root. write_file and file_edit are not scope-aware: a relative path rebases onto the workspace, an absolute one passes through, exactly as before. And a path one read tool emits is spelled so another read tool opens the same file — workspace-relative inside the workspace, absolute under any other read root, since read_file joins a relative path onto the workspace and would otherwise open its homonym.

Per-backend capability matrix ​

The status table says which backends are trusted; this matrix says what each one is proven to do. Only claude_code and claw are battle-tested, and a bot author must be able to tell a proven capability from a plausible one (#1417). The cells:

  • proven — a live e2e through the real backend asserts it, and the test that would fail if it broke is named below. Not "the code exists".
  • refused (C-code) — the compiler refuses the capability on this backend with a typed diagnostic; see the diagnostics reference.
  • warned (C-code) — the compiler names the capability as unwired on this backend with a warning: the run goes through, degraded (e.g. a sync interaction: whose pause can never fire), and the author sees it before launch.
  • unwired (gap) — no code path and no diagnostic guards it: the parity rule above ("wired — or typed-refused — for the other") is not yet honoured. Each one is a gap a session can pick up.
  • unwired (no image) — a variant of the gap above: the code path is there, but no image iterion publishes ships the CLI, so the capability cannot be reached from a sandboxed run at all. The gap is the image's contents, not the backend: a sandboxed run whose RESOLVED image is one iterion publishes is refused before the pulls, naming the image and the missing executable (CAPABILITY_UNSUPPORTED), and the compiler warns (C321) where it cannot know the image — an operator image that bakes the CLI lifts both (#1643).
  • unknown — wired in code (path cited below), but no live e2e through this backend exercises it yet. It may work; nothing has paid to find out. Turning an unknown into proven costs one live e2e — extend the e2e coverage matrix, which stays the single feature×coverage inventory.
BackendStructured output (schema:/output:)Permission gate askSession resume / forkTool events & cost{{outputs.*}} / {{run.*}}SandboxMCP serversask_userWorkspace /commands
claude_codeunknownunknownproven (resume) · unknown (fork)unknownprovenunknownunknownunknownproven
clawprovenprovenproven (fork) · unknown (resume)provenunknownunknownprovenprovenproven
codexprovenrefused (C176)unknownproven (events) · unknown (cost)unknownproven (readonly)unwired (gap)refused (C267, async) · warned (C271, sync)unwired (gap)
piunknownunknownunknownunknownunknownunknownunknownunknownunwired (gap)
kimiunknownrefused (C176)unwired (gap)unknownunknownunknownunwired (gap)refused (C267, async) · warned (C271, sync)unwired (gap)
grokunknownrefused (C176)unwired (gap)unknownunknownunknownunwired (gap)refused (C267, async) · warned (C271, sync)unwired (gap)
opencodeunknownrefused (C176)proven (resume · fork) · unwired (persist)unknownunknownunwired (no image)unwired (gap)refused (C267, async) · warned (C271, sync)unwired (gap)

The citations, per cell that is not self-evident from the table:

  • Workspace /commands. A user prompt that OPENS with /<name> is replaced by the body of <workspace>/.claude/commands/<name>.md — the files a plugin's contributes: commands mirrors there (see plugins.md). claude_code resolves them from the workspace itself; claw resolves them in-process through claw-code-go's pkg/api/commands, from pkg/backend/model/slash_commands.go. Both cells are proven by TestLive_Feat_WorkspaceCommands (task test:live:feat:workspace-commands): one workflow sends the same invocation to a claude_code node and a claw node, both with tools: [], so resolving the command is the only path to the token its file carries. Four differences from claude_code, deliberate and measured on CLI 2.1.220:

    • a command name may carry dots (db.migrate), as on claude_code, which applies no charset at all. What a name cannot spell is traversal: a separator is refused, and so is a component that is nothing but dots. os.Root is what makes containment a guarantee — the charset only keeps the name from expressing what the root would refuse anyway.
    • claw reads the workspace only — no walk up the ancestors, and no user-level ~/.claude/commands/: a workspace is a checkout of a repository the run does not control, and host state does not belong in a sandboxed or multi-tenant run. The read goes through os.Root, so a repository that ships a command file — or the .claude/commands directory, or .claude itself — as a symlink out of the workspace gets a refusal rather than the target. A relative symlink that stays inside the workspace is followed, so a tools: [] node is not a read barrier for files the checkout already contains; an absolute symlink is refused wherever it points, because os.Root rejects absolute targets outright. What the boundary buys is that the repository under review cannot reach the host through it.
    • A command's frontmatter other than description: and allowed-tools: (model:, argument-hint:, disable-model-invocation:) is honoured by claude_code and ignored by claw, which keeps only the body. A command declaring any of those keys logs a warning naming them and #1717, and the keys ride the node's delegate_finished event (command_frontmatter_ignored) so a deterministic gate can read them out of events.jsonl — a log line cannot be asserted on. It is keyed per NODE, not per command file. allowed-tools: is the exception, and the one with a security consequence: it is honoured — a command that declares it narrows the node's tool set to that list (folded onto claw's tool names, plus the always-allowed command / tool_search / todo_write), on BOTH surfaces. A prompt-invoked command narrows the node's advertised tools at build time — intersect, never widen — and a model-invoked one (the command tool) narrows the rest of the generation dispatch-side: a call outside the list is refused with the command's name. The model-invoked narrowing is intersect-only — a deliberate divergence from claude_code, where the operator's next turn lifts the bound: on this surface EVERY invocation is model-side, so a command without allowed-tools: changes nothing and a later command can only shrink the scope further — a restriction the model could lift itself would be none at all. The bound survives the loop's own continuations: the tool-use nudge and retries share the generation's scope, a pause (ask_user, permission gate) checkpoints it beside the conversation, and a delegated subagent's set is intersected with it. Two refinement rules (#2905): an intersection keeps a member the other side's family covers — mcp__srv__* ∩ mcp__srv__tool keeps the member rather than intersecting to empty — and a generation whose conversation no longer shows the invocation's notice (a retry replaying from the bare prompt, a checkpoint compaction that folded the turn away) gets the bound re-stated on its last user turn, so a refusal never names a command the conversation cannot place. Three published value shapes fold with documented approximations: Bash(git:*) grants the WHOLE bash tool (iterion has no per-prefix granularity), mcp__srv__tool / mcp__srv__* fold to the sanitized mcp_srv_tool / the server's whole family, and a bare * declares an explicit no-restriction. model: stays refused-by-silence, deliberately: a contributed command must not re-route a node's spend.
    • $1, $2, … (any index, not just the single digits) are one-based on claw ($1 is the first argument) and zero-based on claude_code, whose docs define $N as shorthand for $ARGUMENTS[N] with $0 the first argument — and whose CLI measures that way on 2.1.220 and 2.1.282 alike (/x alpha beta gamma on [$1][$2][$3] expands to [beta][gamma][$3]). claw keeps the CLI's earlier "like shell scripts" reading, which the docs have since replaced; flipping it would rewrite bodies already written against claw, so the divergence stays named (the re-measure record).
    • !`cmd`, ```! shell substitution, $0, $ARGUMENTS[n], \$ escapes and the ${CLAUDE_PROJECT_DIR} / ${CLAUDE_SESSION_ID} / ${CLAUDE_EFFORT} placeholders are not evaluated. A body using any of these — or the positional forms above — is logged with a warning naming the form, so one file never means two things silently. @path file references are not evaluated either and are deliberately not warned about: telling one from an npm scope (@anthropic-ai/sdk) or a decorator (@mcp.tool() by text alone measured 0% precision on real command bodies, and a warning that is always wrong teaches an author to ignore the ones that are not.

    A prompt may both invoke a command and reference an image attachment (/analyze {{attachments.shot}}): the command body replaces the invocation TEXT and the image blocks travel untouched, so the model receives the bytes on claw exactly as it does on claude_code. The attachment's path also survives as an argument.

    The MODEL-invoked half (#1716): on claw, a node that grants command in its tools: gives the model a tool that resolves <workspace>/.claude/commands/<name>.md mid-conversation, through the same contained pkg/api/commands lookup, under the same byte ceiling and the same kill switch (ITERION_CLAW_SLASH_COMMANDS=off drops both surfaces — the tool is not even registered, so a node declaring it fails loudly at dispatch rather than half-living). Both dials are read on the HOST, because the tool is sandbox-placed and a container holds no dial of its own: the switch arrives as the tool's absence from the advertised set, and the ceiling as a value the task carries (slash_command_max_bytes) — so an operator who tightens it for a checkout under review reaches the registration that actually executes. The returned body is inert text: executing anything it asks for takes the node's own declared tools, so a command grant hands over no capability the node did not already hold. claude_code's native equivalent is its SlashCommand tool, which iterion withholds from every headless spawn whatever the node declares: a command THERE can be Bash-backed and would reach a shell the node never declared — the asymmetry is deliberate, and claw's text-only body is the safe form of the same capability. The frontmatter divergence below covers both paths: disable-model-invocation: is among the keys the lookup reads and discards, so on claw the model path is not bounded by it. allowed-tools: is bounded, on both paths (#1717): the command tool's invocation narrows the rest of the generation's dispatches to the folded list, the refusal naming the command that narrowed — and the tool's answer OPENS with a notice saying so, beside the keys still discarded and the body forms this backend expands differently, so the model reports a command whose header claims what does not apply — and the host persists that answer on the node's tool_called event, where a deterministic gate can read it. The deterministic witness is pkg/backend/tool/claw_command_test.go; the live one is the via_claw_model_tool node of TestLive_Feat_WorkspaceCommands, whose prompt does not open with the invocation.

    "The workspace" is the run's, even when it moves: the engine replaces the executor's workDir at run start when worktree: auto produces a per-run worktree (ClawExecutor.SetWorkDir), and the command and skill tools resolve through a workspace PROVIDER bound to that same field (ClawDefaults.WorkspaceProvider, consulted on every call) rather than a string captured when the registry was built — so the tools and the prompt path can never resolve .claude/commands/ and .claude/skills/ in two different checkouts (#2870). The same provider backs bash's shell cwd and validation, write_file's and file_edit's relative-path rebase, and the read tools' containment (their scope is built over it), so the file and shell tools follow the same per-run worktree (#2889). The witnesses are TestBuildExecutor_WorkspaceLookupToolsFollowSetWorkDir and TestBuildExecutor_WorkspaceFileToolsFollowSetWorkDir (pkg/runview/executor_workspace_follow_test.go).

    A command whose body — or whose expanded text — exceeds ITERION_CLAW_SLASH_COMMAND_MAX_BYTES (256 KiB by default) is refused rather than substituted: on a review run that file belongs to a checkout the run does not control, and it turns straight into a billed request. Both ends are checked because the body sets the multiplier: $ARGUMENTS repeated N times turns a body under the ceiling into N times the arguments.

    Arguments a body does not consume are appended as ARGUMENTS: <args>, which is what the CLI does; dropping them would delete the operator's message rather than merely leave it unexpanded. Whether a body consumed them is the expander's own answer, not a second reading of the text — a placeholder a reader counts ($0, an out-of-range $N) is one the expander leaves literal, and on that disagreement the arguments were neither substituted nor appended. Arguments are split on whitespace; the CLI splits them with a quote-aware shell tokenizer, so "two words" is one argument there and two here.

    Nothing here fails a node. A name that resolves to nothing, a file that cannot be read, and a body that is empty each leave the text unchanged and log the reason. The CLI answers an unknown command locally (num_turns: 0, total_cost_usd: 0, is_error: false) and its node succeeds, so failing here would be a divergence — and it would break an ordinary prompt that merely opens with a slash-shaped word ("/tmp is full, clean it up"). ITERION_CLAW_SLASH_COMMANDS=off restores the pre-capability behaviour. The other backends have no workspace-command convention; a /name prompt reaches them as written.

    On claude_code a workspace command resolves under the project scope and stops resolving under a list that names only user — measured both ways. So the flag does not enable project commands, it scopes them: iterion always passes it, and the ambient-context policy (above) decides which scopes accompany it. What the policy leaves of the user-scope question (#1716's (e)) is decided, per value: under none and workspace (the default) NEITHER backend sees the operator's own ~/.claude/commands/ — claude_code passes no user scope there and claw never reads one — so the default-posture asymmetry the ticket measured is gone; under operator and all a claude_code node sees it and a claw node never does. That residue is deliberate, and the ticket closes on it rather than on an opt-in. Containment is the whole reason: claw's lookup is rooted at the workspace through os.Root, and a sandboxed or multi-tenant run holds no host home to root a user scope at. If symmetry is ever wanted it is re-proven by need — a measured case of an operator reaching for it earns the design, and nothing is built ahead of one.

  • claw, five proven cells. Structured output: TestLive_Feat_Cursors asserts the reviewer's output against its schema (task test:live). Permission ask: TestLive_Feat_Permission_Ask, the run lands paused_waiting_human (task test:live:feat:permission-ask). Fork: TestLive_Feat_Fork (task test:live:feat:fork). Tool events: TestLive_ClawToolCoverage (task test:live:coverage). Cost: the metered figure crosses a $0.0001 cap and fires budget_exceeded (TestLive_Feat_Budget, task test:live:feat:budget) — the budget gate over the meter is what is proven; the figure itself is an estimate from token counts through iterion's pricing registry, the same estimator codex uses, and only claude_code and pi report provider-computed amounts. MCP servers: TestLive_Lite_ClawMCP round-trips a workflow-declared server (task test:live:claw-mcp). ask_user: TestLive_ClawToolCoverage puts ask_user in its must-dispatch list — a run where the tool never fires, or never succeeds, fails — and asserts the human's answer round-trips through the model into the node's output schema (task test:live:coverage). Claw's session resume (conversation rehydration) is wired but not live-asserted; only the fork half of its cell is proven.

  • claude_code. Two cells are proven. Session resume: TestLive_Lite_SessionInheritValidation (task test:live:session-inherit) requires fix._session_id == implement._session_id — --resume really continued the CLI session. The outputs column, by the same test (below). Fork is wired (WithForkSession) but unasserted. Structured output is wired natively (WithOutputFormat plus a two-pass fallback, pkg/backend/delegate/claude_code.go) and the ask gate, cost metering (provider-computed TotalCostUSD), sandbox, MCP forwarding and ask_user (native MCP server) are all wired — none has a live e2e.

  • codex. Structured output and sandbox are proven by TestLive_Feat_CodexWebSearch: the researcher's output is schema- asserted, and under readonly: true the test fails if the node creates a file. Its tool events are proven by the same test's WebSearch lifecycle; the cost figure is only a token estimate, and that estimate's accuracy is unproven — hence unknown. ask is refused (C176): codex runs bypassPermissions and is absent from the gate-enforcing table (pkg/dsl/ir/validate_fallbacks.go). ask_user has no wiring at all — the async pair is refused (C267), the sync form warned (C271): only the interaction protocol's prompt text reaches the model, so the node never pauses.

  • pi. Everything is wired through the embedded extension — gate, ask_user, MCP servers, session fork, provider-computed cost — and all of it on the rpc transport only; under ITERION_PI_MODE=print a permission: node is refused at runtime without a diagnostic code (pkg/backend/delegate/pi.go), which is its own parity gap. Nothing pi does has a live e2e; every pi cell stays unknown.

  • opencode. The gate is refused in both modes (C176): opencode exposes no PreToolUse hook at all, and its own declarative policy is deliberately not wired — gate membership is earned by a live denial, and none has been bought. The 1.x delivery (OPENCODE_PERMISSION) is removed from the 2.x line (zero occurrences in the 2.2.5 binary); the 2.x mechanism is OPENCODE_CONFIG_CONTENT, re-measured on 2.2.5 by TestCLILiveOpenCodeConfigContentDeny — see the fail-open table. A gated node that reaches dispatch anyway (a launch-time override route the compiler never saw) is refused with the typed capability refusal (CAPABILITY_UNSUPPORTED), never run ungated and never retried. ask_user is refused (C267) for the async pair and warned (C271) for the synchronous one (the tool list never reaches the CLI, so the node never pauses — the interaction protocol is prompt text only). Sandbox is unwired (no image): no published image ships an opencode binary, so a sandboxed run on a published image is refused at start — the refusal names the image, the opencode executable and both exits (sandbox: none, or an image of the operator's own that ships it), and C321 says it at compile time — see sandbox.md. Session resume/fork is proven by TestCLILiveOpenCodeSessionResume (task test:clilive): the real opencode 2.2.5 CLI, driven through iterion's own Execute in the 2.x argv, against a scripted model on 127.0.0.1 — the resumed turn's request carries the earlier turn, and a fork answers under a new id with the whole history. An unknown id is version-dependent and the test asserts the version it probes (the opencode section). No credential and no model spend: what is proven is the CLI's rehydration, not a model's use of it. session: persist stays unwired: opencode keeps its sessions in one SQLite database, with no per-session file for the packer, so SessionResumeCapability does not list it and a persist node logs cannot resume; running fresh.

  • kimi / grok. The gate is deny-only — an external PreToolUse hook can hard-block but cannot pause the run — so ask is refused at compile time (C176). The deny half is proven live with a filesystem sentinel (TestLive_Feat_Permission_Deny_Kimi, TestLive_Feat_Permission_Deny_Grok); the ask column is about pausing, which this design cannot do. Session resume/fork is unwired: the session id is parsed and stamped but never consumed — the next call is silently fresh (pkg/backend/delegate/cliagent.go), and a run-level fork fails with "no turn checkpoint" (pkg/runview/fork.go). A session: inherit node on kimi compiles and quietly loses continuity; no diagnostic says so. MCP servers are unwired: workflow-declared mcp: blocks reach claude_code and pi (forwarded) and claw (in-process) — nobody else (pkg/backend/model/executor_build_task.go, mcpForwardingBackends). Structured output, cost (token estimate that drops the field when the model is unpriced — the delegate_*-at-$0 risk) and sandbox are wired at best and unproven: unknown. ask_user is prompt-fallback only — no tool ever reaches the CLI — so the async pair is refused (C267) and the sync form warned (C271): the node runs to completion without ever pausing.

  • {{outputs.*}} / {{run.*}}. The resolver is engine-side — the executor substitutes templates before any backend sees the prompt (pkg/backend/model/executor_template.go), one resolver for seven backends — but what a template reads is what the backend's delegate captured into the node's output, and that capture is per backend. The claude_code cell is proven by TestLive_Lite_SessionInheritValidation (task test:live:session-inherit): the fix node's session id arrives through its edge as {{outputs.implement._session_id}} and the test requires the session the CLI resumed to be that exact id — a template left unresolved could not have resumed it. No claw-pinned live fixture maps an output across nodes (the exhaustive-DSL fixture's unpinned nodes float to host detection), so every other cell stays unknown. interaction: async (ask_user_async) is refused outright for codex/kimi/grok/opencode (C267).

The open parity gaps, in one list: codex/kimi/grok/opencode MCP servers; kimi/grok session resume/fork (silent, unguarded); opencode's session: persist (no packer for its SQLite session store); pi's print-mode runtime refusals without diagnostic codes; and every unknown cell, which is one live e2e away from proven.

Ambient context (ADR-119) — what a node inherits besides its prompt ​

ambient_context: none | workspace | operator | all — on an agent/judge node or the workflow, with a run override (--ambient-context, launch ambient_context) and ITERION_AMBIENT_CONTEXT above the DSL. Default workspace. Two origins:

  • workspace — the repository's instruction files, from the working directory up to the repository root, never above it: CLAUDE.md, .claude/rules/, AGENTS.md … as each backend names them. The repository's settings, skills, commands and hooks are NOT governed here: they carry the engine's own mirrored skills and plugin contributions.
  • operator — the operator's personal agent setup (~/.claude or $CLAUDE_CONFIG_DIR, $CODEX_HOME, pi's agent directory) and the instruction files above the repository root. For a worktree nested in its own main checkout, the main checkout counts as the repository.
backendnoneworkspace (default)operatorall
claude_codeproject + every memory file excludedproject + the memory files above the root excludeduser,project + the repository's memory excludeduser,project
clawno project-instructions blockclaw-code-go's loader: walk stopped at the root, user scope off, .claude/rules on, plus AGENTS.md where no CLAUDE.md shadows itthe walk above the root + the user scopeeverything, the repository's AGENTS.md included
pi--no-context-files--no-context-files + iterion supplies the allowed files, rendered in pi's own shapethe same, operator filespi's native loading
codexproject_doc_max_bytes=0 + a per-run CODEX_HOME without its AGENTS.mdthe same homeproject_doc_max_bytes=0unchanged
kimirefused (C187)a home without its AGENTS.md (KIMI_CODE_HOME overlay) — refused when declared, warned when defaulted, if ~/.agents/AGENTS.md existsrefused (C187)kimi's native loading
opencoderefused (C187)refused when declared (C187); the default is not appliedrefused (C187)opencode's native loading, Claude-Code compatibility layer off
grokrefused (C187)not measured (C185 on a declared value)refused (C187)not measured (C185 on a declared value)

Measured on Claude Code CLI 2.1.282 and codex-cli 0.156.1, each cell checked against the real tool with planted markers and an invalid key (no cost):

  • Project memory walks up to /, not to the repository root. With the workspace under $HOME, --setting-sources project alone still loads ~/.claude/CLAUDE.md and ~/.claude/rules — ~/.claude/ is an ancestor's .claude/. Dropping the user scope does not keep the operator out; iterion therefore also excludes the memory files of every directory above the root (CLAUDE.md, CLAUDE.local.md, .claude/CLAUDE.md, .claude/rules/** — never .claude/** whole, since session worktrees live under a main checkout's .claude/worktrees/).
  • claudeMdExcludes is CONCATENATED across settings layers: the engine's exclusions never remove one a repository or an operator set themselves.
  • A run worktree nested in the repository also loads the primary checkout's CLAUDE.md through that walk — the policy removes the double-load.
  • The project scope always loads on claude_code: the engine's mirrored skills and plugin contributions are only discovered under it (pkg/runtime/plugin_skills.go).
  • Workspace @imports are confined: Claude Code does not follow an import outside the repository in headless mode, and claw-code-go's loader (used by claw) enforces the same for the workspace scope, symlinks included — a pull request's CLAUDE.md cannot read @/proc/self/environ or @~/.ssh/id_rsa into the prompt. The AGENTS.md files the ADR-119 fallback adds to claw are held to the same boundary under the workspace scope: one that is itself a link out of the repository is not loaded there, and under all the fallback follows the walk's unconfined scope, as the ADR-119 amendment states.

The structured-output pass of a claude_code node follows its policy too; before ADR-119 it passed no --setting-sources at all, and so loaded every scope, local included.

Declared or defaulted. A value somebody chose — the node's, the workflow's, a run override or ITERION_AMBIENT_CONTEXT — is applied or refused, never ignored: a backend that loads, whatever iterion passes it, instruction files the value excludes refuses the node before it runs (delegate.ErrCapabilityUnsupported, classified CAPABILITY_UNSUPPORTED, so it is not retried), and the compiler refuses the same where it can see the backend (C187, the node's own value, a workflow value it inherits, and every fallback route). The default is never refused: each backend applies it as far as it can, as the table says. A policy nothing has measured on a backend is warned instead (C185).

kimi, measured on kimi-code 2.1.1 (TestCLILiveKimiAmbientPolicies, -tags clilive: kimi's env-model variables point it at an in-process server that records the request — no network, no cost). kimi reads the operator's $KIMI_CODE_HOME/AGENTS.md (~/.kimi-code by default) and the first of ~/.agents/AGENTS.md and agents.md, then, from the git root down to the working directory, each directory's .kimi-code/AGENTS.md and first of AGENTS.md and agents.md — never a file above the root, never a CLAUDE.md, and nothing of a nested worktree's main checkout. workspace serves kimi an overlay of its home without its AGENTS.md (the mechanism codex uses for CODEX_HOME; a file kimi replaces there by rename is carried back), and a permission shadow home is built from that overlay. kimi resolves ~/.agents from HOME, so no overlay withholds it: when it exists a declared workspace is refused and the default is warned once per process. No switch skips the repository walk, so none and operator are refused. On a containerised run kimi reads the image's home, and iterion leaves it alone.

opencode, read from source (1.19.0, the opencode-ai package, and 2.0.26, @opencode/cli), with one half now measured live on 2.2.5: the WORKSPACE's own AGENTS.md reaches the prompt the CLI sends, and the project it reads is the one PWD names — see the opencode section. The operator-side reading below stays read-from-source. opencode reads its global AGENTS.md from its config directory, every file its global configuration lists under instructions, and — 2.x — every AGENTS.md from the working directory up to the home directory, past the repository root: nothing keeps the operator out, so a declared workspace is refused and the default stays opencode's own loading. Its read tool adds the AGENTS.md of every directory it reads a file in, whatever OPENCODE_DISABLE_PROJECT_CONFIG says, so none and operator are refused too. all is its native loading, minus the Claude-Code compatibility layer iterion always pins off (OPENCODE_DISABLE_CLAUDE_CODE).

grok, read from source (xai-org/grok-build@2bdd1d6; no live measurement). Grok Build reads AGENTS.md, CLAUDE.md and the .grok, .claude and .cursor rules from the git root down to the working directory whenever it trusts the folder, and it trusts any repository that carries no hook or MCP configuration without asking — iterion cannot make it skip them, so none and operator are refused. What it reads from GROK_HOME, the vendor homes and [paths] extra_rule_dirs has not been measured (and a permission-gated grok runs with a private HOME), so workspace and all stay warned (C185).

TL;DR ​

If you have at least one of:

  • Claude Code signed in (OAuth in the macOS Keychain on Mac, or ~/.claude/.credentials.json on Linux/WSL — "forfait")
  • ANTHROPIC_API_KEY set in your environment
  • OPENAI_API_KEY set in your environment
  • XAI_API_KEY set in your environment (xAI Grok)
  • AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT
  • AWS credentials (Bedrock) or GOOGLE_CLOUD_PROJECT (Vertex)

… then opening the studio, hitting New, and clicking Run will work without any further configuration. The agent in the default template has empty backend: and model: — both are filled in at run time from what's available.

Compilation and target capabilities ​

ir.Compile never discovers credentials, reads CLI auth files or probes executables on the compiling host. With no model, node backend or workflow default, C018 warns that execution must resolve a backend on its target. A caller with an authoritative target snapshot can use CompileWithCapabilities: available suppresses that diagnostic, unavailable refuses agents and judges, and unknown keeps the warning. LLM routers retain their advisory C018 for unavailable or unknown targets. Human LLM interaction requirements are unchanged. Explicit DSL choices remain the operator’s intent.

Server validation, cost preview, run-view compilation and runner transport compilation use the unknown-target entry point. They do not substitute the server’s credentials for those of a future runner. The executor remains the execution boundary: it resolves the backend on its target, builds its model client and returns the build error when no model can be resolved. Compilation is not credential validation. Explicit environment expansion, including ITERION_DEFAULT_SUPERVISOR_MODEL, retains its existing contract.

The forfait-first and Anthropic-wire preflight reader deliberately stops at auto or an unresolved template. It cannot certify which backend a future runner selects, so falling through there would narrow a protective guard.

Resolution chain ​

When a node, workflow, and env are all silent, the runtime resolves a backend in this order:

  1. node.Backend — the backend: line on the agent/judge/router
  2. workflow.default_backend — the workflow-level default_backend:
  3. ITERION_DEFAULT_BACKEND — environment override
  4. Auto — the first backend in ITERION_BACKEND_PREFERENCE whose credentials are detected on the host
  5. claw — last-resort fallback

Steps 1 and 2 are read through the same ${VAR} / ${VAR:-default} expansion every routing field honours, and the compiler reads them by their DEFAULT: backend: "${ITERION_SEC_AUDIT_BACKEND:-claude_code}" is screened as claude_code, so the cross-backend checks a dialled node used to escape — the tools: inversion, the session-continuity refusal, the permission gate on the primary route, the effort drift, the memory and provider-hint warnings (C047/C088/C135/C136/C173/C174/C176/C177/C267) — apply to it exactly as to a literal (#1389).

The compiler reads the DEFAULT and never the shell it happens to run in: ir.Compile runs in the server pod, in the runner pod and on a laptop, and a verdict that moved with the ambient environment would give one artifact three answers. What the dial is actually set to is screened where it is actually read — the launch-time --fallback admission, which runs in the process that dispatches the node.

Two forms fall through to the next step of the chain: an empty field, and auto — on a node or the workflow default. Both use ir.BackendFallsThrough after template and environment expansion. An explicit default_backend: auto deliberately leaves the choice to the execution target — and when nothing in the chain decides, C018 answers as it does for no default at all (unknown capabilities: a warning; a target reporting none: an error). A third kind is undecided: a field the source writes but does not answer — a ${X} with no :- default, a bare $X, a {{vars.x}} a launch may override. Those do not fall through to default_backend:, because which branch the run takes is not knowable from the source (an undeclared {{vars.zz}} reaches the registry as that text, a set ${X} names a backend), and a screen that guessed would certify a backend the run never uses.

auto on a route is not a step of the chain: resolveChain normalises auto on a route's provider: and never on its backend:, so an explicit fallbacks: { r: { backend: "auto" } } reaches a registry that has no backend by that name and dies at the moment the chain is needed. It is screened as the name it is.

The empty-template path lands on step 4. The pill in the studio toolbar surfaces what the auto-resolver picked (and turns red when no credential is available). Settings → Backends lists every detected source and the resolved default in full:

Studio settings — detected LLM backend credentials and resolved default

Launch-time per-node/-group overrides ​

Above step 1 sits an explicit launch-time override: you can retarget which model and/or backend specific nodes use for a single run, without editing the .bot. Because the operator is deliberately re-pointing the bot at launch, these win over the node's own DSL backend:/model:.

Three dimensions are overridable — model, backend and reasoning_effort — because they are one decision: a model, the backend that drives it, and how hard it is asked to think.

  • Studio — the Launch form's "Model & backend per node" section lists the bot's LLM nodes (agents + judges) with a model picker (fed by the model registry, so each option carries its reachability, context window and price — see docs/models.md) and a backend select; leave a field on inherit to keep the DSL default.

  • CLI — repeatable --model / --backend / --effort-for, each a selector=value (or a bare value for every LLM node). A selector matches by exact node id (reviewer_claude), id glob (reviewer_*, fix_*), or node kind (agent|judge). Most specific match wins; resolution is per-field so the three compose:

    bash
    # cheap model for reviewers, stronger for fixers, all on claw
    iterion run bots/whole-improve-loop/main.bot \
      --model 'reviewer_*=anthropic/claude-fable-5' \
      --model 'fix_*=anthropic/claude-sonnet-5' \
      --backend '*=claw' \
      --effort-for 'fix_*=max'
  • HTTP — POST /api/runs accepts model_overrides: [{selector, model, backend, effort}]. An effort outside none|low|medium|high|xhigh|max|ultracode is a 400 at admission, since the value reaches the provider verbatim.

The effort override outranks both the node's static reasoning_effort: and a dynamic _reasoning_effort edge mapping, matching how model and backend already sit at the top of the chain. A bot that escalates effort per branch is therefore flattened by a run-wide * override — which is what asking for one means. See ADR-090.

This composes with the mono/dual --review-mode topology (ADR-052): the review mode chooses which family runs (one or two), the override chooses which model/backend each running node uses. A run launched through the studio / HTTP API re-applies its launch-time model/backend rules on resume (they are read back off the run document) — on every launch surface, iterion run included, so the flags do not have to be repeated on iterion resume. --compress remains launch-only. See docs/models.md.

Default preference order ​

claude_code → claw

codex is intentionally not in the short default list, just like the other explicit-only CLI backends. This avoids silently changing a workflow's tool surface: Codex uses its native tools and sandbox, whereas claw uses Iterion-native declared tools. Set backend: codex per node/workflow, or include it in ITERION_BACKEND_PREFERENCE to make it eligible for auto-selection.

claude_code is preferred over claw when the user has the Claude Code OAuth file — that path uses the user's "forfait" subscription instead of metered API calls. Without OAuth, claw is preferred: same auth (ANTHROPIC_API_KEY) but in-process and faster.

💳 On claw (and pi), the Claude Code subscription bills to extra usage, not to your plan. claw authenticates with the subscription OAuth token (set ANTHROPIC_AUTH_TOKEN to the claudeAiOauth.accessToken from ~/.claude/.credentials.json, no ANTHROPIC_API_KEY; claw then sends Authorization: Bearer + the anthropic-beta: oauth-2025-04-20 header), and it works — verified 2026-07-28 with a real claude-haiku-4-5 call. It bills against the subscription's separate extra-usage balance rather than the plan's limits, which is what Anthropic says when that balance empties ("Third-party apps now draw from your extra usage, not your plan limits"). iterion emits a one-time stderr warning naming the billing, and ITERION_FORBID_SUBSCRIPTION_OAUTH=1 refuses the path outright.

This replaces an earlier note that called the path effectively unusable: non-Claude-Code clients used to be throttled to ~zero (immediate 429, no Retry-After) while the official CLI on the same token succeeded. Anthropic has since served third-party clients through extra usage instead. For predictable spend on an anthropic/* model, ANTHROPIC_API_KEY or z.ai (ZAI_API_KEY) remain the metered options. (The OpenAI/ChatGPT forfait via claw also works — see below — because there is no equivalent client-identity gate on that path today.)

Overriding the order ​

Set ITERION_BACKEND_PREFERENCE to a comma-separated list:

bash
# Prefer claw even when Claude Code OAuth is present
export ITERION_BACKEND_PREFERENCE='claw,claude_code'

# Only use codex (must be explicitly listed)
export ITERION_BACKEND_PREFERENCE='codex'

Backends omitted from the list are never auto-selected, even if their credentials exist.

Per-node provider routing & fallback chain (provider:) ​

The backend: field chooses which execution stack runs a node; the optional provider: field is a finer credential-routing hint within that stack. It is resolved per node like every routing field (model:, backend:, provider:, interaction_model:, a fallbacks: route's three fields, a verified action's recovery.model, the workflow's default_backend:): a {{vars.<name>}} reference first — a dotted {{vars.<doc>.<member>}} included, drilled into a json var's document; the run's vars are the one namespace that exists before the node runs; any other template warns C148 at compile time and reaches the backend as text — then ${VAR} / ${VAR:-default} expansion. A dotted reference whose var holds a scalar at run time (a --var cfg=claw override replacing the declared document) resolves nothing on either side of the launch screen: the value holds no members to drill, the text reaches the backend as written and the node fails at its first delegation.

Known hints:

HintEffect
anthropicForce Anthropic-direct (ANTHROPIC_API_KEY / Claude Code OAuth); skip the facades even when their keys are set.
zaiForce the z.ai Anthropic-compatible facade (ANTHROPIC_BASE_URL=z.ai + ANTHROPIC_AUTH_TOKEN=$ZAI_API_KEY).
moonshotForce the Moonshot Anthropic-compatible facade, the Kimi family (MOONSHOT_BASE_URL, default https://api.moonshot.ai/anthropic, + ANTHROPIC_AUTH_TOKEN=$MOONSHOT_API_KEY).
openaiForce OpenAI-direct (OPENAI_API_KEY), skipping OPENAI_BASE_URL overrides.
auto / (unset)Default process-env precedence.

Hints are matched case-insensitively (Moonshot is moonshot). A facade route — pinned by zai / moonshot, or chosen by the default precedence from a facade key — also clears the Anthropic credentials the Claude Code CLI would otherwise inherit from the process or container env: ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN and the CLAUDE_CODE_USE_BEDROCK / _VERTEX / _FOUNDRY switches. The switches rank above ANTHROPIC_AUTH_TOKEN in the CLI's own precedence, and the other two would travel to the facade's gateway alongside its token.

Fallback chain ​

provider: accepts a single value or an ordered, comma-separated chain. The chain is the declarative generalisation of the RESCUE_PROVIDER escape hatch:

iter
agent reviewer:
  backend: "claude_code"
  provider: "${RESCUE_PROVIDER:-zai},anthropic"   # z.ai first, Anthropic on hard failure
  model: "claude-opus-4-8"

Semantics:

  • Each provider gets the node's full retry budget (transient errors are retried in place — see RetryPolicy).
  • Only a hard failure beyond the retry budget — a non-retryable error, or a retryable one whose retries are exhausted — falls through to the next provider. The executor re-issues the same call with the next hint and emits one log note (and an OnProviderFallback observability event), so the operator sees a route change, not a failure.
  • The node only fails if every provider in the chain is exhausted; the surfaced error names the chain that was attempted.
  • A cancelled / timed-out run aborts the chain immediately rather than thrashing through every provider.
  • Env expansion runs on the whole field first, then the result is split on commas — so an env var can supply the entire chain (${PROVIDERS:-anthropic,zai}) and a :-default may itself contain a comma.

Which backends honour the chain ​

claude_code consumes the provider hint in full, and it routes within the Anthropic-compatible family (anthropic ↔ zai ↔ other facades) — i.e. the same model id served by a different credential lane. This is the validated path and the original RESCUE_PROVIDER use case.

claw derives its provider from the model: prefix (openai/…, anthropic/…), but honours a single-value hint on its anthropic provider: provider: "anthropic" forces Anthropic-direct at the credential-resolution chokepoint — the z.ai synthesis from ZAI_API_KEY, ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN are skipped, on the in-process path and inside the sandbox alike (#1718). A hint-pinned node with no Anthropic-direct credential reachable is refused by type (model.ErrProviderHintUnfunded) rather than silently spending a credential the hint excluded. codex ignores the hint entirely. On claw and codex a multi-element chain is a no-op: the runtime uses only the first provider, and the compiler emits a C088 warning. For cross-provider failover under claw (e.g. Anthropic → OpenAI), vary the model: per node instead — a credential hint alone cannot switch the model that the API expects.

Unknown hint tokens (typos) are flagged at compile time with C087 (a warning) and ignored at run time (the node falls back to default credential precedence). Fields containing a ${VAR} env ref are left for run-time resolution and not statically validated.

Single-value provider: (and unset) behaviour is unchanged — the chain form is purely additive and fully back-compatible.

Per-element model (provider:model) ​

A provider-specific model can be pinned per chain element with a provider:model token, so a fall-through swaps both the credential hint and the wire model:

iter
agent reviewer:
  backend: "claude_code"
  provider: "zai:glm-5.3,anthropic:claude-opus-5-5"   # glm-5.3 on z.ai, claude-opus-5-5 on Anthropic

This is the case where the chain's two providers serve different model ids over the same Anthropic-wire API — glm-5.3 is a z.ai model that Anthropic would reject, so a hint-only swap would break on fall-through. The token is split on the first colon (a model id that itself contains a colon survives intact). An element without a model (anthropic in zai:glm-5.3,anthropic) inherits the node's model: baseline; an inheriting element after a model-bearing one restores the baseline rather than carrying the previous override. A malformed element — a colon with an empty provider (:glm-5.3) or empty model (zai:) — warns C172 at compile time. Env expansion still runs on the whole field first, so the :- in ${VAR:-x} is never mistaken for a provider:model separator.

Like the hint chain itself, per-element models only take effect on claude_code (the only backend that walks the chain today).

Cross-backend fallback routes (fallbacks:) ​

provider: swaps a credential on one backend. fallbacks: declares complete alternative routes — a different backend, model and credential — for the case the provider: chain cannot serve: a CLI backend whose subscription forfait has shut, continuing on a metered API through claw. See ADR-087.

iter
agent implement:
  backend: "claude_code"          # forfait
  model: "claude-opus-5"
  tools: [read_file, bash]
  fallbacks:
    api:                          # same model, metered Anthropic key
      backend: "claw"
      model: "anthropic/claude-opus-5"
      on: [usage_window]
    gpt:                          # another API family
      backend: "claw"
      model: "openai/gpt-5.5"
      metered: true

Routes are named, and the name is not decoration: it is the id the model_fallback event and the run report cite, so a bilan reads "fell through to api" rather than an ordinal. Declaration order is the try order. Both surfaces compose — the provider: hints are walked first, then each route.

When a route is taken ​

on: filters which failure may route to an element:

CategoryMeaningEmitted by
usage_windowsubscription 5h/weekly cap — waiting is the only cure for THIS credentialclaude_code; pi when the provider echoes Anthropic-shaped prose
authrejected or expired credentialclaude_code, pi
unavailablemodel the credential cannot reach — a bad or withdrawn id, or one a facade would substitute with its own model under the requested label (z.ai answers any id with GLM, Moonshot with Kimi: refused before the spawn, #2772)claude_code, claw
transient_exhausteda transient condition that survived the in-node retry budgetevery backend
anyescape hatch — clears the filter—

The default when on: is omitted is [usage_window, unavailable]. Two omissions are deliberate: any is not the default because a budget cap or a schema-shape failure re-fails identically on every route, and auth is not, because a rejected credential deliberately pauses for a human rather than being automated around. Both remain available explicitly.

on: is a per-route filter, not a chain terminator: a middle route that refuses the category is skipped so a later route that accepts it still runs. The walk ends only when no remaining route accepts.

An unclassifiable failure always routes, whatever the filter says. Refusing it would strand a run on exactly the failures iterion could not describe — a sandboxed claw route flattens its errors to a string at the IPC boundary, and kimi/grok have no error channel at all.

A usage_window failure skips the in-node retry budget when a route remains: retrying inside a shut window cannot succeed, and the whole chain runs under one per-node timeout:.

When a usage_window failure carries a provider reset instant, the executor also puts the effective (backend, credential hint, model) route on a reactive cooldown until that instant. The ledger is ready to do the same for a typed temporary unavailable failure with a reset instant, but no shipped backend produces that stage-3 condition yet. Later nodes in the same run enter the chain at the first route whose on: filter accepts the remembered category, without spawning the refused backend again. The skip remains visible as a model_fallback event with attempts: 0, cooldown: true and cooldown_until; it is an info line rather than another rate-limit warning. The route is remembered even when the node that hit the wall had nowhere to fall through to — a refusal belongs to the route, not to one node's chain — so the next node whose chain does accept the category skips a spawn the first one had to pay for.

Cooldown is strictly fail-open: an absent/already-passed reset, a reset more than eight days out (the same implausibility ceiling as the durable retry's max_wait — a misparsed provider datetime must not keep a healthy route dark for a whole run), or no later route accepting the failure, all leave dispatch unchanged. Entries expire on read at their own reset instant (no sweeper), and the mid-call usage guard stays armed for parallel branches that were already in flight. Operators can set ITERION_ROUTE_COOLDOWN=off before launching a run to restore the historical probe-on-every-node behaviour. The switch is read once when that run's executor is created; unset, on, and unrecognised values keep the cooldown enabled.

Terminal degrade (action: skip) and the route gate (when:) ​

Two route properties extend the chain beyond backend switches (ADR-091):

iter
vars:
  plan_review_policy: string = "on"   # a route's when: may only read a DECLARED var (C173)

judge plan_review:
  backend: "claw"
  model: "openai/gpt-5.6-sol"
  fallbacks:
    give_up:
      action: skip
      when: "vars.plan_review_policy == 'skip'"
      ## unfiltered on purpose: "skip" here means the peer must NEVER
      ## block — and under `sandbox: auto` a claw failure flattens to a
      ## string at the IPC boundary and classifies UNCLASSIFIED, which a
      ## FILTERED skip refuses (see below). Scope the filter only when
      ## the node runs unsandboxed with typed errors intact.
      on: [any]

action: skip is a terminal degrade: when the walk reaches it, the node COMPLETES with a zero-value output (every schema field at its zero — "" / false / 0 / []) stamped _skipped: true + _fallback_used + _served_by, instead of failing the run. It is the "continue and ignore" half of an optional-node policy — the "pause and retry" half is simply not declaring it: the failure then stays failed_resumable and the run-level usage-window retry parks the run until the window reopens. on: filters it like any route — with one deliberate asymmetry: an unclassified failure (a bare CLI exit, a flattened sandbox error) still routes to any executable route, but a filtered skip REFUSES it — routing an indescribable failure to another backend is a safety net, converting it into a zero-value success is a lie. on: [any] opts in explicitly. The compiler (C173) refuses a skip route that also names a backend/model/provider/metered, an unknown action:, and a skip that is not the LAST route (everything after a terminal is unreachable).

when: gates any route on an expression over vars — evaluated at dispatch, so an ordinary --var picks the active route set per run (that is how ONE plan_review node expresses both wait and skip policies). The compiler checks the expression parses, reads only vars.* (a route has no input/outputs of its own), and holds every dotted reference to a declared var — at run time an absent var reads as false and would silently disarm the route. A root subscript instead warns with C189 without becoming an error: vars['name'] has no dotted path for C173 to check, and a computed vars[expression] key is likewise accepted but unverified. Bare vars and a subscript on a selected var (vars.items[0]) do not trigger C189. A static bracket read still participates in C155's string[]/json default check, with exactly one C189 per gate when both passes inspect it. String literals use single quotes (vars.policy == 'skip'); a route whose gate is false is simply absent from the chain.

Downstream, a deterministic compute reads the stamp (outputs.<node>._skipped == true) and routes around whatever consumed the node's real output — never silently. Observability: the model_fallback event carries to_action: "skip" (with an empty to_backend — nothing serves), and the run header's fallbacks chip lists the node with skipped: true even though no backend ran.

What a fall-through leaves behind ​

Nothing about it is silent:

  • a model_fallback event in events.jsonl (from/to backend, model and provider, the classified reason, attempts spent) plus a run.log warning for a fresh failure, or an info line for a cooldown skip;
  • a model_drift event when the provider-reported model is not the one the node declared (proxy / ANTHROPIC_MODEL / a fallback that also changed the model). delegate_started carries declared_model; delegate_finished / delegate_error add effective_model; run.json nodes_served is the last pair per node;
  • a model_served_via_facade event when the session ran through an Anthropic-shaped facade (ANTHROPIC_BASE_URL). The facade answers whatever claude-* id it is asked for with the model it aliases it to, so the declared and effective ids AGREE and model_drift stays silent — the session fingerprint is the only evidence. Raised from the success path only (a delegation that failed was not served); the attempted route still lands on nodes_served[<node>].fingerprint;
  • _backend / _model on the node output name the route that served, not the one requested;
  • _fallback_used and _served_by are stamped so a bot's deterministic gate can fail closed on a degraded input — the same posture it already takes on an unreadable one. This matters most for a judge: a weaker model still emits a well-formed verdict, and only the finding count changes;
  • a failed route's tokens and cost fold into the node's totals, so max_cost_usd and the org monthly cap see what was really spent;
  • an exhausted chain surfaces every route's error (Unwrap() []error), so the run-level usage-window retry and the credential-pool donor cooldown still find the cause they key on.

A fall-through also drops the failed route's conversation — the session store carries no provider fingerprint, so replaying it would send one provider's signed turns to another.

A best-effort session that no longer loads ​

session: inherit_if_available and session: persist say the session is best effort. Sometimes the id resolves but its backing state is gone — a cloud resume replaces the sandbox container, and the CLI's session files die with it, after which every resume of that node fails identically and forever. "If available" covers that too: the executor retries once with the session dropped.

Deliberately narrow, on three axes:

  • Unclassified failures only. That is where a refused session lands (the backend declined to load it and said nothing typed about why), and being non-retryable it makes the fresh attempt a single extra call. auth / usage_window / unavailable are credential- or model-level — a fresh session hits the same wall — and transient_exhausted is a provider-side cause (throttle, 5xx, TCP blip) the session had no part in.
  • Backends that actually resume with the id — claw, claude_code, codex, pi. For claw, session: persist checkpoints a compacted, versioned message envelope in the run's backend-session store (32k-token target, eight recent messages preserved, 512 KiB hard blob cap). A session_slot may let serial persist nodes share that envelope; absent it, the node id remains the slot. Compaction and envelope loading repair tool protocol half-pairs at message boundaries: orphaned tool_result blocks and unanswered tool_use blocks are pruned before a provider request. The sole exception is the exact pending tool id recorded by an ask_user pause. kimi / grok only report an id, so there the "fresh" call would be byte-identical.
  • inherit and fork never degrade. They asked for continuity unconditionally, and keep failing loudly.

Like a fall-through, it is not silent: a session_degraded event (backend, session id, reason, error) and _session_degraded: true on the node's own output, so a deterministic gate can fail closed on an amnesiac input. It is not a model_fallback and does not set _fallback_used: the same backend, model and credential served — what degraded is the node's input. The node's accumulated claw conversation is evicted alongside, so "fresh" means fresh on every backend.

Provider fingerprints are checked before replay. A claw session produced by OpenAI is never replayed into Anthropic (or the reverse): the runtime emits session_degraded, discards the opaque conversation, and relies on the bot's bounded host projection instead of forwarding provider-signed thinking blocks. Within one live claw fallback chain, the executor instead rolls back the failed attempt to the route's last-good, provider-neutral message snapshot. This keeps a named durable chat slot intact without carrying partial output into the next route.

A schema-invalid answer gets one more turn (the schema re-ask) ​

An LLM node's answer that fails its output: schema on a shape one more ask can fix — a required field missing, or text where JSON was expected — is not the node's verdict yet: the executor re-asks the model once, with the validation error as its next input, in the context the answer was produced in. A type or enum mismatch is the model's answer in a stable shape and fails the node without a re-ask; so does a second answer that is still invalid (structured output invalid after retry).

How the re-ask continues the model's work depends on what the backend can continue. The mode rides the delegate_retry event (reask) and the re-ask's own delegate_started / delegate_finished / delegate_error, marked attempt: 2:

BackendreaskWhat the model is sent
claw, in-process or sandboxedcontinue_conversationThe conversation it just completed — its own answer as the last assistant turn — then the validation error as a user turn: one schema-forced call, tools off, run in-process (nothing in it touches the workspace)
claude_code, codex, piresume_sessionThe session the answer ran in, resumed by id (never forked, never best-effort), with the validation error as the new prompt; the backend's own structured-output pass runs on top
kimi, grok; a session backend that reported no session id; a claw node with no captured conversationrestartThe whole turn again, the validation error appended to the prompt — the floor, kept rather than refused: it is still a chance the node would not otherwise get. One exception, reachable only when the capture itself failed (an in-container runner without the capture sink): a claw node resumed from a pause replays the pause behind the restart and the appended error is dropped with the prompt — the turn runs again without the feedback

Budget: one re-ask. A re-ask that comes back as unstructured text still gets the last-resort extraction (a direct claw call over that text, below); a re-ask that comes back invalid, or fails, fails the node — with the re-ask's own error wrapped beside the validation error, so a usage window hit during the re-ask is still the typed refusal the run-level retry keys on.

Cost: a real turn, billed as one. The re-ask's tokens and cost fold onto the node's _tokens / _cost_usd under the rule an in-place retry follows (a session-total figure at its maximum, per-call figures summed), and its own delegate_finished carries what it ADDED — on a backend whose cost is a session total, the difference — so an accumulator summing one cost per delegation event stays exact.

Why it is a continuation and not a repeat (#1385): on the copilot run that motivated it, the retry of a claw node resumed from a permission pause replayed the pause behind the captured history (91 + 11 messages), carried no feedback, re-ran forty tool steps for ten minutes and ended failed_resumable on the same missing boolean. The re-ask is one turn.

Refusals ​

Two crossings are compile-time errors (C176), because the degraded run would be silently wrong rather than merely worse:

  • a route on a backend/mode pair that cannot enforce the node's permission: gate (kimi and grok accept deny but not ask; codex is not admitted at all) — the anti-prompt-injection boundary must not disappear at the moment the run is under stress. The same check now covers the primary backend, not only fallbacks;
  • a route crossing the claw⇄CLI boundary, because the tools: list does not mean the same thing on both sides. The two directions are not symmetric:
    • claw → CLI is refused outright, whatever the list. Under the always-on bypassPermissions a CLI agent ignores the lowercase tools: list and carries the full native toolset, so a reviewer restricted to read_file would gain Edit/Write the moment the chain falls through — on a node the engine may already have admitted as a read-only parallel branch.
    • CLI → claw is refused only when the list is UNDECLARED, which on claw means zero tools. Declaring the tools explicitly is the documented pattern: inert on the CLI primary, load-bearing on the claw route.

A third refusal is C135: a claw route on a node whose tools: list names something claw cannot resolve. The list is inert on the CLI primary, so a name like run_command or list_files looks harmless there — but the route resolves every name against the in-process registry, so it would fail at the exact moment the run is already falling back. Declare the tools with claw's own names (bash, glob, …); the diagnostic names the nearest one.

A route that changes backend: must pin its own model: (C173): model specs are not portable (claw needs provider/model, claude_code accepts a bare id or an anthropic/ prefix).

At launch, without editing the bot ​

An operator can add one run-level route instead of authoring a block — the studio Launch form's "Fallback route" row, or:

sh
iterion run bot.bot --fallback 'claw:openai/gpt-5.5'

It applies to agent nodes that declare no fallbacks: of their own, and never to judges — a weaker judge still emits a well-formed verdict, so a blanket launch setting must not reach one. An author who wrote a chain vetted where it may go, so their routes win rather than being extended. It takes the default on: set; anything finer belongs in the .bot.

The route is materialised onto the compiled workflow at launch, not resolved privately at dispatch. That is what subjects it to the same refusals as an authored route — an ungated crossing or a claw⇄CLI crossing on a tools-less node is dropped with a warning, never silently taken — and what makes it visible to the three pre-run analyses (sandbox bind-mount, parallel-branch admission, the fan_out_each guard). Without that, a flag could reach exactly the crossings the compiler refuses in the .bot.

The screen reads a node's backend the way the run itself will: a ${VAR:-default} dial by the launching process's environment, and a {{vars.<name>}} reference by the launch's vars (the declared defaults under that launch's --var overrides) — the same template-then-env reading resolveRoutingField makes at dispatch, var values expanded through the bot-vars overlay then the process environment exactly as resolveVars expands them. A reference neither answers stays undecided and is screened as before: no opinion, no guess.

The route does not propagate into a subbot: child. A subbot is a different bot with its own routes, its own judges and its own permission posture; the launch route applies to the run you launched, and a child that needs one declares it or is launched with its own. Launch rules are not persisted either, so repeat --fallback on iterion resume to keep the route: the scenario this feature exists for — a long run outliving a quota window — is precisely the one that resumes.

One route rather than a per-node ordered list, deliberately: the value is "don't lose a long run to a forfait wall", which one alternative delivers, and the Launch form persists nothing between launches — a per-node chain would be rebuilt cell by cell on every launch of a 15-node bot.

Routes from all three sources (provider hints, authored routes, the launch route) are deduped: one resolving to the same backend + credential + model as the route before it is dropped rather than paying a second full retry budget to fail identically.

Scope ​

claude_code → claw is the validated lane. kimi and grok sit on a CLI contract that structurally cannot return an error, so no typed trigger can fire for them; Codex is not included in the v1 fallback lane. A sandboxed claw route works — the trigger comes from the failing route, which claude_code types correctly — but its own failure is always unclassifiable, and it cannot serve a node whose permission policy can produce an Ask decision (mode ask, or any explicit ask: rule — which outranks mode deny).

Sandboxed claw enforces the permission gate for deny-shaped policies. The policy crosses the IPC as a pre-task permission_policy envelope (permission.PolicyConfig, re-parsed in-container by the same parser), and the __claw-runner's own tool loop applies it before every tool — local builtins and proxied tools alike. The pre-task position is the mixed-fleet guarantee: a runner binary too old to know the envelope fatals on "unexpected envelope before task" instead of running the gated node with an empty policy, so the combination fails CLOSED, never open. What cannot cross is an Ask decision — nothing inside the container can pause the parent run — so an ask-capable policy is refused loudly at dispatch (and C136 warns at compile time). Run the workflow unsandboxed (sandbox: none / --sandbox none — a node-level sandbox: is not honoured at run time), or route the node to claude_code.

A sandboxed claw node also refuses a tool the container cannot execute. Tools the runner does not run locally are proxied back and executed on the HOST, so the split is decided by an explicit placement rather than by which names the runner happens to register — and the launcher refuses every forwarded call for a tool that is not launcher-placed, or that the run's permission: policy denies. The tools that start a process, touch the workspace or a model-supplied path, or open a model-supplied URL, and agent, run in-container; launcher-owned state (MCP, ask_user, the task_* / team_* / cron_* registries, todo_write, config, tool_search, plan mode, the privacy pair, web_search) keeps the IPC proxy; and lsp, screenshot, computer_use and the worker_* family have no in-container form, so a sandboxed node declaring one is refused when it executes (its fallbacks: still get their turn) — drop it, or run the workflow unsandboxed (sandbox: none / --sandbox none). A tool_policy allowlist is applied to the in-container tools when the node is built: a tool it denies is not advertised at all. The full table and its reasons: sandbox.md.

The same boundary applies to MCP SERVERS, which are processes rather than tools. claude_code and pi start their own, so a sandboxed node's servers run in the container; claw connects them in the launcher, so under an active sandbox it starts only the operator's — a BUILTIN, or a plugin installed under the iterion home the operator's own environment names, and in both cases enabled and configured by the operator too (three legs; see sandbox.md's table). Builtins are the normal case: every MCP-contributing plugin shipped today is one. A server the node inherited is dropped with an mcp_server_degraded event; a server it names refuses the node when it executes, so a claude_code or pi fallback — which starts that server in the container — gets its turn. Origins, and the two rules that travel with them: sandbox.md.

The OpenAI-compatible gateway (openai_compatible/…) ​

A model spec of the form openai_compatible/<gateway model id> is served by an OpenAI-compatible gateway the deployment's environment names — a regional LiteLLM gateway, a vLLM endpoint, a router in front of a provider no other prefix carries. The id after the prefix travels to the wire verbatim (slashes included): it is the gateway's own id, not one iterion resolves, so the capability tables, the effort matrices and the price catalog have no opinion on it. Two environment variables configure the route, read exactly like OPENAI_BASE_URL:

  • OPENAI_COMPATIBLE_BASE_URL — the gateway root (no trailing /v1 required);
  • OPENAI_COMPATIBLE_API_KEY — optional; a gateway may sit behind network auth alone.

The endpoint is the operator's choice, but the dial is guarded: a public address by default, and ITERION_LLM_ENDPOINT_ALLOW_PRIVATE=1, exported by the operator (a value a project .env plants does not count), for a self-hosted gateway on private infrastructure. When the sandbox's egress runs through the run's network proxy, the call crosses it too, and the guard holds there: the runner checks the gateway's address as it resolves the name, then the proxy — which makes the connection — resolves the name again through the same guard and connects only to the address it validated. An answer the guard refuses is refused there whatever the runner's own lookup saw (DNS rebinding), for every client of the run that reaches the gateway's name; the run's network rules still name the gateway by its host, and the proxy keeps no pooled connection to it. The proxy reads the gateway's settings from the environment when the run starts and at every connection, as each dispatch does, and a gateway once pinned stays pinned: one an environment reloaded mid-run names is pinned from the next connection on — unless the environment changes again before any connection is made. It never resolves host.docker.internal — it dials that alias on its own host's loopback. A gateway route runs on the claw backend only: an explicit backend: naming a CLI backend, or a vendor provider: hint beside a gateway model, is refused when the node is dispatched, and warning C186 says the same at iterion validate time (diagnostics). A node that names NO backend gets claw from auto-detection when its chain carries a gateway route in any position — its own model:, a later provider: step or a fallbacks: route inheriting the node's backend — since the credentials-first answer (claude_code on a machine with the CLI) could never serve that route, and a gateway fallback under it would be refused at its own build without ever firing; the redirect is logged once per node, and an explicit backend: claw makes the same choice silently.

Until the catalog slice prices the gateway's ids, a gateway route is an unpriced route: spend is still metered in tokens and bounded by max_tokens, but max_cost_usd bounds priced routes only, and the budget raises its single cost_usd_unpriced advisory — plus usage_unreported when a call's usage never arrives — as for any unpriced route.

What cannot see a gateway route today. The machine usage-cap pre-flight (usage-caps.md) meters the anthropic wire only, so a gateway route's spend is invisible to it — bound gateway spend with the gateway's own quotas for now. And on a gateway node the gateway key rides the runner process env (claw's bash reads the whole forwarded env), so on a multi-tenant runner, bind it with the egress allowlist (pkg/sandbox/netproxy) and rotate it; the host-side relay — the key never entering the container — is the known follow-up. (The allowlist CAN name the gateway since #2099: network.rules expand ${VAR} against the runner's env, so a static bot scopes egress to the deployment's gateway host — and the session board's curation, the remaining surface that auto-resolved a vendor default for a gateway-served session's content, now refuses unpinned with sessionboard.ErrGatewayBoardNeedsPin, the twin of the supervisors' gate.)

What a gateway model costs and how big it is (ADR-122) ​

The catalog resolves a gateway id with whole-entry precedence — fields are never merged across sources, and an answer carries its provenance:

  1. OPENAI_COMPATIBLE_MODELS — the operator's JSON table, {"<gateway id>": {"context_window": N, "max_output_tokens": N, "input_usd_per_mtok": F, "output_usd_per_mtok": F, "reasoning": B, "tool_call": B, "temperature": B}}. Strict decoding (unknown fields refused); prices arrive as a pair or not at all; a declared 0/0 means unpriced, not free. The three capability flags are each optional: a flag the table leaves out — or that no table answers — is UNKNOWN to every reader (iterion models, the studio picker, the REST catalog), never false; a vendor id keeps its curated verdict.
  2. OPENAI_COMPATIBLE_CATALOG_PROVIDER=<q> — the exact key q/<id> in the models.dev table (the live table when loaded, else the embedded snapshot). <q> is the upstream provider key the gateway's ids live under in models.dev (scaleway, in the measured deployment). The gateway serves bare ids (glm-5.2) or provider-first ids (scaleway/glm-5.2, LiteLLM's naming): an id already carrying <q> as its first segment is also looked up as-is — within each table q/<id> first, and the live table (both keys) before the snapshot. Exact means exact after lower-casing — no other prefix, suffix, vendor or cross-provider-consensus fallback exists. The operator table above stays exact too: key it by the id the gateway serves (scaleway/glm-5.2), or its entry is passed over for the catalog's.
  3. Otherwise unknown, said out loud: the node's _gateway_spec records "source":"unknown", the estimator prices nothing, and compaction keeps the unknown-model floor.

Set these in the environment the deployment EXPORTS — a project .env does not configure them. iterion auto-loads the nearest .env walking up from the working directory, so these names can be filled from a file inside a repository, including the one a run is reviewing. A value that arrived that way is read as nothing said, wherever the read happens (internal/envtrust): each of these names decides the window a node compacts at, the max_tokens it asks for, the price it is billed at and the provenance all of that is recorded under, and the host's answer is what crosses into a sandboxed node as its own. So a table in a project .env prices nothing and refuses nothing — the launch seam says so once per process, with the parse refusal when that table is also malformed, so a typo there is not silence. The same holds for the live tier's own knobs (ITERION_MODEL_SPECS_CACHE, _URL, _TTL, ITERION_MODEL_SPECS, ITERION_MODEL_SPECS_REFRESH, and ITERION_HOME, which roots the cache file): _CACHE names the FILE the live table is read back from, which is the table every model — vendor ids included — is then resolved from. And for the gateway: a planted half of the OPENAI_COMPATIBLE_* pair refuses the gateway by name instead of configuring it, since the endpoint and the key presented there are one credential presentation.

One class is refused outright rather than read as nothing said: the ROUTES. Every lane that plants a dotenv file asks the same question at its plant (internal/envtrust DotenvPlantRefused) — the CLI's applyDotEnv, the studio/desktop server's project snapshot (pkg/projectenv.Snapshot, which feeds every host-spawned command of a run) and the desktop's own env file — and refuses by default any name whose SHAPE decides where a connection is dialled, what is presented or sent there, or which hosts are trusted: a *_URL or *_URI (provider hosts, the OAuth forfait wires the refresh token is POSTed to, the AWS endpoint overrides AWS_ENDPOINT_URL* per service, SEARXNG_URL — read before its *_ENDPOINT sibling — and the deployment's own ITERION_*_URL wires), an *_API_BASE (OpenAI's legacy name), an *_ENDPOINT, a *_HOST (the Azure *_AUTHORITY_HOST pair among them), a *_HOSTS list, an *_ADDRS list, a *_DSN, a *_PROXY (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY, either case) — whether or not a credential is presented there: a planted route would point every wire, the operator's credentials included, at a host the file chose, and the reads are not all iterion's — the vendored provider packages and SDKs read these names themselves, and every child inherits what lands here. The refusal is one line on stderr, where no log level the file itself planted reaches it, naming the file, the variable and the way past it. A .env that legitimately configures its own gateway — the portable laptop case, the key and the host in one file — asks the operator's opt-in: export ITERION_DOTENV_ENDPOINTS=allow in the environment iterion runs with (or export the endpoint itself, which the file cannot overwrite); with the opt-in the routes plant and read as planted, so nothing downstream takes them for the operator's answer — and on the studio lane the opt-in never crosses into the run environment, whatever the file or the operator said: the runs answer their own where they launch. The per-name credential table (internal/envtrust) stays as the belt at the reads: a planted half of the OPENAI_COMPATIBLE_* pair still refuses the gateway by name, and the routing names the host's own .env planted still cross into a sandbox as explicit empties.

Two more classes close at the same plant. The OPERATOR GRADE — the inventory the bot-var gate denies a stored record (internal/envtrustoperator.go, one list serving both gates), widened to every credential-shaped ITERION_ name (CredentialShapedName, the very list the bot gate's name half asks, so the two gates cannot drift) — is refused outright: ITERION_SANDBOX_OVERRIDE=none (the reviewed repository disarming the engine reviewing it), ITERION_DISABLE_AUTH, ITERION_REQUIRE_ORIGIN=0, ITERION_JWT_SECRET, ITERION_BOOTSTRAP_ADMIN_PASSWORD, ITERION_SERVER_TOKEN, ITERION_GH_PAT, the whole infra namespace list. The reads carry the belt: pkg/config's lookup and pkg/server's envOperator answer a planted grade value with nothing, so a stale marker in a child lifts no guard. The EXECUTION GRADE is the generic host-env families whose plant makes a child of iterion execute, load or trust what the file chose — BASH_ENV (a tool-node's bash sources it), LD_PRELOAD (every engine child preloads it), PATH, IFS, SHELLOPTS/PS4, NODE_OPTIONS, EDITOR, SSH_ASKPASS, git's editor chain and pager (GIT_EDITOR/GIT_SEQUENCE_EDITOR/GIT_PAGER — an agent's own git commit without -m runs the editor, measured), the interpreter knobs (PYTHONPATH — its sitecustomize executes at interpreter startup — with PYTHONHOME/PYTHONSTARTUP, RUBYOPT, PERL5OPT/ PERL5LIB, JAVA_TOOL_OPTIONS/JDK_JAVA_OPTIONS/CLASSPATH, CLOUDSDK_PYTHON), the agent config homes including opencode's (OPENCODE_CONFIG*, OPENCODE_PERMISSION), ANTHROPIC_UNIX_SOCKET (the claude CLI's own client dials it — a reader no grep sees), the XDG_*/NIX_*/LD_*/NPM_CONFIG_* families, HOME, the config-dir names and the TLS trust roots (SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, AWS_CA_BUNDLE — the vendored AWS SDK reads it into the S3 blob store's client; internal/envtrustexecution.go, the execution portion of the devcontainer deny table, one inventory both gates read). The same opt-in answers it — these names have legitimate lives in an operator's own file, and the operator decides. And a VALUE spelling shell execution — $(...), <(...)/>(...), backticks, or a ${name@op} expansion operator — is refused on every lane: the studio snapshot used to source the file in bash, which RAN FOO=$(touch /tmp/x) on the host before any question was asked, and the bash it kept for the $VAR expansion later RAN the substitutions the base environment's own variables carried (KEY=${BAR@P} is prompt expansion). The plant set is the parse now (internal/dotenv, one reader, three lanes) and the expansion is Go's (pkg/projectenvexpandReferences: $VAR/${VAR} and an escaped dollar, everything else literal — no shell exists on the lane), the git transport family (GIT_SSH_COMMAND, GIT_PROXY_COMMAND, GIT_CONFIG_*) rides the routing refusal — its reader is the git binary itself — and pkg/git drops a planted transport name again at its own env assembly. The CLI and desktop lanes plant NO expansion: a $VAR/${VAR} reference there is applied as the bytes the file spelled, where the studio lane expands the same file — the loader says so on stderr when it plants one, and a single-quoted value keeps its bytes on every lane. The engine's own liveness knobs (ITERION_CLAUDE_CODE_STREAM_IDLE_TIMEOUT and kin) stay bot-writable by their classification and the operator's export answers, while a planted one reads as nothing said at the duration readers (envtrust.Inherited): the tier they bound keeps its default.

The snapshot is regenerated with task models:snapshot (the committed diff is the review; it carries the source digest and an as-of date, and an answer older than 180 days logs a warning). Its schema 2 holds the numerics and the three capability flags models.dev publishes, each optional: an id the aggregator states as "tool_call": false reads a stated false on an offline or cold-start replica — the refusal a reader owes, not the warning an unknown earns — and a flag the aggregator does not state is absent from the line and reads unknown, never false. A flag you need honoured AGAINST models.dev still goes in OPENAI_COMPATIBLE_MODELS, whose entry wins over both models.dev tiers. Schema 1 (the numerics alone) is not loadable: the file is embedded, so it and its reader ship in the same binary, and a mismatch names task models:snapshot instead of guessing. An id can LEAVE models.dev between two regenerations — it then resolves to unknown, and the operator table is the road for a deployment that still serves it. A sandboxed gateway node receives the host's own resolution (ITERION_OPENAI_COMPATIBLE_RESOLVED), which answers before the container's local sources, flags included — the image's snapshot can be older than the host's, and the numbers never disagree across the IPC. The record's reader is lenient, so skew costs nothing in either direction: an image predating the flags reads the same numbers and the same provenance and takes the flags as unknown, which is what it did before they existed (the kubernetes driver runs the image's baked binary, so a lagging image is the normal case there, not an accident). The flags cross intact on an image at this version or later.

The resolution is the host's, all three names of it. A gateway node's crossing carries the record and the two local sources above it — OPENAI_COMPATIBLE_MODELS and OPENAI_COMPATIBLE_CATALOG_PROVIDER, as explicit empties — for every gateway node, always. Each of the three decides the same four things: the window the node compacts at, the max_tokens it asks for, the price it is billed at and the provenance all of that is recorded under; and an ABSENT name is the one a workspace .env fills inside the container, the runner auto-loading the nearest one over every name the crossing left unset. The explicit empties leave no gap to fill, and they outrank the environment the container was STARTED with, so a repository's own devcontainer.json does not reach a gateway node's runner either.

Provenance is asked once for the three (internal/envtrust), and in the same words for the live tier the second of them resolves against — the ITERION_MODEL_SPECS_* knobs and the ITERION_HOME their cache file lives under: a value this process's own project .env planted is read as nothing said, wherever the read happens. That is what the container needs for a name no host wrote, and what the HOST needs for itself — an iterion run launched inside the repository loads that repository's .env, and a table taken for the operator's there would be laundered into the record the host then crosses as its own answer, carrying no mark the container could refuse. A repository naming the live tier's cache FILE is that same laundering one step out: every exact lookup is then answered from a file in the tree under review, under the provenance catalog-live.

The gateway's pair is asked the same question, and answered by a REFUSAL rather than by a degraded config: a planted OPENAI_COMPATIBLE_API_KEY or OPENAI_COMPATIBLE_BASE_URL refuses the gateway, naming the variable and the file. A planted key would select which virtual key — hence which budget and tenant — its reviewer's calls are billed to; a planted endpoint would send the operator's own key to a host that repository chose, since .env loading stops at the first file found walking up and an operator may well export the key while no endpoint is set yet. Refused rather than half-applied because the degradation does not exist: claw's OpenAI provider, the one client the gateway factory builds, refuses an empty key outright, so dropping the key bought a dead node whose message named OPENAI_API_KEY and Codex's auth.json — neither of which serves a gateway. For the same reason a gateway the environment names but authenticates with nothing is refused on the launch seam, by its own variable's name, rather than at dispatch. A deployment therefore keeps BOTH names in the environment it exports; a laptop whose own .env carries both is configuring its own gateway, which is what that file is for.

The host environment is the channel for these names. The crossing writes all three unconditionally, and docker exec --env / env NAME= outrank the container's own environment, so a value an operator pins for a gateway node through a workflow sandbox: block, the environment overlay or a devcontainer no longer reaches the runner — silently, as for the endpoint and routing names. Set them where the host reads them, and the host's own resolution crosses in the record.

Version skew. The three flag keys are decoded strictly — this is the operator table, not the forwarded record: an iterion older than their introduction refuses the WHOLE table, naming the first of the new keys the table uses (unknown field "reasoning" for a table that lists reasoning first), and that refusal fails every gateway route on that binary (checkGatewayEnv). Put the keys in OPENAI_COMPATIBLE_MODELS only once every replica, runner image and operator CLI reading that env is on a version that knows them.

The record's max_output_tokens is also the route's per-call output budget when the node sets no max_tokens: and the record knows the window too — claw's own 8192 is spent whole by a reasoning model's hidden reasoning before it answers. Each request is fitted to the window: when the prompt (estimated from the serialized system prompt, messages and tools) leaves less room than the budget, max_tokens is lowered, never below 8192, so a vLLM-served gateway does not refuse a prompt that fitted before. A record without a window keeps 8192, and the node's max_tokens: always wins.

Structured output on a gateway route — the recovery ladder ​

A gateway model does not always answer the way claw asks: a forced structured_output call can come back empty, the answer can arrive as plain text or only in the model's reasoning (reasoning_content or reasoning, which claw's chat wire does not carry — iterion reads them off the gateway's stream and shows them as the turn's thinking), and a reasoning model can spend its whole output budget before it answers (the gateway's length cut, now reported as finish_reason: length instead of a clean stop). A node with tools and an output: schema climbs a bounded ladder before it fails. First, for free, when the tool loop ended on an answer turn (not on a tool call at its step limit) that showed no text, a JSON object carrying every required key of the schema that ends its reasoning is taken as the answer. Then one recovery pass, schema-forced, run at reasoning_effort: low when the final turn was cut before any answer (logged). Then one unforced re-ask — the structured_output tool still declared, tool_choice left to the model, a reply that is the JSON object alone accepted; it is skipped after a cut it could not change (an effort already at its floor, or a turn cut by the length of its own answer), and its own transport failure ends the call rather than re-running it. Nothing is ever read from a turn cut at max_tokens, and a reply that merely contains an object — a quoted peer verdict, an injection planted in a tool result, the prompt's own example — is never taken as the answer. A node that still has nothing fails with every turn named (finish reason, output tokens against max_tokens, text and reasoning sizes) and, after a cut, the remedy: raise max_tokens, or lower reasoning_effort when the budget went to reasoning. Cost: the healthy path — the answer in the final turn, or the forced tool block on the first try — pays nothing extra; each further rung re-sends the whole conversation (the chat wire keeps no server-side state), so the worst case is two structured calls past the tool loop where there used to be one, and a cut at the lowest effort stops after one. A node whose final turn was prose still falls back to the text wrapper when both fail, and the schema re-ask above then runs as before — itself one structured call, at most two turns. The unforced re-ask holds for every structured call claw makes on every route; reading an answer off the reasoning is for a node's own output only, never for an internal call whose output is an action (the supervisor's intervention, a self-repair command).

Transient-error & network resilience ​

A brief internet/API outage should not abort a whole run. Every backend call goes through a retry loop (RetryPolicy) with capped exponential backoff + jitter, and the classifier treats connectivity failures as retryable:

  • Detection (delegate.IsNetworkError): net.Error timeouts, wrapped syscall errnos (ECONNRESET, ECONNREFUSED, ETIMEDOUT, …), io.ErrUnexpectedEOF, and a broad message-substring fallback for errors that cross the CLI/SDK boundary as text — fetch failed, socket hang up, getaddrinfo ENOTFOUND, overloaded, 5xx. The run's own context.Canceled / DeadlineExceeded are deliberately excluded so a cancelled or timed-out run never thrashes the retry budget.
  • claude_code silent exits: a connectivity drop surfaces as an opaque session ended without result message (cli_exit_code=N), with the real cause on the CLI's stderr or its API Error: … result render. The delegate re-types it as ErrTransient when the error text matches the full network-signature list, or stderr carries a transport-level marker (matchesTransportStderr — same tier as the shared CLI-agent loop: a crash dump quoting arbitrary source must not read as network evidence; the prose markers stay on the CLI's own error text and result render). One explicit network connectivity issue detected warn, so the loop retries instead of failing the node.
  • Adaptive budget: network/transient errors get a larger attempt budget (MaxAttemptsTransient, default 6) than ordinary retryable errors (MaxAttempts, default 3), so the backoff spans roughly a minute of outage. An explicitly pinned MaxAttempts is always respected (the inflated default only applies when neither is set). Each retry logs network connectivity issue — delegate retry k/n … (backoff …).

Only after a provider exhausts this budget does the run fall through to the next entry in the provider: chain (above).

Per-backend detection rules ​

A backend reports Available: true only when both a binary/runtime and a credential are present. Just having the CLI in PATH is not enough — the runtime still needs an API key or an OAuth file to actually make calls.

claude_code ​

CredentialSource
OAuth (forfait)macOS: the Claude Code-credentials item in the Keychain (where Claude Code 2.x stores it by default — no file is written). Linux/WSL: $CLAUDE_CONFIG_DIR/.credentials.json (default ~/.claude/.credentials.json; non-hidden credentials.json also accepted)
Binaryclaude in $PATH, or ~/.claude/local/claude

On macOS the Keychain is probed for existence only (via /usr/bin/security find-generic-password — the token is never read), so a machine logged into Claude Code is detected even though it has no .credentials.json file. For auto-resolution, OAuth is required. If you only have ANTHROPIC_API_KEY and the binary, claw is preferred (same auth, no subprocess fork). To use claude_code with API-key auth, set backend: claude_code explicitly on the node.

MCP isolation. User, project and plugin MCP server names must stay outside Iterion's reserved infrastructure namespace (iterion, iterion_board, iterion_runs and names whose normalized tool names share the iterion* prefix). Catalog preparation refuses such names with a rename diagnostic; empty names and separator-only aliases are also refused. CLI forwarding protects the same namespace when a task is constructed programmatically. Rename a custom server to use it; internal servers still require their usual interaction settings or capabilities.

iterion spawns the CLI with --strict-mcp-config, so the only MCP servers a node gets are the ones iterion resolves and passes via --mcp-config: the .bot's mcp_server:/mcp: blocks, the target repo's .mcp.json (workflow autoload_project, default on), and iterion's own ask_user/board servers. The operator's personal user-scope servers (~/.claude.json) do not boot inside bot nodes — inheriting them meant undeclared tools reaching the agent, an npx/server boot per node visit (a CPU spike per iteration on loop-heavy bots), and personal API keys on the subprocess argv. ITERION_CLAUDE_CODE_STRICT_MCP=0 is the escape hatch that restores host-config inheritance. Settings remain inherited independently (--setting-sources, above).

Unnamed servers degrade per-server, on every backend. A server whose tools the node does not name that fails to boot costs its OWN tools, never the run: claude_code's CLI skips a server it cannot start, pi bounds each connect with ITERION_PI_MCP_CONNECT_TIMEOUT_MS, and claw's in-process splice skips it with a Warn log plus a mcp_server_degraded run event, so the drop is in the run record, not just the process log. Typical case: a repo-scoped server needing a credential the execution host doesn't have (a token-less Sentry server on a cloud runner pod). A tool the node names EXPLICITLY on a dead server still fails loud at resolution — a declared dependency is never silently dropped, and no degrade event is emitted for it either, since the node is not about to run without those tools.

The event's source says why the server was in that node's reach: declared when the node's own mcp: servers: named it, ambient when it was inherited from the repo's .mcp.json, the plugin catalog or the workflow. It is read from the node's declaration, because ActiveMCPServers — the resolved set the splice walks — holds both merged and cannot tell them apart. origin (project / workflow / plugin) is the other question: who controls the definition. A bot that declares a server whose tools it never names sees source: declared with the origin of wherever that server is defined.

Stdio MCP startup diagnostics. The in-process MCP client drains stderr through the official SDK's command hook and retains only an 8 KiB tail during initialization. On failure, the existing error/event reports a fixed reason, exit code when available, stderr byte count/truncation and a fixed hint (authentication, dependency, network, configuration, unclassified or none). Hints describe recognized text, not a confirmed cause: an EOF alone does not establish that a token is missing. Raw stderr, command arguments, environment values and arbitrary protocol error text are withheld, since a subprocess can print secrets unknown to the run's redaction guard. Retained bytes are cleared after initialization, including success; stderr continues draining without retention. A 500 ms exec.Cmd.WaitDelay prevents an inherited stderr pipe from holding up shutdown after the server exits. MCP selection and the ambient-versus-required failure policy are unchanged.

Orchestration-stall guard. A session blocked on TaskOutput / Monitor with no background work to wait on — no Agent/Task spawned, no run_in_background command — can never return; observed on a facade-served model that waited on a task id it had never created. iterion classifies that silence on a short budget (ITERION_CLAUDE_CODE_ORCH_STALL_TIMEOUT), then recovers in place: the CLI's interrupt aborts the pending call, the model is told which wait could not return, and the session continues — instead of the node restart a killed session costs. A recovery that does not close the turn (ITERION_CLAUDE_CODE_ORCH_RECOVERY_TIMEOUT), or a second stall on the same session, aborts for the executor's retry as before. Each classification is a delegate_stall event (outcome: recovered|aborted) and increments iterion_delegate_idle_deadlock_total{backend,model,outcome} on the runner, so a provider's admission can rest on a number. A blocking call that follows real background work keeps the full hot budget. For a deployment that would rather not expose the surface at all, ITERION_CLAUDE_CODE_DISALLOW_ORCHESTRATION_TOOLS=1 withholds Agent, Task, TaskOutput and Monitor from non-ultracode nodes (opt-in; the default keeps that single-subagent surface — see environment-variables.md).

Subagents run in the foreground. A claude_code session is one-shot. It ends with the node's final output, and nothing reaches the model after that: no completion notification, no scheduled wake-up. Every spawn therefore sets CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1: the main session, every structured-output pass, a resumed session, sandboxed or not. With it, the CLI runs each Agent call in the foreground and returns the agent's report as the tool result. It also drops run_in_background from the Agent and Bash schemas, and its own Agent guidance says the same. Parallelism still works: several Agent calls in one message run concurrently, and all of them return before the next step. Every spawn that keeps the subagent tool, ultracode or not, carries a ## Subagents in this session section that says so. A process the node needs running while it works, such as a dev server, is started from the shell itself (nohup … &).

What a node starts ends with the node. Every agent spawn — claude_code and its structured-output pass, codex, the CLI agents, pi, the claw shell and settings hooks, and a sandboxed claw node's runner — exports a variable of its own, ITERION_NODE_SESSION_<hash>=1, that every process it starts inherits, whether it was backgrounded, nohuped or detached into its own session. When the delegation ends, on every exit path, the engine stops the session: the processes still carrying the variable, those an earlier scan found, and every descendant of either — TERM once to each, a new scan every 50 ms until two in a row find nothing, within five seconds, then KILL the same way. A process still running after that is named as an error; a process of the same user, started since the session began, whose environment cannot be read is named and left running. In a sandbox the sweep runs inside the run's container, in the exec that already terminates the CLI; elsewhere it scans the engine's own /proc, and on a platform without one the engine says once that it cannot. What a sweep stopped is named in the run log and in a delegate_background event (phase abandoned) on every backend; on claude_code it also joins the background work the delegation lost (TerminatedBackgroundTasks), so a run forked from that turn tells the resumed agent its server is gone. A CLI agent's output is not waited for more than two seconds past its exit, so a process that keeps the pipe cannot hold the node. A node that runs after an agent node — the gate that judges its work, above all — therefore starts its own world instead of finding the agent's. Not stopped: a process that detaches from every process of the session and then loses the variable — a daemon that unsets it, or one that daemonizes and rewrites its title over its environment, as nginx and redis-server --daemonize yes do — what a container engine starts (docker compose up -d: the engine's daemon owns it), and what the claw repl tool starts. Why: ADR-127.

An operator who wants background work — subagents and shells that run while the main agent keeps working — asks for it: ITERION_CLAUDE_CODE_BACKGROUND_TASKS=on pins the switch empty in both layers instead (the CLI reads it as set only for 1, true, yes or on). The session then stays open until a background subagent or workflow comes back — the background lifecycle below; a background shell or monitor is not waited for, and dies with the session if it still runs at the final output. The ## Subagents in this session section gives way to a ## Background work in this session section that says so, where the CLI's own guidance says every background task notifies the agent when it completes; with the lifecycle off, the section says nothing is waited for.

A Bash command that outlives its timeout is now killed rather than moved to the background. So every spawn also pins BASH_DEFAULT_TIMEOUT_MS and BASH_MAX_TIMEOUT_MS, derived from this backend's own watchdogs. While a foreground command runs, the session is silent to them, because the SDK drops the CLI's progress lines. A command that outlived the hot idle tier or the no-progress tier would abort the whole session.

  • The maximum sits under the tighter of the two enabled watchdogs, by a margin: a tenth of it, at least 30 s, at most half of it.
  • The default is half the maximum.

With the defaults (15 min and 25 min) that makes 13 min 30 s and 6 min 45 s. With both watchdogs disabled, nothing can abort the session over a silent command, so both timeouts become one hour. The CLI's own 2-minute default would kill a long command for nothing.

A pinned key set through the run's provisioning environment is ignored, with a warning that names it.

Every pinned variable rides two layers, so that nothing lower can move it. There are four: the switch, the node's auto-memory decision (CLAUDE_CODE_DISABLE_AUTO_MEMORY), and the two Bash timeouts — and, when a rewriter is available, its run_env (rtk's stores off; see secrets.md).

  • The process environment, which the run's provisioning environment cannot override.
  • The one --settings object, merged with the auto-memory directory that rides it. At startup the CLI copies the env block of every settings source it loads into its environment, in the order user, project, local, flag, policy, and reads these variables live afterwards. Without this layer, a target repository's committed .claude/settings.json or the operator's user settings could switch background work back on. They could also turn auto-memory back on, against the operator's personal ~/.claude/projects/<cwd>/memory/. The flag layer comes after them; only managed policy settings come later. The CLI applies those env blocks again whenever a settings file changes during the session.

Routing is pinned too. The same env blocks can set the variables that decide where the CLI sends its requests, and over which channel. A target repository's committed .claude/settings.json setting ANTHROPIC_BASE_URL would otherwise send every request of the run, with its API key or subscription token, to an endpoint the repository chose. Every spawn (the session, the structured-output pass, on the host and in a sandbox) therefore pins each variable of ClaudeCodeRoutingEnv in the flag layer:

  • the API endpoints (ANTHROPIC_BASE_URL, the Bedrock, Bedrock Mantle, Vertex, Foundry, AWS and Google Cloud base URLs, ANTHROPIC_FOUNDRY_RESOURCE, CLAUDE_CODE_API_BASE_URL), the provider switches (CLAUDE_CODE_USE_*) and the companions the CLI groups with them (CLAUDE_CODE_SKIP_*_AUTH, ANTHROPIC_CUSTOM_HEADERS, _CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL);
  • the proxies, HTTPS_PROXY, HTTP_PROXY, ALL_PROXY and NO_PROXY under both spellings;
  • the TLS trust: NODE_EXTRA_CA_CERTS, NODE_TLS_REJECT_UNAUTHORIZED, CLAUDE_CODE_CERT_STORE.

Each is pinned at the value the spawn's own environment holds, or "" when it holds none: the CLI reads an empty routing variable as unset, and the empty value still outranks one a settings file sets. A proxy pair the spawn spells one way only is pinned to that value under both spellings, since the CLI's readers disagree on which spelling wins. The pinned values also reach the agent's own commands, like the rest of the CLI's environment: there, a routing variable the spawn left unset is set and empty. Most tools (curl, git, Node, Python's urllib) read an empty proxy or CA variable as unset. The Python Anthropic SDK does not: it takes an empty ANTHROPIC_BASE_URL as its base URL.

The flag settings object travels as a file, never on argv, because a routing value can carry a credential: a proxy URL's userinfo, the sandbox egress proxy's token, a gateway header. On the host it is a 0600 file in a private temporary directory, removed when the pass ends. In a sandbox, the spawn writes it inside the container from the environment the CLI runs with there: the egress proxy and its CA are set when the container starts, and the host never sees them. The CLI keeps the content it read at startup, so a later write to the file does not move the pin, and it refuses to start when the file is missing.

What the pin does not cover:

  • Credentials. They stay in the process environment and are never written to the settings object. A settings file can still replace the key the CLI sends, but only towards the pinned endpoint.
  • Settings that run commands (hooks, apiKeyHelper and the other auth or header helpers): the pin only concerns the env block.
  • The cloud SDK variables (AWS_*, GOOGLE_*, AZURE_*, metadata endpoints). They only take effect once a provider switch selects that cloud. They stay unpinned because the pinned env also reaches the agent's own commands, and those SDKs read a set-but-empty variable differently from an unset one.
  • ANTHROPIC_UNIX_SOCKET: the CLI already drops it from every settings source.
  • Managed policy settings, which outrank the flag layer by design.

ITERION_CLAUDE_CODE_SETTING_SOURCES=none does not turn this off, and does not turn settings off either: it omits --setting-sources, and the CLI then loads every source, local included.

Why this switch, and not a softer lever. With background work enabled, the CLI (2.1.280) runs an agent in the background:

  • whenever the call does not say run_in_background: false;
  • whenever the agent's definition says background: true (the operator's .claude/agents, or the target repository's);
  • under its fork and coordinator modes.

In all these cases it tells the model that the report "arrives in a separate turn", and it no longer has a tool that waits on a task (TaskOutput is gone). A documentation-campaign node took the CLI at its word. It launched its auditors, ended its turn to wait, and emitted its structured output with six of them unread. That happened pass after pass, until the run's budget ran out. Only isolation: "remote" stays asynchronous: that is a cloud agent, gated behind a claude.ai account.

Tools that hand work to a later turn are withheld from every spawn, ultracode included:

  • Workflow: it only ever runs in the background, and nothing in the session can wait on it. Claude Code also arms it on the word ultracode anywhere in its prompt, and a node's prompt carries the content it works on. A PR whose title or diff mentions the mode would switch a reviewer into a background multi-agent orchestration (measured on 2026-09-05: four revi/review runs died at 3–5× their cost cap that way).
  • ScheduleWakeup and CronCreate/CronDelete/CronList: they schedule a prompt for a later turn of this session, which never comes. A durable cron is written into the workspace's .claude/scheduled_tasks.json instead.
  • RemoteTrigger: it schedules remote agents under the account the CLI runs on.

A name the running CLI does not register costs nothing on --disallowedTools.

readonly: true withholds the file-writing tools from every spawn too — Write, Edit, MultiEdit, NotebookEdit — plus EnterWorktree and ExitWorktree, and it does so whatever the node's tools: list says. The flag is the node's access intent, the meaning it already has on codex (a read-only OS sandbox) and on pi (a readers-only tool list), and it is what admits the node into a SHARED workspace as non-mutating — so a writer kept beside it would write in a workspace a parallel branch was told nobody mutates. A node whose deliverable is a written file drops readonly:, which drops that admission with it; on claude_code the deleted declaration is named on the node's log, never dropped behind the author's back — codex replaces the declaration with a read-only OS sandbox and pi with a four-reader tool list, both in silence. The widest bite is a node with no tools: line, the case the flag used to mean nothing in: omission is the full native roster here.

What the flag does NOT buy is a sandbox: it removes writing TOOLS, not the write capability. A shell the node keeps (tools: [bash, …], diagnostic_shell:, or no tools: line) still writes; a subagent the node can still dispatch runs on an argv this backend does not build (whether the CLI propagates a deny list to one is not measured here); and the roster itself is one iterion does not own (#1651), so a write path spelled outside these lists survives. Every residual path is logged on each run of the node. A node that must not write declares a tools: list with neither a writer, nor a shell, nor agent — and runs on codex/pi when the bound must hold against the roster too, since there readonly closes the surface itself: codex runs the node in a read-only OS sandbox, pi pins its tool list to read,grep,find,ls. claw, opencode and cliagent do not read the flag at all.

A declared tool a spawn withholds is not auto-approved either. The --allowedTools list drops every name the same spawn's --disallowedTools removes — the rules above, the orchestration knob, and readonly:'s withholding, which it applies against a declaration — so no name reaches one argv on both flags: the withholding never depends on the CLI resolving deny over allow. That is where a declared write_file on a readonly: node ends up: withheld on one flag, and dropped from the other.

Background work: the session stays open until it comes back. With background work on (ITERION_CLAUDE_CODE_BACKGROUND_TASKS=on, above) — or for the background work the switch does not reach — Claude Code runs subagents asynchronously and lets the main agent end its turn while they work; a finished task is announced and, when the agent is idle, the CLI starts the turn that delivers it by itself. A session that returned at its first result killed that work with the process — and under --json-schema kept the report the turn-end enforcement ([structured-output-enforce]) extracted from an agent that was only waiting (measured on a Doki run: 4 of 5 passes reported before their subagents came back, ~73 % of the run's spend). iterion therefore follows the CLI's own task protocol (background_tasks_changed is the authority on what still runs; task_started / task_notification name each task) and, when a turn ends with work still running or a result not yet delivered, keeps reading the same session instead of closing it. What the CLI delivered is never guessed from the stream's order — its queue folds, coalesces or defers notifications by rules that change with its version and remote flags; iterion asks the CLI instead. It spawns it with CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1 (session state events: idle comes only once no work the CLI waits for runs and none of its finished results is still on its way to the CLI's queue — the queue itself it does not check: it reports idle, then re-kicks a turn for a command already queued) and --replay-user-messages (each message iterion writes carries a uuid the CLI re-emits when a turn takes it); it pins CLAUDE_CODE_BG_TASKS_REPORT_RUNNING=1 — false would make the CLI report idle while agents still run — and clears CLAUDE_CODE_EXIT_AFTER_STOP_DELAY: the session ends when iterion decides, not on the CLI's own idle exit.

The session waits for subagents and workflows (local_agent, local_workflow) — work the CLI's own print-mode wind-down waits for too, up to its own ceiling (CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS, past which the CLI terminates that work). Every spawn pins it at the CLI's own default, 10 minutes, and at nothing else: the point is that the repository under review cannot move it — 0, "wait indefinitely", included — not to shorten a deadline that kills, and iterion's own wave budget, which merely asks for a report, never derives it. The ceiling arms only where the CLI's stdin is closed, which is the structured-output pass: the one spawn with no lifecycle to catch what a kill loses. No spawn can start a workflow today — Workflow is withheld from every node — so the local_workflow arm is there for the task type, not for a tool the model can reach. A background shell (run_in_background: a dev server never ends), a monitor, a teammate or an MCP task does not hold it, by choice — the CLI's wind-down also holds armed monitors (behind a feature flag) and active teammates: those die with the session as they always did, and are reported. A monitor's events still start turns of their own; they are bounded below. The background shells a subagent launched (the CLI flags them owned_by_subagent) report to that subagent: they never hold the session, and while a subagent or workflow still runs they are its, not the main agent's loss — once none does, a shell's end would wake its finished subagent, whose continuation reports to the main agent, so it is reported lost with the session. A subagent's own background agents are held like the main agent's — the CLI's wind-down waits for them too. Nor do the tasks the CLI marks ambient — not activity: its own housekeeping (memory consolidation) and every live-update watcher, requested or auto-started; a watcher the agent asked for dies with the process unrecorded. A node that needs a long command's result runs it in the foreground.

The session ends at rest on the CLI's idle, once it held for a moment (ITERION_CLAUDE_CODE_BACKGROUND_IDLE_SETTLE, 1s — the CLI reports idle an instant before a turn it re-kicks; 0 ends on the first idle, and is warned) with no message of iterion's unanswered — and only an idle reported after the last end of a background task the main agent launched counts: a finished subagent the CLI resumes outside its turn loop ends again with no running in between, and a shell ending right after an idle gets a turn of its own. A CLI that exits cleanly at rest ends the node like an idle that held — unless a turn source may have prompted its last turns (see the ceiling below): no request for the report can reach it any more, and the call ends on a retryable error. The node's report is then the last StructuredOutput the agent wrote in its last turn that made a request: every turn that delivers background work runs the structured-output enforcement again, so a report written while work still ran is replaced; a node whose last such turn wrote none gets no structured output (its usual recovery runs), never an earlier report. The report is not refused when it is written — the CLI counts every StructuredOutput call of a turn, refused ones included, against its own MAX_STRUCTURED_OUTPUT_RETRIES (5), and a refusal loop ends the turn with error_max_structured_output_retries.

While the agent waits by design, the silence and no-progress watchdogs rest and a budget governs each wave of background work (ITERION_CLAUDE_CODE_BACKGROUND_WAIT, default 30m, counted from the first result that left held work running, or from held work coming back without a turn; once all of it came back, the next launch starts a budget of its own). A held result whose end was reported but that has not reached the CLI's queue yet — the CLI queues it only once the subagent's worktree is finalised, and reports no idle meanwhile — is waited for up to ITERION_CLAUDE_CODE_BACKGROUND_RESULT_WAIT (5m, the CLI's own bound for it), with no nudge: none could deliver it; past it, it is recorded lost. A CLI that is neither in a turn nor at rest — a turn it owes, or work iterion does not wait for (an MCP task, a remote agent, a teammate, a timed monitor) keeping it from idle — gets a grace (ITERION_CLAUDE_CODE_BACKGROUND_AUTOTURN_GRACE): one nudge if a result may still be owed, then the session ends with the last turn's report, the tasks that kept the CLI busy named and recorded lost; a nudge no turn ever takes ends the call on a retryable error — how long its answer may take is a wait of its own (ITERION_CLAUDE_CODE_BACKGROUND_ANSWER_WAIT, five graces by default), because the grace decides when to nudge, and a replay plus the first message of the answering turn does not fit in it. The silence watchdogs are suspended while a nudge is outstanding, so that budget is how long a CLI that took the message and went quiet holds the node. Turns the CLI keeps running on its own once nothing held runs — a monitor's events, each re-kicking it right after an idle, so that no idle ever holds — do not restart that grace: the session ends at the close of the first such turn past the grace counted from the first close with nothing held running, unless a held result is still on its way. The grace restarts when held work started or came back, when one of the agent's background tasks ended (a shell ends once), and at a nudge; only turns for work that never ends leave it running.

Two more bounds apply: the node's tool_max_steps (which --max-turns only applies per query), and a ceiling while a turn source runs — a task that makes the CLI run turns for as long as it runs: the watch a Monitor call started — the main agent's; a background subagent's once that subagent finished (the CLI keeps it alive for the watch and resumes it on each event, its result reported again; until then the watch reports to it alone; from then on it counts as long as it runs, those resumes included — not a resume a message asks for, the main agent's or another agent's, whatever name it gives the agent: that is the agent's own work; an agent's agent's watch counts once its owner finished — an agent that took it over with a message is its owner then —, the owner's own owner never entering into it); a foreground subagent's while it runs, its turn being the main agent's — an MCP or WebSocket monitor, a teammate (a monitor armed with no tool call — by a plugin — is not recognised). The session spends at most one wave budget with a turn source running — the time one runs past the node's first turn (its own: no source prompted it), summed over the session, a monitor the agent re-arms at its expiry included: a monitor answered event after event with work that ends would otherwise restart the grace — or open a wave with a budget of its own — forever. Past it, iterion asks for the report at a close where one runs, or where one ended since the previous deciding close (a close that answers a message of iterion's decides nothing) without an agent stopping it (a monitor that expired, or whose command exited — a stop by a teammate's name or agent ID counts as the agent's, the CLI's reply naming the task, and so does a stop of the subagent a watch belongs to, or of an agent above it that was parked at the stop — the CLI stops a parked agent's agents with it, a running one's keep running —, made after the watch started; a stop the CLI refuses — of a task that already ended, of one another agent owns — stops nothing). The ceiling bounds every such turn, the waves its turns launch and a loop on background shells included — iterion does not wait for a shell, here as anywhere; time without a turn source does not count, and a turn with none is never cut by it, however many waves an orchestration runs. When one of these bounds is spent at a close with nothing held running and a held result may still reach the agent — owed, or finished with no idle that held proving it delivered since (the CLI may have queued it behind the commands the last turns ran for, or still be finalising it) — iterion asks for the report instead of ending (the request queues behind what the CLI queued; the agent is told to say which parts depended on work whose result it has not seen); otherwise the session ends at that close with that turn's report. Either way, what still runs, and every result no idle proved delivered, is recorded lost. Once a turn has run while a turn source was running, a turn's report may answer the source — a monitor's event, a teammate's message — rather than the task: from then on the session never ends at rest on one (the idle that held, the grace, a bound, a CLI that starts no more turns) without asking for the report first. When the wave budget, the node's tool_max_steps or the ceiling is spent while held work runs, iterion asks for the report at a turn's close or at the next idle point, never cutting a turn short (the CLIs this lifecycle accepts have no blocking wait to cut: TaskOutput is gone, Monitor returns at once); a request written as the CLI starts its next turn is folded into it, as the CLI folds any queued command. A request for the report — at rest or while work runs — is answered by the turn the CLI's replay shows took it — not by turns the CLI had queued before it — only a report written after that is kept, and its bound (ITERION_CLAUDE_CODE_BACKGROUND_FINALIZE_TIMEOUT; past it, a retryable error) runs from then. The take itself is bounded too, by ITERION_CLAUDE_CODE_BACKGROUND_ANSWER_WAIT from the moment the request went out: the bound slides with the CLI's turn activity — each message of a turn it had queued, and each turn's close — so it only ever bounds idle opportunity, and a CLI that never takes the request ends the call on a named retryable error ("the CLI never took the wrap-up") instead of parking the session to the silence watchdog. A subagent streaming meanwhile does not count as the main agent moving on, and the consecutive tool-error breaker keeps one streak per agent. The session's results merge into one: per-query usage, turns and wall-clock durations add up; the figures the CLI keeps per process (cost, API duration) take their largest value, per-model usage its last non-empty one. Every step is a delegate_background event (phase: waiting|settled|finalizing|abandoned) and increments iterion_delegate_background_total{backend,phase}. The dispatcher's event-silence reaper (stall.timeout_ms) still applies to a wait during which nothing at all streams — a subagent's tool calls are events; a single silent tool call longer than the reaper's timeout is not.

Work that has not reported back when a process of the session ends by any path (error, pause, wrap-up, a background server, a disabled lifecycle, the end of a formatting pass) — still running, or finished without its delivery proven (an idle that held proves a result delivered, and so does a whole grace with no turn — the CLI starts one for anything queued; the order of its events does not: the CLI queues a subagent's result only once its worktree is finalised) — is reported as abandoned and recorded in the run's session ledger — keyed by the CLI session id, persisted in every checkpoint (session_ledger), carried by a Fork API turn (terminated_background). Whichever process resumes or forks that transcript next — an in-process retry, a schema re-ask, a loop re-entry, a resumed run on another replica, a formatting pass — is told the tasks may not have reported back and that any whose result it does not see never will (the record errs toward telling, never the reverse), instead of waiting on them; a formatting pass is told not to re-launch them. Once it took that prompt it settles the entry — it removes what it was told and adds what it leaves behind, so two processes of one session settle to the same entry in either order; a process that died before taking it settles nothing.

The lifecycle engages only when the CLI itself reports background work (older than 2.1.280: not at all): a session that backgrounds nothing still ends at its first result. ITERION_CLAUDE_CODE_BACKGROUND_LIFECYCLE=off restores the previous behaviour — first result, the orchestration-stall guard's historical rule, no state events nor replays asked of the CLI — and keeps the reporting and the session ledger; the per-agent tool-error breaker applies either way. claw has no equivalent: its agent tool spawns a SYNCHRONOUS child conversation that returns a real result, where claude_code's is asynchronous. claw spawns one where the host wires a subagent runner into its registry, and iterion wires one at both of its hosts — in process and in the sandbox container, where the placement of agent puts it — so a delegation returns the child's answer rather than claw's own {"status": "running"} stub (#1940). The child is bounded by the NODE: its effective tools: (never the process registry), its permission policy, its step budget, and its spend, debited to the node so the run's budget counts it. agent itself and the node-scoped MCP tools stay out of a child's set — no grandchildren, and no server reached on the model's word. An embedding that wires no runner still earns the typed refusal backend "claw" cannot serve the agent tool (subagents), naming this backend's native Agent tool (legacy name Task) as the way out; that is the typed-refused half of the parity rule, and no compile-time diagnostic predicts it, since the compiler cannot know which host will build the registry.

Named subagents resolve from the workspace. A subagent_type that names <workspace>/.claude/agents/<name>.md — the file a plugin's contributes: agents mirrors there, which claude_code discovers natively — resolves that file through iterion's own resolver (pkg/backend/model/workspace_agents.go, contained by os.Root at the workspace, like the workspace commands above): the body becomes the child's system prompt, tools: intersects the node's effective set (never widens it), and model: refuses the delegation (#1717 — a contributed file does not re-route the node's spend). The resolver is iterion-side by arbitration (ARB-83): iterion's SubagentRunner executes every spawn a workflow drives, on this backend and claude_code alike. The live e2e exists — task test:live:claw-workspace-agents (TestLive_Feat_ClawWorkspaceAgents, #1714): a delegation to a declared agent must come back with the file's body as the child's prompt, its tools: narrowing the child, and the resolution reported in the tool result, while a tools: [] agent reads nothing. Last verdict: see the ledger (e2e/testdata/live/ledger.json).

codex ​

The Codex backend delegates to the installed Codex CLI through the pinned Agent SDK and uses the CLI's own authentication and native tool loop.

CredentialSource
OAuth$CODEX_HOME/auth.json (default ~/.codex/auth.json)
Binarycodex in $PATH, ~/.volta/bin/codex, ~/.local/bin/codex, /usr/local/bin/codex, /usr/bin/codex

Same logic as claude_code: only OAuth flips it to "available" for auto-resolution. OPENAI_API_KEY alone routes to claw.

A Codex node receives OpenAI's hosted native Web search only when it declares the canonical DSL tool:

iter
agent researcher:
  backend: "codex"
  model: "gpt-5.6-terra"
  tools: [web_search]
  readonly: true

Iterion sends the pinned SDK configuration web_search="live" for that node and web_search="disabled" for every Codex node that omits it. This avoids Codex's normal cached-search default silently widening the DSL tool contract. It does not use claw's web_search implementation, an MCP server, or shell network access. Hosted search remains available with Codex's read-only sandbox, so full_access: true is neither needed nor implied.

The backend verifies the Codex CLI version before starting any Codex task, because every run sends an explicit web_search mode (live or disabled). A CLI older than the capability-specific minimum fails with an actionable error naming the missing mode and required version. Operators can set CODEX_CLI_SKIP_VERSION_CHECK=1 to bypass the probe when a compatible wrapper does not expose a conventional version string; doing so assumes responsibility for support of the top-level web_search mode. Search calls are emitted as WebSearch tool lifecycle events; Bash remains separate. The SDK currently provides a count and best-effort action/source payload, but no per-call monetary amount, so Iterion's _cost_usd does not include an invented search fee. See Web search & fetch for the backend comparison and cost boundary.

Sandbox and full_access ​

A codex node runs under codex's own sandbox. readonly: true always selects read-only (and takes precedence over a conflicting full_access: true). Otherwise, an omitted/empty tools: list keeps Iterion's normal "native tools unrestricted" contract and selects workspace-write; a non-empty list uses read-only only when all declared tools are known readers, and workspace-write when it contains a writer/shell tool (both Iterion snake_case names and Claude-style names are recognised). Unknown/custom names conservatively select workspace-write; use readonly: true for an explicit lock-down. The compiler says so at authoring time: a node routed to codex — as its primary backend, through a fallbacks: route, or by a ${VAR:-codex} default — whose declared list buys workspace-write gets the C316 warning, naming the effect (the list bounds nothing) and the two remedies (readonly: true, or a list codex reads as read-only, such as tools: [read_file]). The question is asked of the one table the delegate runs, toolcatalog.CodexDeclaredListIsReadOnly, so the warning and the sandbox the node gets cannot disagree.

With Codex's default configuration neither read-only nor workspace-write allows shell network egress — so a codex shell cannot reach an external API (e.g. image generation through a local HTTP call). This restriction is separate from provider-hosted Web search described above. A user-level Codex config can override workspace-write network policy; Iterion does not currently rewrite that file, so operators who require a hard network boundary should use an Iterion Docker/Kubernetes sandbox with a backend that supports it.

To grant network access, the pipeline author sets full_access: true on the node. It lifts the sandbox to danger-full-access (unrestricted network + out-of-workspace writes) — the same posture as codex exec -s danger-full-access. It is off by default and opt-in per node in the workflow:

iter
agent make_cover:
  backend: "codex"
  full_access: true    # codex sandbox -> danger-full-access (needed for imagegen / network)
  user: cover_prompt

Other backends ignore full_access (they do not impose the codex sandbox).

Outer-sandbox limitation: the pinned Codex Agent SDK cannot yet route its subprocess through Iterion's Docker/Kubernetes command builder. Iterion fails such a node explicitly instead of silently launching the CLI on the host. Set sandbox: none to rely on Codex's own sandbox, or use claude_code/claw when the outer container boundary is required.

Image inputs (images:) ​

A codex node can receive input images for image-to-image via the node-level images: list. Each entry is a templated path, resolved per run against the node's input/vars/etc. and forwarded to the codex CLI as -i. Empty results (e.g. an optional reference that doesn't apply this run) are dropped.

iter
agent keyframe:
  backend: "codex"
  full_access: true
  images: ["{{input.prev_frame}}", "{{input.identity_anchor}}"]
  user: keyframe_prompt

Use it to seed a generation from a prior image (e.g. reusing the previous keyframe for visual continuity, or a character-identity anchor). Non-codex backends ignore images: (all CLI delegates instead receive launch-time attachments: by path, and the executor requests read_image; the delegated CLI must provide that tool or use its own file/image reader). Native multimodal forwarding is claw-only.

claw ​

claw is in-process and pluralised across providers. It reports Available: true when any of these is set:

ProviderDetection
anthropicANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN
openaiOPENAI_API_KEY, or Codex CLI signed in via "Sign in with ChatGPT" (see OpenAI via ChatGPT forfait below)
xaiXAI_API_KEY (xAI Grok — OpenAI-compatible chat completions at api.x.ai)
foundry (Azure)AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT
bedrockAWS_REGION or AWS_DEFAULT_REGION (full chain handled by AWS SDK)
vertexGOOGLE_CLOUD_PROJECT

When model: on the agent is also empty, the runtime substitutes a sensible default for the first available provider (the detector's SuggestedModel for the first available provider, in this priority order) — currently anthropic/claude-opus-5-5 for Anthropic, anthropic/glm-5.3 for z.ai, moonshot/kimi-k2 for Moonshot, openai/gpt-6-sol for OpenAI, and xai/grok-3 for xAI (the SuggestedModel fields of pkg/backend/detect, in the order the detector lists them).

Stream-silence watchdog ​

Each in-process Claw provider request is protected against indefinite silence, without imposing a total duration limit on a healthy long response. The cold phase allows 5 minutes for the first stream event; after the first event, every event (including a provider ping) resets the hot 15 minute timer. A watchdog expiry is retried through the bounded transient retry budget; a parent node/run cancellation is not retried.

Tune the tiers with ITERION_CLAW_STREAM_COLD_TIMEOUT and ITERION_CLAW_STREAM_IDLE_TIMEOUT (Go durations such as 2m or 20m). Set either to 0 only when that tier must be deliberately disabled.

Claw's own SSE readers add an inner watchdog on the OpenAI chat, foundry (Azure) and Responses wires, CLAW_STREAM_IDLE_TIMEOUT (5 minutes by default), measured on the gap between SSE lines. On those wires it therefore fires before the hot tier above and reports the stall as a stream error event, which iterion reads as the same silence verdict its own watchdog raises: a stall from either watchdog takes the identical transient retry budget in-process — the node's retry loop, and the delegate loop around it. Across the sandboxed crossing the runner's error reaches the host as text, so a stall loses that verdict there — both watchdogs' alike, iterion's own included. What iterion keys on is the watchdog's own report, which both names the window it measured and points at CLAW_STREAM_IDLE_TIMEOUT; a provider refusal that merely quotes a stall carries neither marker; on the generic wire the frame's type is dropped before iterion reads the message, so such a refusal keeps the retryable transport verdict it had before this change, not the verdict its own frame earns. A refusal relaying a whole watchdog report is decided by its frame's own type instead — on the wires that preserve one, which the anthropic-shaped client does not.

That parity also makes a stall the same fallbacks: category as iterion's own — transient_exhausted, where it used to be the catch-all unclassified that bypasses a route's on: filter entirely. A chain whose on: does not name transient_exhausted, the default [usage_window, unavailable] included, therefore no longer routes around a stall once the retry budget is spent: it stops there. Name transient_exhausted on the rescue route that should take it.

The tools: list is load-bearing here (C135) ​

claw is the one backend a node's tools: list actually constrains: it resolves every declared name against the in-process registry and fails the node on one it does not have. So the names must be claw's own — read_file, write_file, file_edit, glob, grep, bash, web_fetch, skill, web_search, the task_* / worker_* families, … — and not the names that circulate in older examples (list_files, run_command, git_diff, search_codebase, tree, patch): none of those has ever been registered.

iterion validate catches this before launch (C135) rather than letting the run die on unknown tool "list_files" after the workspace is prepared, and it names the nearest built-in. The check applies to claw only — on every CLI backend the lowercase list is advisory, so an unknown name there is dead config, not a failure — and it never touches qualified MCP references (mcp.<server>.<tool>, the mcp__server__tool alias form, wildcards), which are resolved when the server connects.

What it lets through, and why. iterion's own board and watch tools are accepted by their bare name (create_issue, subscribe, …): the registry resolves a dot-free name as a unique suffix over the connected MCP tools, and the runtime registers those families for every run.

That same shorthand is why C135 blocks only what it can positively identify — a legacy phantom name, a near-miss typo, an unexpandable ${VAR} — and merely warns on any other unrecognised name. Half the MCP catalog is invisible at compile time: project .mcp.json entries and enabled plugins' servers are merged after compilation, and a claw node gets them spliced in as mcp.<srv>.*, so tools: [firecrawl_search] runs on a host with that plugin enabled. Blocking it would refuse a working workflow to guard a guess. Wiring MCP explicitly (an mcp_server: declaration or an mcp: block, on the workflow or the node) softens even the identifiable names — a server you wire on purpose can expose any name at all.

One thing it does not let through: tools: is the single node field iterion never expands — model:, backend:, command: and timeout: all go through ${VAR} substitution, this one reaches the registry verbatim. A ${VAR} entry can therefore only fail, and C135 says so.

xAI Grok (model: "xai/…") ​

xAI is a first-class claw provider. Set XAI_API_KEY (or store a BYOK key under provider xai in cloud mode) and point a node at a Grok model:

iter
agent planner:
  backend: "claw"
  model: "xai/grok-3"
  # optional: model: "xai/grok-3-mini"  # reasoning variant

Optional XAI_BASE_URL overrides the host (default https://api.x.ai). A trailing /v1 is stripped automatically so pasting the public OpenAI-SDK base URL (https://api.x.ai/v1) still works — claw's OpenAI-compatible client always appends /v1/chat/completions.

In cloud mode the same key can be stored as a per-org BYOK record with provider: xai (see byok.md). Sandboxed runs with network: allowlist already include api.x.ai in the iterion-default preset. A custom XAI_BASE_URL crosses into the sandbox with the key and is dialled from inside the container, so under network: allowlist its host must be allowed too, and a loopback URL does not reach the host (what crosses).

For web search & fetch on claw (the web_search/web_fetch tools, the SearXNG → Brave → DuckDuckGo backend ladder, ITERION_WEB_SEARCH, and the Firecrawl MCP tier), see web-search.md.

OpenAI via ChatGPT forfait (Codex CLI OAuth) ​

When Codex CLI is signed in via Sign in with ChatGPT (rather than the default API-key mode), iterion's claw provider can reuse that OAuth token to drive OpenAI calls through the ChatGPT-Codex backend (chatgpt.com/backend-api/codex/responses) — billed against the user's ChatGPT Plus / Pro / Team subscription instead of the metered api.openai.com endpoint.

Setup:

bash
# 1. Install or update Codex CLI (>= 0.130.0 for gpt-5.5 access).
# 2. Sign in via ChatGPT (NOT API-key):
codex logout                     # if previously logged in via API key
codex login                      # follow prompts → "Sign in with ChatGPT"
# 3. Verify auth.json carries chatgpt mode:
jq '.auth_mode' ~/.codex/auth.json   # → "chatgpt"

Iterion auto-detects this on the next iterion run. The status pill shows the OpenAI provider as available even without OPENAI_API_KEY set.

Precedence:

OPENAI_API_KEY wins when both are present. The reasoning: an explicit env var was a deliberate user action — typically a project-scoped BYOK key, a CI secret, or a shared workspace credential — and silently spending someone else's ChatGPT subscription would be a surprising default. ChatGPT-OAuth activates when OPENAI_API_KEY is unset.

bash
# Force OAuth even with OPENAI_API_KEY set:
export ITERION_OPENAI_USE_OAUTH=1

# Force API-key only (refuse to use OAuth even if no key is set):
export ITERION_OPENAI_USE_OAUTH=0

# Setting OPENAI_BASE_URL (for OpenRouter/Ollama/vLLM) automatically
# disables OAuth so masquerading codex_cli_rs headers don't reach an
# unintended backend.

A sandboxed claw node honours the refusal (0) whatever the run holds, and a host-wide 1 too. When the run holds no OpenAI credential of its own (no key, none pinned for the route, no connected forfait that serves), each dispatch of an openai/… node writes the host's ChatGPT login ($CODEX_HOME/auth.json, else ~/.codex/auth.json) into a directory of its own in the container, points that node's runner at it with the forfait forced over OPENAI_API_KEY, and removes it when the node is done: the sandboxed node spends the forfait an in-process one spends (a long-lived process — studio, desktop — keeps the client it resolved first, so a codex login or logout since reaches its in-process nodes only after a restart, where each dispatch reads the codex home anew). Under a network allowlist the node dials chatgpt.com: an allowlist of your own must name it, as iterion-default does. The copy holds the access token and the account alone — no refresh token, id token or API key — so it renews nothing and expires, but anything running in the sandbox while the node does can read it: leave the switch unset where sandboxed code is not trusted with that token. A switch, a CODEX_HOME or a home directory set by a project .env sends nothing (a warning says so), and with the switch unset claw sends nothing at all. Neither is what keeps the login out of a sandbox: under the default host_state: auto the whole ~/.codex is bound into the container read-write whatever the switch says, host_state: none is what keeps it out, and a pi node on openai-codex/… carries its own copy across through pi's bridge (below).

Stream usage through a gateway. On the OpenAI chat wire, token usage travels only when the request asks for it (stream_options.include_usage). api.openai.com is asked whatever the setup; an endpoint set through OPENAI_BASE_URL is not — providers that reject the stream_options parameter must not see it unasked. When your gateway accepts it, opt in with ITERION_OPENAI_STREAM_USAGE=1: streaming calls then report their usage instead of counting against the run's usage_unreported floor. The ChatGPT forfait never carries the parameter, and the openai_compatible/ gateway factory asks unconditionally — a gateway that rejects stream_options breaks that path regardless of this knob.

The studio status pill renders both detected sources, with the inactive one struck-through and labelled (overridden by …).

Model-version gating. OpenAI's backend gates model access on the HTTP version: header iterion sends with each call. By default iterion derives this from codex --version; override with ITERION_CODEX_VERSION=X.Y.Z if you need to claim a different version without reinstalling the binary. Concretely: gpt-5.5 requires codex-cli >= 0.130; gpt-5.4 works on older versions.

Refresh. iterion does not implement OAuth refresh — it reads whatever access_token is currently on disk. Codex CLI maintains the file as a side effect of normal use; if your token expires mid-run, just codex --version (or any other Codex command) to trigger a refresh, then re-run iterion.

ToS posture. ChatGPT subscriptions don't carve out Codex CLI as the only legitimate surface. (Anthropic Pro/Max was long read as doing exactly that for Claude Code; it now serves third-party clients and bills them to extra usage — see the note under z.ai integration.) Reproducing Codex CLI's OAuth flow from a third-party tool is gray-area but has no explicit prohibition today. We treat this as pragmatic — if OpenAI changes the terms or tightens enforcement, set ITERION_OPENAI_USE_OAUTH=0 and fall back to OPENAI_API_KEY.

Third-party agent CLIs (pi, kimi, grok, opencode, and the CLI-agent seam) ​

Some agent CLIs have an argument protocol disjoint from claude-code's Session mode (--print, prompt on stdin, --append-system-prompt, …), so neither backend: claude_code nor the per-node command: override (which only swaps the binary while keeping claude-code's argv) can drive them. iterion ships dedicated backends for these as instances of a generic CLI-agent seam (delegate.CLIAgentBackend + CLIAgentProtocol, see ADR-065): build the target CLI's own argv, run with a wall-clock timeout (inside the run's sandbox when active), parse stdout into a structured result, retry on no-output / network transient. Adding another such CLI is a new protocol value, not new plumbing.

pi (pi coding agent) ​

pi (earendil-works/pi) is a multi-provider agent harness. It is the backend to reach for when you need a model the other backends cannot run.

iter
prompt task:
  Review the diff for correctness and say what must change.

agent review:
  backend: "pi"
  model: "openai/gpt-5.5"   # or cerebras/…, groq/…, zai/…, github-copilot/…
  system: task              # a prompt declaration, never an inline string

Install: npm install -g @earendil-works/pi-coding-agent (Node ≥ 22.19) or curl -fsSL https://pi.dev/install.sh | sh. Pin a specific host binary with ITERION_PI_BIN. The DSL's per-node command: override is consumed only by claude_code; pi ignores it.

What it brings ​

  • ~36 first-class providers behind one agent loop, with an auto-refreshing model catalogue: anthropic, openai, openai-codex, google, vertex, bedrock, azure, github-copilot, xai, zai, moonshot, deepseek, groq, cerebras, mistral, minimax, openrouter, together, fireworks, and more. pi --list-models is the authoritative list for your install.
  • A provider-computed cost, not an estimate. pi reports usage.cost.total per message against its own pricing catalogue; iterion records it verbatim (cost.AnnotateWithUSD) instead of guessing from the static table. It also reports the real input/output split, where the other CLI-agent backends book every token at the output rate. This makes max_cost_usd and the spend cap quantitatively correct on a CLI backend for the first time.
  • Its own credential store (~/.pi/agent/auth.json) with OAuth flows for Anthropic, OpenAI (ChatGPT/Codex) and GitHub Copilot — an authentication path independent of iterion's.

What it does NOT bring ​

pi deliberately ships a small tool set: read, bash, edit, write, grep, find, ls, and no MCP client at all — plus no subagent/Task, no todo, no web fetch/search, no notebook, no background bash. The iterion pi extension supplies the MCP half; the rest stands. Consequences for a backend: "pi" node:

  • The permission gate DOES work (from v3.7.6), supplied by the iterion pi extension that the backend loads automatically. permission: ask|deny and its allow:/ask:/deny: rule lists resolve through the same permission.Policy as claude_code and claw, so all three reach identical verdicts. RPC transport only — a print-mode node has no channel for it.
  • board capabilities: DO work (from v3.7.6, RPC transport only): the extension bridges iterion's board MCP endpoint onto pi and registers each tool it advertises.
  • ask_user DOES work (from v3.7.6, RPC transport only): the agent can put a question to the operator, which pauses the run and resumes with their answer.
  • Async questions DO work (interaction: async, ADR-081 — from v3.7.6, RPC transport only): ask_user_async posts without stopping and await_answers is the sync point, with the same semantics as claw and claude_code. Answers are delivered mid-run through pi's native steer.
  • workflow mcp_server: blocks DO work (from v3.7.6, RPC transport only), on all three transports: http (streamable HTTP), sse (the legacy binding) and stdio (a child process). The extension carries its own MCP client — pi has none — and discovers each server's tools through tools/list, so schemas and capability gating stay with the server. Two things to know:
    • A sandboxed stdio server runs inside the container, so its command: must resolve there. Same caveat as claude_code.
    • Connecting is bounded by ITERION_PI_MCP_CONNECT_TIMEOUT_MS (default 10000). Servers connect in parallel during pi's session start — which iterion's own 30s handshake is waiting on — so one unreachable server costs its own tools, not the run. Failures are logged, not fatal.
  • __ITERION_SECRET_*__ placeholders are not materialised. Use file secrets instead (secrets.md) — they are real mounted files and work unchanged.
  • node tools: lists are advisory, as for every CLI-agent backend. The one exception iterion enforces is a readonly: node, which pins pi to --tools read,grep,find,ls.
  • A rejected credential is recorded as meter evidence, on the anthropic wire only. pi mints ErrAuthFailed from the upstream 401/403, and iterion records it as a usagecap auth refusal so the next resolution routes around the dead credential instead of re-picking it — the same behaviour as claude_code (usage-caps.md). A refusal on any other provider is recorded NOWHERE: pi routes ~36 providers behind one process, the credential-skip evidence is claude_code-metered end to end, and a mislabelled reading would bench a healthy key. Visible as a debug line naming the provider.

A node that needs the remaining native gaps — subagents, todo, web fetch/search, notebooks, or background shell — should stay on claude_code or claw. pi's value is the models those cannot reach, not replacing them on a workflow that depends on those tools.

Behaviour worth knowing ​

  • Context files are the dominant per-call cost. Per directory pi loads the FIRST existing file among AGENTS.override.md, AGENTS.md, AGENTS.MD, CLAUDE.md, CLAUDE.MD — one file per directory, not both (0.84.3, resource-loader.js) — walking up from the working directory to /, plus the agent dir's own file. The ambient-context policy (above) scopes this: every value but all turns pi's own loading off and has iterion supply the allowed files, rendered exactly as pi renders its own. Two consequences:

    • If your repo carries an AGENTS.md meant for a different agent, it reaches pi nodes too.
    • Measure it before you budget. When iterion's own CLAUDE.md was 103 KB, a one-word prompt cost 26,933 input tokens with context files against 448 without — sixty times the input, on every call, before the node does any work (ADR-085). It stays on by default for parity with claude_code; set ITERION_PI_NO_CONTEXT_FILES=1 to turn it off when a node does not need the repo's instructions.
  • The target repo's .pi/ directory is refused. pi executes project-local extensions as TypeScript inside the agent process — the process holding the run's credentials — so trusting a checked-out repository turns prompt injection into code execution. iterion passes --no-approve. Opt in per node with ITERION_PI_TRUST_PROJECT=1, and only for a repository you control.

  • Skills are passed explicitly. iterion mirrors bundle/plugin/library skills into <workspace>/.claude/skills/, which is not one of pi's own lookup roots, so it hands pi that path via --skill.

  • Sessions live with the run, under the store dir (or the workspace when sandboxed) — never ~/.pi/agent/sessions, so concurrent nodes cannot collide and a pruned run takes its sessions with it.

  • pi retries upstream failures itself — 3 attempts, 2s/4s/8s backoff by default — inside iterion's own retry loop. Two consequences, both observed live:

    • Those attempts are invisible to iterion's rate-limit classifier, which only ever sees the outcome of the last one.
    • The reported cost is short by the discarded attempts. Only the final attempt's transcript survives in pi's agent_end, so a node that made four billed upstream calls reports one call's tokens and cost. iterion logs a WARN naming the retry count when this happens — an unexplained slow node with a suspiciously low cost is the symptom.

    Print mode has no lever to disable this. Pin ITERION_PI_AGENT_DIR to an agent dir whose settings.json sets retry.enabled: false (or a small retry.baseDelayMs) if it matters for your quota accounting.

  • Model patterns are fuzzy-matched, and a near miss is dangerous. pi resolves an unknown pattern against its catalogue rather than failing. Measured: zai/glm-5 resolves to glm-5v-turbo — a vision model, not GLM 5.x. iterion logs a warning whenever the model pi actually used differs from the one requested; watch for it on first use. (A far miss like zai/glm5 is safer: it passes through literally and the provider rejects it with a clean 400, typed as deterministic so no retries are burnt.)

  • session: inherit needs the edge to carry _session_id. Session resume works — but the id travels on the upstream node's output map, and a bare a -> b edge does not forward it, so the node silently runs fresh and the agent "forgets" the previous conversation. iterion now warns; the fix is a -> b with { _session_id: "{{outputs.a._session_id}}" }. This is not pi-specific: it applies to every backend.

💳 Subscription credentials bill to extra usage ​

A Claude Pro/Max OAuth subscription works on pi, and on claw. Anthropic does not reject a third-party app using it — it answers:

Third-party apps now draw from your extra usage, not your plan limits. Add more at claude.ai/settings/usage and keep going.

So the path is supported, but billed against a separate extra-usage balance rather than your plan's limits. That is the one surprising part, so iterion logs a warning naming it on every node that spends a subscription token outside the vendor's own CLI. When the balance is empty the API returns a 400 invalid_request_error whose text mentions nothing about credentials; iterion translates it into a message naming the cause and the ways out, so it does not read like a broken token.

To refuse it instead, set ITERION_FORBID_SUBSCRIPTION_OAUTH=1. Worth doing on a shared or cloud instance, where spending an operator's extra-usage balance is a cost decision taken on behalf of everyone using it. The refusal applies to pi and claw alike; claude_code and codex are unaffected — they spawn the vendor's own CLI, which draws on the plan normally.

For production Anthropic work, a metered ANTHROPIC_API_KEY or a claude_code node remains the predictable choice.

Your own pi login in ~/.pi/agent/auth.json is your relationship with the vendor — nothing is read or injected. github-copilot is untested on this point and iterion injects nothing for it.

openai-codex is the exception, and it is wired. When iterion holds a Codex ChatGPT credential (host ~/.codex/auth.json, or the per-run copy a cloud runner materialises), a pi node targeting that provider gets it seeded into a throwaway PI_CODING_AGENT_DIR — subject to the same ITERION_FORBID_SUBSCRIPTION_OAUTH refusal as the Anthropic path, with a per-node warning when it is used. Verified end to end on a cloud run (2026-07-31): forfait honoured, bash tool call executed in-sandbox, structured output returned.

The gotcha that costs a run: the provider is chosen by the model prefix.model: "openai/gpt-5.4-mini" selects the metered openai provider and spends OPENAI_API_KEY; the forfait path requires model: "openai-codex/gpt-5.4-mini". The bridge silently does nothing for openai/… nodes because they never ask for it — so an exhausted API key reports You have no credits remaining even on an instance whose ChatGPT forfait is connected and working. (ITERION_OPENAI_USE_OAUTH is the claw equivalent of this switch and has no effect on pi.) pi --list-models shows only providers already authenticated on the host, so it will not list openai-codex before a run seeds it.

Environment variables ​

VariableEffect
ITERION_PI_BINThe pi binary on the host (e.g. a bun --compile single-file build on a host with no Node): an absolute path, or a bare name on PATH. A relative path with a separator is refused — it would resolve against the workspace.
ITERION_PI_MODEprint rolls back to the one-shot transport. The default is the long-lived --mode rpc session (tool events, native steering, authoritative accounting, pre-flight handshake).
ITERION_PI_AGENT_DIRPins PI_CODING_AGENT_DIR. Reproducible pi config, but hides the operator's own auth.json — so the OAuth breadth above goes with it.
ITERION_PI_OFFLINE0 re-enables pi's catalogue refresh inside a sandbox (off by default there: an egress policy would stall startup).
ITERION_PI_TRUST_PROJECT1 trusts the target repo's .pi/ resources. See the warning above.

kimi (Moonshot kimi-code) ​

Moonshot's kimi-code takes the prompt as -p <prompt> (kimi --print … errors with unknown option '--print'):

kimi -p <prompt> --output-format {text,stream-json} [-m <alias>]
iter
prompt task:
  Implement the feature described in the issue and run the tests.

agent implement:
  backend: "kimi"
  model: "kimi-code/kimi-for-coding" # complete kimi-code alias is preserved
  system: task
  permission: deny                   # supported on host runs

grok (xAI Grok Build CLI) ​

xAI's Grok Build coding agent (grok on PATH, typically installed under ~/.grok/bin/grok) is headless via -p / --single, not claude-code Session mode:

grok -p <prompt> --output-format json \
     --permission-mode bypassPermissions --always-approve \
     [-m <model>] [--rules <system>] [--reasoning-effort <level>]
iter
prompt task:
  Implement the feature described in the issue and run the tests.

agent implement:
  backend: "grok"
  model: "grok-4.5"          # or grok-4.5-build, grok-3, …
  # model: "xai/grok-4.5"    # also fine — the xai/ prefix is stripped
  system: task               # delivered as --rules (appended to Grok's native prompt)
  reasoning_effort: high     # optional; mapped to --reasoning-effort

The node's system: is passed as --rules (append), never as --system-prompt-override, so Grok keeps its native agentic baseline (tools, plan, posture). Credentials come from the CLI itself (Grok Build login / ~/.grok config) — iterion does not inject XAI_API_KEY for this backend. That is distinct from calling the xAI HTTP API via backend: claw + model: "xai/…".

opencode ​

opencode is a multi-provider agent CLI. Its headless mode is a subcommand, and iterion drives it as:

opencode run --format json [-m <provider/model>[#<variant>]] [--session <id> [--fork=true]]

with the composed prompt on stdin.

The subcommand opens argv: the 2.x parser resolves it before it knows any flag and refuses one that precedes it (Unrecognized flag: --format in command opencode, with its usage text on stdout, measured on 2.2.5). ITERION_OPENCODE_ARGV=1 re-emits the 1.x argv — every flag first, --variant <effort>, run last — for a host still on that line.

What else the 2.x line changed, each re-measured on 2.2.5 against the real CLI (#2915):

  • --variant exists nowhere, at the root or under run: the variant rides the model reference, and an unknown one is now refused explicitly instead of dropped in silence.
  • A failure the CLI encodes in its stream travels on stdout, with an empty stderr ({"type":"error",…,"error":{"type":"provider.no-route", "message":"Model unavailable: …"}}) — which is why the shared CLI-agent path parses the stream on a non-zero exit too, instead of reporting exit status 1.
  • It resolves its project from PWD, not from the process's working directory, so the spawn now sets both: a node whose PWD still named the engine's cwd read the engine's own checkout.
  • run talks to a background service on a port derived from the release channel (49374 on latest), started on demand and reused. A run carrying a BLANK OPENCODE_* variable fails to start it, and two homes fight over the port — which is why the real-CLI tests unset those variables and either pin a free port and stop what they started, or pass run --standalone, which starts a private server for that one run.
  • One text event carries a whole streamed reply (so the parser's concatenation cannot double-count), and an empty stdin is You must provide a message rather than a default prompt.
iter
prompt task:
  Implement the feature described in the issue and run the tests.

agent implement:
  backend: "opencode"
  model: "anthropic/claude-sonnet-4-6"   # opencode's own -m format IS provider/model
  system: task                           # folded into the prompt: opencode has no system flag
  reasoning_effort: high                 # optional; rides the model as `-m provider/model#variant`

What it brings ​

  • Any provider opencode is configured for, selected with the model spec it already understands: -m provider/model is iterion's spec shape, so the value passes through unchanged (no prefix stripping, unlike grok).
  • A provider-computed cost and a real token split, read off the stream's step-finish parts (emitted as step_finish) rather than estimated. opencode reports output excluding reasoning and input excluding the cache halves; iterion adds both back so the figures mean what the rest of the engine means by them.
  • A reasoning-effort dial, carried INSIDE the model reference (-m provider/model#variant; --variant exists nowhere on 2.x, at the root or under run). The accepted variant names are per-model — opencode derives them from each model's own reasoning options — so iterion passes its level through verbatim and lets opencode refuse an unknown one explicitly (Variant unavailable for scripted/m1: nonexistentvariant, exit 1, measured on 2.2.5). xhigh and ultracode collapse onto high before argv. A variant with no model is a hard refusal (Invalid model reference: #high), so an effort on a node that names no model is dropped with a warning naming it, never spelled into argv. A reference that already spells its variant (provider/model#low) is emitted as written — a second # is Invalid model reference — and a reasoning_effort set beside it is dropped with the same kind of warning.
  • Session resume and fork for session: inherit, inherit_if_available and fork. Every event of the stream carries the session id; iterion hands it back as --session <id> (plus --fork=true for a fork, which opencode branches into a new id — spelled with its value because under the 1.x hatch a bare boolean right before run makes the parser take run as that value and print its help), and a schema re-ask continues the session it failed in rather than restarting the turn. Proven against the real opencode 2.2.5 CLI by TestCLILiveOpenCodeSessionResume (task test:clilive): the model is a scripted server on 127.0.0.1, and the authority is what the CLI sends it — a resumed turn's request carries the earlier turn. An id opencode no longer holds is version-dependent, and the test asserts the version it probes: 1.19 exits 1 with Session not found, so the call fails (and inherit_if_available degrades to a fresh session, loudly); 2.2.5 CREATES a session under that id, so there a resume whose transcript is gone succeeds as an empty conversation and only the id the stream reports says so (both measured).

What it does NOT bring ​

The observations below were measured against opencode 1.1.19 unless a bullet names a newer build — the 2.x re-measurements this page carries (2.2.5) say so where they stand, and the fail-open section below names its own build per table. opencode moves fast, so re-measure before trusting a claim against another build.

  • No permission gate, in any mode. opencode exposes no PreToolUse hook, so a node with an armed gate is refused at compile time (C176) and again at dispatch (the typed capability refusal, CAPABILITY_UNSUPPORTED) — never run ungated, never retried. opencode does carry a declarative in-process policy, but the 1.x delivery (OPENCODE_PERMISSION, verified to reach its resolved configuration) is removed from the 2.x line: the 2.2.5 binary carries no occurrence of the variable, and the 2.x env-delivered config is OPENCODE_CONFIG_CONTENT (highest user precedence since upstream #11628). The 2.x mechanism is re-measured by TestCLILiveOpenCodeConfigContentDeny — it holds alone and against a key-positioned file allow, and still fails open beside an agent-level permission, a default_agent, or an invalid policy string. It is deliberately not wired: membership in the compiler's gate table is earned by a live denial, never declared, and no credential has witnessed one. Note also that in a headless run an ask verdict is auto-rejected (and later builds add a --auto flag that auto-allows it instead) — neither is "pause and ask the operator". The policy's own measured limit is the reason the cell is refused rather than merely unwired: it fails OPEN beside an operator config.
  • No async questions. interaction: async is refused by C267; interaction: in its synchronous form is inert, as on every CLI-agent backend, and C271 now says so at compile time.
  • No session: persist. Persist carries a conversation across visits by packing the CLI's session files after each one, and opencode keeps every session in one SQLite database (<XDG data>/opencode/opencode.db, 1.19 and 2.x alike) with no file per session to pack. A session: persist node therefore logs backend "opencode" cannot resume; running fresh and runs fresh; packing would take opencode's own export / import.
  • No MCP forwarding, and tools: does not constrain it. opencode runs its own tool set: the list never reaches the CLI (toolcatalog.ReceivesToolList), so a tools: line on such a node is inert. C270 says so for the one spelling whose inertness inverts its meaning — tools: [], which declares NO tools and here yields opencode's whole toolset. A non-empty list is dropped just as silently, with no diagnostic; bound the node with deny: rules instead, or run it on a backend that receives the list.
  • No command: override. Only claude_code consumes a node's command:; C174 says so rather than letting it look honoured.
  • No provider: hint. opencode resolves its own credentials from its own auth store, so iterion's credential-routing hint has nowhere to land (C088).

⚠️ OPENCODE_PERMISSION is not a gate on its own ​

Measured on 1.19.0 (the bullets above are 1.1.19): the real CLI, driven by a scripted local model whose one tool call is bash with touch permission-sentinel. The authority is the sentinel on disk, never model prose. A scripted model costs nothing and exercises opencode's real policy engine — it is NOT the live denial gate membership asks for, which is a real model's real tool call.

operator config (global opencode.json)OPENCODE_PERMISSIONsentinelreading
nonenonecreatedcontrol: the bench does execute, so the rows below are not vacuous
none{"bash":"deny"}absentthe tool is withheld from the model altogether
none{"bash":{"*":"allow","touch *":"deny"}}absentdenied at the call
none{"bash":"deny",} (invalid JSON)createdfail-OPEN, silent: nothing on stderr
agent.build.permission.bash=allowdeny at the callcreatedfail-OPEN: an agent-level permission merges after the top-level one
permission:{"*":"ask","bash":"allow"}{"*":"deny"}createdfail-OPEN: the merge keeps key POSITION, and the last match wins
permission:{"bash":"allow","*":"ask"}{"*":"deny"}absentthe same content with the two keys swapped — the verdict depends on the operator's key order
default_agent: "wide", wide.permission.bash=allowdeny at the callcreatedfail-OPEN
the three fail-OPEN configs together, plus an agent iterion defines and selects with --agent—absentthe dedicated agent merges last, so it wins
the same, with an agent name the config does not define—createdfail-OPEN: stderr says it falls back to the default agent and the exit code is 0, and the JSON stream never names the agent it used

Four configurations therefore turn a deny into an allow, and each one is silent where it counts: three are the OPERATOR's (an agent-level permission, a permission key placed after *, a default_agent) and the fourth is a policy string iterion itself could emit invalid. So the env variable alone is not a gate, and wiring it is a doctrine question before it is a patch (iterion#1640). The doctrine iterion already holds answers it: the config it constrains has to be its own, which is the refusal shape of the section below — one question per directory level, a greppable env opt-in, never an enumeration of spellings.

Beyond that refusal and the live denial, a flip owes: a policy marshalled so it cannot be invalid; a dedicated agent plus POSITIVE confirmation that the agent loaded, since the fallback to the default one is silent in the stream; ask still refused; and the list → ls row of pkg/backend/toolcatalog/canonical.go, which lands with the flip and never before — today it would grant a rule reach for a policy that never runs. A translation also inherits opencode's own pattern semantics, read from the 1.19.0 source: * becomes .* and ? becomes . where iterion treats ? as a literal, a trailing " *" becomes ( .*)?, \ becomes /, nothing escapes, integer-looking JSON keys are reordered to the front (which moves precedence under a last-match lookup), write/edit/apply_patch share the single edit key, sub-agents (task) run under THEIR agent's permission, and a bash command is split into sub-commands (tree-sitter) and judged one by one where iterion judges the whole line as one candidate.

Re-measured on 2.2.5 (TestCLILiveOpenCodeConfigContentDeny, scripted model, 0 USD — the delivery host now runs the 2.x line, where OPENCODE_PERMISSION no longer exists; the policy travels in OPENCODE_CONFIG_CONTENT, and the probe drives the CLI directly: it measures opencode's policy engine, which iterion does not wire, not iterion's argv):

probe (2.2.5)sentinelreading
no policy anywherecreatedcontrol: the bench executes, the rows below are not vacuous
OPENCODE_CONFIG_CONTENT={"permission":{"bash":{"*":"allow","touch *":"deny"}}}absentdenied AT THE CALL (Permission denied: shell — shell is the 2.x internal name; the bash key holds through a compatibility mapping), the denial is fed back to the model, the turn finishes, exit 0
the same with the 2.x-native shell keyabsentthe native key holds too — a translation may target either spelling
{"permission":{"bash":"deny"}} (whole tool)absentthe tool is WITHHELD from the model, as on 1.19 (a forced call errors No tool named "shell" is currently available — bash is translated to the 2.x-internal shell before evaluation), exit 0
invalid OPENCODE_CONFIG_CONTENT ({"permission":{"bash":"deny",})createdfail-OPEN, silent, still: dropped without a word, exit 0 — a policy iterion emits must be marshalled, never string-built
file permission:{"*":"ask","bash":{…allow}} + env denyabsentthe v1 key-position fail-open is CLOSED by the env delivery: OPENCODE_CONFIG_CONTENT wins over the file (the #11628 precedence fix, observed)
file agent.build.permission.bash=allow + env denycreatedfail-OPEN persists: an agent-level permission merges after the env content
file default_agent:"wide" with wide.permission.bash=allow + env denycreatedfail-OPEN persists

So on the 2.x line the env delivery closes exactly one of the four v1 holes (key position) and the other three stand. The flip's owed list is unchanged in shape — a dedicated iterion-defined agent selected with --agent (the last row of the 1.19 table held on 1.x) plus positive confirmation the agent loaded, a marshalled policy, ask refused, the list → ls canonical row — and now carries one 2.x addition: re-measure the dedicated-agent row on 2.x.

⚠️ The target repository runs as code ​

opencode loads project resources from every .opencode/ directory at or above the working directory, and from an opencode.json (or .jsonc) at any of those levels — and it EXECUTES what it finds, inside the process holding the run's credentials: plugin/*.ts and tool/*.ts are imported as modules, and a package.json there is installed with its lifecycle scripts. No config entry and no flag are needed. Against a checked-out branch you do not control, that turns prompt injection into code execution.

iterion therefore refuses a run whose checkout carries opencode resources at any of those levels. The question asked per level is "is there a .opencode/ here at all, or an opencode.json" rather than a list of known code paths — a guard that enumerates spellings gains one more every release:

delegate: opencode: /src/myrepo carries .opencode/, which opencode loads and
executes inside the agent process; refusing to run against an untrusted
checkout (set ITERION_OPENCODE_TRUST_PROJECT=1 for a repository you own)

The walk stops at the operator's HOME or the filesystem root — never at a git worktree root. 1.1.19 stopped at a .git and iterion mirrored it; 2.2.5 climbs straight past one and loads the ancestor's plugin (measured by the live screen bench). A host carries one line or the other, so the walk takes the union of their two reaches: a stop predicate stronger than the CLI's is a hole. A resource the operator keeps ABOVE a checkout is therefore refused too — lift it with ITERION_OPENCODE_TRUST_PROJECT=1, which is the cheaper side of the trade.

This is a refusal rather than a flag because opencode offers no way to turn the discovery off: OPENCODE_DISABLE_PROJECT_CONFIG, OPENCODE_PURE and OPENCODE_DISABLE_DEFAULT_PLUGINS were each measured on 1.1.19 to leave the plugin running.

Separately, opencode's Claude-Code compatibility layer reads the operator's ~/.claude/CLAUDE.md into its system prompt and loads .claude skills — which would make a node's effective prompt depend on a directory iterion does not compose, and which aborts the whole CLI on a single malformed skill file. iterion defaults OPENCODE_DISABLE_CLAUDE_CODE=1 on every opencode invocation (a run's own env: still wins on that key), the same posture pi takes with --no-prompt-templates --no-themes.

Behaviour worth knowing ​

  • Not in the stock sandbox image. The published image bakes claude-code, pi and codex only (and codex outside the sandbox's PATH); a sandboxed opencode node on a published image is refused at start rather than dying at exec: not found, and an operator image that ships the binary passes — the refusal is on the image's measured contents, not on the name.
  • Its stderr is a JavaScript stack trace. The shared CLI-agent retry classifier reads only the CLI's OWN transport-level error lines out of that dump — short, unindented error headers and Node's [cause]: chains. Stack frames, application error prose and quoted source are not evidence; quoted source shows up indented in Bun's codeframes but Node echoes the throwing source line at column 0, so a line-length cap excludes minified-bundle echoes. Not airtight: a SHORT column-0 source echo, or an app's own error header naming an errno, still matches — indistinguishable from the genuine article for any text classifier, and the cost is a bounded, backed-off retry. A JSON Parse error: Unexpected EOF from a malformed JSON file it reads once matched the unexpected eof signature and burned all three retries on a deterministic configuration failure; that class of false positive is now excluded by construction (matchesTransportStderr).
  • The prompt goes on stdin, never in argv. opencode takes its message as a variadic positional, which silently swallows a prompt beginning with - (it prints its help text instead of running) — an iterion prompt routinely opens on a markdown bullet. Stdin also sidesteps MAX_ARG_STRLEN. opencode reads non-TTY stdin to EOF, and blocks forever on a stdin left open — which is exactly why iterion hands it a reader over the composed prompt that EOFs immediately after it, and never an open pipe.

Environment variables ​

VariableEffect
ITERION_OPENCODE_TRUST_PROJECT1 trusts the target repository's .opencode/ resources. Read from the process environment, so on a shared server it lifts the refusal for every concurrent run, not just yours. See the warning above.
ITERION_OPENCODE_BINThe opencode CLI on the HOST, for a host whose PATH the iterion process does not share: an absolute path, or a bare name on PATH. A relative path with a separator is refused — it would resolve against the workspace and run a binary out of the checkout. Detection and execution apply the same rule. Ignored inside a sandbox, where a host path means nothing.

Behavioural notes (generic Kimi/Grok delegates) ​

  • Explicit opt-in. kimi / grok are never auto-detected; set backend: "kimi" or backend: "grok". No host credential silently re-targets a run away from claude_code / claw.
  • Credentials are resolved by the CLI itself from its own env/config (e.g. $MOONSHOT_API_KEY for kimi, Grok Build OAuth for grok) — iterion inherits the host environment and does not fight the CLI's native resolution.
  • Permission gate. These CLIs still run their own built-in toolset and a node's tools: list remains advisory. For permission: deny, iterion points the CLI at a per-invocation shadow home (KIMI_CODE_HOME / GROK_HOME) that links the real credentials but adds an iterion PreToolUse hook; the operator's home is untouched, and that shadow home is created outside the workspace so a repo-scoped write rule cannot reach the gate's own registration. The policy itself rides base64-encoded in the hook argv the CLI freezes at session start, never as a re-read file. Both denial paths are live-proven — a real model's real tool call blocked, with a filesystem sentinel rather than model prose as the oracle (e2e/live_feat_permission_{kimi,grok}_test.go) — and both are admitted by C176 for deny. Grok keeps its --permission-mode bypassPermissions --always-approve flags: grok's own authorization pipeline runs PreToolUse hooks first, before always-approve short-circuits it. External hooks cannot pause the parent run, so ask (including explicit ask: rules under deny) is refused. Guarded sandbox runs are also refused because neither CLI currently carries its home and hook binary into the container — so a gated node needs an unsandboxed run: sandbox: none on the workflow, or --sandbox none (a node-level sandbox: is not honoured; the shipped default is auto, and C136 warns at compile time rather than letting the run die at the agent node). Windows is refused: the hook command is POSIX-quoted and a spawn failure is an ALLOW.
  • Effort: kimi has no dial (ignored); grok maps reasoning_effort to --reasoning-effort (ultracode degrades to high).
  • Sessions are captured for observability (sessionId) but resume/fork is not yet wired for CLI-agent backends.

Editor UX ​

The studio calls GET /api/backends/detect at mount time. The status pill in the top-left of the toolbar shows:

  • 🟢 Green + auto-resolved backend name when at least one credential is detected.
  • 🔴 Red "no creds" when nothing is detected. The Run button is disabled in this state.
  • Click the pill for a per-backend breakdown, sources, hints, and a link back to this page.

The detection result is cached server-side for 30 seconds. Click Refresh in the popover to re-probe after fixing your env.

Workflow-level pinning ​

To pin a backend across an entire workflow (e.g. force claude_code even when OAuth is missing, expecting CI to inject it later):

iter
workflow review:
  entry: reviewer
  default_backend: "claude_code"   # every node without its own backend: uses it
  reviewer -> done

agent reviewer:
  # inherits backend: claude_code
  model: "anthropic/claude-sonnet-4-6"

Per-node overrides take precedence:

iter
agent reviewer:
  backend: "claw"
  model: "anthropic/claude-haiku-4-5-20251001"

Using a non-Anthropic provider via the Anthropic wire format (z.ai / GLM) ​

Some providers ship an Anthropic-compatible HTTP endpoint so existing Claude Code clients can talk to them with zero code change. The most common case today is z.ai's Coding Plan, which serves GLM-4.5 / GLM-4.6 through https://api.z.ai/api/anthropic (or whatever endpoint your z.ai dashboard lists — confirm there). Anthropic itself encourages this kind of integration for partner providers; z.ai's own docs describe the Claude Code wiring.

Iterion-desktop sources ~/.iterion/env at startup (commit 84a7fc2) and claudesdk/process.go forwards the entire host env to the spawned Claude Code subprocess. There are two equivalent ways to wire it.

Shortcut: ZAI_API_KEY alone ​

Recommended path — drop a single line in ~/.iterion/env:

bash
# ~/.iterion/env
ZAI_API_KEY=<bearer token from your z.ai dashboard>

When iterion sees ZAI_API_KEY set AND no ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN / CLAUDE_CODE_USE_BEDROCK / _VERTEX / _FOUNDRY set, it automatically configures ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic and ANTHROPIC_AUTH_TOKEN=$ZAI_API_KEY for both the spawned Claude Code subprocess (backend: claude_code) and the in-process claw provider factory (backend: claw). Restart iterion-desktop after editing the file so the launcher re-sources it.

If ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN, or a cloud-provider switch) is also set, that takes precedence — the shortcut is intentionally "auto-route only when no Anthropic auth is configured". This lets a user keep a fallback Anthropic key for some workflows without losing the z.ai default.

Moonshot: provider: moonshot (the Kimi family) ​

Moonshot publishes the same kind of Anthropic-compatible endpoint, https://api.moonshot.ai/anthropic (the api.moonshot.cn gateway answers the same wire). It is a first-class provider: provider: "moonshot" on a claude_code or pi node, moonshot/kimi-k2 as a claw model spec, and iterion api-keys create --provider moonshot for a team-scoped BYOK key.

Two differences from the z.ai wiring above, both deliberate:

  • The base-URL override is MOONSHOT_BASE_URL, not ANTHROPIC_BASE_URL. The latter is z.ai's own documented knob, so on a host configured for z.ai it already holds z.ai's endpoint — honouring it for a Moonshot key would send that credential to another vendor's gateway.
  • There is no "MOONSHOT_API_KEY alone" shortcut. That variable is already how backend: "kimi" feeds Moonshot's own CLI, so an ambient value means "the kimi CLI is configured", not "route every Anthropic-wire node to Moonshot". Under an explicit provider: moonshot hint it IS honoured; a BYOK key is the way to route unpinned nodes.

With the hint set and no key reachable — neither BYOK nor MOONSHOT_API_KEY — the node is refused by name. It does not fall back to Anthropic: that would be another account and another bill, silently.

Explicit form: ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN ​

For full control, set the two env vars directly:

bash
# ~/.iterion/env
ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic
ANTHROPIC_AUTH_TOKEN=<bearer token from your z.ai dashboard>
# Leave ANTHROPIC_API_KEY UNSET — if both are present, Claude Code
# prefers the API key and routes back to Anthropic, defeating the
# purpose.

Workflows then run unchanged: backend: claude_code still selects the same delegate, but the network destination is z.ai and the underlying model is GLM. Model strings stay Anthropic-shaped (claude-opus-4-7, …); z.ai's gateway maps them to its own GLM families internally.

Important caveats

  • This only works when Anthropic's wire-format aliasing exists at the provider side. If you're pointing at OpenRouter, Ollama, or another OpenAI-shaped endpoint, use backend: claw with model: openai/…
    • OPENAI_BASE_URL instead.
  • API keys only — no forfait via iterion. Both Anthropic's Consumer Terms (Pro/Max plans) and z.ai's Coding Plan terms restrict subscription benefits to officially supported tools. Driving either provider's subscription/OAuth forfait through iterion (or any other third-party orchestrator) is a ToS violation. Always use a BYOK API key path: ANTHROPIC_API_KEY, ZAI_API_KEY, or the BYOK panel in the cloud UI. The legacy in-cloud OAuth-forfait wiring (pkg/server/oauth_routes.go::OAuthKindClaudeCode) is scheduled for removal — see .plans/zai-glm-byok.md.
  • Cost: iterion's token-usage panels currently price against an Anthropic rate card. When you route to z.ai the wire shape is unchanged so token counts are still reported, but the dollar estimates are not accurate until a per-provider rate card lands.

Client identity (User-Agent + custom headers) — claw backend ​

Every claw provider (anthropic, openai, vertex, foundry) sends an honest User-Agent: claw-code-go/<version> by default. Before 2026-07 no UA was set at all, so Go's Go-http-client/2.0 leaked onto the wire — the worst possible fingerprint against endpoints that gate service on the calling tool (z.ai's Coding Plan risk-control flags "SDK-based access" traffic; repeated violations can suspend the account). Two exceptions: the ChatGPT-OAuth path keeps its protocol-required codex_cli_rs/<codex version> identity, and bedrock requests carry the aws-sdk UA (SigV4-signed).

Override precedence (first non-empty wins), identical on the in-process claw path and the sandboxed __claw-runner (the three env vars below are forwarded into the container):

  1. ITERION_LLM_USER_AGENT — iterion surface, injected into every claw provider factory (pkg/backend/model/registry.gowithClientIdentity, which reads the chain here by name).
  2. CLAW_USER_AGENT — claw's own env override (works for any claw-code-go embedder).
  3. The per-path default (claw-code-go/<version>, or codex_cli_rs/<version> in ChatGPT-OAuth mode).

For arbitrary headers, claw honours ANTHROPIC_CUSTOM_HEADERS with Claude Code's exact semantics: newline-separated Name: Value pairs, applied last on every request so they can override any default header — including the User-Agent. The channel serves a sandboxed node (the runner reads the forwarded block); an in-process node on the host ignores it — the engine resolves its identity itself (below):

bash
# ~/.iterion/env — identify as a specific tool against a gated endpoint
ITERION_LLM_USER_AGENT="my-tool/1.0"
# arbitrary headers for a sandboxed node, Claude Code style (overrides UA too):
ANTHROPIC_CUSTOM_HEADERS="User-Agent: my-tool/1.0
X-My-Header: value"

A malformed ANTHROPIC_CUSTOM_HEADERS line (no Name: part) fails the request with an explicit parse error rather than being silently dropped.

A credential is not a client identity. The block is applied last, so a line named Authorization, x-api-key or api-key replaces the credential the engine resolved for the node — at whichever vendor's gateway that node was routed to. On the claude_code delegate a funded facade route therefore clears the variable outright (#1476), and the claw sandbox crossing refuses those lines by name (#2244): the in-container runner receives the block without them, with every other line intact, and the launcher logs which line it would not carry (never its value). Put such a credential in the route's own variable — ANTHROPIC_AUTH_TOKEN for a bearer, ANTHROPIC_API_KEY for a key — which crosses as the node's real credential and is accounted for as one.

The variable also crosses explicitly, empty included: the in-container runner auto-loads the reviewed workspace's .env, which fills only names the process environment leaves unset, so an absent name would let the repository under review write the headers of every request the node sends. The host environment is the supported channel for it; a value set through the sandbox spec or a repository's devcontainer.json does not reach the runner.

An in-process (non-sandboxed) claw node no longer reads the variable at all: the engine resolves the identity itself and builds every client with claw's NoAmbientHeaders, so an ambient block — the operator's shell, or a planted .env — cannot authenticate or re-identify a request the engine routed (#2244). The sandboxed __claw-runner is the channel's one reader: its environment is the crossing's own forwarded env, already filtered. That crossing is also the only channel an engine-routed node has for an arbitrary identity header (x-tenant and friends): an in-process node carries no ambient block, the route's credential carries the one credential the provider's contract defines (bearer or key — never an arbitrary header name), and ITERION_LLM_USER_AGENT carries only the user-agent leg. A node that must present additional identity headers runs sandboxed; an in-process one has no channel for them today.

ToS caveat. Presenting as another tool is a decision between you and the endpoint you target (e.g. your z.ai subscription's supported- tools policy) — iterion/claw default to the honest identity and never spoof on their own. It changes nothing about the Anthropic subscription path above: that works on its own merits (billed to extra usage), and no User-Agent you configure affects how it is billed.

Troubleshooting ​

SymptomLikely causeFix
Pill is redNo credential detectedSet ANTHROPIC_API_KEY or sign in to Claude Code, then click Refresh
Pill is green but Run errors out with "no provider"Workflow uses model: openai/... but only ANTHROPIC_API_KEY is setSwitch model to an Anthropic spec, or add OPENAI_API_KEY
Pill says "claude_code" but you wanted "claw"OAuth is found and ranked firstexport ITERION_BACKEND_PREFERENCE='claw,claude_code'
Pill says "claw" but you wanted CodexCodex is an explicit-only backend and absent from the default orderSelect backend: codex explicitly (or add it to ITERION_BACKEND_PREFERENCE) and ensure $CODEX_HOME/auth.json exists
Editor pill stale after fixing envServer cache (30s)Click the pill → Refresh