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

Reuse & iteration: groups, foreach, subbots ​

This page documents the composition + iteration constructs added on top of the data-driven fan-out (fan_out_each) and named resources (resources:/needs:) described in dsl.md and routers.md. Together they let a .bot reuse a cluster of nodes, iterate over a collection (in parallel or in order), and compose whole bots.

ConstructKindWhat it does
group / usecompile-time macroReuse a named cluster of nodes + internal edges, instantiated N times
as foreach name(item in coll)edge clauseIterate a body once per element of a collection, in order, stateful
router mode: fan_out_eachrouterOne parallel branch per element, topologically scheduled (key/depends_on) — see routers.md
subbotnodeRun another .bot as a nested run (a real run — it may contain loops)
resources: / needs:concurrencyNamed counting semaphores / instance-lease pool — see dsl.md

group — reusable node clusters (compile-time macro) ​

A group declares a parameterised cluster of nodes and the edges between them. use <group> as <prefix> clones it: every node gets the id <prefix>.<name>, the internal edges are rewired, and {{params.X}} is substituted from the with { ... } bindings — all at compile time, so a group never reaches the runtime. External edges address an instance's nodes via the dotted reference prefix.node.

iter
group review_block(target, max_fix):
  judge check:
    model: "anthropic/claude-sonnet-4-6"
    output: verdict
  tool fix:
    command: "echo fixing {{params.target}}"
    output: empty
  check -> fix when not approved
  fix -> check as fix_loop("{{params.max_fix}}")

use review_block as r1 with { target: "frontend", max_fix: "3" }

workflow w:
  entry: analyze
  analyze -> r1.check
  r1.check -> done when approved
  • Params are substituted once into every string property, including nested blocks and lists. Named and inline prompts whose text depends on an instance receive a private specialization; a consumer outside the group keeps the original prompt. Source declarations and other instances are not mutated.
  • A reference to a member, such as outputs.gate.value in a compute expression or {{outputs.gate.value}} in a prompt, becomes outputs.r1.gate.value for use ... as r1. External references, expression string literals and plain prose keep their spelling. Values supplied by with are inserted verbatim, after local references are bound, without another substitution pass.
  • Two uses of the same group must use distinct prefixes; a colliding prefix.node id is caught by the standard duplicate-node check.
  • Diagnostics: C116 (unknown group), C117 (unknown/missing param).

as foreach — ordered, stateful iteration ​

A back-edge ... as foreach <name>(<item> in "<collection>") re-enters its body once per element of the collection, in order, stopping when the collection is exhausted. The current element is exposed under the each.<name> namespace:

start -> proc
proc  -> proc as foreach scan(item in "{{outputs.start.items}}")
proc  -> done

Inside the body: {{each.scan.item}} (drills into object fields, e.g. {{each.scan.item.id}}), {{each.scan.index}}, {{each.scan.count}}, {{each.scan.first}}, {{each.scan.last}}, {{each.scan.empty}}.

  • Use foreach for ordered / stateful iteration; use fan_out_each for an independent parallel map.
  • The element is resolved against the current index every iteration. A node references {{each.scan.item}} in its own prompt/command (resolved at execution, regardless of which edge entered it), so the first iteration — entered via the forward edge — sees the element too. Data flow is edge-driven: if instead you thread the element through an edge with { ... }, bind it on both the forward-entry and the back-edge (they are separate data flows).
  • An empty collection runs the body once (the forward-entry run) with each.<name>.empty == true; gate with when not each.<name>.empty if the body must not run on an empty list.
  • as foreach and as <loop> are mutually exclusive on one edge (C118).
  • Both forms may be wholly contained in a fan_out_all, fan_out_each, or llm multi: true body. The branch/item owns its counter, snapshots, outputs, artifact versions, and checkpoint cursor. C244 applies only when the cycle crosses the router/collector boundary, crosses siblings, or otherwise lacks one unambiguous branch owner.

subbot — run another .bot as a node ​

