

Canopy
Graphs
Last updated
The graph engine runs multi-step processes as a DAG, combining agent invocations, shell checks and validation gates.
Structure
Graph
└── Spec (ordered)
└── Node ──Edge(pass/fail/always)──▶ Node
- Specs — ordered units of work inside a graph.
- 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. With"decision": truein its config, a router instead asks the platform’s decision model (a fast typed-choice classifier such astypesafe/jev, invoked in print mode with the request JSON on stdin) which declared route to take — no agent is spawned. Routes map onto the model’scriteriaas label/description pairs; the renderedprompt_template(with{{previous_output}}also available) becomes the request state, and an optional"min_confidence"(0–1, default 0) sends low-confidence answers to the declared fallback. The platform must declare a decision-model invocation (decision_args) in the registry or the node is refused.quorum— engine-managed node that closes an ensemble, never created directly.
- Edges — connect nodes with routing conditions:
pass,fail,always. - Node MCP profile — agent and ensemble nodes launched by the graph
engine receive a fixed set of 18 tools in
tools/list(CANOPY_MCP_PROFILE=node); check nodes, hooks, and other sessions keep the full list. See MCP Tools.
Template variables
Agent node prompts support these placeholders:
| Variable | Expands to |
|---|---|
{{graph_name}} |
The graph’s name |
{{workdir}} |
The graph’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) |
{{spec_code}} |
Engine-generated quick reference (e.g. FIX-7) — empty on legacy specs without a type |
{{spec_type}} |
Conventional Commits type (feat, fix, etc.) — empty on legacy specs without a type |
{{spec_scope}} |
Optional scope (e.g. tui) — empty when absent |
{{spec_commit_message}} |
Prebuilt commit message (fix(tui): open F1) — empty on legacy specs without a type |
{{node_id}} |
The node’s unique ID |
{{previous_feedback}} |
JSON output of the previous node (truncated if oversized) |
Check node commands
Check node command strings have their own marker vocabulary, separate
from the agent-prompt table above — a check command cannot use
{{spec_content}}, {{node_id}}, {{previous_feedback}} or
{{output:…}}. Every marker a check command supports:
| Marker | Expands to | Quoting |
|---|---|---|
{{graph_name}} |
The graph’s name | shell-quoted — safe for values with spaces or quotes |
{{workdir}} |
The directory the command runs in (the graph’s workdir, or the run’s sandbox worktree / workdir_override when one applies) |
shell-quoted |
{{spec_id}} |
The spec’s unique ID | shell-quoted |
{{spec_name}} |
The spec’s name (empty on an idea run) | shell-quoted |
{{spec_start_head}} |
The git HEAD commit at the start of the spec run, empty when the workdir is not a Git repository or on a legacy run | raw — a hex SHA |
{{spec_start_dirty}} |
The number of non-ignored entries reported by git status --porcelain at the spec boundary, empty when unknown |
raw — a number |
{{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) |
raw — a hex SHA |
The shell-quoted markers expand to a single POSIX-quoted shell word, so a
name containing spaces or quotes can never break or inject into the
command. Write them bare ({{spec_name}}) or already single-quoted
('{{spec_name}}' — the engine strips that exact wrapper so the quoting
is not doubled), never inside other quotes; a \{{marker}} escape stays
literal text. The raw markers expand unquoted exactly as before.
Any other {{…}} marker fails the check node before the command runs: the
run records unknown marker {{x}} in check command and nothing reaches
the shell. graph_preflight reports the same defect as an error before
spending any probe quota.
Every check command also runs with these environment variables (there is
no CANOPY_WORKDIR — the command’s working directory already is the
workdir):
| Variable | Value |
|---|---|
CANOPY_SPEC_ID |
The spec’s unique ID |
CANOPY_SPEC_NAME |
The spec’s name; the empty string on an idea run |
CANOPY_GRAPH_ID |
The graph’s unique ID |
CANOPY_GRAPH_NAME |
The graph’s name |
CANOPY_SPEC_START_HEAD |
The same value as {{spec_start_head}}; the empty string when unknown |
CANOPY_SPEC_FILE |
Absolute path of a file holding the spec’s full description — the idea text on an idea run, an empty file for a spec with no description. The engine writes it just before the command runs and removes it once the node finishes, so read it inside the command (quote the expansion: "$CANOPY_SPEC_FILE"). There is deliberately no {{spec_content}} marker in check commands: a file has no quoting problem and no command-length limit |
CANOPY_SPEC_CODE |
Engine-generated quick reference (e.g. FIX-7) — empty on legacy specs without a type |
CANOPY_SPEC_TYPE |
Conventional Commits type (feat, fix, etc.) — empty on legacy specs without a type |
CANOPY_SPEC_SCOPE |
Optional scope (e.g. tui) — empty when absent |
CANOPY_SPEC_COMMIT_MESSAGE |
Prebuilt commit message (fix(tui): open F1) — empty on legacy specs without a type |
{{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:
{{spec_start_dirty}} is the number of non-ignored entries reported by
git status --porcelain at the spec boundary. It is empty when the
workdir is not a Git repository or when reading a legacy run, rather
than being reported as clean.
spec_end_dirty/spec_end_dirty_paths (readable via graph_get/
graph_node_runs_list, not a template placeholder) record the same count and
the first 20 paths at the moment the spec’s attempt actually ends —
Completed, Failed, or Interrupted. A spec that ends Failed or
Interrupted with a dirty tree folds a one-line note onto its own failure
summary/blocker text and (for a plain Failed ending) onto the on_failed
hook’s {{node}} value.
graph_run and the autorun scheduler refuse to launch the next spec when the
workdir is currently dirty and a spec on that same workdir ended Failed,
Interrupted, or Skipped (never Completed). The warning names the spec
whose last recorded run in this graph left the workdir dirty
(graph_runs joined to spec_end_dirty > 0); when no run of this graph
recorded dirt the warning says dirty before this graph ran and names no
spec. A resuming spec is still refused while another spec on the same workdir
ended dirty: the refusal names the blocking predecessor — its name (id when
the name is empty), the graph it ran in, its end status and end time — and,
when the next spec is resuming its own dirt, says so in the same line. Finish,
reset or remove that blocking spec (or set allow_dirty_start) to proceed.
When the launch proceeds, the warning says why — allow_dirty_start is
set on the graph, or the next spec is resuming the dirty work it left itself —
instead of telling you to set a flag. When it blocks, the message says
not launched before the Set allow_dirty_start hint. Set
allow_dirty_start: true via graph_update to downgrade a refusal to a
warning. graph_preflight separately reports a dirty workdir as a warning
unconditionally, with no “previous spec” logic — call it before graph_run to
see this regardless of history. A queue run applies the same guard between
its specs, not only at launch — see Spec queues.
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 agent nodes that receive the same prompt,
plus a quorum that waits for every member before routing onward. A
parallel ensemble runs 2–8 members at once by default (up to 16 if you
raise graphs.ensemble_max_members_parallel). A cascade or round_robin
ensemble runs its members one at a time, so it takes 2–16 by default (up to
32 with graphs.ensemble_max_members_sequential). Both caps are listed in
Tuning the engine. It replaces what would otherwise be N member
nodes, N prompts, and 2N+2 edges wired by hand — graph_add_ensemble
creates the whole unit in one call:
graph_add_ensemble {
graph_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. Each member may also set its own timeout_minutes,
overriding the ensemble’s shared value for that member only — the leash a
flaky free-tier member needs (say, 3 minutes) and the room a careful review
needs (say, 50 minutes) no longer have to be the same number. A member
without one uses the ensemble’s timeout_minutes, exactly as before this
field existed. The difference matters at scale: a 3-minute leash instead of
a 60-minute one on a stalling member saves 57 minutes of wall clock per
rotation, on a graph that rotates many times a night.
graph_update_ensemble changes the shared prompt
(propagated to every member without its own override), the member list,
quorum config (min_pass, straggler_timeout_minutes,
quorum_grace_minutes), exit wiring
(on_pass_to/on_fail_to take a node id or another ensemble’s id, chaining
quorums with no intermediate node), and entry wiring (from_node replaces
every entry; add_entry_from/remove_entry_from add or detach one source so
several nodes can enter with no relay), all without touching member nodes
directly. Every entry source fans out to every member after a member-list
growth or shrink, and that replacement is atomic. add_entry_from completes
missing edges when the source already exists, so it does not require a
remove-then-add cycle. graph_preflight reports incomplete source reachability
with the source and member counts before graph_run spends probe quota.
graph_delete_ensemble removes the whole unit. graph_get returns the
ensemble as one unit (ensemble_id, members, quorum config, every entry
source) alongside its expanded nodes.
on_fail_to = null (the default when an ensemble is created without one)
ends the spec when the quorum fails: a member that reports FAIL — even with
partial work — leaves the quorum with nowhere to route, so the run stops
there and the graph ends failed instead of routing the failure onward. This
is sometimes deliberate, so it is never an error; graph_preflight warns
about it in graph_warnings for every ensemble whose quorum has no
fail/always exit. Set on_fail_to with graph_update_ensemble, or add
the edge with graph_add_edge, when a failed quorum should route somewhere.
The quorum’s 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
(ensemble_concurrency_cap, default 4, counted across every graph) so an
8-member ensemble queues rather than forking every member at once.
By default a parallel quorum waits for every member branch to
terminate (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. A parallel ensemble
may instead set quorum_grace_minutes: the moment passed >= min_pass, a timer of that many minutes starts; members finishing
inside it are consolidated as usual, and when it expires the members
still in flight are terminated with reason quorum met and counted
as fail (distinct from ensemble straggler timeout in run history).
0 terminates stragglers immediately on quorum. The join output then
carries quorum_met_at and grace_minutes. None (the default)
keeps the wait-for-all behaviour.
straggler_timeout_minutes and quorum_grace_minutes are both
parallel-only — graph_add_ensemble/graph_update_ensemble refuse
to set either on a cascade or round_robin ensemble, and switching
an ensemble’s kind to one of those clears both fields automatically.
The reason: a cascade or round_robin ensemble runs its members
one at a time, never concurrently, so there is no “quorum” to wait for
stragglers around and no ensemble-wide deadline is ever armed. Each
member attempt there is bounded only by its own effective timeout —
its own timeout_minutes override, or else the ensemble’s shared
timeout_minutes — timed from that attempt’s own start, and every
infra retry of that attempt gets that same timeout fresh. A member that
hangs can never eat into a later member’s (or its own retry’s) budget.
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 costs one iteration against the ensemble’s shared budget,
same as any node, but no longer cold-starts every member: a parallel
member whose last run in this spec passed resumes that session (one that
failed, including a terminated straggler, cold-starts); a round-robin
bounce after a pass re-invokes the same member resumed, and after a
failure moves on to the next member cold and stays there for the rest of
the spec; a cascade bounce resumes the member that last passed, falling
through to the next member cold only if the resumed one fails again. A
member’s own resume: false still forces a cold start. The TUI graph view renders an ensemble
collapsed as one box (name [N models] + quorum) with live per-member
state while running, expandable on inspect.
Availability at dispatch
The roster (roster_set, or canopy roster set — see the
CLI reference and MCP tools) declares a
platform/model pair — or a whole platform — out for a while. At dispatch an
ensemble simply skips members whose pair is effectively unavailable: a
parallel quorum spawns (and counts) only the available members, a
cascade falls through them in order, and a round_robin rotation steps
over them without leaving the full member list’s index space, so a member
that comes back is picked up where the rotation already is. Nothing is
edited — the member list stays as written, the filter is re-evaluated at
every dispatch (a pair declared out mid-run is skipped from its next
dispatch on), and an entry past its until simply reads as available
again.
An ensemble blocks instead of dispatching when the roster leaves it
unrunnable: every member unavailable (any kind), or a parallel ensemble
with fewer available members than min_pass. A single agent node or
agent hook whose pair is unavailable blocks the spec the same way. These
blocks go through the ordinary blocked path — the graph pauses with a
blocker naming the pair and its reason, on_blocked fires, and no attempt
is spent, so an availability block never burns the attempt ceiling; clear
the entry (or wait out its until) and relaunch. graph_preflight never
probes an unavailable pair: it lists them under skipped_unavailable and
counts an ensemble the roster would block as would_fail.
Crews
A crew is a named, ordered list of members — (platform, model, timeout, infra-retry budget) — kept in one place, so the same “designer crew” or
“implementer crew” is written once instead of copied into every graph. The
tools are crew_list, crew_get, crew_create (members or
from_ensemble), crew_update and crew_delete; the CLI mirrors them as
canopy crew list | show | create --from-ensemble | delete (member editing
is MCP/TUI only). from_ensemble refuses an ensemble whose members carry a
per-member prompt_override — a review panel with its own angles stays
literal.
An ensemble binds to a crew with graph_add_ensemble {crew} or
graph_update_ensemble {crew: "name"}; crew: null unbinds and keeps the
current member rows as literal members. A crew change is its own call — it
is never combined with a literal members list (or any other ensemble
field) in one update. A bound ensemble renders the shared
prompt_template for every member. Binding replaces the member rows through
the one sync path and clamps min_pass to the crew size. Member order is
meaningful: cascade tries it in order, round_robin rotates through it,
and a crew-bound agent node takes the first member the roster reports
available.
Editing a crew reaches every bound ensemble at its next dispatch: the engine
re-syncs a bound ensemble’s members just before it dispatches (never while a
member is in flight), and crew_update also syncs every bound ensemble whose
graph is not running/pausing immediately. A node or hook names a crew in
its config ({"crew": "name"}) instead of platform; crew_delete is
refused while anything still binds the crew and the error lists every
binding.
No-verdict blocks
A parallel or round-robin ensemble that exits with no verdict and no
outgoing Error edge cannot route — every member infra-failed or walked
off without filing a verdict. Rather than failing the spec, the graph
pauses with a blocker that names the ensemble and the member reasons,
and the spec is marked interrupted (not failed). That makes the
block resumable in place: graph_continue retry_current_node re-enters
the ensemble at its join cursor (already-passed nodes such as the entry
node are not re-run), and graph_update_ensemble lets you rewire the
members before retrying. A graph_run on a blocked (paused + blocker)
graph refuses and reports the blocker, so a blind relaunch never masks a
crew that is silently filing nothing; clear the blocker first, or
graph_reset the spec. A directed run —
graph_continue run_from_node (or graph_run --from) — is the general
escape hatch: it starts a spec at a node you choose, reusing the recorded
outputs of every earlier node, and accepts a paused, completed or failed
graph as long as it has no active run.
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': HEAD moved from a1b2c3 to d4e5f6 while it ran,
plus the new commits in that range, capped at 5) lands in the run
output, in canopy graph info, and in the next node’s
{{previous_feedback}}. The engine cannot know who moved HEAD, so
the message never claims the node committed: the commit may come from
this node, another session, a hook, or a human — the listing shows
each new commit as a1b2c3d subject line (2026-10-16) so you can tell
at a glance when it belongs to something else entirely.
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 (graph_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
graph_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 graph 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
graph_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 graph_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.
Infra-retry budget
An agent attempt that never files a verdict (no graph_complete_node,
no graph_report_blocker) is an infrastructure crash, retried with a
doubling backoff. Three keys control the budget, with engine defaults
of 2 retries / 60 s crash-shape window / 30 s backoff:
infra_retry_limit: 0
infra_crash_max_seconds: 60
infra_backoff_seconds: 30
infra_retry_limit: 0 means one attempt and no retry: a member that
exits with the infra-crash signature falls through to the next cascade
member immediately.
Precedence, most specific first: member → ensemble → node config →
platform config → engine default. A member’s own value wins over the
ensemble’s default; a plain node’s own config key wins over its
platform; the platform value lives in config.toml’s [[clis]]
entries (infra_retry_limit, infra_crash_max_seconds,
infra_backoff_seconds) and is read fresh on every dispatch, so editing
config.toml needs no daemon restart. More specific ensemble/member/node
overrides win; absent everything, the engine default (2/60/30) applies.
A retry-skipped (infra_retry_limit: 0) crash is recorded with
infra_retry_skipped: "limit 0" alongside infra_crash/infra_attempt,
readable via graph_node_run_get (which already surfaces the
infra_attempt/infra_crash markers when present).
graph_get reports the effective value and its source
("node"/"platform"/"default") per node under infra_config, and
the ensemble’s own JSON carries the raw ensemble-level fields plus each
member’s *_override fields so member vs ensemble vs platform can be
told apart. graph_audit_node_configs lists every node/member whose
effective budget differs from its platform’s own under
infra_config_overrides.
Execution limit
A spec that fails with a genuine verdict in separate graph executions is
counted per spec, across relaunches and daemon restarts. Once it reaches
the limit (spec_attempt_limit, default 3) the graph pauses with a blocker
instead of failing again. Infrastructure failures do not count.
To clear the limit, do one of these:
- Reset the spec by id.
graph_resetwithspecs: [<spec id>]zeroes that spec’s execution count and leaves its worktree untouched. Thengraph_runorgraph_continue run_from_noderelaunches it. Agraph_resetwithoutspecskeeps every spec’s count. - Give an explicit directive.
graph_runwithstart_node_id,graph_continuewithrun_from_node, orgraph_continuewithretry_current_node(on a failed graph, or on a pending spec with a recorded node) resets the targeted spec’s count before its run starts. The response saysExecution limit reset for spec '<name>'.
Automatic re-runs (cron auto-continue, autorun) never reset the count.
Lifecycle
Create → run → (pause / continue) → complete. graph_continue
supports retry and skip strategies for stuck nodes, and
graph_report_blocker escalates to a human when intervention is
needed. Iteration limits prevent infinite retry graphs. graph_reset
returns a completed/failed graph to pending so graph_run can restart
it, and graph_schedule_autorun sets a future time at which the graph
auto-resumes (useful for quota-limited graphs that fail and need to
wait before retrying). Call it again with at omitted to cancel a
pending schedule.
If the daemon stops while a node is running — a graceful shutdown or a
crash — the killed run is finalized as interrupted and routes no edge
(in particular, never its fail edge). On the next start the graph is
reconciled to Paused with the spec Interrupted, and
graph_continue with retry re-runs the interrupted node itself with
the same input it had (the same command for a check node, the same
previous_feedback for an ensemble member) — not whichever node an
edge would have led to.
When a node is terminated, canopy also ends the harness session that node
started, for platforms that declare how to end a session (opencode does).
Without that, a terminated opencode session could be resumed by the
platform’s background service after a restart and edit the repo beside the
next attempt. Every harness node runs with the node’s workdir as its
working directory, so the session list the end step searches is the same
one the node created. The outcome is recorded on the run’s output as
session_end — "ended <id>", "not configured" (the platform declares
no way to end a session), "not found" (no id on the run and no title
match), or "failed: <reason>" — readable via graph_node_run_get.
An agent node that has filed its verdict must not keep running. Once the
verdict is recorded, its process gets a grace period to exit on its own
(graphs.verdict_grace_seconds, default 15; 0 disables the shutdown). If it
is still alive when the grace period ends, canopy terminates its process
group and ends its harness session as described above. The next node,
ensemble member or join starts only after that process is gone; in a parallel
ensemble each member gets the same grace period and is ended without waiting
for the others. Termination never changes the verdict: a pass stays a pass.
The node’s output records ended_after_verdict — "exited" when the process
left on its own, "terminated" when canopy ended it — readable via
graph_node_run_get.
A spec interrupted by a no-verdict ensemble block (see
No-verdict blocks) is resumed the same way:
graph_continue with retry re-enters the blocked ensemble with its
current member list, without re-running nodes or specs that already
passed, and graph_continue with skip marks that blocked spec
skipped and moves on to the next one. graph_reset still restarts
the blocked spec from its entry node.
When the work up to some node is already done and good — after a crash,
a rewired ensemble, or a hand fix — a directed run starts a spec at
a node you choose instead of at Start: graph_continue run_from_node [--spec <spec>] (or graph_run --from for the first spec the run
executes). --spec is optional: on a queue-driven graph it defaults to the
graph’s current spec — an in-flight member (running or interrupted)
first, else a failed one, else the next pending one — so a bare directed
run restarts the failed spec rather than the next fresh one. It reuses the
most recent recorded output of each node upstream of the start node for that
spec; the start node and everything downstream of it always run again, and
its own old output is never reused. If an upstream node’s latest run is
failed, error or interrupted, the directed run is refused and that node
is named: a failed prerequisite is never treated as passed. The spec cursor
is set to the start node, and the graph then runs with its normal edges,
routers, ensembles and gates. On a queue-driven graph the chosen spec is
what runs first: queue members before it are marked skipped, members
after it run in their usual order, and any other member left running by a
stale or mistaken state is reset to pending and left for a later run. The
per-node iteration counters restart at 1 (earlier attempts stay in the run
history). A directed run accepts a pending, running, interrupted,
failed or completed spec on a graph with no active run; a running
graph refuses. An ensemble is targeted by its name; targeting the node the
ensemble fans out from starts at that node itself, runs it, and fans out
to the ensemble from there — the named node always runs first and is never
reported as skipped. A join node or a single ensemble member is refused,
naming the ensemble.
If a reachable prompt or check references a node with no recorded output
for the spec, the run is refused and the missing node is named.
Concurrency
Running many graphs at once, against different working directories, is a supported capability — not an accident of the implementation. Start, run, pause, resume, or finish one graph, and no other graph’s status, specs, node runs, or worktree are affected. There is no cap on how many graphs can run concurrently, and nothing needs to be configured to enable it: it is the default behavior of the daemon.
The one boundary: two graphs must not share a workdir. Two graphs 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 graphs at different working directories (or at worktrees of the same repo) and they run independently with no coordination required from you.
Event-keyed hooks
A graph carries hooks keyed by event, over four events: on_completed,
on_failed, on_blocked, on_spec_completed. More than one hook may be
registered for a single event, via graph_update’s hooks map (each key is
an event name, each value is an ordered array of hook configs). Hooks
registered for one event run in declaration order. There are three hook
modes — exactly one per hook:
-
Agent (
platform+prompt, optionalmodel/effort/timeout_minutes): launches a CLI process with the rendered prompt. -
Command (
command): runs a shell command directly with the same{{...}}placeholders substituted before execution. -
Interactive (
prompt+target_session_idortarget_session_name): enqueues a message into a live interactive session, exactly as if sent from the promptbuilder, marked as hook-originated. Fire-and-forget: the hook never reads or waits for a reply. -
on_completedfires exactly once when a run transitions tocompleted(only when the dispatch completed at least one spec). A completed →graph_reset→ completed cycle fires it again, once per completing run. -
on_failedfires when the graph reachesfailed. -
on_blockedfires when it stops with a blocker set (transition topausedwith blocker data). -
on_spec_completedfires once per spec reachingcompleted, whether the specs come from the graph’s own bound specs or from a queue.
Hooks are not retroactive — a hook registered after its event has already happened does not fire. A hook that fails is recorded and never changes the graph’s own status, and never stops the remaining hooks of that event from running.
Editing hooks on a running graph takes effect for that run: the engine reads
the graph’s stored hooks at the moment each event fires, so a graph_update
landing before an event fires is the edit that fires on the current run’s
remaining events — the same way ensembles, nodes and prompts already take
effect at the next dispatch. When graph_update changes hooks while a run is
active, its response names those remaining events. An edit made after an
event already fired does not re-fire it.
Every hook run records the event it served and which hook of that event it
was (event, hook_index), plus a copy of the exact hook config that
actually fired (hook_config) — so a later reader can see what ran even
after the hooks are edited again. Hook runs are readable from graph_get’s
completion_hook_runs and canopy graph info’s hook-runs listing alongside
the graph’s node runs.
Hooks accept the complete marker vocabulary {{graph_name}}, {{workdir}},
{{completed_specs}}, {{spec_name}}, {{spec_id}}, {{blocker}}, and
{{node}}. Event context determines which values are populated:
on_completed provides {{completed_specs}} (name + one-line summary of
each spec this run completed, one per line); on_spec_completed provides
{{spec_name}} and {{spec_id}}; and on_failed/on_blocked provide
{{blocker}} and {{node}} (the name of the node that ended the run).
Every known marker with no value for the event renders exactly (none) in
command, agent, interactive, and graph-idea hooks. Unknown marker names are
rejected by graph_update with the marker name and supported list; no hook
firing leaves a marker literal.
The existing configuration keeps working, unchanged and unattended:
whatever a graph has in its on_completed column becomes a hook on the
on_completed event with no user action and no loss, and a graph_update
call passing today’s on_completed shape still registers that hook.
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.
An interactive hook targets a session either by its exact id
(target_session_id) or by its name (target_session_name) — exactly one
of the two must be set. A session id is not stable over time: a hook
configured with one will eventually point at a session that no longer
exists (a daemon reinstall or TUI restart changes ids), and firing then
fails loudly naming the id rather than redirecting the message anywhere
else. target_session_name exists for exactly this case: names survive a
restart that ids don’t, so a name-targeted hook is resolved against the
live session set fresh on every fire — never cached — the same set
session_list reads. Exactly one live session with that name resolves and
delivers; zero matches fails naming the name, and more than one match fails
listing every matching id, rather than guessing which one was meant. Session
names are not required to be unique, so a hook aimed at a name that two
people are using at once will fail until only one of them is live.
Both targeting modes support the same event placeholders as the other hook modes, rendered before enqueueing. The message carries its provenance — graph id, event, and (for a name-targeted hook) the name it was aimed at — as structure alongside the prompt, so the recipient, and the delivery history, can tell both that it came from a hook and which session actually received it, without that origin being buried in the prompt text.
A message enqueued while no TUI is running is queued, not lost: it stays pending and is delivered when a TUI later starts.
Standalone spec backlog
Specs don’t have to belong to a graph. 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 |
spec_section_get |
Extract one canonical section from a spec body |
spec_convert |
Migrate legacy heading-format spec bodies to the tagged <spec> format |
Spec bodies are written in the tagged <spec> format — all seven canonical
section tags (<objective>, <functional_requirements>,
<non_functional_requirements>, <constraints>, <guidelines>, <in_scope>,
<out_of_scope>) with markdown inside each. A body that does not parse is
rejected on write with the offending tag named. Existing heading-format specs
stay readable during the transition and are reported as needing conversion;
spec_convert migrates them one row at a time and never rewrites a running
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 graph. When a graph runs against a queue, it drains the queue’s pending specs (in queue order) through the graph instead of its own bound specs:
| Tool | Description |
|---|---|
queue_create |
Create an empty queue |
queue_add_spec |
Append a spec to a queue — a grouped add lands after the group’s last pending member |
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 |
queue_set_group |
Set or clear a member’s context group |
Pass a queue to graph_run via queue_id (or canopy graph run <graph> --queue <queue-id>).
Context groups. A queue member may carry a group label. Grouped
specs stay together: queue_add_spec with a group lands the spec right
after the group’s last pending member, and queue_set_group (or
canopy queue group) moves a member next to its group. queue_reorder
refuses an order that would split a group’s pending members, and reports
the positions that would have split it; nothing changes on refusal. Members
of one group share a warm harness session: a grouped spec resumes the
session captured by its previous successfully-completed sibling instead of
analysing the repo from cold. Ungrouped members never share a session.
Queue membership is unaffected by graph_run — specs stay standalone.
The graph info CLI and graph_get MCP tool show queue-driven progress
by reconstructing what ran from the run history.
Dirty-tree guard between specs. The launch guard that refuses a dirty
worktree at graph_run also runs every time a queue run moves from one spec
to the next: after a completed, failed, skipped, interrupted or limit-blocked
spec alike. A refusal pauses the graph (it does not fail it). The blocker
lists the first 20 dirty paths and the ways out: graph_continue run_from_node
on the next spec, cleaning the tree by hand and then graph_continue skip_next_spec, or allow_dirty_start on the graph. A directed start on that
spec is not refused again by the guard. allow_dirty_start lets the run
advance.
When a queue run drains all runnable members, the graph settles on its own:
completed if every member completed or was skipped, or failed — naming
the failed members in its blocker/summary — if any member failed. A graph
reported as running that has no node run in progress and no scheduled
resume surfaces a consistency_warning in graph_get.
The 36 MCP tools
| Stage | Tools |
|---|---|
| Authoring | graph_create, graph_update, graph_add_spec, graph_update_spec, graph_remove_spec, graph_add_node, graph_update_node, graph_add_edge, graph_update_edge, graph_delete_edge, graph_delete_node, graph_add_ensemble, graph_update_ensemble, graph_delete_ensemble, graph_copy_node, graph_copy_ensemble, graph_audit_node_configs |
| Sharing | graph_export, graph_import, graph_archive, graph_restore |
| Inspection | graph_get, graph_list, graph_crew, graph_node_runs_list, graph_node_run_get, graph_events |
| Runtime | graph_run, graph_reset, graph_schedule_autorun, graph_schedule_continue, graph_pause, graph_continue, graph_complete_node, graph_report_blocker, graph_preflight |
Graphs can be authored programmatically by agents through these tools,
or edited in the TUI graph editor with inline JSON config
validation. The canopy graph CLI subcommands mirror the runtime tools
from the terminal: list/info/crew/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 graph when an
MCP client can’t reach it. See the
CLI reference.
Export and import
A graph’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 graph becomes sending a file, not narrating the
graph_add_node/graph_add_edge/graph_add_ensemble calls that built
it, and the file is also a diff: review a change to a graph, or keep
one in a repo next to the code it operates on.
canopy graph export <graph_id> [--output <path>]
canopy graph 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 graph — 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 Graph (2)") and reports which
name it used. The same two operations exist as MCP tools,
graph_export { graph_id } and
graph_import { document, workdir?, name? }, so a graph is drivable end
to end through MCP as well as the CLI.
Every daemon database connection waits up to 5 s for a concurrent writer
before giving up, so imports normally queue behind a busy database rather
than failing. If the wait still expires, graph_import returns
graph_import: database busy after 5 s — nothing was written, retry:
the import is all-or-nothing, nothing partial is left behind, and the
call is safe to retry as-is.
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
graph, or export refuses with the names it found), no workdir, no
specs, and no run/status state. A graph file is a shape and a set of
instructions, not somebody else’s backlog or history.
platform/model are always included for every agent node and
ensemble member (format_version: 2), so an exported file round-trips
its harness bindings through import unchanged. A node that uses its
platform’s default model exports "model": null explicitly, and an
ensemble member without a binding or override exports explicit nulls
for the unset fields. Import accepts both format_version: 1
(members with no binding, reported as missing a platform) and 2
(bindings restored verbatim); it never rejects a platform the
importing machine has not configured — validating that a pair is
usable belongs to graph_preflight. import’s response always lists
every agent node left without a platform, so there’s exactly one
thing to check before running an imported graph:
nodes_missing_platform in the MCP response, or the same list
printed by the CLI.
An ensemble’s kind is carried in the document for cascade and
round_robin; export omits it for parallel, which is what an
absent kind means on import. Import refuses a kind value it does
not know — naming the ensemble and the value — and refuses a document
that carries any field it does not know how to restore, as part of
its all-or-nothing contract. format_version 5 remains the newest
accepted version; 1, 2, 3 and 4 documents import unchanged (they carry
fewer fields, never more).
A crew-bound design travels by name, never by its members:
graph_export writes crew: "<name>" on a bound ensemble (with no
members array), on a bound agent node’s config, and on a bound agent
hook — and, since version 5, a graph’s event-keyed hooks ride along
in the document as well, which is what gives a bound hook something to
travel in. graph_import keeps the reference when a crew of that name
exists locally: the ensemble binds and re-materializes the crew’s
members, and a bound node or hook resolves its pair at dispatch from
the crew. When the name is missing locally the import still succeeds,
lists every unresolved binding under missing_crews (next to
nodes_missing_platform) in the MCP response or the CLI output, and
leaves the binding pending — a pending ensemble cannot dispatch until
a crew of that name is created, and graph_preflight reports it as
would_fail.
A crew-bound ensemble travels without members (the crew supplies them)
but not without its wiring: an absent members key is simply an empty
list, and the ensemble’s entry_from_node/entry_condition, every
extra_entry_sources item, and any on_pass_to/on_fail_to exit
chained into another ensemble are all recorded at import. They are wired
through the same sync path the engine already uses the moment the crew’s
members are materialised — during the import when the crew exists
locally, or on the crew’s first sync (dispatch, or crew_create/
crew_update) when it is created later. Either way the result is a fully
wired ensemble, so a shared design reaching a machine that has the crews
arrives runnable; a document that names neither a crew nor any members is
still refused, naming the ensemble.
An ensemble round-trips as one ensemble — not as its
expanded member/quorum nodes — via its own ensembles array entry.
The entry_from_node, on_pass_to, and on_fail_to fields use a bare
string for a plain node. When wiring to another ensemble’s quorum, export
uses { "ensemble": "<name>" } so an ensemble target is distinct from a
plain node with the same name; import resolves that table to the target
ensemble’s join node and restores its member fan-out edges.
An ensemble may be entered from more than one node
(graph_update_ensemble’s add_entry_from). Export writes every such
source in extra_entry_sources, an array of
{"from_node": "<node name>", "condition": "<pass|fail|always|error>"} entries
beyond the primary (entry_from_node + entry_condition). Import restores each as
an edge to every member, so multi-source entry — e.g. a committer entered on
pass by an unlock check and on fail by a commit-verification check — survives
the round trip. The field is omitted when an ensemble has only its primary source.
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": 5,
"name": "implement-and-review",
"description": "Implement a spec, get two model opinions, then commit.",
"nodes": [
{
"name": "implementer",
"kind": "agent",
"position": 1,
"config": {
"platform": "opencode",
"model": null,
"prompt_template": "Implement: {{spec_content}}",
"timeout_minutes": 30
}
},
{
"name": "committer",
"kind": "agent",
"position": 4,
"config": {
"platform": "opencode",
"model": "opencode/muse-spark-1.3-contributor-free",
"prompt_template": "Review the feedback and commit if satisfied.",
"commit_rights": true,
"timeout_minutes": 15
}
}
],
"edges": [],
"ensembles": [
{
"name": "reviewers",
"kind": "cascade",
"prompt_template": "Review this diff for correctness: {{previous_feedback}}",
"entry_from_node": "implementer",
"entry_condition": "always",
"on_pass_to": "committer",
"min_pass": 1,
"timeout_minutes": 20,
"members": [
{"platform": "copilot", "model": null, "prompt_override": null},
{"platform": "opencode", "model": "opencode-go/qwen3.7-plus", "prompt_override": null}
]
}
]
}
Note what is absent: no id anywhere, 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. implementer uses its platform’s default model,
hence the explicit "model": null; a format_version: 1 document
with {} members still imports, with every unbound node reported as
nodes_missing_platform. reviewers runs as a cascade: one member at
a time, first usable verdict wins. reviewers has a single entry source, so
extra_entry_sources is omitted; an ensemble entered from several nodes carries
each additional one there.
Archive and restore
Graphs can be archived instead of deleted — they leave the main
graph_list/sidebar view but their row, specs, and full run history
are untouched and can be restored at any time:
canopy graph archive <graph_id>
canopy graph restore <graph_id>
Over MCP: graph_archive { graph_id } and graph_restore { graph_id }.
Archiving refuses a running graph — pause it first. The archived graph
is still reachable directly by id via graph_get regardless of
archived state. Restoring is a plain flag flip — no data is lost or
moved.
Node run history
When a graph fails, the node run history lets you diagnose exactly what happened:
The CLI prints the most recent node runs in canopy graph info <graph>.
Over MCP: graph_node_runs_list { graph_id, node_id?, spec_id?, limit? }
returns the most recent runs first (default 20, capped at 200).
graph_node_run_get { run_id } fetches one run’s full stored input and
output, including infra_attempt/infra_crash markers when present, and
ended_after_verdict/session_end when an agent filed a verdict.
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.
Event log
Every graph keeps its own durable, structured history in the graph_events
table — the engine writes one row per event as the run happens:
| Group | Kinds |
|---|---|
| Graph | graph_started, graph_paused, graph_resumed, graph_completed, graph_failed, graph_blocked |
| Spec | spec_started, spec_completed, spec_failed, spec_skipped |
| Node | node_started, node_finished (payload: verdict, platform, model, duration_s) |
| Hooks | hook_fired (payload: event, mode, ok, error) |
| Config | config_changed (a node, ensemble or hook edit made while the graph runs) |
| Notes | note (reserved for the CM47 spec-note mechanism; payload: owner_name, chars) |
Read it from the CLI:
canopy graph log <graph-id-or-name> [--since <id|RFC3339>] [--kind <k>] [--spec <id>] [--follow]
Each line is <created_at> <KIND> spec=<name> node=<name> <message>.
--follow polls the daemon every 2 s and prints new events until Ctrl+C.
Over MCP, graph_events { graph_id, since_id?, spec_id?, kinds?, limit?, include_payload? } returns events oldest-first with next_since_id for
paging.
This replaces the printf … >> file logging-hook pattern. You no longer
need a command hook that appends to a per-graph log file on on_completed /
on_failed — the engine records that history itself, intact and queryable.
Ordinary logging hooks keep working; they simply stop being necessary.
Unlike the shared activity stream (bounded per workdir, free text), this log
is never pruned by count. canopy clean removes events older than
graph_event_retention_days (default 90) for graphs that are not running.
Deterministic commits
Canopy can build a deterministic commit message from a spec’s type and scope, and number specs automatically per type — a short code that serves as a quick reference and counts features and bugs.
-
Number specs by change type. Give each spec a
type(one of the Conventional Commits types:feat,fix,docs,style,refactor,perf,test,build,ci,chore,revert). Canopy assignscode = <TYPE>-<n>with TYPE upper-cased (FEAT-12,FIX-7,DOCS-3) andnthe next number for that type among the specs tagged with the same workdir (counter persisted per workdir and type; never reused, even after a spec is deleted). The code is immutable and never set by callers. Settingtypelater on a legacy spec throughspec_updateassigns its code at that moment; changing the type afterwards does not renumber it. -
Conventional titles. When
spec_create/graph_add_specreceive a name of the form<type>(<scope>)?!?: <subject>and no explicittype, canopy parses it and stores the subject as the name, with type and scope extracted. An explicittypewins over the parsed one. -
Commit message. Every spec exposes
CANOPY_SPEC_COMMIT_MESSAGEto check nodes and{{spec_commit_message}}to agent prompts. The message is<type>(<scope>): <name>(or<type>: <name>without scope). A deterministic commit node uses$CANOPY_SPEC_COMMIT_MESSAGEas the commit message and never guesses. -
Opt-in enforcement. A graph created or updated with
require_spec_type: true(defaultfalse) refuses a spec without a type ingraph_add_spec, andgraph_runrefuses a queue holding one, naming the spec.queue_add_specrefuses an untyped spec while any graph has the setting on, because queues are not tied to a single graph. Legacy specs keep working everywhere else.
Recipe: deterministic commit node
Add a check node with commit_rights: true and use the prebuilt message:
{
"name": "commit",
"kind": "check",
"command": "git add -A && git commit -m \"$CANOPY_SPEC_COMMIT_MESSAGE\"",
"success_condition": "exit_code_0",
"commit_rights": true
}
When the spec has type: fix and scope: tui, and its name is
"open F1 from inside a session", the node commits with message
fix(tui): open F1 from inside a session.
Spec notes
Every spec carries one working-memory note per owner: a short document a
node writes while it works, and that every later node, model and iteration
of the same spec receives in its prompt. Notes are working memory for the
next agent, not a log — the history of changes lives in the event log’s
note rows above.
Ownership. The owner of a note is the node that wrote it — except when
the writer is an ensemble member, in which case the owner is the ensemble,
so every member shares the one note and a replacing member continues where
the cut one left off. Each spec has at most one note per owner, plus one
operator note written by people and orchestrators without a run. A node
can only write its own note: spec_note_write takes no owner argument.
Writing and reading. An agent writes with spec_note_write(run_id, body) — the engine resolves the owner from the active run and refuses a
run_id that is no longer running. The body is at most 2 500 characters;
longer writes are refused naming the limit. spec_note_write(spec_id, body)
without a run_id writes the operator note. Read with
spec_notes_get(spec_id) (newest first), or canopy spec notes <spec-id>.
Prompt injection. Every agent-node prompt gets a [SPEC NOTES] section
before [REPORTING] with a 5 000-character budget: the caller’s own note
first, then the Designer’s note (the owner of the graph’s first agent node
or ensemble after Start), then the rest newest-first. A note is included
whole or as a stub naming its owner, size and time — never truncated — so
the same notes always give the same section bytes.
Lifetime. Notes survive bounces, resumes, graph_continue and node
retries of the same spec. graph_reset keeps them unless called with
clear_notes: true. Deleting the spec deletes its notes.
Check nodes. Before a check node’s command runs, the engine writes the
spec’s notes to a temp file — one ## <owner_name> section per note — and
exports its path as CANOPY_SPEC_NOTES_FILE. A presence check like
grep -q '^## Designer' "$CANOPY_SPEC_NOTES_FILE" needs no canopy
binary inside a check command.
Node blueprints
Rather than pasting a full config into every graph_add_node call, a
node can reference a blueprint — a reusable {name, kind, config}
template — by name:
graph_add_node { spec_id, name, blueprint: "cargo-gates" }
graph_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 graph editor
validates blueprint references inline.
Tuning the engine
These [graphs] settings (see canopy config list) adjust the engine’s
retry budgets and caps. Each keeps its default until you set it.
| Setting | Default | What it does |
|---|---|---|
graphs.max_iterations_per_node |
5 | Node visits a spec may make before it is blocked (the iteration ceiling). |
graphs.infra_retry_limit |
2 | Infra-crash retries before the infra failure falls through the error edge. |
graphs.infra_crash_max_seconds |
60 | Longest an infra crash may last before the retry budget is exhausted. |
graphs.infra_backoff_seconds |
30 | Backoff between infra retries. |
graphs.straggler_timeout_minutes |
30 | Default timeout (1–480) for a new ensemble; existing ensembles keep theirs. |
graphs.ensemble_max_members_parallel |
8 (range 2–16) | Largest member count a parallel ensemble may have. Lowering it never edits an existing ensemble. |
graphs.ensemble_max_members_sequential |
16 (range 2–32) | Largest member count for a cascade/round_robin ensemble, and the largest crew. |
graphs.check_output_max_kib |
64 (range 16–4096) | Check-output tail kept, in KiB. |
graphs.verdict_grace_seconds |
15 (range 0–300) | Seconds an agent has to exit after filing its verdict before canopy terminates it. 0 disables the shutdown. |
Two more engine settings sit outside the graphs. prefix (see
CLI reference):
spec_attempt_limit (default 3) caps how many separate executions of a spec
may fail with a verdict before it is blocked, and ensemble_concurrency_cap
(default 4) caps how many ensemble members run at once across every graph.
The three graphs.infra_* settings resolve with this precedence, strongest
first: member > ensemble > the platform’s clis[] entry > the global
setting. Nothing else supplies a value.
The member caps (graphs.ensemble_max_members_parallel /
graphs.ensemble_max_members_sequential) bound new writes only:
lowering a cap below an existing ensemble’s member count never edits that
ensemble, and an over-cap create is refused with the setting key named.