Loops
The loop engine runs multi-step processes as a DAG, combining agent invocations, shell checks and validation gates.
Structure
Loop
└── Spec (ordered)
└── Node ──Edge(pass/fail/always)──▶ Node
- Specs — ordered units of work inside a loop.
- Nodes — five kinds:
agent— invokes a CLI tool with a prompt template.check— executes a shell command.gate— validates previous output (e.g.output_contains).router— classifies and routes by token matching with fallback.quorum— engine-managed node that closes an ensemble, never created directly.
- Edges — connect nodes with routing conditions:
pass,fail,always.
Template variables
Agent node prompts support these placeholders:
| Variable | Expands to |
|---|---|
{{loop_name}} |
The loop’s name |
{{workdir}} |
The loop’s working directory |
{{spec_id}} |
The spec’s unique ID |
{{spec_name}} |
The spec’s name |
{{spec_content}} |
The spec’s description (falls back to name) |
{{node_id}} |
The node’s unique ID |
{{previous_feedback}} |
JSON output of the previous node (truncated if oversized) |
Check node commands additionally support {{spec_start_head}} — the
git HEAD commit at the start of the spec run — and
{{spec_committed_head}} — the git HEAD left behind by the last
commit_rights: true node’s own execution during this spec run,
empty until one actually commits (see Commit rights).
{{spec_start_head}} only proves some commit landed since the spec
began; it’s satisfied just as well by a commit from outside this run
sharing the same worktree (another agent, a human, a cherry-pick) as
by this spec’s own work. A check node that exists to gate “did this
spec’s work actually get committed” should use
{{spec_committed_head}} instead:
test -z "$(git status --porcelain -- src/)" \
&& test -n "{{spec_committed_head}}" \
&& test "$(git rev-parse HEAD)" = "{{spec_committed_head}}"
This requires a node in the graph to declare commit_rights: true —
without one, nothing ever populates {{spec_committed_head}} and the
check above always fails. {{spec_start_head}} remains useful on its
own for anything that only needs “did the tree change at all”, where
a concurrent commit from outside this run isn’t a concern.
Ensembles
An ensemble is a group of 2-8 agent nodes that receive the same
prompt in parallel, plus a quorum that waits for every member
before routing onward. It replaces what would otherwise be N member
nodes, N prompts, and 2N+2 edges wired by hand — loop_add_ensemble
creates the whole unit in one call:
loop_add_ensemble {
loop_id, name: "proposers",
prompt_template: "...", # one prompt, shared by every member
members: [
{ platform: "opencode", model: "mimo-v2.5-free" },
{ platform: "opencode", model: "glm-4.6-free" },
{ platform: "opencode", model: "qwen3-coder-free" }
],
from_node, condition: "always", # entry wiring
min_pass: 2, # default: all members
on_pass_to, on_fail_to # exit wiring
}
Canonical uses: (a) N cheap/free models draft a solution in parallel, the quorum consolidates their proposals, and an arbiter (or the implementer itself) receives all of them and implements the consensus — expensive quota is spent once, on a pre-digested task; (b) 2-3 free reviewer models review the same diff in parallel and the quorum consolidates their findings into one verdict.
Members differ by platform/model, and each may set its own
prompt_override to review the same input from a different angle instead
of sharing the template. loop_update_ensemble changes the shared prompt
(propagated to every member without its own override), the member list,
quorum config (min_pass, straggler_timeout_minutes), and exit wiring,
all without touching member nodes directly. loop_get returns the
ensemble as one unit (ensemble_id, members, quorum config) alongside
its expanded nodes.
The quorum fires only once every member branch has terminated
(pass, fail, or straggler timeout past
straggler_timeout_minutes, which kills the still-running process
and counts it as a fail) — it never fires early. Its consolidated
output is one document with a ## <platform/model> [pass|fail]
section per member, in member order. The quorum reports pass when at
least min_pass members passed. Member execution is capped by a
global concurrency limit (default 4) so an 8-member ensemble queues
rather than forking every member at once.
Members are agent nodes only, and nested ensembles (an ensemble wired
into another ensemble’s members or quorum) are rejected. A bounce back
into an ensemble re-runs every member and costs one iteration against
the ensemble’s shared budget. The TUI loop view renders an ensemble
collapsed as one box (name [N models] + quorum) with live per-member
state while running, expandable on inspect.
Commit rights
Most graphs want exactly one node to land work in git — a committer at the end of the chain — while every earlier node leaves its changes uncommitted so the reviewers downstream have a real diff to review. Asking for that in the prompt does not hold: three different models have committed anyway against an explicit, capitalised “you have no commit rights” rule, and each time the reviewers that followed were handed an empty diff and the quality gate quietly became a no-op.
So the engine enforces it. Mark the committer in its node config:
commit_rights: true
The engine records git rev-parse HEAD before and after every node it
runs and compares the two. A node without commit_rights that moved
HEAD is a deterministic fail, whatever the node itself reported —
it routes through the fail edge like any other failure, and the
reason (Node 'X' committed but has no commit rights: HEAD moved a1b2c3 -> d4e5f6) lands in the run output, in canopy loop info, and
in the next node’s {{previous_feedback}}.
Three things are deliberate:
- Enforcement is opt-in per graph. It activates only once some node
in the graph declares
commit_rights: true. A graph that designates nobody can’t be told apart from one written before this key existed, so enforcing there would fail the very node it relies on to land work. Existing graphs are unchanged until you name a committer. - Rights are explicit configuration, never inferred from a node’s name, kind, or prompt. Nodes without the key have no commit rights.
- The engine never undoes the commit. It reports and routes; it
will not
reset,revert, or rewrite your history, because the unauthorized commit usually contains the correct work made by the wrong node, and an unattended daemon rewriting history is a far worse failure than the one it is fixing. Both hashes are in the output — undo it yourself if it doesn’t belong.
Ensemble members are checked as a group rather than individually: they run concurrently against one workdir, so a moved HEAD can’t be attributed to a single member, and the quorum fails as a whole. Non-git workdirs are unaffected, as is any node that edits files without committing — the normal case.
The same commit_rights: true node is also what populates
{{spec_committed_head}}: whenever its own execution moves HEAD, that
new HEAD is recorded against this spec run, overwritten each time it
commits again (e.g. a review/retry cycle). A downstream check node
uses it to verify this run’s own committer landed a commit, not
merely that HEAD differs from wherever the spec started — see
Template variables.
Editing the recommended check command text here does not retroactively
touch any graph. A node’s command is whatever text was written into
its config when the graph was authored (loop_add_node, or copied from
a blueprint) — the engine reads it fresh at execution time but never
rewrites it. A spec already using the old {{spec_start_head}}-only
comparison keeps using it until someone explicitly runs
loop_update_node on it; only graphs authored or edited after adopting
{{spec_committed_head}} get the stronger check.
Self-report requirement
A harness can exit 0 having done nothing: every tool call refused,
a quota exhausted, a provider outage — the process still ends cleanly
and the loop engine sees a clean exit code. By default that is
recorded as Pass if the process also produced output, exactly as it
always has. Set require_report: true in an agent node’s config to
close that gap for a node whose graph depends on being able to tell:
require_report: true
With the flag set, an agent run that exits 0 but never calls
loop_complete_node itself is recorded as a deterministic fail
(failure_kind: "no_report") and routes down the node’s fail edge —
it is never retried as an infra crash, since nothing actually crashed.
An explicit self-report always wins regardless of this flag, pass or
fail; require_report only judges the case where none was ever made.
Whether or not the flag is set, every agent run that finishes without
calling loop_complete_node carries "unreported": true in its output
— this is unconditional, so a resilience node downstream can always
tell “the harness ran and chose not to report” apart from “the harness
never ran,” without needing require_report itself. Ensemble members
are judged individually, exactly like a lone node.
Lifecycle
Create → run → (pause / continue) → complete. loop_continue
supports retry and skip strategies for stuck nodes, and
loop_report_blocker escalates to a human when intervention is
needed. Iteration limits prevent infinite retry loops. loop_reset
returns a completed/failed loop to pending so loop_run can restart
it, and loop_schedule_autorun sets a future time at which the loop
auto-resumes (useful for quota-limited loops that fail and need to
wait before retrying). Call it again with at omitted to cancel a
pending schedule.
Concurrency
Running many loops at once, against different working directories, is a supported capability — not an accident of the implementation. Start, run, pause, resume, or finish one loop, and no other loop’s status, specs, node runs, or worktree are affected. There is no cap on how many loops can run concurrently, and nothing needs to be configured to enable it: it is the default behavior of the daemon.
The one boundary: two loops must not share a workdir. Two loops racing to commit, check out, or edit files in the same working tree will fight over it — the engine does nothing to make that safe, and doing so is deliberately out of scope. Point concurrent loops at different working directories (or at worktrees of the same repo) and they run independently with no coordination required from you.
on_completed hook
A loop can carry one optional on_completed hook: an agent-node-style
config (platform, model, prompt, timeout_minutes) set via
loop_update. The engine fires it exactly once, right when a run
transitions to completed — never on failed or paused, never
retroactively for a loop that completed before the hook was configured,
and never twice for the same completion. A completed → loop_reset →
completed cycle fires it again, once per completing run.
The hook’s prompt supports its own small placeholder set (not the
full node template-variable list above):
{{loop_name}}— the loop’s name.{{workdir}}— the loop’s working directory.{{completed_specs}}— name + one-line summary of each spec this run completed, one per line ((none)if the run completed zero specs).
It runs through the same spawn path as a loop agent node (detached,
process-group tracked), and its run is recorded and visible in
loop_get / canopy loop info alongside the graph’s node runs — but
its pass/fail never changes the loop’s final status, since the run is
already completed by the time it fires. A hook failure logs a
warning and sends a desktop notification if available; the
loop-completed notification itself notes when a hook was launched.
The first intended use is a documentation-maintenance agent: on
completion, review the specs this run closed, the resulting code, and
docs//README, then update the docs to match what actually shipped.
Standalone spec backlog
Specs don’t have to belong to a loop. The spec backlog lets you create, list, update and delete specs independently, optionally tagging each to a workdir for filtering:
| Tool | Description |
|---|---|
spec_create |
Create a standalone spec |
spec_list |
List specs (filterable by workdir, status) |
spec_update |
Update a spec’s name, description, or workdir tag |
spec_delete |
Delete an unbound spec |
spec_set_status |
Admin transition: complete, skip, or reopen a standalone spec |
The TUI sidebar shows backlog specs under the Backlog section, filtered to the selected project’s workdir.
Spec queues
A queue is an ordered list of existing specs decoupled from any one loop. When a loop runs against a queue, it drains the queue’s pending specs (in queue order) through the loop’s graph instead of its own bound specs:
| Tool | Description |
|---|---|
queue_create |
Create an empty queue |
queue_add_spec |
Append a spec to the end of a queue |
queue_list |
List a queue’s members (or all queues) |
queue_remove_spec |
Remove a spec from a queue |
queue_reorder |
Full replacement of a queue’s order |
Pass a queue to loop_run via queue_id.
Queue membership is unaffected by loop_run — specs stay standalone.
The loop info CLI and loop_get MCP tool show queue-driven progress
by reconstructing what ran from the run history.
The 32 MCP tools
| Stage | Tools |
|---|---|
| Authoring | loop_create, loop_update, loop_add_spec, loop_update_spec, loop_add_node, loop_update_node, loop_add_edge, loop_update_edge, loop_delete_edge, loop_delete_node, loop_add_ensemble, loop_update_ensemble, loop_copy_node, loop_copy_ensemble, loop_audit_node_configs |
| Sharing | loop_export, loop_import, loop_archive, loop_restore |
| Inspection | loop_get, loop_list, loop_node_runs_list, loop_node_run_get |
| Runtime | loop_run, loop_reset, loop_schedule_autorun, loop_schedule_continue, loop_pause, loop_continue, loop_complete_node, loop_report_blocker, loop_preflight |
Loops can be authored programmatically by agents through these tools,
or edited in the TUI loop editor with inline JSON config
validation. The canopy loop CLI subcommands mirror the runtime tools
from the terminal: list/info/export are read-only inspection, and
import/run/pause/continue/reset/autorun delegate the
matching MCP tool to the daemon — a second way to drive a loop when an
MCP client can’t reach it. See the
CLI reference.
Export and import
A loop’s design — its name, description, nodes, edges, and ensembles —
can leave one machine as a single JSON file and be recreated on
another. Sharing a loop becomes sending a file, not narrating the
loop_add_node/loop_add_edge/loop_add_ensemble calls that built
it, and the file is also a diff: review a change to a loop, or keep
one in a repo next to the code it operates on.
canopy loop export <loop_id> [--output <path>] [--with-models]
canopy loop import <path> [--workdir <dir>] [--name <name>]
export writes to --output, or to stdout (so it can be piped) when
omitted. import always creates a new loop — it never updates,
merges, or overwrites an existing one; --workdir defaults to the
current directory, and --name overrides the file’s own name. If the
resolved name is already taken in the target workdir, import still
succeeds under a numeric suffix ("My Loop (2)") and reports which
name it used. The same two operations exist as MCP tools,
loop_export { loop_id, with_models? } and
loop_import { document, workdir?, name? }, so a loop is drivable end
to end through MCP as well as the CLI.
What the file excludes is deliberate: no ids (edges reference
nodes by name, which is what makes the file reviewable and
hand-editable — node names must therefore be unique within an exported
loop, or export refuses with the names it found), no workdir, no
specs, and no run/status state. A loop file is a shape and a set of
instructions, not somebody else’s backlog or history.
platform/model are stripped from every agent node and ensemble
member by default, for the same reason a node blueprint
never carries one: a shared design pinned to a harness or model the
recipient doesn’t have is broken on arrival, and one pinned to a model
they do have is worse, since it silently spends their quota on someone
else’s choice. Pass --with-models (with_models: true over MCP) only
when exporting your own loop to restore later on your own machine.
import’s response always lists every agent node left without a
platform, so there’s exactly one thing to check before running an
imported loop: nodes_missing_platform in the MCP response, or the
same list printed by the CLI.
An ensemble round-trips as one ensemble — not as its
expanded member/quorum nodes — via its own ensembles array entry.
Here is a complete, hand-writable example: an implementer, a 2-model ensemble of reviewers, and a committer the quorum routes to on pass.
{
"format_version": 1,
"name": "implement-and-review",
"description": "Implement a spec, get two model opinions, then commit.",
"nodes": [
{
"name": "implementer",
"kind": "agent",
"position": 1,
"config": {
"prompt_template": "Implement: {{spec_content}}",
"timeout_minutes": 30
}
},
{
"name": "committer",
"kind": "agent",
"position": 4,
"config": {
"prompt_template": "Review the feedback and commit if satisfied.",
"commit_rights": true,
"timeout_minutes": 15
}
}
],
"edges": [],
"ensembles": [
{
"name": "reviewers",
"prompt_template": "Review this diff for correctness: {{previous_feedback}}",
"entry_from_node": "implementer",
"entry_condition": "always",
"on_pass_to": "committer",
"min_pass": 2,
"timeout_minutes": 20,
"members": [
{},
{}
]
}
]
}
Note what is absent: no id anywhere, no platform/model on
implementer/committer/the ensemble’s members (this file was
exported without --with-models — import will report all three as
nodes_missing_platform), and the ensemble’s own member/quorum nodes
never appear in nodes — only its entry_from_node/on_pass_to
(both plain node names) and its members array do.
Archive and restore
Loops can be archived instead of deleted — they leave the main
loop_list/sidebar view but their row, specs, and full run history
are untouched and can be restored at any time:
canopy loop archive <loop_id>
canopy loop restore <loop_id>
Over MCP: loop_archive { loop_id } and loop_restore { loop_id }.
Archiving refuses a running loop — pause it first. The archived loop
is still reachable directly by id via loop_get regardless of
archived state. Restoring is a plain flag flip — no data is lost or
moved.
Node run history
When a loop fails, the node run history lets you diagnose exactly what happened:
canopy loop runs <loop_id> [--node <node_id>] [--limit <n>]
Over MCP: loop_node_runs_list { loop_id, node_id?, spec_id?, limit? }
returns the most recent runs first (default 20, capped at 200).
loop_node_run_get { run_id } fetches one run’s full stored input and
output, including infra_attempt/infra_crash markers when present.
Secret-shaped substrings are redacted before the output crosses the
boundary.
This is the second step of failure diagnosis: list to find the offending run, then fetch its output here — the exact stderr/stdout/ reported_output the engine recorded.
Node blueprints
Rather than pasting a full config into every loop_add_node call, a
node can reference a blueprint — a reusable {name, kind, config}
template — by name:
loop_add_node { spec_id, name, blueprint: "cargo-gates" }
loop_add_node { spec_id, name, blueprint: "implementer-claude", config_overrides: { "model": "opus" } }
config_overrides is a shallow merge on top of the blueprint’s config
template — override keys win, every other templated key is preserved.
An unknown blueprint name returns an actionable error listing every
available blueprint.
Five builtins are seeded automatically at daemon startup if missing
(re-seeded if deleted from the DB directly): implementer-claude,
cargo-gates, reviewer-committer-mimo, commit-check,
resilience-mimo. Builtins can’t be deleted. Manage blueprints with
blueprint_list, blueprint_create, and blueprint_delete (custom
only). The TUI sidebar lists available blueprints, and the loop editor
validates blueprint references inline.