A subbot node runs a child .bot as a real nested run in the same store. Because it is a full run, the child has independent isolation and budget boundaries. Bounded retries and human gates can also be inlined directly in a fan_out_each branch; a subbot is no longer required solely to obtain local counters or resumability.

iter
subbot run_ticket:
  source: "child.bot"                 # resolved relative to the parent .bot
  with { issue: "{{outputs.plan.id}}" }   # → the child's vars
  output: ticket_verdict              # schema of the child's terminal output
  needs: worktree_slot                # optional resource lease for the child run
  isolated: true                      # child confines writes to its own run/worktree

workflow w:
  entry: plan
  plan -> run_ticket
  run_ticket -> merge when validated
  run_ticket -> fail else
  • The child's terminal-node output is mapped to outputs.<subbot>.<field>, so downstream when/with reference it normally. A child that keeps a contract instead hands its parent the contract's output ports, projected from the child's per-node outputs — a from: <node>.<field> port carries that field, a whole-schema or file port carries the node's whole output, and a port the child did not produce is an absent key.
  • needs: leases a resource for the duration of the child run; the leased instance id (from a named pool) is passed to the child as _lease_<resource> so it can pick e.g. a worktree index.
  • isolated: (default false) is the mirror of an agent/judge node's readonly:. See Fanning subbots out in parallel below — it is what makes the parallel pattern legal, and what leaves the child the worktree its own worktree: auto gives it; a child declared without it works in the parent's tree.
  • A depth guard bounds nested subbot recursion. Diagnostic C119 (no source).
  • The runtime invokes a host-supplied SubbotRunner; both the iterion run CLI (pkg/cli/run.go) and the studio's in-process engine (pkg/runview/subbot.go, wired for Launch AND Resume) provide one that compiles + runs the child sharing the parent store. Children carry ParentRunID, which is what folds them into the parent's card on the /pipelines board.

Child bundle resources ​

All four launchers (CLI, dispatcher, studio and cloud runner) pass the same bundle returned by compilation to the child engine. A directory or its Every child file inside a bundle brings that bundle's prompts, skills and devbox.json, including a sibling step.bot or a workflows/ export. Its persisted FilePath remains the selected child, while BundlePath identifies its resources. A loose file outside any bundle stays bare; resource-looking siblings alone do not promote it to a bundle.

In a shared workspace, each active child pass borrows .claude/skills, commands, agents and settings.json. An unchanged skill installed by an ancestor can be replaced by the child's same-named skill, including directory skills and flat-file aliases. User-edited workspace skills retain precedence. On success, failure, cancellation or a human pause, the saved resource trees are restored before control returns. A child is published as finished only after successful restoration; a restoration failure is failed_resumable with RESOURCE_RESTORE_FAILED, so reattachment cannot reuse a false success. This code requires manual inspection rather than automatic resume. If an abandoned executor ignores cancellation, cleanup drains for at most five seconds, reports that same failure, and retains the snapshot and workspace lease. Background cleanup restores the resources and releases the lease only after that executor exits; it never restores files under a live reader or publishes a late success. Code edits elsewhere survive; deliberate edits inside those borrowed resource trees do not. A failed restore returns an error naming the retained backup. Nested children and externally resumed children use the same scope rules.

Resource setup and executable nodes coordinate within the running process: children using one workspace serialize while they borrow its resources, and parent executable nodes wait for their return. Distinct workspaces keep their parallelism. This does not provide a cross-process workspace lock; the normal single-server/store and child-ownership rules still apply.

A child's devbox project is staged separately. On the host its PATH belongs to its executor; in a shared sandbox its profile is prepended only to its command handle and inherited by its descendants. sandbox_devbox_provisioned reports target: shared_sandbox, its bin_dirs, or installation errors. Failure to provision remains visible and best-effort, as for other devbox sources. The parent retains its PATH and sandbox lifecycle; only the child's staging directory is removed on return. Copy-based sandboxes also save and restore the borrowed resource trees. File-secret refresh capability remains available through the child's handle; attachment mounting and the restriction on standalone resumes of copy-based sandbox children are unchanged.

