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

🧩 The .bot DSL ​

Agent workflows, as code. Define readable, versioned workflows in a declarative, indentation-significant language.

Source files end in .bot; deterministic bundles end in .botz.

This page is the language guide; what's new in the DSL is the tour of the 2026-09 authoring-first programme — the dsl: 2 profile, import, the dry run, fmt/fix, the contract, the skill — with what it measured. For exact accepted syntax use the readable grammar, the property reference (every kind's properties, generated from the parser's own registry), the formal EBNF, and the diagnostic catalogue. The parser, IR compiler, and validators under pkg/dsl/ remain the implementation source of truth; an unknown property (E012) names the closest accepted one and the block it belongs to, from that same registry.

A .bot file travels through a fixed pipeline before it runs:

File shape ​

A file may contain these top-level declarations:

text
import "lib/<file>.bot"   (at the head, after dsl:, before every declaration)
vars, presets, attachments, secrets, mcp_server,
prompt, schema, cursor, supervisor,
agent, judge, router, human, tool, compute, emit, wait, await_answers, subbot,
group, use, workflow

The syntax profile. A file may open with dsl: 2 — its first significant line, after blank lines and comments (the ## --- frontmatter included). Absent means profile 1: the grammar at the 1.0 cut, frozen. The header governs changes of MEANING only, and profile 2 carries four: a "…" string reads the standard escapes (\" \\ \n \t \r \0) with no directive, where profile 1 keeps every backslash verbatim; a blank line inside a prompt body is kept as a paragraph break, where profile 1 drops it; the profile-1 ## strict-escape: on directive is refused (E042), as is the retired project_root: (E043). Everything else on this page reads the same in both profiles. New files start with the header (bots create and the studio write it); an existing file moves with iterion dsl migrate --to 2 <file|bundle>, which re-spells the literals so their values do not change, names the prompts whose paragraphs will now reach the model, raises the bundle's requires.iterion to the build that reads the profile, and leaves every other byte alone. iterion validate says when a headerless file is one profile 2 would read otherwise (C144); a bundle written in profile 2 with no requires.iterion draws C252 and is refused at push. A header this build does not read is E040; one below a declaration — or below an import — is E041: the lexer took its profile off the first significant line, and read the whole file as profile 1.

Names and keys are unique. A node id is unique across every node kind (C041 — emit, wait and await_answers included), a prompt, a schema, a cursor or a group among its own kind, and a key of vars:, presets:, attachments: or secrets: appears once in its block (E010) — in one file as across two: a duplicate key used to shadow the other in silence.

A bot in several files. A file may open — after dsl: and the comments, before its first declaration — with import "lib/<file>.bot" lines, one per fragment. A path is relative to the file that imports it and must resolve under the bot's lib/ directory, next to the main (no absolute path, no .., no symlink); a fragment may import its siblings by bare name, and a file is read once however many files import it. The unit — the main and every fragment its imports reach — compiles as ONE program: the declarations of every kind are appended in the order the files are reached, vars:/presets:/attachments:/secrets: merge by key, and a name declared in two files, or a key declared twice anywhere, is refused naming both places (E010); a fragment holds no workflow, and two workflows in a unit are refused too. Each file keeps its own dsl: header (and its own C144). An import after a declaration is E044, a path outside lib/ or malformed E045, a fragment missing or unreadable E046, a cycle E047; a main loaded from the filesystem's own root keeps its absolute names in client-boundary answers, with a warning saying why — no name cut is safe there (E048); a file that imports, compiled alone (a document, an upload), is refused by name (C030). See import under reuse for what every surface does with the unit.

How much a file may hold. A source is read within four bounds: 10 MiB of text, past which the file is refused whole (E001); 100 levels of indentation, past which the deeper line is refused (E003); 1024 runes for one identifier, past which the name is refused where it is scanned (E057) — a name rides whole every diagnostic that names its container, so the cap is lexical, like the size cap, and ITERION_DSL_READ_BUDGET does not move it; and a read budget of 2^20 records, which the files of a bot share — every word, value, punctuation mark, prompt line (a blank one included), line end, indentation step and the end of each file is one, a comment two, for what keeping it and writing it back costs. The lexer spends the budget while it reads, so a bot past it is refused at the record it ran out on (E001), in whichever of its files, before the work it asks for — the file is refused whole, and the fragments past it are not read; at the density of code, some 7 bytes a record, the budget binds before the size cap, from about 7 MiB. ITERION_DSL_READ_BUDGET moves it wherever a bot is read, 0 removing it (environment variables). An expression — a compute field's, a quoted when's, a loop cap's, a fallback when: — is read on a budget of the same count, the expressions of a bot together: every token (a name, a value, an operator, a bracket, a punctuation mark) is one record, the end of the expression one, a group's expressions once per use on the text the use expands them to, a loop cap once per edge that carries it, and one past the count is refused where it ran out, before the tree it would have built (C320); the expression lives inside one string, which the lexer counts as a single record. A bot the budget cuts short is read up to where it ran out and no further — the fragments past it are not read at all — and no reader takes that reading for the bot: the parser and the unit loader answer it as a refusal, positioned and naming the variable that moves the budget, so a save, a launch, a floor check (C256, which the push refuses) or a route decided from it refuses with that cause; only the studio's opens show what was read, as a salvage that names the cause and the remedy, its document marked as one: no save, apply or render accepts it — what was not read would be written back without its declarations. The mark is the document's top-level salvage key (reserved: a document carrying any case-fold of it there decodes nowhere, and a refused open — a file past the budget or the size cap, one that is not UTF-8 — issues no digest beside it, so no save of that file can present one; the digest a whole read issues is what its save carries, and a mismatch is a conflict naming what changed). The YAML twin is held to the same 10 MiB and 100 levels, and to the budget twice, each refusing the document: its reading spends the budget as the .bot's does, a value one record and a comment line two, counted on what it decodes (E050) — before the decode runs, the values its : , ? - [ and { could open, and the comments its # could hang on them — two each, a comment the decode keeps costing more than a value — counted outside the text of its | and > blocks (a quoted or plain value's text counts), may reach twice the budget, which bounds what the decode costs; then the .bot it spells is read on the budget as any is (E001, with the document's remedy) — a list element spells as two records, a prompt as one a line, spent before its lines are split — and the fragments it imports share what that reading leaves. A frontmatter block is held to the budget by the values its decode can build — every indicator counted, a | block's text included — not by the two records each of its lines spends: one past it is not read, and the catalogue lists its bot without the identity it declares and says why. A file past the size cap reads in parts: each fragment a bot imports is held to the 10 MiB and the 100 levels on its own; the read budget stays the bot's, so splitting a bot is no way past it — raising the count is. However many mistakes a file holds, each of its readers — the parser, the unit loader of its own, the twin's — reports at most 1000 of each code and 2000 of each severity, every code's first kept, the last one reported of a code saying how many more it did not; the count a surface shows is the reading's, and ITERION_DSL_READ_BUDGET=0 lifts these caps with the budget. The compiler reports every diagnostic — one a prompt's several readers share is reported once, the readers named in it, so R references read by N nodes draw O(R) findings rather than R × N — and its callers compare its whole list.

Declarations may appear in any order subject to validation. A prompt, schema, mcp_server, cursor, supervisor, group or workflow header with no indented body — followed by a blank line and another declaration, or by the end of the file — declares an empty one (the studio saves a declaration the moment it is created); a body at the wrong indentation, or a comment alone under the header, is still the indentation error, and node declarations keep needing a body. An empty schema referenced by a node draws C140; an empty supervisor is not armed (C191); an empty workflow draws the compiler's own diagnostics (no entry first); a use of an empty group draws C141. A block header — vars:, budget:, memory:, mcp:, auth:, cursors:, recovery:, compaction:, resources:, presets:, attachments:, secrets:, sandbox: and its build:/network: — may stand bare the same way and declares an empty block, kept as the author wrote it: a nested one ends at its parent's dedent or before a blank line and a sibling; a top-level one, having no dedent to end it, needs the blank line (or the end of the file). What an empty block means is the compiler's call: an empty mcp: wires nothing (a tool name it cannot resolve stays the error it was), a bare sandbox: is the inline block form, which C044 refuses until it carries an image: or build:. Two things do not follow the rule: fallbacks: must name at least one route (a bare header is refused, by name — a chain with no route is not a chain, and a route with no name has no written form either: the studio refuses it at save), and a property spelled the same in a block and in its parent (user: in a sandbox and on an agent), written at the parent's level after a blank line, is the parent's — the blank line is the author's signal, as for declarations. # starts a comment that runs to the end of the line (## is the same comment; both forms are accepted everywhere except inside a string, a prompt body or a | block scalar, where a # is text). A comment keeps the place it was written at through a rewrite — a studio save, iterion fmt: it is carried by the declaration it was written around, above the line it leads, and written back there, ## being the canonical form. The indentation written INSIDE a comment is part of its text, and a blank line between two ## lines is kept as the paragraph break it is. A comment whose line the writer does not emit — a property equal to its default, an empty list — is written at the end of the block it was in rather than dropped. Two places have no declaration to hold them and land at the file's head: a comment at the end of the dsl: header's own line, and one above the file's FIRST declaration separated from it by a blank line — there the file's own comments and the first declaration's are written one after the other, and that blank line is what tells them apart. Values accept quoted strings, backtick-delimited raw strings, | block scalars, and one plain bare word (backend: claw) where the grammar expects a string; a value that is not one word (20m, gpt-5.5) keeps its quotes. A word from a fixed list (session: fresh, await: wait_all, mode: fan_out_each, transport: stdio, interaction: human) reads quoted as well as bare, and a word outside the list is refused either way. A list is written inline (tools: [bash, grep]) or as one - item per line indented under the property, comment lines allowed between items; both read as the same list, and [] is the empty list's only form. A with { n: 3, ok: true } map reads a number or a bool as the string it spells.

Inputs and reusable values ​

Variables and presets ​

iter
vars:
  project: string
  mode: string [enum: "autonomous", "interview"] = "autonomous"
  agent: string [matching: "^[A-Za-z0-9._][A-Za-z0-9._-]*$|^$"] = ""
  max_retries: int = 3
  verbose: bool = false
  threshold: float = 0.8
  config: json = "{\"key\":\"value\"}"
  tags: string[] = "[\"security\",\"performance\"]"

presets:
  quick:
    max_retries: 1
    verbose: true

Supported types are string, bool, int, float, json, and string[]. Runtime precedence is --var over --preset, recipe values, and declaration defaults. See recipes.

A var's text has one reading, whether it is the default written in the .bot or a value supplied by --var, a launch payload or a preset: the ${VAR} / ${VAR:-default} forms are expanded first, then the text is narrowed to the declared type. tags: string[] = "${LIST:-a,b}" and --var 'tags=${LIST:-a,b}' therefore start the run with the same ["a", "b"]. A json var expands only the braced forms — a document's $ is data ({"cost":"$5"} is five dollars, {"awk":"{print $1}"} is a program) while ${PROJECT_DIR} still resolves. Every other type keeps the full reading, bare $NAME included. A string[] accepts either the JSON array form or comma-separated text; a json value that is not JSON stays the text the author wrote.

Nesting in ${A:-${B:-c}} is bounded: 32 levels resolve, and past that the segment is emitted as written, ${ included, rather than silently resolving to nothing — a var's text is not always the author's, and a launch payload carries whatever a caller sent.

A site written against the text of a list-typed default reads something else under that reading, and says nothing: length(vars.tags) counted characters and counts elements, TAGS={{vars.tags}} in a command: keeps the first element and runs the rest as a command, a script: receives a list where it received a quoted string. C155 names every expression, shell body and script body in that position.

Constraints — [enum: ...] and [matching: "<re>"] ​

A string var can narrow what it accepts beyond "a string". Both constraints are string-only (C125 / C160), a var may carry at most one of each — in either order, and a repeated bracket is refused rather than merged — and both are enforced independently:

  • [enum: "a", "b"] — the value is one of a declared set.
  • [matching: "<re>"] — the value satisfies a Go RE2 pattern (no backreferences, no lookaround; C162 refuses one that does not compile). The pattern is a quoted, non-empty string: RE2 matches by search, so "" would be found in every value and constrain nothing — write "^$" for "the empty string only".

Where each is checked. A literal default is checked at compile time (C126 / C161) on the text as written — a default is never re-checked later. A default carrying an env reference expands at run start, not at compile time, so the verbatim comparison could not judge it: the compiler expands what has an answer with no environment (the ${VAR:-default} forms) and warns instead — C181 when a reference stays unresolvable, which includes the engine-supplied names (${PROJECT_DIR} and kin: the RUN answers those itself, so no compile-time reading may) — the constraint is then checked on no path for that default: named, never silently excused — and C182 when the compile-time reading violates (it is still only the value a launch with nothing set starts with, and the operator's env is an explicit choice). Only text whose $ is no reference at all stays on the literal path. :- is the only operator the expander has, so ${VAR:+alt} and ${VAR-default} look up a variable literally named VAR:+alt / VAR-default (the run's lookup filters no name), and a computed name (${${SEL}}, ${PRE${X}}) resolves to whatever the launch environment makes of it: both are C181. An operator-supplied value (--var, an HTTP payload, a dispatcher's bot_args, a preset overlay) is refused at launch, before a worktree or a sandbox is created, naming the var, the offending value and what it failed. Launch values are judged after the same ${...} expansion the run applies, so the gate and the run never read a reference differently.

An in-source presets: entry that sets a var to a value its constraint refuses is warned about at iterion validate (C165) rather than left for the run that happens to select it. A warning and not a refusal, deliberately: only a run that selects that preset is affected, and that run is refused at the launch gate — so an error would block a run selecting another preset, or none, and would strand a paused run whose declaration was tightened after it started. The warning stops at naming what it can see: whether the run is really refused also depends on the launch (a --var override supersedes the preset, and an engine-resolved var such as review_mode is overwritten before the gate reads it).

Two limits to that warning, both because the compiler is not the run. A preset value the run's expander rewrites (${VAR}, ${VAR:-x}, $NAME) is left to the gate, which sees it expanded — the compile-time environment is not the launch environment. Note the asymmetry with a default, which is the same text read for a different purpose: = "${A:-fast}" on a constrained var is refused, because the launch gate never re-checks a default — it judges the values a launch supplies — so a default excused at compile time would be checked on no path at all. The same text as a preset value is re-judged there, so it is left to the gate. (Both are expanded at run time; it is the checking that differs.) And a bundle's file-based preset (presets/<name>.md) is merged after compilation, so it too is the gate's to judge.

A resume does not re-check stored values: a run admitted at launch stays resumable when its declaration is tightened afterwards.

Anchoring. Go matches by search, not by full match, so [a-z]+ also accepts --flag and codeX --some-flag. Anchor the pattern with ^...$, anchoring every branch of an alternation (^a$|^b$); C163 warns when it is not. (?m) counts as unanchored: under the m flag the anchors become LINE anchors and a value carrying a newline passes a pattern that reads as if it could not.

Writing the pattern. It is an ordinary quoted string, so under dsl: 2 a backslash follows the profile-2 escape rules: write "^\\d+$", or use a backtick raw string — `^\d+$` — which iterion fmt normalises to the escaped form.

Checking at the keyboard. iterion run refuses a bad value outright. Under the dry run the refusal is a pass that dies, which bare iterion validate --exec reports without failing — use iterion validate --exec --strict for a non-zero exit.

A workflow may declare an additional vars: block. Top-level and workflow variables are merged during compilation, and a workflow-level entry that re-uses a top-level name replaces it — so a redeclaration that carries no constraint would drop one the earlier declaration made. That is refused (C164): constrain the var on the redeclaration too (with either constraint), or on neither.

Attachments ​

iter
attachments:
  specification: file
    description: "Product specification"
    accept_mime: ["application/pdf", "text/markdown"]
    required: true
  mockup: image

Attachments are uploaded/persisted inputs, not scalar vars. They are available as {{attachments.specification}}, .path, .url, .mime, .size, and .sha256. A workflow may also carry an attachments: block. See attachments.

Secrets ​

iter
secrets:
  forge_token: "${FORGE_TOKEN}"
  deploy_key:
    value: "${DEPLOY_KEY}"
    as: file
    mount_path: "/run/iterion/secrets/deploy_key"
    env: "GIT_SSH_KEY_PATH"
    hosts: ["github.com", "api.github.com"]
    sinks: [file, prompt]
    optional: false
    description: "SSH key used by the deploy step"

Value secrets render as opaque placeholders and are materialised only at execution sinks. File secrets render as their mounted path. sinks: names the materialization sinks the secret may reach — prompt (a prompt body hands the model the placeholder), agent (the model's own tool input is materialised), command (a tool node's command: / postcondition: / script:), params (an action's params:), file (as: file), egress (the sandbox proxy substitutes it on the wire toward hosts:); omitted, the secret reaches every sink. A reference that reaches a sink its secret does not grant is a compile error (C323), and the two sinks the compiler cannot see — agent, egress — are honoured at run time from the same declaration (secrets). A declaration without value: resolves by name from the local/cloud store. Use {{secrets.forge_token}} or {{secrets.deploy_key.path}}; undeclared references are compile errors. A var the SERVER mints for the run (forge_publish_token) is the same kind of placeholder in a tool node's command: or postcondition:, materialised into the child process's environment rather than the command line — the raw {{!vars.x}} form keeps the value in the text, and a script: body reads the variable by name in its own language (see secrets). The protection layers, egress scoping, stores, and limitations are documented in secrets and the secrets reference.

Prompts, schemas, and templates ​

Prompts ​

iter
prompt review_system:
  You are a reviewer for {{vars.project}}.

prompt review_user:
  Review:
  {{input.code}}
  Previous result: {{outputs.prior.summary}}

A prompt body is the indented text under the header, its lines joined by single newlines. The first line's indentation is the body's: a deeper line keeps its extra indentation, and no later line can be shallower than the first (the body ends there — the studio refuses such a body at save, naming the line). Under profile 1 a blank or space-only line inside the body is skipped — a paragraph break reaches the model as a single newline; under dsl: 2 it is kept as an empty line, the paragraph break the author wrote. In both profiles leading and trailing blank lines are dropped and a body never ends with a newline. A carriage return before a newline is folded away with it. That canonical form, per profile, is the only one the syntax carries: the studio's save writes a document's prompts in it, and its save guard compares them in it.

A prompt may also be written where it is used: system: "Review the diff", user: | with the text below, instructions: on a human node, system: on a router or a supervisor. The string — quoted, raw or a | block scalar, whose paragraph breaks and trailing newline it keeps in either profile — becomes an inline prompt of the file, named after its body (_inline_<hash>), so the same text on two nodes is one prompt and a node's rename changes nothing; the studio writes it back inline. A bare name still refers to a declared prompt.

{{include "relative/path.md"}} inlines a file at compile time. Paths are relative to the file that contains the include — the .bot for a prompt declared in it, a bundle's prompts/ directory for a prompts/*.md — may not escape that directory (including through symlinks), and are capped at 256 KiB. Included content may contain normal runtime templates. On a cloud launch the includes are resolved into the prompt bodies by the server before the run is queued, so the runner never needs the files; a .bot uploaded inline (iterion remote runs launch x.bot, a studio launch of a loose file) has no files beside it, and an include in it is refused at publish — launch such a bot as a bundle.

What an include resolves against is the prompt's source file, named in full, never the working directory of the process compiling it. A prompt whose source is not a file on this host — a document validated from the studio canvas (JSON, no positions), an AST that reached a runner with a marker still in it, a source compiled inline (the studio's run-from-editor, an API payload), a file named by a relative path (which a server started from a bot's directory could otherwise resolve against its own) — is refused with C055; the studio still saves such a document, marker intact, and the next parse of the file on disk resolves it beside that file. To use an include, run the file itself (iterion run path/to/main.bot, or the bundle on cloud).

Literal template delimiters ​

Write {{"{{"}} to emit the two literal opening braces. For example:

bot
prompt teach:
  Write {{"{{"}}vars.name}} exactly as shown.

The rendered prompt is Write {{vars.name}} exactly as shown. The result is not interpreted again. Ordinary }} needs no escape. Whitespace around the quoted expression is accepted ({{ "{{" }}); arbitrary quoted expressions and nested templates remain errors.

