Recipes
Usage Patterns is about shapes. This page is about building them: the calls in order, the prompts that survived contact with real models, and the failure each piece is there to prevent.
Every recipe assumes a queue of specs and a graph that drains it. That split matters more than it looks: the graph is the machine, the specs are the work, and keeping them separate is what lets you point the same graph at a different backlog tomorrow.
Before anything: the spec is the real input
A graph is only as good as what you feed it, and the most common cause of a disappointing run is not the topology. It is a spec that left a decision to the implementer.
A spec that says “choose an appropriate mechanism and document why” has delegated architecture to a model optimized for throughput. It will choose something, it will be defensible, and it will be a decision you did not make about a system you own. Decide first, then write the spec with the decision already in it and the reasoning attached:
DECISIONS ALREADY MADE - DO NOT RE-LITIGATE
1. Back up with VACUUM INTO, not a file copy. Copying a live SQLite with a
WAL can produce a broken copy even when the original is healthy.
2. Exactly one backup, verified immediately after it is written. N backups
force you to choose which to restore, which is the decision you are least
able to make well mid-incident.
Everything below assumes specs written that way. A section named IN SCOPE /
OUT OF SCOPE is worth more than any prompt engineering downstream of it.
Recipe 1 — Drain a bug backlog unattended
Shape: gated implement + resilience (patterns 1 and 3). What it is for: you have twenty small specs and a night.
This is the graph that built most of canopy. Four nodes, six edges.
The graph
graph_create {
name: "bugs-then-features",
workdir: "/path/to/repo"
}
The nodes
graph_add_node {
graph_id, name: "implement", kind: "agent", position: 1,
config: {
platform: "claude", model: "sonnet",
timeout_minutes: 40,
prompt_template: "..." // see below
}
}
graph_add_node {
graph_id, name: "gates", kind: "check", position: 2,
config: {
command: "cargo fmt --all && cargo clippy --all-targets -- -D warnings && cargo nextest run --locked"
}
}
graph_add_node {
graph_id, name: "review", kind: "agent", position: 3,
config: {
platform: "opencode", model: "some-free-model",
commit_rights: true,
timeout_minutes: 25,
prompt_template: "..."
}
}
graph_add_node {
graph_id, name: "resilience", kind: "agent", position: 4,
config: {
platform: "opencode", model: "some-other-free-model",
timeout_minutes: 8,
prompt_template: "..."
}
}
The edges
| from | condition | to |
|---|---|---|
| implement | pass |
gates |
| implement | fail |
resilience |
| gates | pass |
review |
| gates | fail |
implement |
| review | fail |
implement |
| resilience | pass |
implement |
review --pass--> has no edge: that is how a spec completes.
resilience --fail--> has no edge either: the spec fails honestly, having
already scheduled the graph’s own wake-up.
The prompts
The implementer’s, in the tagged-section style the engine uses:
# [ROLE]
You implement one spec in an existing Rust codebase. You are not the
architect: every design decision you need has already been made and is in
the spec.
# [SPEC]
{{spec_content}}
# [PREVIOUS FEEDBACK]
{{previous_feedback}}
# [HOW]
- Read before you write. Match the surrounding code's naming and idiom.
- You have NO COMMIT RIGHTS. Leave your work uncommitted; a later node
reviews the diff and commits it.
- Run the project's gates yourself before reporting pass.
# [REPORTING]
Call graph_complete_node with status "pass" only if the spec is fully
implemented. Partial work is "fail" with a description of what is missing.
The reviewer’s, which carries the load this whole shape exists for:
# [ROLE]
You review one diff against one spec, and you commit it if it holds up.
# [WHAT]
The gates already passed. That means nothing is broken. It says nothing
about whether the spec was HONORED, and that distinction is your entire
job.
# [HOW]
- Write a verdict for EVERY requirement in the spec, one line each, BEFORE
you decide. A reviewer that must enumerate is a reviewer that must look.
- A missing required test is CHANGES_NEEDED even if the code is perfect.
- When in doubt, CHANGES_NEEDED. A bounce costs minutes; a falsely-closed
spec costs days.
- Do not approve out of courtesy, for effort spent, or because it almost
meets the spec.
- Stage only the files this spec touched. `git add -A` sweeps a previous
spec's leftovers into this commit.
- Conventional Commits format. No trailers.
- Prove it: run `git log -1 --oneline` and include the output. A reviewer
once reported a commit that did not exist.
# [SPEC]
{{spec_content}}
The resilience node’s, kept deliberately short — it is a cheap model doing one narrow job:
# [ROLE]
The previous node's CLI died. Decide whether it can be retried.
# [HOW]
1. Read the failure output below. Do not open files, do not run git.
2. If it names a usage, session or quota limit: call
graph_schedule_autorun with the failure text copied verbatim as
quota_reset_message. Do NOT compute a time yourself — the engine
derives it from the message. Then report FAIL.
3. If the engine answers that it could not derive a reset instant:
call graph_report_blocker describing which limit was hit and what a
human must do. That call finalizes the run; do not also report.
4. If it is a transient failure you can repair: repair it, report PASS.
5. Anything else, or any doubt: report FAIL, summary "UNDIAGNOSED".
# [OUTPUT]
{{previous_feedback}}
Step 3 exists because of a real run. The implementer died on
You've hit your monthly spend limit · raise it at claude.ai/settings/usage
— a limit with no reset time in it, because a spend ceiling is not a rolling
window. The classifier did the right thing: it passed the message to the
engine, the engine could not derive an instant, and it refused to invent one.
But with no third branch it fell through to a plain fail, and the graph
stopped with its reason readable only in the database.
graph_report_blocker is the difference between a graph that is failed and
one that is blocked with a sentence explaining what you have to do. Prefer
it for every failure that will not lift on its own.
Note what the prompt does not do: it never computes a reset time. Models do not know what time it is, and a real run that was allowed to do the arithmetic scheduled its wake-up for 6am the next day — a 23-hour stall. Let the engine parse the message; let the model only decide which message it is looking at.
Running it
graph_run { graph_id, queue_id }
The graph drains the queue’s pending specs in order. Completed specs are
skipped on relaunch, so recovery after a crash is just graph_run again.
What will bite you
prompt, notprompt_template. The engine readsprompt_template, thenprompt_preset, then falls back to a default. There is noget("prompt")anywhere in it. A graph configured withpromptruns silently on the fallback — eight specs here ran with no instructions at all before anyone noticed, because the fallback is plausible and the results look like a model ignoring you.alwayswhere you meantpass. Covered in pattern 1; it is the single most expensive edge mistake.- A resilience node on the implementer’s quota. It will be dead exactly when it is needed.
Recipe 2 — Spend every free tier before touching a paid one
Shape: free-tier cascade (pattern 6). Requires the router node. What it is for: four CLIs, four small ceilings, one backlog.
The idea
Every implementer is the same node with a different harness. A single router receives all their failures and decides where the work goes next, based on why it failed rather than on the mere fact that it did.
The nodes
graph_add_node { graph_id, name: "impl-a", kind: "agent", position: 1,
config: { platform: "cursor", prompt_template: "..." } }
graph_add_node { graph_id, name: "impl-b", kind: "agent", position: 2,
config: { platform: "antigravity", prompt_template: "..." } }
graph_add_node { graph_id, name: "impl-c", kind: "agent", position: 3,
config: { platform: "codex", prompt_template: "..." } }
graph_add_node { graph_id, name: "impl-d", kind: "agent", position: 4,
config: { platform: "kiro", prompt_template: "..." } }
graph_add_node {
graph_id, name: "triage", kind: "router", position: 5,
config: {
platform: "opencode", model: "a-free-model",
routes: [
{ label: "exhausted", description: "the CLI ran out of quota, credits or session limit" },
{ label: "transient", description: "a crash, a network error, a missing dependency" },
{ label: "broken", description: "the CLI ran fine and the work itself is wrong" }
],
fallback: "exhausted",
prompt_template: "..."
}
}
The routes are three because the decisions are three. Resist adding a fourth until a real run produces a failure that fits none of them.
The router’s prompt
Short, because this node has exactly one decision to make:
# [ROLE]
An agent CLI just failed. Classify why, in one word.
# [HOW]
Read the output below. Do not fix anything, do not implement anything, do
not comment on the code.
- "exhausted" — it ran out of quota, credits, or session limit. This also
covers a CLI that simply stopped responding and hit its timeout: some
harnesses report a limit, others just go quiet.
- "transient" — it crashed, lost the network, or hit a missing dependency.
- "broken" — the CLI worked normally and the work itself is wrong.
# [OUTPUT]
{{previous_feedback}}
Notice what the prompt does not do: it names no vendor, matches no message, parses no format. Every harness ends its quota differently and each one changes wording between releases; the model generalizes where a pattern cannot.
The edges
| from | condition | to |
|---|---|---|
| impl-a | fail |
triage |
| impl-b | fail |
triage |
| impl-c | fail |
triage |
| impl-d | fail |
triage |
| triage | route:transient |
impl-a |
| triage | route:exhausted |
impl-b |
| triage | route:broken |
(no edge — the spec fails) |
The honest limitation
The exhausted route can only point at one next harness, so the cascade is really “a, then b” per spec rather than a true a→b→c→d walk. Getting the full walk needs one router per stage, or a memory of which harnesses are already spent — which the engine does not have. Build the two-stage version, get the value, and wait for per-harness availability before wiring all four.
Recipe 3 — A review panel that covers four axes
Shape: specialist panel (pattern 7). Status: the version below is what works today; the ensemble version with per-member prompt overrides is also available.
Today: four plain nodes in series
A specialist panel can be four ordinary agent nodes chained one after another. Slower than parallel, identical in coverage:
| from | condition | to |
|---|---|---|
| gates | pass |
review-security |
| review-security | pass |
review-performance |
| review-performance | pass |
review-maintainability |
| review-maintainability | pass |
review-patterns |
| review-patterns | pass |
commit |
| (any review) | fail |
implement |
Any specialist failing bounces the work — which is the semantics you want and
which min_pass cannot express.
The specialist prompt shape
One template, one axis substituted. The last two sections are what keep a cheap model from turning into a style bot:
# [ROLE]
You review one diff on ONE axis: SECURITY. Other reviewers own the other
axes. Do not review theirs.
# [WHAT]
Injection, authz, secrets in code or logs, unsafe deserialization, TOCTOU,
anything that widens what an attacker can reach.
# [HOW]
- Cite file:line for every finding.
- FAIL only for a defect on your axis that would matter in production.
- Everything else — style, taste, a nicer way to write it — goes in your
report, never in your verdict.
- Finding nothing is a legitimate and expected outcome. Do not manufacture
a finding to look useful.
# [SPEC]
{{spec_content}}
That last rule is not politeness. A model that believes an empty report reflects badly on it will produce findings, and a panel of four such models will bounce every change forever.
With per-member prompt overrides
The same panel collapses into one ensemble that runs the four in parallel
with min_pass equal to the member count, cutting wall-clock to the slowest
reviewer instead of the sum. Each member carries its own prompt_override
specialized to its axis.
Recipe 4 — Resolve a GitHub issue while you sleep
Shape: event intake + adversarial pair (patterns 8 and 9).
What it is for: an issue labeled auto gets a branch and a proposed fix
by morning.
The front half: outside events become a file
No public endpoint, no tunnel. One cron line:
#!/usr/bin/env bash
# ~/bin/canopy-issue-inbox.sh — run from cron every 10 minutes
set -euo pipefail
INBOX="$HOME/.canopy/inbox/github-issue.json"
mkdir -p "$(dirname "$INBOX")"
gh issue list --repo you/yourrepo --label auto --state open \
--limit 1 --json number,title,body > "$INBOX.tmp"
# Only publish when there is actually an issue, and write atomically so the
# watcher never reads a half-written file.
if [ "$(jq 'length' < "$INBOX.tmp")" -gt 0 ]; then
mv "$INBOX.tmp" "$INBOX"
else
rm -f "$INBOX.tmp"
fi
The atomic mv matters. A watcher fires on the first write event, and a
graph that starts reading a file another process is still writing will read
half an issue.
The back half: the graph watches that file
graph_create {
name: "issue-autofix",
workdir: "/path/to/repo",
trigger: { type: "watch", path: "~/.canopy/inbox/github-issue.json", events: ["modify"] }
}
The intake node turns the file into work and stops:
# [ROLE]
Turn one GitHub issue into one spec. You do not fix anything.
# [HOW]
1. Read ~/.canopy/inbox/github-issue.json.
2. Reproduce the problem well enough to state it precisely. An issue is a
report, not a specification.
3. Write the spec with spec_create: objective, functional requirements,
in scope, out of scope, acceptance. Where the issue is ambiguous, choose
the narrowest reading and say so in OUT OF SCOPE.
4. Append it with queue_add_spec.
5. Report pass with the spec id.
From there it is Recipe 1, plus an adversary before the commit:
# [ROLE]
Your job is to BREAK the change in the diff below. You succeed by finding a
concrete way it fails.
# [HOW]
- Produce a reproduction: an input, a sequence, a race, a boundary. Not an
opinion, not a "this could be risky".
- Report FAIL if you broke it — include the reproduction.
- Report PASS if you could not. That is a real result and an expected one.
- Do not fix anything. Breaking and repairing are different jobs.
Note that the adversary’s fail means it succeeded. Wire
adversary --fail--> implement and adversary --pass--> commit.
What to keep an eye on
- The graph never learns which file woke it. A watch-fired graph is told it fired, not by what, so the intake node reads a fixed path. Watching a directory with several inbound files does not work today.
- The script must be idempotent. The same issue rewritten produces the same spec twice unless the intake node checks for an existing spec, or the script only writes on change.
- Nothing here pushes. The graph commits on a branch; opening the PR stays a human decision, and that is the correct default for work done unattended.
Recipe 5 — Raise coverage without ever measuring it locally
Coverage instrumentation relinks the whole crate and runs the suite under
llvm-cov. On a laptop, or under WSL, that is the kind of job that takes the
machine down rather than merely being slow. But CI already measures coverage
on every pull request, on a runner that does not care.
So do not measure locally. Read the measurement CI already took, and let a cheap model turn it into tests.
The shape
coverage verdict (check, gh) --pass--> test writer --pass--> cargo gates
^ |
| pass
+----fail------+ v
| committer
+----fail-------+ |
pass
v
check committed + push
|
fail
v
committer
A cron trigger fires the graph on a schedule. The first node is
deterministic and spends no tokens: if CI’s coverage job is green, it exits
non-zero and the tick ends there.
The verdict node
The whole point is that this node is bash, not a model. It answers one question — is there work? — and, when there is, hands the next node the actual numbers instead of an invitation to guess.
set -u
branch=$(git rev-parse --abbrev-ref HEAD)
run=$(gh run list --branch "$branch" --workflow CI --status completed \
--limit 1 --json databaseId --jq '.[0].databaseId')
[ -n "${run:-}" ] && [ "$run" != "null" ] || { echo "NO-CI"; exit 1; }
cov=$(gh run view "$run" --json jobs \
--jq '.jobs[]|select(.name|endswith("Coverage"))|.conclusion')
[ "$cov" = "failure" ] || { echo "NOTHING-TO-DO: coverage is $cov"; exit 1; }
job=$(gh run view "$run" --json jobs \
--jq '.jobs[]|select(.name|endswith("Coverage"))|.databaseId')
log=$(gh api "repos/{owner}/{repo}/actions/jobs/$job/logs" \
| sed 's/^[0-9TZ:.+-]\{20,\} //')
table=$(printf '%s\n' "$log" \
| grep -E '^(Filename|TOTAL|[A-Za-z0-9_/.-]+\.rs)[[:space:]]' | tail -70)
if [ -n "$table" ]; then
echo "--- COVERAGE REPORT FROM CI ---"; printf '%s\n' "$table"
else
echo "--- NO COVERAGE TABLE: the job died before measuring ---"
printf '%s\n' "$log" | grep -E 'FAILED|panicked at|^error|test result:' | tail -20
fi
exit 0
That last branch is not defensive padding. A coverage job fails for two very different reasons — the percentage dropped, or the suite died before any percentage existed — and the writer node needs to know which. Print the failing test when there is no table, and the next node fixes a test instead of inventing coverage for code that never ran.
The node’s stdout arrives at the writer as {{previous_feedback}}.
The two rules that make the writer safe
A model asked to raise coverage will, given the chance, change the code to be easier to test, and will run the coverage tool to see how it is doing. Both have to be shut down explicitly, in the prompt’s prohibited section:
NEVER measure coverage on this machine. Not cargo llvm-cov, not tarpaulin,
not any equivalent. CI measures coverage; you do not. This rule has no
exception and no "just once to check".
NEVER change non-test code. No production behaviour, no signatures, no
visibility, no dependencies. Your diff must contain test code and nothing
else. If a function cannot be tested without changing it, report FAIL naming
the function — a production change smuggled in as a test change is a failed
run, even if the tests pass.
The committer enforces the second one by reading git diff rather than
trusting the report, and treats any non-test hunk as an automatic
request-changes. Free models are agreeable; the deterministic check is what
holds.
Worth adding for any suite that runs under cargo nextest: tell the writer
so. nextest gives each test its own process, and a suite that relies on that
will accept a test that sets an environment variable — which then breaks the
day someone runs cargo test.
Pushing from the graph
The final check verifies the commit landed and pushes it, so CI re-measures without a human in the loop:
test -z "$(git status --porcelain -- src/)" || exit 1
test -n "{{spec_committed_head}}" || exit 1
test "$(git rev-parse HEAD)" = "{{spec_committed_head}}" || exit 1
branch=$(git rev-parse --abbrev-ref HEAD)
case "$branch" in main|master|develop|HEAD) echo "refusing"; exit 1;; esac
out=$(git push origin "HEAD:$branch" 2>&1); code=$?
printf '%s\n' "$out" | tail -5; exit $code
{{spec_committed_head}} (not {{spec_start_head}}) is what makes this
gate mean “the committer node landed this spec’s own commit”, not just
“HEAD moved since the spec began” — the latter is satisfied just as
well by a commit someone else made in the same worktree while this
spec’s loop was running. It requires the committer node above to carry
commit_rights: true; see Commit rights.
Two requirements. The remote must be HTTPS with a credential helper — with
gh installed that is git config credential.helper '!gh auth git-credential',
and an SSH remote will hang on a passphrase prompt no one is there to answer.
And the branch allowlist is not optional: this is the one node in the graph
whose mistakes leave the machine.
If the workflow files themselves are ever part of what gets pushed, the token
needs the workflow scope on top of repo, or the push is rejected with a
message that names the workflow file and nothing else.
What will bite you
- A completed spec never re-runs. Spec selection takes
runningfirst, thenpending/interrupted—completedandfailedare skipped, and a launch with no eligible spec is an error, not a quiet no-op. A cron graph over a single standing “raise coverage” spec therefore works exactly once. Give it a queue of specs — one per module — and let the cron trigger walk the queue. - Each cycle costs a full CI run. The graph pushes, CI re-measures, the next tick reads the new verdict. Set the cron interval above the CI round-trip or ticks will keep reading a stale verdict.
- Coverage percentage is not the goal, and a model optimising it will tell you it is. Tests that execute a line without asserting on the result move the number and catch nothing. Say so in the writer’s prompt and reject them in the committer’s.
Cross-cutting: things that bite in every recipe
A cycle with no entry node. The natural retry shape — implement --> review plus review --fail--> implement — is a cycle where every node has
an incoming edge. The engine resolves a spec’s start node as the one with no
incoming edge, and falls back to the lowest position. Set your intended
start to position: 1 and treat position as load-bearing.
Duplicate outgoing edges. Two edges leaving one node, both matching, and
pointing at different targets aborts the run — after the node’s work is
done. Dedupe by (from_node, condition) before you add them.
The CLI resolves from the daemon’s environment, not your shell. A binary
on your $PATH but not the daemon’s fails to spawn mid-run. Use an absolute
binary in ~/.canopy/config.toml.
Small specs are a quota strategy. A big spec means a long run means a burnt ceiling means a failed spec. Splitting work into specs that finish in under half an hour is not tidiness; it is the difference between losing one spec and losing a night.
Commit rights are configuration, not instruction. Three different models
have committed against a capitalised “you have no commit rights” rule, each
time handing the reviewers an empty diff. Mark the committer with
commit_rights: true in its node config and let the engine enforce it.