Human gates inside a child (pause / park / resume) ​

A child .bot may contain human nodes. When the child pauses, the child run persists paused_waiting_human (checkpoint + interaction) and the parent's subbot node parks: the parent stays running, holding its branch slot, while AwaitSubbotTerminal polls the store. The pending review surfaces on the parent's pipeline-board card (with its exact child run_id/node_id); answering it from the card's sidebar — or iterion resume --run-id <child> — resumes the child externally, and once it reaches a terminal state the parent picks up its terminal output (reconstructed from the child's node_finished events) and continues. Several gates in one child, and gates across parallel isolated: children, all work the same way — each pause is just another answerable review on the parent's card. A child that ends failed/failed_resumable/cancelled after its pause fails the parent's subbot node (resuming the PARENT re-runs that subbot with a fresh child). Runnable demo: examples/pipeline-board-demo.

Not under a parent sandbox that keeps its scratch in the container, or copies its workspace. A child that can pause — a human gate, an interactive node, an LLM node whose permission gate can ask — is refused adoption into such a sandbox, by name: resumed externally, it would run in a sandbox of its own, without the parent's ${PROJECT_SCRATCH_DIR} or off the parent's tree. Declare the gate in the parent, or give the parent a host-backed scratch. A child adopted there that parks anyway (a recovery pause, an operator's pause) is refused its lone resume the same way: resume the parent, which re-runs the subbot (ADR-106).

The park is restart-safe (re-attach). The parked parent is an in-memory goroutine, so a studio/CLI restart drops it: the orphan sweep promotes the parent to failed_resumable while the child stays answerable. To keep the child's work from being lost, the runner records the in-flight child id on the parent (Run.SubbotChildren, keyed by the subbot node's execution — node id + loop iteration + fan-out branch) before running it. On a resumed re-execution the runner re-attaches to that same child instead of spawning a fresh one: a still-paused child simply re-parks, and a child answered while the parent was down has its terminal output picked up. A child that ended failed/cancelled, or one that was pruned, falls back to spawning fresh. Both the studio in-process runner and iterion resume share the one re-attach oracle, so a bot behaves identically on either. See ADR-084.

Fanning subbots out in parallel ​

A subbot runs a whole child .bot that may do anything, so the workspace-safety guard conservatively treats it as mutating: it refuses to fan the same subbot template out concurrently (max_parallel_branches > 1) over a fan_out_each, because two children could race the shared git worktree/index. By default the only legal subbot fan-out is therefore serialized (max_parallel_branches: 1).

isolated: true opts out of that guard. It is an author assertion that the child does not mutate the parent's shared workspace — it confines every write to its own run store (runs/<child_run_id>/) and/or its own worktree — so N replays are safe to run at once. It is the exact analogue of readonly: on an agent/judge node: the runtime cannot prove the child is workspace-independent, so you certify it, and the guard then admits the parallel fan-out (both fan_out_each and static fan_out_all).

The flag also decides which tree the child works in. A child declared isolated: keeps the per-run worktree its worktree: auto gives it; a child declared without it works in the parent's tree, where the parent reads its result instead of chasing a branch of its own. That is why an isolated fan-out can overlap at all: a tree lent to a child admits one writing run at a time, so siblings sharing one would take turns however high max_parallel_branches is.

iter
router dispatch:
  mode: fan_out_each
  over: "{{outputs.plan.tickets}}"
  as: ticket

subbot run_ticket:
  source: "episode.bot"
  with { id: "{{outputs.dispatch.ticket.id}}" }
  isolated: true        # each child writes only to its own run store

workflow w:
  entry: dispatch
  dispatch -> run_ticket
  run_ticket -> collect