This is renderer syntax, shared by both DSL profiles. Inside a DSL 2 quoted string, escape its embedded quotes using the normal string syntax:

bot
dsl: 2
tool show:
  command: "printf '%s' '{{\"{{\"}}vars.name}}'"

Prompt and multiline script bodies carry {{"{{"}} directly. Profile 1 retains its existing string-decoding rules; prefer a body for text with embedded quotes or use DSL 2 for the quoted-string example above. \{{ is not an alternative escape: the optional profile-2 shorthand is not introduced here.

Includes expand first; group parameters and local references are specialized at compilation. The literal form survives those passes, so {{"{{"}}include "missing.md"}} never reads a file and {{"{{"}}params.name}} never binds a group parameter. At final rendering only the original template positions are substituted. A runtime value containing {{vars.x}} or {{"{{"}} remains that value, without a second pass.

The same form works in prompts (including recipes and multimodal text), images:, commands, scripts, postconditions and data mappings (with, fail messages). In command/script source it emits raw braces, without adding shell or JSON quotes; quote the surrounding authored text as appropriate for that language. Dynamic references keep their existing escaping rules. A standalone literal is a string: it cannot serve as an integer loop cap, and a foreach collection still needs an array (the existing non-array/empty behavior stays).

These semantics require queue schema 19. Old runners reject the message before compiling the AST; deploy the new runners before the publisher. The new reader still accepts schemas 10–19. This PR is stacked after the schema-18 loop cap change; future connector transport work starts at 19→20.

Verified against the actual v18 reader at commit bd3906a725ee4b24fd7669e58e57a0556dbb2ce1: a v19 message produced from a compiled literal prompt was refused before AST decoding with queue: schema version: 19 unsupported (want 10–18).

Schemas ​

iter
schema review_request:
  code: string

schema review_result:
  approved: bool
  summary: string
  issues: string[]
  confidence: string [enum: "low", "medium", "high"]
  score: float
  metadata: json

Schemas define structured node inputs/outputs. Field types match variable types (string, bool, int, float, json, string[]); string fields may carry enum constraints. A seventh type, file, declares an operator-supplied binary and is valid only on a human node's output_schema — no model can produce one, so the compiler rejects it elsewhere with C129. See human-in-the-loop.

An LLM node's answer is held to its output: schema at the end of its turn. When it fails on a shape one more ask can fix — a required field missing, or text where JSON was expected — the executor re-asks the model once, with the validation error as its next input, in the conversation (claw) or session (claude_code, codex, pi) the answer came from; a type or enum mismatch, or a second invalid answer, fails the node. The re-ask is a real, billed turn and the run's events name it. See the schema re-ask.

Template namespaces ​

ReferenceMeaning
{{vars.name}}Resolved workflow variable.
{{input.field}}Current node input (prompts, tool commands, compute exprs). On an edge with mapping: the source node's output — the payload available when the edge fires. A router copies its input to its output (an llm router also records selected_route/selected_routes and reasoning on that same map). An entry router’s input is the run payload; a mid-graph router only has what its incoming with mappings supplied (C032 if {{input.x}} names something else). Launch-time values use {{vars.name}}.
{{outputs.node}} / .fieldPrior node output or a field within it.
{{outputs.node.history}}Outputs accumulated across loop iterations.
{{artifacts.name}}Published artifact.
{{attachments.name}} / .path / .url / .mime / .size / .sha256Attachment metadata.
{{secrets.name}} / .pathOpaque value placeholder or mounted file-secret path.
{{loop.name.iteration}} / .max / .previous_outputDeclared-loop state.
{{each.name.item}} / .index / .count / .first / .last / .emptySequential edge-foreach state.
{{run.id}}Current run id.
{{run.url}}The run's own page on the deployment executing it (<public origin>/studio/runs/<id>), for the body a bot publishes outside — a pull request, a review, a chat message. EMPTY where the deployment publishes no usable public origin — ITERION_PUBLIC_URL unset (a local iterion run), or refused by deeplink.PublicOrigin because it carries no http(s) scheme, holds whitespace or carries userinfo (the engine logs which, once): print the id alone rather than a root-relative path, which a forge resolves against ITSELF. See the run namespace.
{{run.tree_noise}}The canonical tree-noise pathspecs (':(exclude,top).claude' ':(exclude,top)devbox.lock' ':(exclude,top).iterion-script-*') — what a scope gate or a whole-tree staging must exclude, because the run's setup and tooling wrote it, never the pass's work. In a prompt it renders ready to paste into a git command. In an EXECUTABLE body it does not: a tool command: shell-escapes it into ONE argument, and a script: body JSON-encodes it ("':(exclude,top).claude' …" — one double-quoted word under sh/bash) — git add refuses it, and a git status-based gate silently ignores it and lists the noise anyway. Read $ITERION_TREE_NOISE in both (set for every tool process, host and sandbox), unquoted: the value is space-separated and must word-split into one pathspec per entry — "$ITERION_TREE_NOISE" collapses them into a single pathspec that matches nothing, so the exclusion vanishes in silence. See the run namespace and bot authoring.
{{run.elapsed_seconds}} / .cost_usd / .tokens / .iterationsWhat the run has consumed so far — see the run namespace.
{{run.max_duration_seconds}} / .max_cost_usd / .max_tokens / .max_iterationsThe run's effective budget caps.
{{params.name}}group parameter during compile-time expansion.

{{outputs.<node>.<field>}} is readable from any node that runs after the producer — in a prompt, a command, an expression or a fail message — with no with threading; {{input.<field>}} only carries what the node's own input: schema declares and the incoming edge with mapped. Thread through with when a value must travel under a chosen name (a loop feedback field, a fan-out item); read outputs.* directly otherwise. An {{input.X}} no incoming edge maps is a compile-time warning (C308, the field is empty on every path); one another incoming edge maps — a loop's back-edge, a sibling when path — is legitimate and empty on those other paths only, which the dry run names as its own unmapped_on_path finding (a warning) while the never-mapped case stays unresolved_ref.

fan_out_each also exposes the current item as {{outputs.<router>.<as-name>}}. Environment expressions use ${NAME} (and supported default forms), which the ENGINE reads in a tool body before the body reaches its reader — the rule, and what a dry run previews of it, is stated with the tool bodies below. In a tool command or script, {{!input.field}} is the explicit raw-substitution form; ordinary {{input.field}} is shell-escaped. Use the raw form only when the value is intentionally executable shell syntax, because it crosses the command-injection boundary. In a prompt the bang form is inert: a prompt has no shell, so {{!input.field}} renders the value exactly like {{input.field}}, and the compiler names the spelling (C169).

Inside a fan-out branch, every namespace above resolves exactly as it does on the trunk — a node renders the same whether it was reached by a plain edge or by a fan_out_all / fan_out_each router. {{outputs.*}} resolves against the BRANCH's own view: its upstream trunk outputs plus what this branch has produced, plus the per-item binding a fan_out_each stamped. Sibling branches are invisible to each other, which is what makes the render deterministic; their outputs only become readable at the convergence node. {{run.*}} is the run's, not the branch's — the whole run's consumption and caps, shared by every branch.

