Writing a pattern from scratch
Four stages, each one complete runnable file: a gated task, a decomposition, a map over a task directory, and an until pattern that recurses until a check passes.
A tutorial for Gantry Patterns, the TOML orchestration format Gantry executes. Each stage is one runnable file, a small addition to the one before: a single gated task, a decomposition, a map, a goal loop. The normative reference is the pattern format, the narrative authoring guide is the authoring guide, and the shipped corpus is orchestration patterns.
What you are building, and how to check it
A pattern is finite TOML data describing an orchestration: which fresh agent sessions run, in what order, and what happens when one of them fails or claims work it did not do. It performs no computation at load time and carries no expression language. Every intelligent step is one fresh agent session with a clean context; every decision routes on a file an earlier step wrote or on a step's own outcome; ground truth is the gate, Gantry's authoritative project check package, which no agent can override.
Two commands let you work through this tutorial without starting a run:
gantry pattern show <name> --asciiloads the named pattern through the full reader and renders a static diagram; a file that does not load is refused with the loader's error, so this is also the validation check. Asked for outside any run, it resolves the name through the global config mirror~/.config/gantry/patterns/, then the bundled tier; a run consults a project-local.gantry/<plan>/patterns/<name>.tomlfirst. Save the file you are drafting into that mirror and show it by name.gantry triage <plan>prints which pattern that plan would run under and the whole registry, each entry with its tier and description.
To run a stage: gantry --pattern <name|file.toml> [inputs…] — the selector takes either a discovery name or a TOML file path, and inputs bind positionally to the entry pattern's parameters.
Stage 1 — execute, gate, review, on a single task
The smallest pipeline that proves its work: one agent implements, the gate judges, one agent reviews. Save this as one-task.toml:
# ## one_task — one task: execute, gate, repair, review
[header]
pattern_language_compatibility = "3"
entry = "one_task"
description = """
One task: a fresh agent executes the plan, the gate judges the result, a repair agent gets one
attempt at a red gate, and a reviewer judges the finished change.
"""
[patterns.one_task]
[[patterns.one_task.parameters]]
name = "plan_source"
kind = "path"
default = "plan.md"
declared_plan_source = true
description = "The task prose the executor reads, and the run's identity."
[[patterns.one_task.steps]]
name = "execute"
type = "agent"
prompt = "execute"
inputs = ["{{plan_source}}"]
[[patterns.one_task.steps]]
name = "build_gate"
type = "gate"
command = "bin/gate"
[[patterns.one_task.steps]]
if = { build_gate = "red" }
then = "repair"
else = "review_task"
[[patterns.repair.steps]]
name = "repair_agent"
type = "agent"
prompt = "troubleshoot"
[patterns.repair.steps.text]
POSITION_CLAUSE = """
A previous agent left this tree red against the project gate. Repair its work rather than starting
over: make the smallest verified repair you can, and verify it before you finish.
"""
[[patterns.repair.steps]]
name = "repaired_gate"
type = "gate"
command = "bin/gate"
[[patterns.repair.steps]]
if = { repaired_gate = "red" }
then = "stop_still_red"
else = "review_task"
[[patterns.review_task.steps]]
name = "review"
type = "agent"
prompt = "review"
diff = "run-base"
outputs = [
{ name = "review_verdict", path = "state/review-verdict.txt", values = ["complete", "blocked"] },
{ name = "review_note", path = "state/review-note.md" },
]
[patterns.review_task.steps.text]
REVIEW_SUBJECT = "task"
[[patterns.review_task.steps]]
if = "review_verdict"
then = { complete = "nothing", blocked = "stop_review_blocked" }
else = "stop_missing_verdict"
[[patterns.stop_still_red.steps]]
type = "stop"
note = "The gate is still red after one repair attempt."
[[patterns.stop_review_blocked.steps]]
type = "stop"
note = "The reviewer declared itself blocked."
[[patterns.stop_missing_verdict.steps]]
type = "stop"
note = "The reviewer finished without writing review_verdict; a reviewer that wrote no verdict reviewed nothing, so the run stops rather than treating silence as approval."
The [header] declares the reader contract (pattern_language_compatibility = "3"), the entry pattern where execution starts, and a human-facing description. Every [patterns.<name>] table is one callable pattern; this file defines six, and five of them exist only to be branch targets.
The entry pattern one_task declares one parameter, plan_source, of kind path. declared_plan_source = true marks it as the input that supplies the run's identity — the file Gantry hashes to recognize this run again — and default = "plan.md" is what a caller gets without passing anything.
execute is an agent step: one fresh agent session, given the prompt id execute (resolved through Gantry's prompt tiers) and shown {{plan_source}} as an input. It declares no success flag and no way for the agent to grade itself, because an agent's claim that it finished is not proof. Proof is the next step, build_gate, a gate step whose command declares the project check Gantry runs with baseline checks and red/green verdict handling.
The if step that follows is the step-outcome form: if = { build_gate = "red" } tests the named step's outcome, using the gate color alias red. Its then and else arms each name a pattern: a red gate goes to repair, a green one to review_task.
The repair pattern is the failure path, decided up front. A fresh agent runs with the troubleshoot prompt, briefed through the step's text table — POSITION_CLAUSE is a prose placeholder in the prompt, and the text you write here is the whole of the repair agent's briefing. The step declares no outputs, so of troubleshoot's three end states only "repaired and verified" is reachable. Then the gate runs again, and the second if routes a still-red tree to a stop, which ends the run with one authored note. This pattern gives repair exactly one attempt; the shipped sprint.toml recurses instead, which stage 4 builds.
review_task shows declared outputs. The reviewer declares two: review_note, a file contract, and review_verdict, a branchable value — a file that must contain exactly one member of its closed values vocabulary, here complete or blocked. diff = "run-base" is a boundary reference: the review span shown to the agent covers everything since the run's starting commit.
The final if is the output form, exhaustive by rule: the then table names every declared value exactly once. complete routes to nothing, the engine-provided identity pattern, so execution falls off the end and the run completes. blocked routes to a stop. The else arm handles absence: the session ended and the verdict file was never written. Absence is not a value and is never defaulted.
Three facts, three routes:
- A produced value routes through
if = "output_name"with its exhaustivethentable. - An absent output routes through that
if'selsearm. - A failed step — a dead session, a required output that was malformed, a gate that could not produce a verdict — routes through
if = { step_name = "failed" }. A file containing anything outside the declaredvaluesvocabulary is the producer'sfailedoutcome, never a synthesized default.
A failed outcome nobody routed reaches the engine-forced stop. execute has no if = { execute = "failed" } arm, so an executor session that dies stops the run rather than letting the gate judge an empty tree. A failed step is recoverable only when an if says so; the route is the policy.
Check and run it:
cp one-task.toml ~/.config/gantry/patterns/one-task.toml gantry pattern show one-task --ascii gantry --pattern one-task plan.md
What you just learned: [header], pattern_language_compatibility, entry, description; [patterns.<name>]; parameters (kind = "path", default, declared_plan_source); the agent, gate, and stop step types; prompt, inputs, text, diff; declared outputs with and without values; the if step in its output form and step-outcome form, gate colors green/red, then/else, nothing. Spec: § 2 File Shape, § 3 Header, § 6 Producing Steps, § 7 Outputs, § 8 if, § 12 stop.
Stage 2 — minimal decomposition
The smallest decomposition: a planner agent writes milestone briefs into a directory, and a for step runs the stage-1 pipeline once per brief — no per-milestone planning pass, no replanning. Save as split.toml:
# ## split — decompose a plan into milestone briefs, then run each through the stage-1 pipeline
[header]
pattern_language_compatibility = "3"
entry = "split"
description = """
Decompose a plan into ordered milestone briefs, then run each brief through the execute–gate–repair–review
pipeline. Split once, then iterate: no per-milestone planning pass, no replanning.
"""
[patterns.split]
[[patterns.split.parameters]]
name = "plan_source"
kind = "path"
default = "plan.md"
declared_plan_source = true
description = "The plan prose the planner reads, and the run's identity."
[[patterns.split.steps]]
name = "plan_milestones"
type = "agent"
prompt = "write-milestones"
inputs = ["{{plan_source}}"]
outputs = [
{ name = "milestones", path = "milestones/" },
{ name = "plan_scope", path = "state/plan-scope.txt", values = ["work", "none"] },
]
[[patterns.split.steps]]
if = "plan_scope"
then = { work = "build_briefs", none = "stop_no_work" }
else = "stop_missing_scope"
[[patterns.build_briefs.steps]]
name = "build_each_brief"
type = "for"
list = { directory = "{{milestones}}" }
body = "one_task"
[patterns.build_briefs.steps.parameters]
plan_source = "{{task}}"
# The stage-1 pipeline, now the body the `for` calls once per brief. The parameter lost its default
# and `declared_plan_source`: the `for` binds it, and run identity belongs to the entry pattern.
[patterns.one_task]
[[patterns.one_task.parameters]]
name = "plan_source"
kind = "path"
description = "The brief this invocation builds."
[[patterns.one_task.steps]]
name = "execute"
type = "agent"
prompt = "execute"
inputs = ["{{plan_source}}"]
[[patterns.one_task.steps]]
name = "build_gate"
type = "gate"
command = "bin/gate"
[[patterns.one_task.steps]]
if = { build_gate = "red" }
then = "repair"
else = "review_task"
[[patterns.repair.steps]]
name = "repair_agent"
type = "agent"
prompt = "troubleshoot"
[patterns.repair.steps.text]
POSITION_CLAUSE = """
A previous agent left this tree red against the project gate. Repair its work rather than starting
over: make the smallest verified repair you can, and verify it before you finish.
"""
[[patterns.repair.steps]]
name = "repaired_gate"
type = "gate"
command = "bin/gate"
[[patterns.repair.steps]]
if = { repaired_gate = "red" }
then = "stop_still_red"
else = "review_task"
[[patterns.review_task.steps]]
name = "review"
type = "agent"
prompt = "review"
diff = "iteration-start"
outputs = [
{ name = "review_verdict", path = "state/review-verdict.txt", values = ["complete", "blocked"] },
{ name = "review_note", path = "state/review-note.md" },
]
[patterns.review_task.steps.text]
REVIEW_SUBJECT = "task"
[[patterns.review_task.steps]]
if = "review_verdict"
then = { complete = "nothing", blocked = "stop_review_blocked" }
else = "stop_missing_verdict"
[[patterns.stop_no_work.steps]]
type = "stop"
note = "The planner reported no actionable work in the plan."
[[patterns.stop_missing_scope.steps]]
type = "stop"
note = "The planner finished without writing plan_scope, so there is no honest answer to whether the plan holds work; the run stops rather than iterating a directory nobody vouched for."
[[patterns.stop_still_red.steps]]
type = "stop"
note = "The gate is still red after one repair attempt."
[[patterns.stop_review_blocked.steps]]
type = "stop"
note = "The reviewer declared itself blocked."
[[patterns.stop_missing_verdict.steps]]
type = "stop"
note = "The reviewer finished without writing review_verdict; a reviewer that wrote no verdict reviewed nothing, so the run stops rather than treating silence as approval."
The bottom two thirds repeats stage 1 with three edits; the top is new.
plan_milestones is a planner agent running the shipped write-milestones prompt, whose output contract names the milestones directory output this step declares. A path ending in / declares a directory output: a list, never a decision value, and the format's only list form. The planner also declares plan_scope, a branchable value carrying what a directory cannot — whether the plan held any work at all. An empty plan routes to stop_no_work rather than being inferred from an empty directory, and a planner that wrote no scope file routes through else to its own stop.
build_each_brief is a for step over list = { directory = "{{milestones}}" } — the directory the planner just wrote. Each iteration invokes the body pattern, here one_task, exactly as a call would, with the bindings in the step's parameters table: plan_source = "{{task}}", where {{task}} is the active task file path. There is no level key: the depth a for opens is read from the composed task path, and the interface renders position as numbers, so nothing consumes a label. The for is sequential — there is no parallel map — the directory is re-read before each iteration, and when a body invocation falls off its end that task is recorded complete: no pattern declares a commit or a DONE-row.
The three edits to the stage-1 half: one_task's parameter lost declared_plan_source and its default, because run identity now belongs to the entry pattern split and the for always binds the parameter; and the reviewer's diff changed from run-base to iteration-start, the start boundary of the enclosing for iteration, so each brief's reviewer sees that brief's work as one change rather than the whole run so far.
for has a fail property with the closed vocabulary stop (the default) and continue, and it governs only what the body did not route. A brief whose repair still ends red routes to stop_still_red, and an authored stop ends the whole run whatever fail says. What the default decides is the rest: a failed body invocation the body left unrouted also ends the run. Stage 3 makes the other choice.
What you just learned: the for step (list = { directory = ... }, body, parameters, fail); directory outputs as the format's only list form; the {{task}} reference; the iteration-start boundary reference. Spec: § 9 for, § 7 Outputs, § 13 Boundary References.
Stage 3 — a simple map
Stage 2's for iterated a directory its own planner wrote. Point the same body at a directory that already exists — task files you wrote yourself, or a roster left by an earlier run — and you have a map. Save as run-directory.toml:
# ## run_directory — run a pre-existing directory of task files, each carrying its own prompt and gate
[header]
pattern_language_compatibility = "3"
entry = "run_directory"
description = """
Run every task file in a pre-existing directory. Each file carries its own worker prompt and gate
command; a failed task is recorded and the run continues to the next.
"""
[patterns.run_directory]
[[patterns.run_directory.parameters]]
name = "tasks"
kind = "path"
declared_plan_source = true
description = "The directory of task files, and the run's identity."
[[patterns.run_directory.steps]]
name = "run_tasks"
type = "for"
list = { directory = "{{tasks}}" }
body = "carried_task"
fail = "continue"
[[patterns.carried_task.steps]]
name = "build_task"
type = "agent"
prompt = "{{task.prompt}}"
[[patterns.carried_task.steps]]
name = "task_gate"
type = "gate"
command = "{{task.gate}}"
[[patterns.carried_task.steps]]
if = { task_gate = "red" }
then = "repair_task"
[[patterns.repair_task.steps]]
name = "repair_task_agent"
type = "agent"
prompt = "{{task.prompt}}"
[patterns.repair_task.steps.text]
POSITION_CLAUSE = """
A previous agent attempted this task and left the tree red against the task's own gate. Repair its
work rather than starting over, and verify the repair before you finish.
"""
# The last step of the repair arm, deliberately unrouted: a red here is an unrouted failed gate, so
# the body invocation fails and `fail = "continue"` on the `for` records it and moves on.
[[patterns.repair_task.steps]]
name = "repaired_task_gate"
type = "gate"
command = "{{task.gate}}"
Two new task references put the work definition inside the task files. prompt = "{{task.prompt}}" hands the file's prose body to a fresh worker as its whole prompt, and command = "{{task.gate}}" runs the gate command the file carries — the command changes, the gate package's authority does not. A task file carries TOML front matter, the shape the shipped write-map-roster prompt specifies:
+++ gate = "the command this task must pass" +++ The worker prompt for this one task.
The failure policy changed, in two places. First, fail = "continue" on the for: a failed body invocation is recorded and the run moves to the next task. Second, the repair arm's re-gate is its last step with no if after it — a red repaired_task_gate is then an unrouted failed gate, the unrouted failure for.fail handles. The if after task_gate has no else: a non-matching outcome continues to the next step, and there is none, so a green gate completes the task.
Now open the shipped map.toml next to this file. Its map_task body — build_task on {{task.prompt}}, task_gate on {{task.gate}}, a repair agent, a re-gate — is this stage's body. What map adds is more decided-up-front failure handling: an agent that writes the roster from a source document, a command step that checks the roster's shape and routes a complaint back into a rewrite, per-task verdict files under run/verdicts/, a streak rule that stops the run after two failed tasks in a row, an on_plan_change handler, and an on_stop = "write_handover" hook. The streak rule is the check_streak command: lower its tail -n 2 and its -ge 2 to 1 and the run stops after one failed task. Meaningful variants of a pattern differ by lines, not by engine features; forking the file is the extension mechanism.
What you just learned: the {{task.prompt}} and {{task.gate}} task references and the task-file front-matter shape; command on a gate step; fail = "continue" and what counts as an unrouted failure it handles. Spec: § 9 for, § 6.2 gate, § 5 Names And References.
Stage 4 — a simple goal loop
The last shape: instead of building a fixed plan, work until an executable acceptance check passes. The format has no loop word — the only unbounded repetition is a pattern tail-calling itself — so the loop is: run the check; if it fails, plan the gap, build that plan with stage 2's split, and call the pattern again. Save as until-done.toml:
# ## until_done — recurse until an acceptance check passes
[header]
pattern_language_compatibility = "3"
entry = "until_done"
include = ["split.toml"]
description = """
Work toward a goal expressed as an executable acceptance check: run the check, and while it fails,
plan the gap it reports, build that plan through `split`, and start over. Ends when the check
passes, or when the gap planner reports no actionable work left.
"""
[patterns.until_done]
[[patterns.until_done.parameters]]
name = "until_source"
kind = "path"
declared_plan_source = true
description = "The goal prose the gap planner reads, and the run's identity."
[[patterns.until_done.parameters]]
name = "check"
kind = "path"
default = "bin/goal-check"
description = "The acceptance check record path: exit zero is achieved and non-zero is a gap."
[[patterns.until_done.steps]]
name = "run_check"
type = "command"
run = 'mkdir -p "$orchestration/state"; "$orchestration/$check" > "$orchestration/state/gap-report.txt" 2>&1'
outputs = [
{ name = "gap_report", path = "state/gap-report.txt" },
]
# Exit zero means the check passed: execution continues past this `if`, falls off the end of the
# entry pattern, and the run completes. Non-zero is the command's failed outcome, routed to the gap.
[[patterns.until_done.steps]]
if = { run_check = "failed" }
then = "close_gap"
[[patterns.close_gap.steps]]
name = "plan_gap"
type = "agent"
prompt = "write-gap-plan"
inputs = ["{{until_source}}", "{{gap_report}}"]
outputs = [
{ name = "gap_plan", path = "state/gap-plan.md" },
{ name = "gap_scope", path = "state/gap-plan-scope.txt", values = ["work", "none"] },
]
[[patterns.close_gap.steps]]
if = "gap_scope"
then = { work = "build_gap", none = "stop_no_gap" }
else = "stop_missing_gap_scope"
[[patterns.build_gap.steps]]
name = "build_gap_plan"
type = "call"
pattern = "split"
[patterns.build_gap.steps.parameters]
plan_source = "{{gap_plan}}"
# The tail call: the last step on every path through the cycle, which is what makes the recursion
# legal. There is no loop word and no cycle counter; the recursion ends when `run_check` succeeds or
# the planner declares `none`.
[[patterns.build_gap.steps]]
name = "next_cycle"
type = "call"
pattern = "until_done"
[patterns.build_gap.steps.parameters]
until_source = "{{until_source}}"
check = "{{check}}"
[[patterns.stop_no_gap.steps]]
type = "stop"
note = "The acceptance check is still red and the gap planner found no actionable work left."
[[patterns.stop_missing_gap_scope.steps]]
type = "stop"
note = "The gap planner finished without writing gap_scope, so there is no honest answer to whether work remains; the run stops rather than building a plan nobody vouched for."
include = ["split.toml"] composes stage 2's definitions into this file's namespace, and include entries resolve through the same pattern tiers as ordinary selection — the project tier, the global mirror, then the bundled tier — not relative to the including file's own directory. So put both files in ~/.config/gantry/patterns/; a sibling file in some other directory would not be found. One flat namespace also means step and output names must be unique across the composed whole — this file's names are all new — and the included file's own header is ignored: only the entry file's header configures the run. build_gap_plan invokes stage 2's entry with type = "call", binding its plan_source to the gap plan; call is how one pattern reuses another, and its parameters table is the whole interface.
The check is a command step: deterministic process text, the format's replacement for every predicate. The command receives its data as environment variables — the check parameter as $check, the orchestration directory as $orchestration; {{...}} never substitutes into command text — and its stdout lands in the declared gap_report output. Control flow reads the step's outcome, not its prose: if = { run_check = "failed" } routes a non-zero exit to close_gap, and a zero exit continues past the if, falls off the end of the entry pattern, and completes the run. A command in a deciding position either routes on its outcome like this or writes a one-word values output and lets an if read that; it never branches on stdout text directly.
The loop is next_cycle, a call of until_done from inside until_done. Recursion is the format's only loop: there is no repeat, until, retry counter, or max_cycles word, and an unattended recursive run is unbounded — an operator bounds it with lifecycle controls or an authored entry parameter, never with syntax. The loader requires every call participating in a cycle to be in tail position — no step in the caller may run after it returns — which is why next_cycle is the last step of build_gap; a non-tail recursive call is a load error. Each recursive invocation gets fresh per-invocation state, so cycle one's gap_plan is not readable in cycle two.
The loop ends three ways: run_check succeeds and execution falls off the end; the gap planner declares none and reaches stop_no_gap; or the planner writes no scope at all and reaches stop_missing_gap_scope.
The shipped pattern of this shape is until (config/patterns/until.toml). Its header claims the CLI selector flag until through [[header.cli_flags]], so gantry --until goal.md and gantry --pattern until goal.md are the same bound invocation. The shipped file is this stage plus refinements: it probes for the acceptance check with a command and, when absent, has a read_only agent author it from the goal prose; it protects the check from the agents that build against it; it accepts --param max_cycles <n> as an ordinary entry parameter whose command-authored branch stops after that many completed build cycles; and it builds each gap plan through the full build/milestone/sprint stack instead of split. This tutorial's version assumes the check already exists.
What you just learned: the command step (run, environment-variable data flow, outcome routing); call with parameters; include and tier-based include resolution; recursion as the only loop, the tail-position rule, and fresh per-invocation state. Spec: § 6.3 command, § 10 call, § 11 Recursion, § 3 Header.
Where to go next
- The shipped corpus (orchestration patterns):
build.toml→milestone.toml→sprint.tomlis the default milestone build (the stage-2 shape, elaborated, with recovery built from a sharedtroubleshootpattern and recursive retries),map.tomlis stage 3, anduntil.tomlis stage 4. Every file carries comments explaining its policy choices. - The authoring guide is the narrative authoring guide — how to think while writing a pattern, idiom by idiom.
- The pattern format is the normative reference: every table, key, and closed vocabulary, and what the loader rejects. When this tutorial and the spec disagree, the spec wins.
- Fork a shipped pattern. The bundled patterns are materialized into the global mirror
~/.config/gantry/patterns/; copy the nearest one under a new name and run it withgantry --pattern yours plan.md, or by file path withgantry --pattern ./yours.toml plan.md. A project-local copy at.gantry/<plan>/patterns/<name>.tomloverrides a same-named global fork, which overrides the bundled tier;gantry triage <plan>shows the registry with each pattern's tier and description. Edit a mirrored copy and yours is what runs from then on, upgrades included;gantry config resetrestores the shipped text.
docs/patterns/tutorial.md in the Gantry repository, rendered as it stands