Contract — use isolated: ONLY when true. If the child does write the shared worktree (e.g. it commits code to the same checkout), leaving it serialized (max_parallel_branches: 1) or giving each child a real worktree (needs: <worktree_pool> + a child that keys its worktree off _lease_*) is the safe path. A false isolated: assertion re-opens exactly the parallel shared-workspace race the guard exists to prevent.

parallel_safe: — the same opt-out for a tool node ​

A tool node is conservatively mutating too (it runs a command that may write anything), so the same guard refuses to fan a tool template out concurrently. When each replay writes only to a disjoint, item-keyed target — e.g. one keyframe per scene_id under runs/<run>/keyframes/candidates/<scene_id>/ — the replays never race, and parallel_safe: true opts the tool out of the guard on a fan_out_each:

iter
router keyframes_dispatch:
  mode: fan_out_each
  over: "{{outputs.prepare.keyframes}}"
  as: keyframe
  key: scene_id

tool generate_keyframe_scene:
  command: "render --scene {{outputs.keyframes_dispatch.keyframe.scene_id}}"
  parallel_safe: true   # each replay writes only to its own scene-keyed path

workflow w:
  entry: keyframes_dispatch
  keyframes_dispatch -> generate_keyframe_scene
  generate_keyframe_scene -> verify   # wait_all

Unlike isolated: (own run store/worktree) the tool still writes to the shared workspace — it just partitions those writes by the fan-out item. Unlike readonly: it is not read-only.

parallel_safe: is scoped to fan_out_each — the one place a single template node is replayed over distinct items, so the item-keyed disjointness holds by construction. It has no effect on a static fan_out_all or an llm-router multi-select, where the parallel branches are different nodes with no shared item key: there a tool stays conservatively mutating (use max_parallel_branches: 1, or give each branch its own output path and keep them under the one-mutating-branch limit). A parallel_safe tool followed by any other mutating node in the same template branch is still guarded.

Contract — use parallel_safe: ONLY when replays write disjointly. If two replays can touch the same path (a shared aggregate file, a fixed output name), leave it serialized (max_parallel_branches: 1). A false parallel_safe: assertion re-opens a last-writer-wins race — the same failure mode the guard exists to prevent.

Parallel child runs are data-race safe on the store: they share the parent's RunStore, whose per-run artifacts/events/checkpoints are isolated by run_id and guarded by a store-wide lock, and each child gets its own engine. On the filesystem the declaration decides, and a parallel fan-out is isolated: by definition (the guard admits no other): each of those children keeps the per-run worktree worktree: auto gives it, so concurrency is bounded by the parent's max_parallel_branches (and any needs: lease pool) — never by a tree they queue on, since they share none. That holds exactly as far as the child actually gets that worktree: a child whose own .bot declares worktree: none, a workspace that is not a git repository or has no commits yet, a store with no filesystem root (the cloud store), or a parent sandbox the child adopts (a sandbox: run, a runner pod — one tree mounted whatever the node declares) leaves the siblings in the parent's tree, where its exclusive writer makes them take turns however high max_parallel_branches is — measured peak 1 of 3 in each of those five shapes.

A child the parent did not declare isolated: works in the parent's tree: the engine asks whether this run is a subbot child, not whether a sandbox is shared, so a local iterion run answers as a runner pod does, and the child's commits land where the parent reads its result — the parent's checkout, or the parent's own per-run worktree when the PARENT declares worktree: auto, which is how a whole family is isolated from the operator's checkout. Those are the children the guard serializes, so one of them writes at a time. Three consequences of working in a lent tree are worth knowing: for the child's whole pass that tree's .claude/{skills,commands,agents,settings.json} are ITS mirror and are restored to the parent's at its exit — anything else written under those paths while it ran is replaced along with it; .claude there must be a real directory, since a symlink refuses the child's run rather than writing through it; and the child marks its node boundaries through workspace versioning instead of the per-node git refs a worktree run writes, so its first boundary pays one full hash of the lent tree (ITERION_WORKSPACE_TRACK is that cost's hatch, at the price of what iterion rewind could restore).