In a data mapping — an edge -> dst with { … }, a subbot node's own with:, an emit node's with:, or a fail message: — the runtime resolves the template through the mapping resolver, which supports the namespaces the compiler validates there: {{vars.*}}, {{outputs.*}}, {{input.*}} (on an edge, source output; on a subbot/emit with: the reference has no input: surface to check against so C149 warns, prefer {{vars.*}} for a declared launch-time value or {{outputs.<node>.<field>}} for a producer's output — but the compiler cannot prove absence of the key, since a parent's launch payload may forward undeclared keys through this channel when the launch passed --allow-unknown-inputs (#1757): the forwarding is the opt-out's declared use, the typo'd key is what the default refusal names), {{artifacts.*}}, {{loop.*}}, {{each.*}}, {{run.*}}, {{attachments.*}} (the path / url / mime / size / sha256 sub-field rule of a prompt body, path by default — #2864) and the literal-open form {{"{{"}}. {{secrets.*}} is refused in a data mapping AND inside a compute node's expr: (C150); {{attachments.*}} is refused in a compute expr: only (C151): secrets materialise only at execution sinks (a tool's command: / script: / postcondition:, a tool action's params: value, or a prompt body), and pkg/dsl/expr's evalNamespaces excludes both namespaces deliberately, so a compute expression would render either to nil. A ref-less literal in a mapping travels verbatim as a string, unconditionally — and so does a template that interpolates a reference into prose ("{{vars.n}} "; only a mapping that is exactly one value reference passes the value's type through — the literal-open form {{"{{"}} renders the literal text) — a bool / int / float / string[] field on the destination reads it as a string whatever the text ("true", "42" and "[\"a\"]" included; a compute passing it through to a typed output fails SCHEMA_VALIDATION), and a json field gets the text where a literal visibly attempts an encoding ("[]", "{}", "''", "null"; a plain word is a JSON string already and stays silent; a bare number stays silent as a deliberate exception — arithmetic on it reads the string); C152 warns on both, with the remedy per type: a scalar constant comes from a compute's expr: (ok: "false", n: "0", referenced as {{outputs.<compute>.<field>}}) or a var of the type; a list or an object comes from a compute's expr: collection literal (xs: "['a']", doc: "{k: 'v'}") or a producer's typed output (a node whose output: schema declares the field and prints it, {"xs": ["a"]}) — or the field is declared string when one string is what is meant.

A tool command: / script: / postcondition: resolves {{input.*}}, {{vars.*}}, {{secrets.*}}, {{run.*}}, {{outputs.<node>.<field>}}, {{artifacts.*}}, {{attachments.*}} and {{loop.*}} — the last four from the same template snapshot a prompt renders from, on the trunk and in a branch alike (inside a branch, the branch's own view, per-item binding included). An artifact, an attachment field or a loop counter is substituted like an output — shell-escaped as one word in a command: / postcondition:, as a JSON literal in a script: (a counter as a number, an artifact as an object) — and its {{!…}} raw form crosses the same command-injection boundary; inside quotes you wrote, such a reference is refused (C137, an error for these three: before they resolved here, the braces reached the shell literally). An output is substituted exactly like an input: shell-escaped as one word in a command: / postcondition:, as a JSON literal in a script:; the {{!outputs.…}} raw form crosses the command-injection boundary like {{!input.…}} does. An output the referenced node has not produced yet takes the missing-input rule too — the {{…}} placeholder stays in a shell body so bash -c fails on it visibly, and renders as null in a script body. A list renders by its DECLARATION in a command: / postcondition:, because a json value and a string[] one arrive as the same thing and only the declaration separates them:

declaredrenders asempty value
json var or input fieldits compact JSON text, always one shell token ('[1,2]'); a value that is itself a string is handed over unquoted, so it is not round-trippable through json.loads — declare the slot string when the value is text'[]' — the token stays, so the command's arity does not move
string[] var or input fieldone shell-escaped word per element ('a.go' 'b.go')no token at all — that is what an empty argv list means
anything else the declaration does not reachthe value's own shape: a scalar list space-joins into several words, anything else is one JSON tokenno token

An author who declares json writes a program that parses JSON, so the value must survive as one word: KEY={{vars.langs}} python3 -I -c "json.loads(os.environ['KEY'])". Do not interpolate a json reference into program source inside a command: — the value lands shell-quoted, which a Python or JS parser reads as a string literal. Use a script: node for that (every namespace renders as a JSON literal there, [1,2] and not '[1,2]'), or pass the value through the environment as above. A JSON literal is valid source in JS and in any JSON-superset syntax; true, false and null are not Python or Ruby literals, so a language: py script must read such a value through json.loads of a {{!…}}-free string rather than paste it into an expression.

string[] renders nothing when empty ON PURPOSE, and that is the one case worth designing around: cmd {{input.files}} --flag loses its argument and --flag shifts one place left. Declare the slot json when the site needs a token whatever the length.

{{vars.<name>.<member>}} reads the MEMBER of a json var, the same thing an expression reads from the same text — a prompt, a data mapping and a tool body all drill it, and a member the document has not keeps its placeholder like any other unwired reference.

A tool body's environment forms are the ENGINE's, and it reads them first. In a command:, a script: and a postcondition:, ${NAME} and ${NAME:-default} are answered by the engine — through the overlay-then-process chain every ${ITERION_*} of the DSL reads (ADR-093), under the deployment's process-env policy — before the body is handed to bash -c or to an interpreter, and before the {{…}} references above are substituted. That order is the injection boundary: only the author's own text is read, so a value coming from an input, a var or an output stays the inert single-quoted word the escaper made of it even when it carries a $NAME of its own. A plain ${NAME} is answered only when the engine's environment carries the name — otherwise the braces travel to the reader as written — and a ${NAME:-default} is answered unconditionally: the engine's value first, the author's default as the fallback. Nothing else is read: a bare $NAME, $?, $1, the parameter expansions ${NAME#x} / ${NAME:+x} / ${NAME%x} / ${#NAME}, and a ${…} whose name is not a bare C identifier ([A-Za-z_][A-Za-z0-9_]* up to the :-) — no reference at all, whatever the environment carries, which is what keeps a script: template literal's member access (`${p.name}`) intact where a bare `${count}` is answered the moment the engine's process carries count — all reach the reader as written, for the shell to answer (a body that assigns the name it reads braced is C188, pitfalls). The reading is the environment forms' alone — the {{…}} substitution is the stage after it, with the rules above. iterion validate --exec previews the body this reading produces under an environment that answers nothing: a ${NAME:-default} reads as its default and a plain ${NAME} as written, so the preview and its bash -n check are the same on a laptop and in CI (cli-reference).

env_expansion: braced | none declines that reading (ADR-130). braced is the rule above and the default: an absent property is not a third state. none makes the engine stand back — ${NAME} and ${NAME:-default} reach the body's reader exactly as the author wrote them, the :- arm included — which is the contract a language: js body whose ${…} is the language's, or a .bot a third party must read as what it executes, was missing. It is the node's, so its three bodies share it: a postcondition: disagreeing with its own command: about whose ${X} that is would be a trap, not a feature. Under none nothing else moves: the {{…}} substitution still runs after it, with the escaping and the {{!…}} raw form above, because that boundary is the substitution's and not the expansion's — and the expansion is skipped where it sat, never moved, so the injection order holds. What answers a braced read then is the tool process's own environment, which is not one thing: the curated sandbox environment for a sandboxed node, the engine's own for a sandbox opt-out. Two consequences come with the value: the ADR-093 overlay stops answering the body's ${ITERION_*} reads (it is consulted at expansion time only, and the names it answers do not reach the tool process — ITERION_TREE_NOISE is the engine's own export into that process, not the overlay's, which is why a declining body still answers that one for itself), so a body that mixes a self-assignment with a dial read trades the dial for the fix; and a name set to the empty string changes answer without the text moving — the engine's presence semantics yield "", bash's own :- yields the default. There is no workflow value, no run override and no environment variable, deliberately: a run-level none would turn off, from outside the file that depends on it, a mechanism bodies READ (${ITERION_DISPATCHER_PORT:-4891}), and the author's override already exists — it is this line, in the .bot. An older engine refuses a bundle carrying the property loudly (E012, unknown property), which is the honest direction for a declaration whose absence would re-enable the expansion the author declined.

iter
tool emit_report:
  script: |
    const head = `${count} findings`;   // the language's own interpolation, not iterion's
  language: js
  env_expansion: none

A declaration reaches {{vars.<name>}} (the vars: block), the node's own {{input.<field>}} (its input: schema) and {{outputs.<node>.<field>}} (the producer's output: schema). A key an edge delivers to a node that declares no input: schema, and a drilled path ({{input.a.b}}, {{outputs.n.f.g}}) whose leaf no schema types, carry none and take the shape rule of the last row. A json slot holding JSON null holds a value, not a hole, and renders 'null'. A string[] one does not take that rule — a producer that had nothing to say is not an empty argv list, so a null there keeps the {{…}} placeholder like any reference nobody wired, and the missing wiring stays visible.

LLM nodes: agent and judge ​

agent performs work; judge is the semantically evaluative twin. They accept the same properties.

iter
agent reviewer:
  description: "Read-only branch reviewer"
  backend: "claude_code"
  model: "anthropic/claude-sonnet-4-6"
  provider: "anthropic,zai"
  input: review_request
  output: review_result
  system: review_system
  user: review_user
  session: fresh
  tools: [bash, read_file, grep]   ## `tools: []` = declared empty; no line = undeclared
  tool_policy: [git.*, read_file]
  capabilities: [board.read]   ## `capabilities: []` = no grant (overrides the workflow's); no line = inherit
  skills: ["review-playbook"]
  tool_max_steps: 10
  max_tokens: 12000
  reasoning_effort: high
  ambient_context: none   ## none|workspace|operator|all (default workspace, ADR-119)
  timeout: "20m"
  readonly: true
  publish: review_artifact
  artifact_labels: [review, branch]

Important property groups:

GroupProperties
Model executionmodel, backend, provider, and the claude_code-compatible binary override command. See backends and delegation.
Data/promptinput, output, system, user, publish, artifact_labels, deliverables, description.
Conversationsession: fresh|inherit|inherit_if_available|fork|artifacts_only|persist, interaction, interaction_prompt, interaction_model. persist (ADR-089) resumes this node's own last CLI conversation on re-entry (claude_code / pi / codex); judges and humans stay graph nodes. Trunk-only (C243).
Tools/accesstools, tool_policy, capabilities, skills, permission, mcp, sandbox. The exact Claude spellings Read/Bash/Grep resolve on claw as read_file/bash/grep when the bundle declares the tool-alias engine floor; exact names and unique MCP shorthand win first. See tool-name aliases. A declared tool_search searches claw's whole registry but answers for THIS node (#2717): each match is marked available against the node's list — an available match carries its schema, an unavailable one says why and hands none out — so select: of a tool the node cannot execute reads as unavailable, never as a call that will work.
Limitstool_max_steps, max_tokens, reasoning_effort, timeout, compaction, compress.
Schedulingawait, needs, and the workspace-safety assertion readonly.
Backend-specificfull_access and images are honored by the Codex backend; other backends ignore them.
Persistent contextmemory and cursors.

ambient_context: none | workspace | operator | all picks what the node inherits from its surroundings besides its prompt (ADR-119): the repository's instruction files (workspace, the default — CLAUDE.md, .claude/rules/, AGENTS.md …), the operator's personal agent setup and the files above the repository root (operator), both (all) or nothing (none). Declared on the node or the workflow, with a run override (--ambient-context) and ITERION_AMBIENT_CONTEXT below it. Enforced on claude_code, claw, codex and pi; kimi applies workspace and all, opencode all. A declared value a node's backend cannot apply is refused — a compile error (C187) and a refusal at dispatch — and one it has not been measured against warns (C185); the default is applied as far as each backend can. An invalid value is a compile error (C184). Review bots that judge a checkout they do not control usually want none. See backends.md and ADR-119.

readonly: true classifies the node as non-mutating for parallel workspace safety, and bounds what its delegate may change — with the lever each backend has, and they differ: codex runs it in a read-only sandbox, pi narrows its tool list to readers, claude_code withholds from both of its spawns the file-writing tools (Write, Edit, MultiEdit, NotebookEdit) plus the worktree-moving pair (EnterWorktree, ExitWorktree), and claw, opencode and cliagent do not read the flag at all — on those, the node's tools: list is its only bound (#1816). Wherever it is read, the flag beats the node's own tools: list: a writing tool the list names is withheld too, because the flag is the node's access intent and is also what lets a parallel branch share its workspace. On claude_code the deleted declaration is named on the node's log; codex answers it with a read-only OS sandbox and pi by pinning the tool list to four readers, both in silence. A node whose deliverable is a written file drops readonly: — that is the escape hatch, and its non-mutating admission goes with it. On claude_code the flag removes the writing TOOLS, not the write capability: it is not a read-only sandbox, so a shell the node keeps still writes, a subagent the node can still dispatch is a session this backend does not bound, and the native roster is one iterion does not own, so a write path spelled outside those lists survives too (#1651). Every residual path is reported on the node's log; 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 as well. full_access: true is a high-authority Codex-only opt-in; readonly wins if both are present.

Node-level nested blocks include:

iter
agent worker:
  # ...model/prompts...
  compaction:
    threshold: 0.85
    preserve_recent: 4
  memory:
    enabled: true
    scope: "campaign"
    autoload: ["CONTEXT_BRIEF.md"]
    read: true
    write: true
    pre_compact_inject: true
    visibility: "bot"
  cursors:
    enabled: true
    rigor: high
  mcp:
    inherit: true
    servers: [repo_tools]
    disable: [legacy_server]

See memory and knowledge, cursors, permissions, skills, and sandboxing.

Routers and convergence ​

Iterion has five router modes:

iter
router all_reviews:
  mode: fan_out_all

router per_ticket:
  mode: fan_out_each
  over: "{{outputs.plan.tickets}}"
  as: ticket
  key: id
  depends_on: deps

router decision:
  mode: condition

router alternate:
  mode: round_robin

router smart:
  mode: llm
  model: "anthropic/claude-sonnet-4-6"
  system: routing_prompt
  multi: true
  • fan_out_all activates every outgoing edge.
  • fan_out_each replays exactly one unconditional template edge per runtime array item; key/depends_on optionally impose a dependency DAG.
  • condition makes edge guards explicit.
  • round_robin selects one outgoing edge per traversal in declaration order.
  • llm selects one or several candidates and is the only router mode that makes a model call.

Parallel branches converge at an agent, judge, human, tool, or compute node:

iter
compute collect:
  output: collection_result
  await: wait_all       # or best_effort
  expr:
    completed: "true"

The collector fires exactly once, after every branch has settled — wait_all fails the run when any branch failed, best_effort runs with the survivors and lists the failures as _failed_branches (and on the join_ready event). Neither mode fires on the first arrival. Without await:, the collector is the first node with more than one distinct predecessor; a fan-out target that a condition router also reaches directly is still a branch head, not the collector, while a trunk edge bypassing the fan-out into a node below the heads (plan -> collect else) does elect that node.

When a fan-out is invoked again, convergence replaces the outputs.* view of its branch region with the current successful results. Failed branches, unreached nodes and an empty fan_out_each contribute no current output; their previous invocation's value resolves to nil at the collector and downstream, including after pause/resume. Outputs outside the invocation remain available. This changes older runtimes' behavior, which could silently reuse a previous verdict after the current branch failed.

During execution, each branch still receives its immutable input snapshot, so deliberate feedback from the preceding pass remains possible. Published artifacts.* keep their separate last-published value and version history; use that namespace explicitly when a consumer needs the last known result.

For a best_effort collector, incoming with mappings behind failed nodes form a fallback floor. In fan_out_each, each item's recorded execution and route choices are examined separately: one item's when decision cannot decide for an item that failed before routing. The resulting candidate edges are combined at the collector. Equal mappings survive; conflicting values for the same key remain absent; mappings from successful incoming edges take precedence. This floor survives checkpoint/resume. It does not synthesize per-item outputs, and a route rejected by every item contributes nothing.

Routers are fan-out sources and never declare await. See routers and composition/iteration/sub-bots.

Human interaction ​

iter
human approval:
  description: "Release approval"
  input: approval_request
  output: approval_response
  instructions: approval_prompt
  interaction: human
  min_answers: 1

interaction is one of none, human, llm, llm_or_human, review, async, or human_or_host (the host application may answer in the operator's place, whichever comes first — see the assistant dock; C212 refuses the mode without its host_event field and the field without the mode). A review gate additionally accepts review_url, posture, merge_strategy, merge_into, and max_turns. The async mode is an agent/judge mode (not a human-node mode): the node posts non-blocking questions with ask_user_async and keeps working, syncing on demand via an await_answers node — see async interaction. Human nodes may also publish labeled artifacts and converge with await. See human-in-the-loop and review/merge gate.

Resume a pause with iterion resume --run-id <id> --file workflow.bot --answer key=value.

Deterministic nodes ​

tool ​

A tool executes either a shell command or a script; it does not call an LLM.

iter
tool run_tests:
  description: "Run the repository test suite"
  command: `make test`
  output: test_result
  publish: test_result_artifact
  permission: ask
  needs: [test_slot]

command and script are mutually exclusive. A script adds language: js|py|sh|bash (default sh). Tools also accept input, output, publish, artifact_labels, deliverables, await, sandbox, compress, permission, needs, and env_expansion: braced | none — what the engine expands in the node's bodies before they run (the rule and the decline).

Declared deliverables. deliverables: ["report.md", "out/annex.html"] names the files the node produces that the run collects and serves as downloads — workspace-relative paths, each served under its base name, the declared twin of the [iterion] attachment= line a tool prints (attachments). The compiler refuses a path the run could never collect (C317) and two entries one node would serve under the same name (C318). agent and judge nodes accept the same property; a human node's file comes from the operator's upload and a compute node writes none, so neither accepts it.

The output contract. A tool node's stdout is its output: the runtime parses it as a JSON object, and that object is what {{outputs.<tool>.<field>}}, an edge when, and the declared output: schema see. Stdout that is not a JSON object is wrapped as {"result": "<text>"} — a downstream {{outputs.run_tests.passed}} then never resolves, and its raw {{…}} reaches the next command as text. A non-zero exit code fails the node (resumable), stdout and stderr attached; when the failure is a result rather than an error — a test suite that fails, a scanner that finds something — wrap the command so it exits 0 and reports the verdict as a field:

iter
tool run_tests:
  command: `if make test >/tmp/test.log 2>&1; then ok=true; else ok=false; fi; jq -Rs --argjson passed "$ok" '{passed: $passed, log: .[-20000:]}' </tmp/test.log`
  output: test_result        # schema: passed: bool, log: string

(jq -Rs reads the whole log as one JSON string and ships in the default sandbox image; the .[-20000:] tail bounds what reaches the schema field — and every downstream prompt that reads it — because nothing else does: a 50 MB log would land in the judge's context whole; a python3 -c "…json.dumps…" wrapper reads well but python3 is NOT in that image, and a missing interpreter turns the payload into invalid JSON that the runtime then wraps as {"result": …} in silence — declare any interpreter you rely on in the bot's devbox.json.) A command: runs through bash -c, on the host and inside a sandbox alike (executor_tool.go, toolNodeCommand); a script: runs the interpreter its language: names, and language: sh is whatever sh is on PATH — dash on Debian-derived images, so keep scripts POSIX. Python runs isolated. A tool node's working directory is the workspace, and python puts it first on sys.path for -c, -m and stdin, as it puts a script's own directory for a script file (which lands in the workspace under a copy-based sandbox): a json.py the judged tree carries would replace the standard module inside the node. A language: py script runs python3 -I; in a command:, write python3 -I whatever hands python its program — -c, -m, a heredoc, a < redirect or a pipe (the catalog refuses the bare form, TestCatalogPythonRunsIsolated). -I also ignores PYTHON* variables and the user site: the body reaches the standard library and the system site-packages. A body that needs PYTHONPATH or a sibling module names its interpreter itself in a command: — python3 lib/run.py keeps the script's directory and the environment — and owns what that directory holds. Every {{ref}} in a command: is shell-escaped as one word; do not wrap it in quotes of your own (C137).

A process the command leaves behind does not fail the node — and a job meant to outlive the node must redirect its streams. A recipe that backgrounds work (server &, nohup … &) hands that job the node's own stdout, and the wait for a command's output ends only when every holder of that pipe has closed it.

On the host, the engine stops waiting 2 s after the command itself exits, and ITERION_PROC_WAIT_DELAY (environment) raises that bound or removes it. The node keeps its exit status and the answer it printed, and what the bound abandoned is reported on the node's tool_called event and in the run log ("the command exited 0 and a process it left still holds its output"). The bound does not only stop waiting: it closes the node's output AND kills the process group the command led — the cut is the node's teardown of that group (#2883), dispatched where the sentinel is read, because exec's context watcher is gone as soon as the shell is reaped and the kill it armed is never dispatched. So a job still holding the output dies AT the bound — mid-sleep, before the write that would have taken SIGPIPE is due — and a job that traps PIPE dies all the same, the group kill being no signal a trap can shield it from: measured on the same server & recipe, where the job's pid stops naming a live process well before its next write was due, the trapping arm's handler never fires, and a silent holder — one that would never write again — is reaped with the group instead of surviving the node's window (TestABackgroundJobThatKeepsWritingDiesAtTheBoundAndRedirectionIsHowItOutlivesTheNode, and TestAGrandchildLeftHoldingTheOutputIsKilledWithTheGroupAtTheBound for the silent straggler itself). What the group kill cannot reach is a holder that LEFT the group it was spawned in — its own setsid — while keeping the pipe: the group is then empty, the kill answers ESRCH, the report names no kill, and the holder outlives the node on its own clock (TestACutReportsNoKillWhenTheGroupIsAlreadyGone). Its lines also share that one pipe with the command's own answer, so they interleave and the JSON the node meant to publish no longer parses: the output comes back as the {"result": …} wrapper, and a downstream {{outputs.deploy.deployed}} never resolves, its raw {{…}} reaching the next command as text. Whether a mark comes with it depends on whether anything was CUT. A job that writes and lets go INSIDE the bound cuts nothing: the node ends with that job, keeps its success, and publishes the contaminated wrapper with no _drain_cut and nothing reported — measured on ( sleep 0.5; printf LATE ) & printf '{"deployed":12,"failed":0}', where the node is green in about half a second (0.50 s on the host, 0.59 s through the docker driver — the job's own sleep ends it, not the bound), its artifact carries result: "{\"deployed\":12,\"failed\":0}LATE" and no mark, and the run log says nothing (TestAJobThatLetsGoInsideTheBoundContaminatesTheAnswerWithNothingToReport). A job still holding the pipe when the bound fires IS cut, and a wrapper built from a cut run carries a private _drain_cut: true beside it — the one thing that tells a reader those bytes may be a prefix rather than the answer (a whole JSON object the command printed itself is published unmarked even on a cut run: those bytes are an answer, not a possible prefix). The mark is one-way: it says these bytes may be short, never that an unmarked answer is the one the command printed. It travels with the output (the artifact a publish: writes, the node_finished data) and is readable as {{outputs.<node>._drain_cut}} on any tool node, declared schema or not: an _-prefixed field is exempt from the outputs-ref validator (C031/C032) and from the type checker, the exemption session metadata already travels on — so a misspelled _drain_cutt compiles too, with no diagnostic, and its raw {{…}} reaches the shell as text. An edge gate is the other surface, and the schema does decide there: deploy -> warn when _drain_cut is C014 on a node that declares an output: schema, and compiles and fires on one that does not. So redirect the job's streams: server >/tmp/server.log 2>&1 & is what lets the job outlive the node — the ONLY form that does, now that holding the output past the bound is what gets the group killed. Nothing then holds the node's output, nothing is cut so nothing is killed, the node ends as soon as the command does, the answer parses, and there is nothing to report.

Under a sandbox the engine ACTIVATED, whose driver runs the command elsewhere — the run's own sandbox_started event, or the parent's sandbox a subbot runs in (sandbox_shared carrying adopted: true; the same event says adopted: false of a child that did NOT take it), on the docker/podman or the kubernetes driver (those are the driver names; auto is the MODE that reads a repository's .devcontainer/, never a driver) — the engine waits for that driver's exec client, not for the command, so the bound in force is the client's: the driver's own when it set one — which ITERION_PROC_WAIT_DELAY reaches only because that driver resolves its default through the same variable, as the kubernetes one does, never because this site applies it over a bound a layer below chose — this site's default otherwise, where the override always reaches (TestTheSharedRunnerKeepsABoundChosenBelowItAndHandsTheCutBackUnwrapped, TestTheOverrideReachesThisSitesBoundAndStopsAtOneChosenBelow). Both guarantees above belong to the HOST path, and that path is every run the engine did not place in a sandbox: sandbox: none, and a declared auto the host degraded to unsandboxed for want of a container runtime (the run's sandbox_skipped event, #1425 — a CI runner or a laptop with no docker/podman), which gets the host reading whole, measured: the report (stopped waiting after 2s (raise ITERION_PROC_WAIT_DELAY to wait longer)), the _drain_cut: true on the answer, and the knob as the fix — ITERION_PROC_WAIT_DELAY=6s waits that run's 4 s tail out, publishes the whole output and leaves no mark. A cut under an activated sandbox is a cut of the CLIENT's pipes, so neither the report nor _drain_cut says anything about the in-container job holding the command's output: do not read the mark's absence on a sandboxed node as "whole", nor its presence as that job's doing. What a given driver does with that wait is a measurement, not a rule. The docker driver sets no bound on the client it builds, and docker exec itself stops copying some 2 s after the exec'd process exits, then exits 0 — measured with the docker CLI 29.9.2 against ghcr.io/socialgouv/iterion-sandbox-slim:edge, a podman exec client's grace being its own: a holder writing at 4 s and one writing at 10 s both leave the command's own bytes in hand after 2.05 s, and the same recipe at bounds of 10 ms, 100 ms, 2 s and off comes back at 2.04–2.07 s alike with its object parsed and unmarked, so raising the knob widens nothing there. The kubernetes driver does bound every kubectl it builds (kubectlDrainBound, 2 s, through this same override, TestKubectlWaitDelayBoundsAnUnresponsiveProcess), and a client bounded that way raises the sentinel the host's reading answers — about the client's own drain. So a recipe that needs its whole answer waits for its children, on either path: server >/tmp/server.log 2>&1 & for a job meant to outlive the node, wait for the job whose bytes the node publishes — measured through the docker driver, waiting for the job that WRITES the answer (head now, tail at 4 s) comes back at 4.06 s with the whole object, while wait on a job that merely HOLDS the output comes back at 4.06 s too and splices its bytes into the answer instead — or have the job write a file the node reads.

Verified Actions add a deterministic outcome check and bounded recovery:

iter
tool deploy:
  command: `./deploy.sh`
  goal: "The service is deployed and healthy"
  postcondition: `./scripts/check-health.sh`
  policy: recover       # required | recover | best_effort
  recovery:
    max_repair_attempts: 2
    max_agent_attempts: 1
    model: "anthropic/claude-sonnet-4-6"
    agent_tools: [read_file, bash]

parallel_safe: true is a narrowly scoped assertion for fan_out_each: concurrent replays must write only to disjoint item-keyed targets. It does not make a tool generally read-only.

The third recipe: action: (ADR-098). A tool node calls a connector operation instead of a shell:

iter
tool comment:
  action: forgejo.issue.comment
  connection: forge_main
  params:
    owner: "{{vars.owner}}"
    repo: "{{vars.repo}}"
    index: "{{outputs.pick.number}}"
    body: "{{outputs.draft.text}}"
  timeout: 30s
  output: comment_result

command:, script: and action: are mutually exclusive — a node has exactly one answer to "how does this do its work". action: names an operation of a connector package (<connector>.<resource>.<verb>), connection: the binding that authenticates it, and each params: value renders the same {{...}} namespaces a command does, then coerces to the type the operation declares (so "{{outputs.pick.number}}" reaches an integer field as a number, not as "42").

A parameter's name is the vendor's, not iterion's, so quote the ones that are not identifiers — "user-id": 1, "status-types": "pull". The Forgejo package ships 22 of them, two of which are required path parameters.

The output is {status, pending, data}, plus {items, complete} when the operation paginates. Read complete: a walk that stopped at its declared ceiling looks exactly like one that finished. Read pending: a 202 means the vendor accepted the work, not that it happened.

What an action node refuses, and why. Its offer is that no LLM decides the operation, builds the arguments or reads the answer — so the two properties that could reintroduce one are compile errors: recovery: / policy: recover (C262, whose ladder ends in an LLM repairing the call) and postcondition: (C263, a shell exit code that would overrule the vendor's own typed answer). A failure is a node failure carrying its error class (not_found, rate_limited, unauthorized, …); branch on it with a when edge rather than expecting the node to return one.

unknown_outcome is a first-class result. When a mutating operation's request goes out and its answer is lost, and the vendor offers no idempotency key, iterion reports that it cannot tell whether the call happened — and never retries it automatically. Repeating might duplicate a comment, a release, a payment; reporting success would be a lie. A call that never LEFT is not that case and is not reported as one: a refused dial, a name that does not resolve, a header that cannot be sent — nothing reached the vendor, so they are ordinary retryable transport failures. Only a cause that proves nothing was sent is treated that way; an unrecognised failure stays undecided, because guessing in that direction is what duplicates an effect.

Reaching a self-hosted instance. Every connector call goes out on the guarded dialer, which refuses a private, loopback or link-local address — the guard that keeps a workflow from fetching http://169.254.169.254/ on the machine an operator is signed into. A self-hosted Forgejo or GitLab is exactly the legitimate case for it, so the exception is deployment-controlled and greppable: ITERION_CONNECTOR_ALLOW_PRIVATE=1 opens the guard for the process. The refusal names the variable, and iterion connections add warns at once when a --base-url will be refused, rather than leaving the first run to discover it.

Where packages are read from. An operator's own tier is <iterion home>/connectors/<id>, and it is the one consulted by default. A project tier — <workspace>/connectors/<id>, where iterion connectors gen writes by default — outranks it, and is consulted only with ITERION_CONNECTOR_PROJECT_CATALOG=1. That default is deliberate: the workspace is the repository a run acts on, a connection pins the host its credential may reach and the place in the request it travels in, and nothing pins what the operation does — so a repository shipping connectors/forgejo/ could keep the connector id, the scheme and the placement while redefining forgejo.issue.comment as a DELETE. Granting the tier is a deliberate act for your own project; a bot pointed at somebody else's repository never makes it. A project catalog that exists while the grant is closed says so when a connector fails to resolve, rather than reading as absent. The grant is not the only condition: the tier is consulted only for a run whose workspace holds the tenant's own code, because the package's own word is what the trust guard below reads — a deployment that granted the tier and also served the fork review lane would otherwise let the reviewed tree declare effect: read on a DELETE and have it resolve as a read. A run the lane serves is told the tier was not consulted for it, and not how to open it; an operator whose own tier is merely ungranted still reads the variable that does.

When nothing here can serve the action, the run is refused before it starts. A plane with no connector catalog wired — the cloud today, a runner, a fresh install — used to accept the launch and fail at the action node, after the run had spent its earlier nodes and taken their effects, for a verdict every input of which was known at launch. The refusal now happens at admission, on every surface that builds a run (iterion run, a resume, the launch API, a subbot, the dispatcher, the runner, a replay), and it names each action node, the operation it wants, the package to install and the connection it would have used. The node's own refusal stays where it is: it is what protects a run whose IR arrived by a path that never came through admission. The bound is load-bearing, so it keeps a way out: ITERION_CONNECTOR_ADMISSION=off restores the old behaviour for the process — the run starts and fails at the node — for the two cases where that is what you want: an action node on a branch this launch will not take, and a caller executing one node rather than a run. The refusal names the variable, and off is its only spelling.

A run over code the tenant did not write resolves reads only. The fork review lane serves a pull request whose head was authored outside the tenant, and the run document says so — a marker that rides the run rather than the surface that launched it, because a resume and a forked child rebuild their credentials from it long afterwards. An action: node on such a run would authenticate with the tenant's own binding, so the resolution refuses every operation whose effect: is not read: before the connection is even looked up, so the wording cannot tell a fork which aliases the tenant has, and before anything is unsealed. Reads still resolve, because producing a review is what the lane is for. The refusal is typed and names the operation, its effect and the binding it did not spend; an effect: this build does not recognise reads as mutating, so a typo in a package fails closed. This bound keeps no per-process way out, and that is deliberate: the way to let a tree mutate with your credential is to run it as your own code, which is an admission decision and not a resolver's. Such a run also loses the project tier of the catalog (above), and for the same reason read the other way round: the refusal's own predicate is the effect: the catalog served, so a tier whose packages the reviewed tree writes cannot be one of the tiers consulted for it. The operator's own tier still answers it, and reads still resolve. The same rule covers what a refusal LISTS: a connector no consulted tier holds is refused naming the id the run asked for — the near-miss list of the operator's installed packages, which the layered lookup joins into that refusal, is served to the tenant's own runs only, or a reviewed tree could enumerate the tenant's connectors by naming ids until the wording changed (#2810).

--capability agent reaches the MCP facade; --capability trigger is accepted and still inert. A connection declares what it may be used for: action for a deterministic node, agent for an agent reaching the operation through the MCP facade, trigger for an inbound delivery believed on this connection's word — an event the connector package declares in its triggers.yaml. The facade is live (ADR-098 §2): an agent or judge node declaring capabilities: [connectors] is registered with an iterion-owned MCP server (iterion_connectors) whose tools are the package's curated operations, plus a search_operations / call_operation pair that discovers the rest — ids and summaries — without serving it: what a model may call is what the operator curated (mcp: true), whatever the run's trust; the server resolves the connection — tenant-scoped, the agent grant checked at the same chokepoint as action: — and performs the call there, so the credential never enters the sandbox. The facade rides the board transport's shape: the per-run X-Iterion-Run token, the gateway listener started with the sandbox, so it needs a server-wired run (the studio or the cloud) and a sandbox; the run's trust applies to facade calls exactly as to action nodes — a fork review lane run reads only. The facade is served to the claude_code and pi backends; on claw the capability is refused by type, because claw's tool loop runs in the runner and the credential must not resolve there. The webhook ingress is not written, so a trigger grant is stored as the operator wrote it and honoured by nothing: iterion connections add says so when it writes it and iterion connections list says so every time it reads it back — one predicate per capability, read by every surface at once, is what flipped the agent sentence off on both at once when the facade landed. What may be sent and what may be believed are both decided when the connection is entrusted; where the inbound secret lives is the webhook configuration's hmac_secret_sealed, which already has its cloud twin, and the binding between the two is the inbound lot of the connector catalog.

compute ​

compute evaluates bounded expressions without an LLM or shell:

iter
schema stats:
  count: int
  ready: bool

compute summarize:
  input: review_result
  output: stats
  expr:
    count: "length(input.issues)"
    ready: "input.approved && length(input.issues) == 0"

Expressions support field/index access, arithmetic/comparison/boolean operators, conditional/map/filter/reduce forms, and the total built-ins length, concat, unique, contains, join, tail, if, sort, keys, values, slice, sum, min, max, flatten, floor, and round. min/max take either ONE array (min(input.nums)) or two or more values (min(max(floor_s, cap * ratio), cap * 0.5) — the shape a clamp is written with; arguments are flattened one level, so max(list, 7) compares the list's elements against the scalar). They share namespaces with quoted when expressions and are bounded by an evaluation-work limit; see DSL totality.

Collections are written as literals. A list [expr, ...] evaluates to an array — xs: "['a', 'b']" fills a string[] field directly (this is the rich form the C152 remedy names: a collection constant comes from a compute, referenced as {{outputs.<compute>.<field>}}), and [] is the empty list. An object {key: expr, ...} evaluates to a JSON object — doc: "{k: 'v', xs: ['a']}" fills a json field; a key is an identifier or a quoted string, each declared once (a repeat is refused at parse), and {} is the empty object. Elements and values are full expressions, references included ([input.name, 'fallback']), and literals nest. Strings inside a literal use either quote ('a' / "a" — the single form embeds in a double-quoted DSL value without escaping, in both profiles). Two collection facts to know: equality never walks into a collection — == is false and != true even between identical contents ([1] == [1] is false; compare length(...), an element, keys(...)/values(...), or join(...); the compiler warns with C306 when it can see it) — and a string[] field holds only an all-string list (the compiler warns with C307 when a literal breaks it, e.g. ['a', 1] — quote the element or type the field json). The literals are additive syntax, accepted in both syntax profiles: a profile gates changes of MEANING, and no previously valid expression read a leading [ or { as anything but an error.

The output is typed by its schema. A compute output is conformed to the declared field types where it is produced, on the trunk and inside a fan-out branch alike: an integral number under int is stored as the integer it reads as, an integer under float as a float, and a value that cannot be conformed fails the node with the field named — a fractional float under int (10.58 from a division), a string under bool, a number under string. The engine never picks a rounding for you: write it, with floor(x) (towards negative infinity) or round(x) (half away from zero), both of which return an integer.

iter
schema gauge:
  used_pct: int

compute plan_budget_gate:
  output: gauge
  expr:
    used_pct: "floor(run.elapsed_seconds * 100 / run.max_duration_seconds)"

The run namespace ​

A node can read the run's own consumption and the caps it is running under. This is what a phase-budget guard is built from — "the plan phase has used a third of max_duration, stop planning" — without self-measuring wall-clock in a tool node or mirroring the budget: block through vars that drift from it in silence.

MemberTypeMeaning
run.idstringThe run id.
run.urlstringThe absolute studio page of this run, built from the deployment's public origin (ITERION_PUBLIC_URL, carried by the runner beside the server) — the address a forge-facing bot publishes so a reader reaches the steps, the models and the cost behind its output. Empty string when no origin is configured, or when the configured one is not an absolute http(s) address a stranger could follow.
run.elapsed_secondsfloatActive time consumed. Monotonic, so an OS suspend does not count; prior active time is preserved across a resume.
run.cost_usdfloatLLM spend booked so far. A call whose price could not be resolved is NOT in it — see budget.
run.tokensintTokens consumed so far.
run.iterationsintNode executions recorded so far.
run.max_duration_secondsfloatThe effective duration cap.
run.max_cost_usdfloatThe effective cost cap.
run.max_tokensintThe effective token cap.
run.max_iterationsintThe effective iteration cap.
run.tree_noisestringThe canonical tree-noise pathspecs (pkg/treenoise), shell-quoted and space-separated: ':(exclude,top).claude' ':(exclude,top)devbox.lock' ':(exclude,top).iterion-script-*'. Constant for a run. Tool scripts get the same list in ITERION_TREE_NOISE — the bare pathspecs, NOT the quoted prompt form: the prompt rendering is single-quoted for a shell command line, the env value word-splits into one pathspec per entry.

The four max_* members are the caps in force right now: the budget: block after the iterion run --max-* flags, the recipe/preset, the cloud platform ceiling and any live raise_budget have been applied. That is the point — a guard written against the DSL literal would be wrong on every run that re-budgeted.

Two conventions:

  • A max_* of 0 means UNBOUNDED on that axis — the run declared no cap there — never "no allowance left". Divide by one without checking and a guard reads +Inf.
  • A workflow with no budget: block has no tracker at all, so cost_usd / tokens / iterations read 0 (nothing meters them) and every cap reads 0. elapsed_seconds still advances — it is the one figure a bot cannot reconstruct for itself. A guard that compares against a cap therefore needs the budget: block that declares it.

An unknown member (run.no_such_thing) is UNRESOLVED, never an empty value. In an expression it is nil — the same silence as vars.<unknown> — and comparing it is what fails, loudly, at the node. In a rendered body it takes the missing-ref rule every namespace follows: the {{…}} placeholder stays in a prompt and in a shell command: / postcondition:, so bash -c fails on visible braces instead of running one argument short, and it renders as null in a script: body so the interpreter still parses.

The members are available in compute expressions and quoted when conditions, in prompt bodies, in tool command: / script: / postcondition: templates, and in every {{…}} data mapping — an edge with, an emit payload, a subbot with:, a fail node's message: — on every dispatch path, a fan-out branch included. An expression resolves them at evaluation time; a prompt or a command is rendered once at node dispatch, so those read the run as it was when the node started; a fail node's message: is rendered at fail time.

Alongside them, every executed node's output carries _duration_ms next to the _tokens / _cost_usd keys the backends write — the per-node timing counterpart, stamped by the engine so tool and compute nodes get it too.

iter
schema gauge:
  used_pct: int
  exhausted: bool

compute plan_budget_gate:
  output: gauge
  expr:
    used_pct: "if(run.max_duration_seconds > 0, floor(run.elapsed_seconds * 100 / run.max_duration_seconds), 0)"
    exhausted: "run.max_duration_seconds > 0 && run.elapsed_seconds > run.max_duration_seconds * 0.33"

used_pct is declared int, so the division is wrapped in floor(...): a compute output is typed by its schema, and a fractional result under an int field fails the node rather than travelling on as a float with an integer's label.

emit and wait ​

These nodes coordinate concurrent branches through immutable run-scoped events:

iter
emit publish_ready:
  event: "ready"
  with {
    revision: "{{outputs.build.sha}}"
  }

wait await_ready:
  event: "ready"
  timeout: "30s"
  output: ready_payload

wait.timeout is mandatory: the language does not permit an unbounded silent wait.

Typed terminal failure — fail <name>: ​

done and the bare fail are reserved edge targets, not declarations. Routing to fail ends the run as failed with the engine's own generic outcome — FAIL_NODE, "workflow reached fail node" — which is all an operator sees whichever of a bot's refusals fired.

A workflow that refuses for a reason declares a named fail node instead. One per reason; the bare fail keeps its untyped behaviour.

iter
fail plan_exhausted:
  description: "the plan phase outgrew its share of the budget"
  code: PLAN_BUDGET_EXHAUSTED
  message: "planning used {{outputs.plan_budget_gate.pct}}% of max_duration ({{run.elapsed_seconds}}s of {{run.max_duration_seconds}}s)"
  resumable: true

fail not_actionable:
  code: LOT_NOT_ACTIONABLE
  message: "nothing in this lot is actionable"
FieldMeaning
code:UPPER_SNAKE identifier stamped on the run's failure_code. C247 refuses any other shape — the value is persisted and read by machines (iterion runs list, the studio, the merge-gate notice, the alert sinks) — and C248 refuses one that collides with an ENGINE code (BUDGET_EXCEEDED, TIMEOUT, USAGE_LIMIT_BLOCKED, …), which the retry machinery reads as control flow.
message:The operator-facing reason, stamped on the run's error. Templated with the usual {{...}} references — outputs.*, vars.*, input.*, loop.* and the whole run.* namespace — and resolved at fail time, so the figure that caused the refusal is the one reported: a budget guard names the ceiling the run actually had ({{run.max_duration_seconds}}), not the budget: literal.
resumable:true parks the run failed_resumable instead of terminal failed, with its checkpoint anchored on the GUARD that routed in — so the resume re-evaluates that guard, not the fail node. Off by default: a fail node is intentional termination.
description:Human-readable node label, as on every other node kind.

The default stays terminal because that is what a deliberate refusal usually means. Declare resumable: true when continuing is genuinely the cure — a phase-budget guard whose remedy is "raise the cap and carry on" would otherwise make the operator re-pay the phase the run already completed, the exact cost the guard exists to avoid.

Two things a resumable: true node must be authored against: the guard it follows is re-executed on the resume, so it has to be re-runnable (deterministic gates are — a compute or a tool reading run.* is the shape this is built for); and the promise is only kept when ONE predecessor routed in, so a fail node used as a fan-out convergence, or declared as the workflow entry:, degrades to terminal with a WARN.

Nothing picks a refusal up by itself — not --auto-resume, not the cloud runner's redelivery. Reaching a fail node is a decision, and the one failure an automatic retry can never fix: the graph would re-execute the same guard against the same inputs and refuse identically, burning a pod and a sandbox per turn. The engine's error carries a sentinel the runner ACKs on, and the runner separately refuses to resume a run parked on a bot-defined code. Only a human with changed inputs moves it. See resume.

Inside a fan-out branch, both fields are bounded. A branch cannot end the run by itself — the collector decides — so a fail node reached inside a fan_out_all / fan_out_each body reports its diagnosis as a typed BRANCH error. The code: still reaches the run's failure_code when every failed branch agrees on it (the collector keeps a common code rather than laundering it into EXECUTION_FAILED); when branches disagree, the aggregate is untyped. resumable: true cannot be honoured there at all — the branch has no authority to park the run — and the engine logs a WARN naming the node. Put a guard whose refusal must be resumable on the trunk.

Reuse and nested execution ​

group / use ​

Groups are compile-time macros containing agents, judges, routers, humans, tools, computes, and internal edges. Each use prefixes cloned node ids and substitutes {{params.*}} across string properties, including nested blocks and lists. Named and inline prompts used by group members are specialized when their bodies depend on the instance; concrete consumers outside the group retain the original prompt.

Inside a group, outputs.gate.value in an expression and {{outputs.gate.value}} in a template refer to that instance's gate member (for example outputs.security.gate.value). References to nodes outside the group keep their names. Expression string literals and ordinary prose are unchanged. Parameter values are inserted once, verbatim, after local references are bound; a value supplied by use ... with is not reinterpreted as another parameter or as a group-local reference. Each instance owns its nested data, and expansion leaves the source AST intact.

iter
prompt inspect_prompt:
  Inspect the change and report material defects only.

group check(rule):
  agent inspect:
    model: "anthropic/claude-sonnet-4-6"
    user: inspect_prompt

use check as security with {
  rule: "security"
}

workflow grouped:
  entry: security.inspect
  security.inspect -> done

External workflow edges address expanded nodes as <prefix>.<node>.

import — a bot in several files ​

Groups reuse a cluster inside a file; import splits one program across files, so each fragment is edited alone and the main keeps the graph:

text
my-bot/
├── main.bot           # dsl: 2, the imports, the workflow (and the vars, when it has any)
├── lib/
│   ├── schemas.bot    # a fragment: schemas
│   └── nodes.bot      # a fragment: prompts and nodes (may `import "schemas.bot"` itself)
└── manifest.yaml      # requires: { iterion: ">= 3.145.0" } — the release that reads import
text
# main.bot
dsl: 2
import "lib/schemas.bot"
import "lib/nodes.bot"

workflow w:
  entry: worker
  worker -> done

Every surface reads the unit, never the main alone: iterion validate main.bot (a fragment validated alone says where it is validated), run, resume, fork, rewind --auto, the dispatcher, the studio (which opens the merged document with each declaration's file on it and saves each declaration back where it came from — a new one to the main; its Source view is a picker over the unit's files, showing ONE file's text at a time, with the merged program as a read-only entry beside them, and an edit re-parses the unit with that file replaced), the cloud editor, the bot registry (the launch form's vars come from the whole unit), the catalog, and the recipes embedded in the binary — iterion run feature-dev/main.bot from any directory writes the whole bot to its cache, and the studio's Examples list serves an embedded bot in several files as one flat program. A remote launch uploads the program written out as one file; the cloud snapshot freezes lib/ with the rest, and a subbot declared in a fragment resolves like one declared in the main. The run's identity covers every file of the unit and every {{include}} its prompts read, so a fragment edited under a parked run is a source change (iterion resume refuses it without --force), and the run records every file it executed (workflow_sources) for rewind --auto to diff. A bundle that imports declares the engine floor that reads it (requires: { iterion: ">= 3.145.0" }): validate asks for it (C252), a push refuses without (409). iterion bots create <slug> --template library scaffolds the shape. A fragment is shared BETWEEN bundles as a bundle DEPENDENCY, never as an escape from that confinement: the producer declares exports.fragments, the consumer dependencies.fragments, and the install materializes the bytes under the consumer's own lib/, so an import keeps meaning exactly what it means here (ADR-145). A bundle that never had that install — one a launch freezes, one the cloud editor holds in a store — has the declared fragment resolved for it instead, through the same producer rule: lazily, scoped to the bot's team, and read-only (no save writes a dependency's file back, which would make the consumer ship the fragment it declares a dependency on).

subbot ​

iter
subbot run_ticket:
  description: "Implement one planned ticket"
  source: "child.bot"
  with {
    issue: "{{outputs.dispatch.ticket.id}}"
  }
  output: ticket_result
  needs: [worktree_slot]
  isolated: true

A subbot is a real nested run with its own loops, state, and budget. Parent budget totals do not aggregate child budgets. isolated: true is an author's assertion the runtime cannot prove — use it only when the child cannot mutate the parent checkout — and it is what the engine reads to leave the child the worktree its own worktree: auto gives it: a child declared without it works in the parent's tree, which is where the parent reads its result. In a shared sandbox there is one tree whatever the declaration says.

See groups, iteration, resources, and sub-bots for pause/resume, board-write, and concurrency boundaries.

The public contract — contract ​

A bot has a face the outside reads without opening its prompts: what it takes, what it produces, the files it delivers, the deterministic checks that condition what follows, and the effects it has. contract <name>: declares it once at top level; the workflow names the one it keeps with contract: <name>. A contract carries no prompt, tool or provider setting — and it is bound to the program (ADR-099): every declared contract is held to what the program does — an input the program has no var for, an output no node produces — and a contract the program contradicts is a compile error, never a display.

iter
vars:
  goal: string

schema report:
  summary: string
  pr_url: string

prompt build_user:
  Implement {{vars.goal}} and report the pull request URL.

agent build:
  model: "anthropic/claude-sonnet-4-6"
  user: build_user
  output: report

contract feature:
  display_name: "Feature dev"
  responsibility: "Implements a feature and opens a pull request"
  version: 1
  inputs:
    goal: string
      description: "What to build"
  outputs:
    pr_url: string
      from: build.pr_url
  criteria:
    goal_is_not_empty:
      kind: min_length
      port: input.goal
      params: {min: 1}
  effects:
    opens_pr:
      description: "Opens a pull request on the repository"

workflow feature_dev:
  contract: feature
  entry: build
  build -> done

What the compiler holds, and where:

  • C300 — every input is a declared var of the port's type, its value comes from the launch (an input carries no from: and no file:), and its requiredness and default are the var's — an input is required exactly when its var has no default, and defaults to what the var defaults to, read as the launch reads a value of that type (a string[] or json var's text as a list or an object); a port may repeat them, never contradict them; on a var without a default, a nullable: true port may be required: false — with no default, or default: null — since omitted, the run starts with the var unset, which is null; a var's [enum: …] is the domain the contract advertises. A contract is declared once, its version: starts at 1, and the workflow's contract: names a declaration.
  • C301 — every output names its producer: from: <node>.<field>, a field of the node's output schema of the port's type; from: <node> for a port typed with the node's whole output schema, or for a file port — a port with a file: block — on a node that publishes an artifact (publish:). An instance of a group is named <prefix>.<node>; an await_answers binds through its implicit answers field, typed json. An output is never defaulted.
  • C302 — a criterion names a declared port, singular (input.goal, output.pr_url), an evaluator that takes the port's type (a file port is a file, which none takes), and parameters its declaration accepts; a default has the port's type, and is null only on a nullable: true port.
  • C303 (warning) — a criterion whose kind: has no registered evaluator (this build ships min_length and pattern) is named: no criterion is evaluated at run time, registered or not — a criterion is a declared check for readers (and for the evaluators a future release will run), and C303 only warns that the declared kind ships no evaluator.
  • C304 (warning) — an output whose producer is on no path to done is produced only when the bot fails.

A default: or params: is one JSON value on one line — "text", 12, true, null, [...] or {key: value} — with no bare word, no signed number and no exponent: the .bot text refuses the other forms outright, at the lexer (E001, E002), and a value that reaches the compiler through a document — the studio's — and the text cannot write is C302.

iterion validate renders the bound contract (each port with its producer, each criterion with its evaluator, each effect) and returns it as public_contract in --json, the result the MCP local_validate tool reads — for a program that compiles, never for one that does not. The studio saves a contract back to the file it came from, and Verify holds the saved text to the document: a name that is not an identifier, or a value the text cannot write, is refused by name before a file is touched. Two more readers are held to the contract as warnings: the manifest (C254 — a launch.primary entry that is not a contract input, a produces[].node that produces no contract output; launch.hidden, the inputs the form never renders, is not held) and a parent's subbot (C255 — a with: that misses an input the child's contract requires, and an output: schema field the child's contract does not produce, or produces at another scalar type; a json field accepts any port shape, so only the scalars the two sides share are compared).

What a parent receives. A subbot node whose child keeps a contract receives the contract's output ports, projected from the child's run after it finishes — not the child's terminal-node output. A from: <node>.<field> port carries that field's value; a port typed with the node's whole output schema, or a file port, carries the node's whole output; the port name is the key the parent reads. A port whose producer — or whose field — the child did not produce is an absent key, which is what a skipped producer means: nullable: marks the ports a finished run may legitimately leave out. A child that keeps no contract keeps the historic semantics (its terminal-node output is the subbot's output). The projection happens on every surface that runs a subbot — iterion run, the studio, the dispatcher and the cloud pod — including the paths that rebuild a child's output from the store after a human gate or a restart. iterion bots create <slug> --template contract scaffolds a bot that keeps one.

A bundle that declares a contract declares the engine floor that reads one (requires: { iterion: ">= 3.150.0" }, parser.ContractSince): validate asks for it (C252), a push refuses without it (409), a studio authoring commit likewise (400, bot_engine_floor), iterion dsl migrate raises a bundle's floor to what its sources need. A comment survives a studio save where it was written: above the declaration it leads, above a property inside a block, beside an edge, at the end of a line. Each one travels on the declaration it was written around, so moving that declaration on the canvas moves its comments with it, and unparse.Verify — the guard the save and iterion fmt both call — refuses a text that lost one.

Cursors and supervisors ​

A cursor declares reusable prompt calibration; a supervisor is a concurrent watcher, not a graph node:

iter
cursor rigor:
  description: "Review strictness"
  values:
    normal: "Focus on material defects."
    high: "Demand direct evidence and adversarial checks."

supervisor guard:
  watches: [worker]
  model: "anthropic/claude-sonnet-4-6"
  system: supervision_policy
  cooldown: "30s"
  max_evals: 20

Cursor declarations use either values: or numeric bands:, never both. Supervisors enqueue node-scoped steering messages while watched nodes are active. See cursors and supervisors.

MCP servers ​

iter
mcp_server code_tools:
  transport: stdio
  command: "npx"
  args: ["-y", "@example/code-tools"]

mcp_server remote_tools:
  transport: http
  url: "https://tools.example.com/mcp"
  auth:
    type: "oauth2"
    auth_url: "https://tools.example.com/oauth/authorize"
    token_url: "https://tools.example.com/oauth/token"
    client_id: "iterion"
    scopes: ["tools.read"]

Supported transports are stdio, http, and sse. Workflow mcp: blocks may set autoload_project, servers, and disable; node blocks use inherit, servers, and disable.

The resolved set is authoritative on claude_code: iterion passes it via --mcp-config --strict-mcp-config, so the operator's personal user-scope MCP servers (~/.claude.json) do NOT boot inside bot nodes — a node's mcp: block (plus the repo's .mcp.json through autoload_project and iterion's own ask_user/board servers) is the complete truth. Set ITERION_CLAUDE_CODE_STRICT_MCP=0 to deliberately restore host-config inheritance. (pi and claw are strict by construction — pi's MCP client only connects declared servers, claw registers in-process tools.)

Workflows and edges ​

A workflow selects the entry node, configures run-wide controls, and declares edges:

iter
workflow review:
  entry: prepare
  default_backend: "claude_code"
  worktree: auto
  compress: on
  ambient_context: none
  permission: ask
  allow: ["Read(*)", "Grep(*)"]
  ask: ["Bash(git push:*)"]
  deny: ["Bash(rm:*)"]
  capabilities: [board.read]
  skills: ["review-playbook"]

  budget:
    max_parallel_branches: 4
    max_duration: "30m"
    max_cost_usd: 10
    max_tokens: 400000
    warn_tokens: 300000
    max_iterations: 100

  resources:
    test_slot: 2
    worktree_slot: ["slot-a", "slot-b"]

  compaction:
    threshold: 0.85
    preserve_recent: 4

  sandbox: auto

  prepare -> reviewer
  reviewer -> done when approved
  reviewer -> prepare when not approved as retry(3)

Workflow controls are vars, attachments, entry, default_backend, tool_policy, capabilities, skills, mcp, budget, resources, compaction, interaction, worktree, compress, permission, allow, ask, deny, and sandbox.

Budget fields ​

max_duration is a Go duration string, and the only budget field resolved through ${VAR:-default} — max_duration: "${RUN_BUDGET:-30m}" lets a bot be re-timed per environment without editing the .bot. Two consequences worth knowing:

  • A value that does not parse is not a compile diagnostic. The runtime logs the duration cap is NOT ENFORCED for this run at WARN and carries on with no time limit, so a "2h3Om" typo costs the cap silently unless you read the log.
  • If max_duration was the only limit declared, that same typo drops the whole budget tracker (no cost, token, or iteration accounting either) — the tracker is only built when at least one limit resolved.

The numeric fields (max_cost_usd, max_tokens, max_iterations, warn_tokens) are typed, so they fail at compile time instead (C046 for a malformed max_cost_usd).

Budget and loop back-edges ​

A loop's back-edge is declined when the budget can no longer fund another iteration. The runtime prices one iteration by what the previous one consumed — the distance between two consecutive arrivals at the loop's decision point, on every axis the workflow actually caps (max_cost_usd, max_tokens, max_iterations, max_duration) — and skips the back-edge once another iteration would reach the threshold where the engine stops starting nodes at all (90% of the cap). Stopping merely before the cap would not be enough: the run would fall through into an exit path that same threshold then refuses. The run instead leaves through its own exit path with room to walk it — for the campaign shape below, the gate -> publish fall-through that also serves loop exhaustion.

iter
gate -> publish when converged
gate -> work as passes(4)
gate -> publish            # exhausted, or unaffordable: ship what is banked

This matters for any loop that banks work as it goes (commits in stride, a published report, a PR opened by a tail node). Without it a loop starts an iteration it cannot pay for, dies mid-iteration on BUDGET_EXCEEDED, and the tail that would have delivered the work never runs.

A loop is priced from the moment it is entered, and re-priced on each re-entry — so a loop reached late in a run (a second phase) is charged for its own iterations, never for the work that preceded it, and a nested loop re-entered per outer iteration starts fresh. A loop that has not been measured yet reports nothing rather than guessing. The prices ride the checkpoint, so a resumed run keeps measuring across the pause.

The decline is visible, never silent: a budget_warning event carrying reason: loop_budget_guard with the loop, the blocking dimension, the remaining allowance, the price of the last iteration, and the axis's used/limit (durations in seconds, with an explicit unit). A conditional back-edge is only priced on a crossing where its when actually holds. The event's detail says the decline in prose — the toast, the run report and the alert Reason render it, where the bare figures would read as "budget at 59%", the opposite of what happened — and names the escape hatches below, so an operator reading the decline knows the way through.

The guard is on by default and switched off through the usual precedence chain — --loop-budget-guard off (on run and resume) → the workflow's loop_budget_guard: off → ITERION_LOOP_BUDGET_GUARD=off → the default on. Turning it off restores the run-until-you-hit-the-wall behaviour, hard failure included. An invalid value is diagnostic C133, not a silent fall back to the default. The 90%-hard-limit and exceeded checks stay as the backstop for a single node that overruns on its own.

One loop can lift the guard for itself, on the clause that declares it: as reask("vars.max_reask") guard: off. That is the answer for a loop whose iterations shrink — a re-ask of the still-pending subset, a retry of the failed batch — which the guard otherwise prices at its first, largest pass and declines even when the next iteration would fit: such a loop is bounded by its cap alone, so the affordability pricing adds nothing on it. The clause sits between the launch override and the workflow block in the chain (the operator's launch word outranks it), an invalid value is C178, and every edge sharing the loop must agree on it. Two honest costs: a run with an opted-out loop gets no exit grace (the grace borrows its "no further iteration" half from the guard), and the clause is the bot's word — an operator's hatch stays the run-wide chain.

The middle path between keeping the guard and lifting it is declaring the loop's work signal: as reask("vars.max_reask") sized_by "outputs.fold_probe.pending" (#2677). The guard then scales the previous crossing's cost by size now / size at the previous crossing, sampled each time the loop's mark is taken, so a shrinking iteration is priced by the real traversal — a 10-item re-ask after a 43-item one costs 10/43 of it, not the whole — while a growing one prices up by the same ratio. The signal is a bare expression over vars/outputs (no template braces; an unreadable or provably non-numeric one is C326, and every edge sharing the loop must agree on it). A reading that resolves to nothing or to a non-number prices the crossing unscaled — the default pricing, never an invented decline — and a size of zero prices the iteration at nothing, which is exactly what a drained re-ask costs. The samples ride the checkpoint beside the marks, so a resumed run prorates across the pause. One honest boundary: the FIRST crossing of a loop whose signal appears inside its own body has no earlier reading to scale against and prices full — if that first re-ask must cross, guard: off is the clause, not sized_by. A decline that prorated says so: the budget_warning carries sized_by, size_previous and size_now beside the figures, and the detail names the scale it applied.

The run-level override travels — onto the cloud queue (RunMessage.loop_budget_guard, schema v7) and into a detached subprocess — so a runner pod re-resolving the chain from its own empty environment cannot quietly replace what the operator asked for. It is not persisted on the run, so iterion resume --loop-budget-guard must re-state it.

Exit grace. Once a cap is spent (100%+), the run may still walk forward — never around a declared loop — spending up to 10% beyond the declared cap to reach a terminal node, so work it has already paid for gets delivered (the PR opened, the report written) instead of dying on disk. Every graced node is recorded as a budget_exit_grace event naming the exceeded axis and its own used/limit pair. The allowance is proportional and bounded: past cap × 1.1 the run fails as BUDGET_EXCEEDED, exactly as before — including the max_duration axis, where a graced node is given a real deadline at the graced ceiling rather than running unbounded. ITERION_BUDGET_EXIT_GRACE overrides the ratio; 0 (or off/no/false/none) makes every declared cap absolute — the setting for deployments where a cap must be a hard invoice ceiling (shared instances, pooled credentials). It parses fail-closed: a value outside [0,1], or one that is not a number, also means 0, with a one-time stderr warning — an operator reaching for this variable wants a tighter policy, so an unreadable value must never grant the permissive default.

The grace is refused in these cases:

  • the loop budget guard is off — the "no further iteration" half of the safety argument belongs to that guard, and with it lifted a graced run could take a back-edge and keep looping on a spent budget. The clause form counts too: ONE loop carrying guard: off refuses the grace for the run, because that loop's back-edge stays crossable past a spent cap;

    The guard prices loop-named back-edges. A foreach back-edge is bounded by its collection rather than by affordability, so a graced run inside a foreach body keeps iterating until the proportional ceiling stops it — the spend bound holds either way, but "it cannot iterate again" is a promise only the declared-loop form makes.

  • a parallel branch is executing — sibling spend shares the same tracker, so a branch cannot reserve the run-wide allowance for itself.

An external ceiling is absolute even when the declared cap is lower. Each axis ends its grace at min(cap × (1 + ratio), ceiling): 2h under a 2h platform ceiling remains 2h; $9.50 under a $10 donor allowance stops at $10, not $10.45. Budget.ClampToCeiling marks CapImposed whenever a positive ceiling exists and keeps its amount independently of the declared cap. Platform amounts come from the runner's existing ITERION_CLOUD_MAX_* environment, while queue schema 27 carries the donor amount as BudgetOverrides.donor_max_cost_usd. An unrelated axis keeps its normal grace. Both HTTP raise-budget and WebSocket raise_budget clamp live raises to these same ceilings; replayed raises on resume obey the newly admitted ceilings too. A legacy imposed budget with no amounts retains its absolute cap behavior.

Both exceeded stop-paths — the check before a node runs and the overrun noticed after one succeeds — go through the same decision, so a node is never refused by a rule stricter than the one that admitted it. What the second path still catches is a node whose own spend carries the run past cap × (1+ratio): it completes, and the run then ends. The grace buys the node its chance to deliver, not immunity from the ceiling.

The grace only exists past the cap. The separate 90% hard limit, which refuses to start a new node while an axis sits in [90%, 100%) to bound concurrent overage, is not graced and is unchanged: it is reached only when nothing is exceeded yet, so it stops a run before the grace could ever apply. The counter-intuitive consequence is real — a run refused at 92% of its cap gets no grace, while a run already at 105% may walk on to its terminal node. Raise the cap and resume for the former; the latter is the case the grace was built for.

iter
workflow campaign:
  entry: work
  loop_budget_guard: off    # this loop must burn its cap, not stop short

max_cost_usd only counts spend it can price ​

A node's cost is known when the backend meters it (the claude_code and pi CLIs report their own figure) or when the model resolves in one of three price sources, in order: claw's live registry, then the spec aggregator's published pair (models.dev via pkg/backend/modelspecs — taken only when BOTH rates are positive, since a half-published pair would price the other half at zero), then pkg/backend/cost's static table. When none answers, cost.Annotate deliberately omits _cost_usd: an absent value means no cost data, never this call was free.

The budget honours that difference rather than folding the absence into a 0.00 sample. Tokens burned at an unresolvable price are counted apart, and the first time it happens under a declared max_cost_usd the run emits one advisory budget_warning on dimension cost_usd_unpriced, whose detail names how many node executions and how many tokens the ceiling could not see at that point. That figure is a floor, not a total: the warning is raised once per ceiling — the operator is told, not spammed — while the counters keep climbing behind it, so a run that goes on to burn forty unpriced nodes was told about the first. The run continues — an operator may legitimately want it to — but the ceiling never again reads as enforced when it is only partial.

What reaches it is a model absent from all three pricing sources — typically one newer than the static table and not yet published, or one whose published pair is half-known. A backend that publishes no dollar figure of its own, like codex, is not a separate cause: it falls back to those same sources, so it only goes unpriced when its model does. If the warning fires, run iterion models pricing to see which source (if any) answers, then either add the model to the table or expect max_cost_usd to bind on the priced nodes only.

Neither ceiling counts usage the provider never reported ​

The budget counts what the provider reported. A claw call whose usage the provider did not report in full is counted apart. Two causes:

  • A provider that sends no usage at all. On the OpenAI chat wire, usage is sent only when requested, and claw requests it from api.openai.com and Foundry only: an endpoint set through OPENAI_BASE_URL is not asked unless the operator opted it in (ITERION_OPENAI_STREAM_USAGE=1), so its calls count as unreported unless it sends usage anyway.
  • A call that ended before its final account: a cut, a stall, a failure frame carrying none, or a request abandoned unanswered by the cold-stream watchdog. A request the provider refused before serving any of it — a rate limit, an overload, an exhausted balance, an over-window prompt, or any condition no new request clears, named by the error's code or type — is billed nothing and is not counted. The verdict is read on the chat and Responses wires; the Anthropic wire forwards the frame's message only, so a refusal named only in prose still counts there.

Whatever the call consumed reaches the run as a lower bound at most: the tokens it streamed before failing, or nothing. The first such call under a declared max_tokens or max_cost_usd emits one advisory budget_warning on dimension usage_unreported. Its detail names how many calls went unreported so far. As for cost_usd_unpriced, this figure is a floor: the warning is raised once per ceiling and re-armed when a ceiling is raised. The count rides the checkpoint, so a resumed run keeps it — and raises the warning once more. A failed attempt counts too, retried or not; when that count is all it spent, it takes no max_iterations slot.

Each node output carries the count under _usage_unreported_calls, and each completed claw step that went unreported carries usage_unreported: true on its llm_step_finished event. Outside a budget, the runner logs that fact once per attempt and per route — for the steps it saw: a failed call emits no llm_step_finished, so only the budget counts it.

The target repo's toolchain — repo_devbox: ​

Two devbox.json files can supply a run's binaries, and both are honoured: the bot's own, shipped beside its main.bot, and the target repo's, at the workspace root. repo_devbox: governs the second one only.

It exists because "the repo pins a toolchain" and "this run needs that toolchain" are not the same statement. A run that builds the repo needs it. A run that reads a diff and writes comments does not — and pays for it anyway: on iterion's own tree that bill is 319 Nix paths, 406 MiB downloaded, 1.8 GiB unpacked (a desktop GUI stack among them), before the first node executes, on every review. A cold install can also outlast the window a sandbox has to come up, which turns a cost into a dead run.

Default on — a repo that pins its toolchain usually pins it to be built. Switched off through the usual chain: --repo-devbox off (on run and resume) → the workflow's repo_devbox: off → ITERION_REPO_DEVBOX → the default on. An invalid value is diagnostic C134, not a silent fall back. The bot's own devbox.json is never affected: a bot that declares crane needs crane whatever repo it is pointed at.

A declined source is reported, not dropped — the sandbox_devbox_provisioned event carries skipped_sources: ["repo"] with the config and the reason it declined (skipped_configs / skipped_reasons), and the run logs it. Without that, the only trace of the decision would be a binary missing later, which reads as an agent bug. The same channel reports what remains declinable on the bot's side: a devbox.json that cannot be read, or one too large to carry into a sandbox with no bundle mount (sandbox.md).

A bot's devbox.json is honoured on every driver, including the ones whose workspace is a copy inside a pod. There the bundle cannot be read from in-container, so its config is carried there: the install prologue writes it out before running devbox install. That matters because the pod driver is where bots actually run — declining there (as iterion did until 2026-09-10) made the documented way for a bot to declare its binaries work on a laptop and go silently inert in production.

The override does not travel onto the cloud queue: what a cloud runner needs is the workflow's declaration, which rides the .bot itself. So a bot's repo_devbox: off holds in cloud, while --repo-devbox is a local run's override.

iter
workflow review_pr:
  entry: review
  repo_devbox: off    # this run reads the repo, it does not build it

The mid-run safety net — workspace_checkpoint: ​

On a copy-based driver (kubernetes) the workspace is a tar copy inside the pod, so nothing a run produces leaves it until teardown — and a pod that dies hard takes the run with it. The workspace checkpoint closes that window: every ten minutes the runner reads the pod's tree into a temporary index, commits it without touching the run's own history, and force-pushes it as iterion/run-<id>-checkpoint. Details: pkg/runner/loop_checkpoint.go.

workspace_checkpoint: is the same statement as repo_devbox:, one step further: not "does this run build the repo" but "does this run write commits FOR it". If it does not, the net holds nothing of the run's — and it still pushes. Two consequences an author should weigh:

  • the push lands on the run's own remote, which is the repository the bot was pointed at. A branch appears there, on every run;
  • the tree is read with git add -A, so a bot's scratch directory goes with it — untracked, and nothing ignores it. Measured 2026-09-08: a review bot's .review-pr/findings.md, an internal artifact whose board posting had been deliberately disabled, reached a public repository that way and stayed 29 hours.

For a reviewer or an auditor that is pure downside: its conclusions live in node outputs, durable in the store without any push. The shipped read-only bots (review-pr, revi-converse, sec-audit-source, sec-audit-deps) therefore decline it, guarded both ways by bots/workspace_checkpoint_test.go — which also asserts the committing bots keep theirs.

Default on: most bots exist to produce the commits this protects. Resolved workspace_checkpoint: → ITERION_WORKSPACE_CHECKPOINT → on; an invalid value is diagnostic C139, not a silent fall back. There is no per-run override yet, so a bot's off is final for that bot — declaring it is a statement about what the bot is, not a per-run cost dial.

iter
workflow sec_audit_source:
  entry: inventory
  workspace_checkpoint: off   # reads the repo; writes findings, not commits

Edge forms ​

iter
src -> dst
src -> dst when approved
src -> dst when not approved
src -> dst when "approved && length(outputs.scan.findings) == 0"
src -> fallback else
src -> dst as retry(5)
src -> dst as retry("{{outputs.plan.max_passes}}")
src -> dst as retry("vars.max_passes - 1")
src -> dst as retry(unbounded 200)
src -> dst as foreach scan(item in "{{outputs.plan.items}}")
src -> dst with {
  context: "{{outputs.src}}",
  produced: "{{input.field}}",
  mode: "{{vars.mode}}"
}

A chain a -> b -> c reads as the edges it names (a -> b, b -> c), and the clauses at the end of the line belong to the last segment: a -> b -> c when ok guards b -> c only; a clause before a further arrow is refused (E032) — write that segment on its own line. Optional when/else, as, and with clauses may appear in any order, once each. else is the explicit fallback when no sibling guard matched — and no loop or foreach back-edge from the same node still has work, which wins over every fallback. A quoted when uses the bounded expression language. In a with mapping, {{input.field}} is the source node's output (C034 checks that output schema); {{vars.name}} is a workflow variable; {{outputs.node.field}} names any prior node. There is no silent fallback from input to run-level inputs.

Quoted when expressions are evaluated in parallel branch bodies as well as on the trunk, against that branch's private outputs, artifacts, loop state, and shared run variables. Migration note: older runtimes skipped expression-form edges inside fan_out_all, fan_out_each, and llm multi: true branches, so an existing workflow may now take a guarded route that previously fell through to else or an unconditional edge.

Every cycle must carry an as <loop>(...) clause. A cap may be a literal, one runtime template reference, a quoted expression over vars.* and outputs.*, or unbounded with a fuel ceiling. If an unbounded loop omits its local fuel, budget.max_iterations must supply it; the runtime also applies a no-progress liveness monitor. as foreach is different: it walks a finite array sequentially and binds the each.<name> namespace.

Counting crossings. as retry(N) permits N back-edge crossings, hence N+1 executions of the loop body when entered once. To request a total of max_passes executions, write as retry("vars.max_passes - 1"). The expression is evaluated at each attempted crossing against the current variables and outputs, including after a fresh-engine resume with raised variables. A dynamic result of zero permits no further crossing. Literal caps retain their existing minimum of 1.

The compiler refuses undeclared references and definitely non-integer cap types. At a selected crossing, an absent, fractional, negative or overflowing result fails explicitly with EXPRESSION_FAILED, naming the loop and its cap; it is never treated as zero. A fallback cap is not evaluated as a failure if another condition selects an exit. Prompt/display lookups may see an unresolved cap before its producer has run and do not abort the run. Migrate a loop-cap variable declared as string to int; the compiler now reports that mismatch even when its default happens to contain digits. Numeric strings from dynamically typed legacy outputs remain accepted by a cap that only reads a value — "{{outputs.gate.remaining}}" and the un-braced "outputs.gate.remaining" alike. A cap that computes must produce a number: + concatenates as soon as either side is a string, so "outputs.gate.remaining + 1" over a field holding "3" is refused rather than read as 31. Use floor or round when converting a fractional expression is intended.

Expression caps require a runner from the release that introduced queue schema v15 — that is this feature's floor, not the version in flight. A publisher emits queue.SchemaVersion and a runner accepts down to queue.MinSchemaVersion, so read those two constants in pkg/queue/types.go before sequencing a mixed-fleet deploy: this page cannot track them, and a stale number here is the exact failure the queue rollout contract exists to prevent. A runner below the floor rejects the message before compilation.

Leaving an exhausted loop. Once a bounded loop has spent its iterations the back-edge is declined (the log says edge to "…" skipped — loop "…" exhausted), and a node left with no other edge ends the run with LOOP_EXHAUSTED — iterion validate names the shape beforehand (C145). The exit is written as a second, bare edge from the same node — the loop-exhaustion exit — taken once the loop is spent, whatever order the two edges are written in (a loop edge the engine still takes — budget left, no liveness stall — wins over the node's other fallbacks, an else included):

iter
fixer -> run_tests as fix_passes(3)   # the back-edge, taken while iterations remain
fixer -> fix_passes_exhausted         # fires once they are spent (or when the budget cannot fund another)

Migration note: older runtimes read a node's fallbacks in source order, so a bare exit written above the loop back-edge ended the run after one pass — the loop was dead code. The order no longer decides: a .bot that relied on that accidental one-shot now loops as written, up to its cap. Write the exit below the back-edge anyway; it reads as what it is.

A loop back-edge does not count toward C010 (one unconditional edge per node), so this pair is the one legal shape with two unconditional edges; the bare edge also serves a conditional back-edge (… when not approved as fix(3)) once its cap is reached. Route it to a typed fail <name>: when exhaustion is a refusal, or onward when the work banked so far should still be delivered.

Guarding that exit with the back-edge's own when. Order decides nothing for a bare or else exit; it decides everything once the exit carries a when. Conditional edges are read in declaration order and the first match wins — and a back-edge carrying a when is read among them, never through the order-independent fallback slot, which only an unconditional iteration edge reaches. So a guarded exit works, and only works, declared after its back-edge: the back-edge wins while the iteration has work, the exit fires once the iteration is declined. The exit also has to leave the iteration: an exit whose target leads back to this node without crossing the back-edge — the back-edge's own target, or anything else inside the body — re-enters the body with the cap untouched, so only the run's budget ends it, and C011 refuses that pair in either declaration order.

iter
write_audit_md -> select_candidate when not was_batch as package_loop(3)
write_audit_md -> bucket_families when was_batch
write_audit_md -> phase2_decider when not was_batch

Declared before the back-edge the exit matches on the first pass and the loop never crosses, so C011 refuses that order and names it; two exits sharing the condition stay refused in any order, the second being dead. Declaration order is not the layout: a used group's edges are declared after the workflow's own, so a back-edge written inside a group is declared last however high in the file it sits — guard its exit from inside that group, or write the exit bare, the form no order decides. What the guarded form buys over the bare exit: an output carrying no was_batch at all matches neither edge, and the run dies of NO_OUTGOING_EDGE instead of leaving by an exit that checked nothing.

A bounded loop or foreach may live wholly inside one fan_out_all, fan_out_each, or llm multi: true branch. Every branch/item owns independent counters, loop snapshots, outputs, artifact allocations, and a durable cursor; siblings may therefore finish after different numbers of iterations, and a restart or human pause resumes the same local scope without replaying completed iterations. The collector becomes ready only after those local lifecycles terminate, under the existing wait_all / best_effort policy.

C244 is reserved for iteration with no unambiguous owner: an iteration edge on the fan-out router, a back-edge from the collector into a body (join -> a1 as more), a cycle crossing sibling branches, or a shared-node shape owned by more than one branch. A loop that wraps the fan-out from the join (join -> router as outer(N)) remains a normal trunk loop. Use a subbot when independent budgets, workspace isolation, or a reusable capability boundary are desired—not merely to obtain per-item counters. See composition/iteration/sub-bots.

A loop cap — or a foreach — on a round_robin or llm router's outgoing edge is refused at compile time (C309): those routers select their target on their own (round_robin alternates over its unconditional edges, llm takes the target the model named) without going through the loop-aware evaluator, so as spin(N) — or as foreach spin(item in …) — on such an edge would silently do nothing. Put the loop on the edge that re-enters the router — that edge does go through the evaluator, honours the cap, and wants the exhaustion exit C145 asks for.

Terminal targets done and fail are reserved and are never declared.

Worktrees, sandboxing, permissions, and budgets ​

  • worktree: auto executes in a per-run worktree and preserves a run branch, unless the run works in place — From a .bot to a running graph draws when it does. Final merge behavior depends on CLI/studio flags and delegated merge authority; see worktree finalization and resume.
  • sandbox: auto resolves a devcontainer/default image. Block form supports image/build, user/workspace, host-state, environment, mounts, post-create, and network policy; image: takes a full reference or a bare stock-class name (slim, full, sec, browser, rgaa) that resolves to the published variant at launch (an unknown bare word warns, C325); see sandbox.
  • permission: off|ask|deny plus allow:/ask:/deny: rules creates an execution-time tool gate. A rule names a tool (Bash, Bash(rm:*)), a glob (mcp__github__*) or a tool CLASS (class:shell — every registered built-in that executes code, present and future). Mode and rule lists are declarable on the workflow block and on an agent/judge node; a node list REPLACES the workflow list of the same kind, and the CLI overrides append to whichever won. See permissions.
  • compress: off|on|ultra controls output compression where supported; see ultracode.
  • Workflow budgets are shared across branches in that run. Hitting cost, token, duration, parallelism, or iteration limits emits budget events and stops/parks according to the failure path. Nested subbot runs retain their own budgets. warn_tokens is the advisory exception: crossing it emits a single budget_warning event (advisory: true) suggesting an audit of what consumed the tokens, and execution continues — use it instead of max_tokens when heavy consumption is legitimate (judge/rewrite loops going to their bounds) but worth an operator's look.
  • resources are named semaphores. Integer values declare capacities; string arrays declare leaseable named members exposed to nodes that list the resource in needs:.

Writing the twin in YAML ​

A .bot can be written in YAML. The author document x.bot.yaml holds the declarations of x.bot under the same names — dsl: (required), then catalog:, vars:, schemas:, prompts:, nodes:, workflow: and the rest, in the order the writer puts them (author-schema.md lists every key and how each value is written) — with YAML's own values. It is a way of writing the .bot, never a second truth (ADR-102): no surface launches, packs, hashes or stores a document — every launcher refuses one by name, and a bundle leaves it out — and the .bot is what you commit, review and run.

yaml
dsl: 2
schemas:
  verdict:
    ready: bool
prompts:
  ask: Review the change and say whether it is ready to merge.
nodes:
  - agent: reviewer
    model: anthropic/claude-sonnet-4-6
    output: verdict
    system: ask
workflow:
  name: review
  entry: reviewer
  edges:
    - reviewer -> done when ready
    - reviewer -> fail when not ready

The loop:

  1. Write x.bot.yaml. The JSON Schemas iterion-author.schema.json (one per profile beside it) give an editor completion and a first check.
  2. iterion validate x.bot.yaml reads the document into the program it describes — in the unit and the bundle of x.bot, every finding at the line of the document you wrote — and --exec runs the dry run on it. Once x.bot is there, it warns (E056) while that .bot is not the program the document describes — another program, or a text that is no program at all — the verdict unchanged: which of the two is current is yours to say. Each side is read as a launcher reads it — the fragments its import lines name merged in, the prompts of the bundle the two share merged into each — so an import is no difference of its own, nor is a prompt a prompts/*.md supplies; where the program question has no subject E056 says nothing — a side that describes no program at all and carries no refusal of its own is a fragment (a file under lib/, a file of prompts alone), and a document its own load or compile refuses leaves nothing to hold the .bot to, the verdict naming that refusal itself. A comment or a spelling is not that either: the two texts are what fmt --to bot --check reads (step 4).
  3. iterion fmt --to bot x.bot.yaml writes x.bot beside it, proven the same program; a program with no written .bot form is refused, nothing written (E054). Once x.bot is there, a document you changed writes it again with --force — which replaces the file whole: the comments and the frontmatter keys the .bot carried and the document does not are gone, and fmt counts the comments, names the first, and names the keys. A program that .bot held and the document does not describe — a .bot edited in place — goes with them, uncounted: read the two first.
  4. Commit x.bot. In CI, iterion fmt --to bot --check x.bot.yaml says whether the .bot beside the document is still the one it writes. iterion diagram x.bot.yaml draws that .bot; iterion fmt x.bot.yaml rewrites the document in its canonical form — the form of the examples on this page.

iterion fmt --to yaml x.bot goes the other way: the document of an existing .bot, and a note for what the document does not carry — the .bot's comments, the frontmatter keys beyond catalog:'s four — which the .bot keeps until --to bot --force writes it from the document.

Three rules a YAML author needs that a .bot author does not. The authoring probe measured the first two as the traps the YAML form adds (dsl-authoring-probe.md); the third is YAML's own:

  • Quote a value that holds : or #, or starts with a character YAML reserves. A plain jq '.status: .code' — or a text ending in : — reads as a mapping, and the document is refused (E050), the message saying to quote the value. A value that starts with {, [, !, &, *, |, >, %, @ or a backtick is YAML syntax, not text: a {{input.task}} template written unquoted is a mapping, and a leading ! is a tag YAML drops — ! grep -q x f would lose its negation; both are refused, the message saying to quote. A space followed by # starts a YAML comment, and that is no error: the plain value simply ends there — echo "see #123" reads as echo "see; fmt --to bot counts the comments it leaves out of the .bot and names the first, and validate --exec finds a command cut in two. Quote in single quotes, a ' inside written twice ('it''s'): inside double quotes YAML reads backslash escapes — "\t" is a tab, "\x41" is A.
  • An edge is one .bot edge line, and its inner quotes are the .bot's. An edge that holds : — a with mapping — is quoted whole in YAML, single quotes outside, the .bot's double quotes inside, and a ' in it written twice (''):
yaml
dsl: 2
schemas:
  summary:
    text: string
  verdict:
    ok: bool
prompts:
  ask: Summarise the change.
  check: Is this summary complete? {{input.summary}}
nodes:
  - agent: writer
    model: anthropic/claude-sonnet-4-6
    output: summary
    system: ask
  - judge: reader
    model: anthropic/claude-sonnet-4-6
    output: verdict
    system: check
workflow:
  name: summarise
  entry: writer
  edges:
    - 'writer -> reader with { summary: "{{outputs.writer.text}}" }'
    - reader -> done when ok
    - reader -> fail when not ok

Written plain, or inside double quotes of its own, the same line ends early and the document is refused (E050), the message naming the single quotes:

yaml
dsl: 2
workflow:
  name: summarise
  entry: writer
  edges:
    - writer -> reader with { summary: "{{outputs.writer.text}}" }
  • Write a number as digits, without a leading 0. YAML reads 010 as the octal 8 where the .bot reads 10, 0x10 as 16, +3 as 3, 1e2 as 100 and 01.5 as 1.5. The converter refuses these spellings rather than re-spell them in silence (E051, YAML's reading named): write the number you mean in the .bot's digits (8 or 10, 16, 100), or — where the value may be text — quote it ('010', a zip code). A - is kept where the value is always text — a with value, a connector's parameter (offset: -10); where a number or a text is taken (a preset's value, a JSON value) a signed number is refused, quote it if it is text; a property that takes a number takes no sign. The same holds for a bool where text is taken: True is YAML's spelling, true the .bot's, and draft: true in a connector's params: is the text true.

What else differs. A prompt declared under prompts: as a | block is a .bot prompt body: its leading and trailing blank lines are dropped, the first line's indentation is taken off every line, and profile 1 drops its interior blank lines — when the body you wrote is read otherwise, validate says so (E053, a warning), fmt refuses to rewrite the document rather than put that reading in your place, and fmt --to bot writes the reading into the .bot. YAML's scanner also ends a line at an invisible U+0085 (NEL), U+2028 (LS) or U+2029 (PS): inside a literal block body one is read as the newline the scanner meant (E053 names the reading), but a trailing line holding only such a character is chomped like a blank line before the body is ever read — so the document itself is scanned raw, and one warning lists every line that holds one (E055). A # that follows a plain TEXT value on its line warns the same way — the value ends at the # and the rest is a comment YAML drops (command: echo "see #123" reads echo "see); a number, a bool, a null, a quoted or block scalar keep their trailing comment as the note it reads as. A prompt text written in place (system: |) is a string, kept as written. dsl: is required (E052). catalog: is the .bot's ## --- frontmatter, read by the one reader the catalogue uses. Comments are not carried from one form to the other: --to bot counts the comments the .bot is not written with and names the first, and --to yaml counts the .bot comments the document leaves behind. Within a document they are kept: fmt rewrites a commented document in its canonical form with each comment on the node it hangs on, and refuses — leaving the document as it is — only one whose comment the written form cannot hold where it was written. A conversion under --force replaces its target whole, the program that target described included, and names what the target carried that the new text has no form for.

Validation and references ​

Three steps stand between a .bot and its first paid run, each cheaper than the next, each catching what the previous cannot:

  1. iterion validate workflow.bot — parse and compile. Diagnostics occupy sparse ranges: DSL/compiler/runtime consistency checks use C001–C199 plus the async-interaction band C240–C242, C243 (session: on a non-model node) and the structural band C244–C249; bundle checks use C200–C234. Every finding comes with its position and a fix: line, and --json carries each as an object (code, severity, file/line/column, message, hint, node_id, edge_id) — with edit when the remedy is mechanical (the literal before and after; iterion fix applies it). The diagnostic catalogue is authoritative. Two of the warnings name the deaths a new bot meets first: C145, a bounded loop with no exit at its cap (the run would die of LOOP_EXHAUSTED), and C146, a division into an int field without floor()/round().
  2. iterion validate --exec workflow.bot — the dry run. A program that compiles is run twice under a simulation, every condition true then false, without a model, a shell or the workspace: prompts, command:, script: and postcondition: are rendered by the production renderers and every {{…}} left unresolved is named with the reason; shell text is held to bash -n (dash -n for language: sh); a human is answered by a schema-shaped output, a wait at once; a subbot child is read within the bundle and simulated the same way. A json-typed field has no defined shape and the dry run guesses none: it keeps the object shape (a one-element list only where a fan_out_each, a foreach or a map/filter/reduce iterates it, so the body is entered), and an expression that fails on a value the dry run invented — a shaped json field, a json var without a value, a value derived from one — is inconclusive, not a death: the pass goes on, the finding names the value and what would decide it (--var, a fixture, a typed field). A fan-out's workspace-safety verdict is taken on the launch given: where a var the program declares has no value — no default, no --var — a routing field that launch could still move (a templated backend:, a when: gate on a fallbacks: route) is read as its author wrote it, which refuses rather than admits, and the refusal names those vars and the --var that decides them. The report (exec in --json) lists each pass, the findings by node, the inconclusive expressions under their own heading, the nodes whose output was only a shape (a condition read from one decided nothing about the real bot), the branches of a fan-out whose end was neither a ceiling nor a declared refusal (a best_effort fan-out finishes the run around them; the dry run names them under dead_branches, each once with its crossings), what no pass reached, and two verdicts: clean (nothing to fix, nothing undecided) and failing (a death or a defect finding). It never decides valid: it says what the first paid run would have met — --strict makes failing the exit code, for a CI gate; an inconclusive expression is printed, not failed. --fixtures f.json answers nodes with recorded outputs. The promise is bounded — the dry run reduces the runtime class of failure, it does not remove it: a value only a model produces stays a shape, a binary only the image has stays unchecked, said as such (ADR-100).
  3. iterion run — the real thing, on the build the bot declares in requires.iterion.

Around them: iterion fmt writes a file in its canonical form — the text the studio saves — proven the same program before it is written (--check in CI), and iterion fix applies the mechanical remedies the diagnostics carry (today C137, the quotes an author writes around a {{ref}} the runtime quotes already). Both refuse by name what they cannot rewrite without changing the program, and leave it as it was. From Claude Code, the MCP tool local_validate returns the same JSON as validate (exec and fixtures included).