A commit the child makes is durable in the tree it was lent, so the next attempt of that child does not start from the blank slate a fresh worktree was. A rewind restores files and never git history (what a rewind puts back): a run that owns a worktree reverts through git, so the replayed child meets a tree without its output and the run finishes, while a run working in place — worktree: none, an auto the engine degraded, or an auto child in ITS parent's tree — keeps the commit while the files go back, so the replayed child finds nothing to commit and the run stops there. That is the shape an in-place run's own committing tool node has always had. A retry meets the same tree the same way: a plain resume of a family whose child run failed AFTER committing re-runs that child from its entry, into the tree that already carries its commit.

A node that commits in a tree it does not own therefore has to tolerate its own replay, and no catalog child commits through a bare git commit: one writes its commit with plumbing (commit-tree, which an empty diff does not fail), the others leave it to an agent whose gate reads the committed STATE. A tool node whose git commit exits 1 on an empty index makes every replay of its family a dead run. Two declarations change the shape instead, and each is worth exactly the worktree worktree: auto really gives the run it names. isolated: on the subbot node leaves the child the worktree its own worktree: auto gives it, and a replay of that child meets a tree without its commit — the child's commits ride a storage branch of their own, which only an operator's merge brings into the lent tree — at the price of the invisibility that declaration asks for; in the five shapes above it leaves the child nothing, and the replay dies in the lent tree as described. worktree: auto on the run the rewind targets keeps the child in place and makes that target a worktree run, so the rewind reverts the family's tree through git where one aimed at an in-place child does not — as far as that run gets a worktree itself: a store with no filesystem root, or a workspace git cannot serve, degrades it in place too and leaves the replay where it was.

A shared sandbox (a runner pod, a sandbox: run) mounts ONE tree whatever the node declares, so a child adopting it works in place even when it is isolated:; there the assertion means its run store alone.

Two accounting/hygiene boundaries are worth knowing before a heavy fan-out:

  • Budget does not compose across the boundary. A child run is budgeted purely from its own .bot; the parent's max_cost_usd / max_tokens / max_duration do not bound the children, so N parallel children can collectively spend well past the parent's cap. Bound them with a per-child budget block, the run-level --timeout, and an explicit max_parallel_branches.
  • Same-target board writes are not cross-child atomic. If several parallel subbots hold board.* capabilities and mutate the same issue or board config, the last writer wins (writes never corrupt the file, but an update can be lost). Give each child a distinct target, or serialize board-mutating subbots.

Putting it together ​

The headline pattern — map a sub-bot over a dependency DAG of work items, each in its own worktree slot — combines all of these:

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

subbot run_ticket:
  source: "implement_ticket.bot"
  with { issue: "{{outputs.dispatch.ticket.id}}" }
  output: ticket_verdict
  needs: worktree_slot
  isolated: true                       # each child mutates only its leased worktree

workflow tickets:
  entry: plan
  resources:
    worktree_slot: 5
  budget:
    max_parallel_branches: 5

  plan -> dispatch
  dispatch -> run_ticket
  run_ticket -> collect when validated   # collect declares await: best_effort
  run_ticket -> escalate else            # a rejected ticket reaches a human (C012 without it)
  collect -> done

isolated: true is what makes this parallel, twice over: it certifies that the child confines its writes to a workspace of its own rather than to the parent's shared checkout, so the safety guard admits the concurrent replays — and it is what leaves each child the per-run worktree worktree: auto gives it, the distinct tree those replays need. The worktree_slot lease is a plain counted resource: it caps how many children hold a slot at once and hands each its instance id as _lease_worktree_slot, it creates no worktree. Without isolated: the fan-out would be rejected (see Fanning subbots out in parallel); drop isolated: and add max_parallel_branches: 1 if the children genuinely share one workspace — the parent's tree is then the one they share.

See examples/composition/ for a runnable tool-only demonstration.