The authoring guide
The narrative companion to the format reference: how to think while writing a pattern, and which shape the format wants for each kind of work.
The narrative guide to spec.md, the normative format reference. The spec is the contract; this guide explains how to write a pattern.
1. What you are writing
A pattern is finite TOML data that describes how Gantry composes work. It performs no computation at load time and carries no expression language, embedded scripting language, arithmetic, variables, counters, or author-defined control operators.
The language has nine primary words:
patterndefines a callable sequence.agent,gate, andcommandare the only producing steps.for,if,stop, andcallare the only composition steps.outputis the value word: a file or directory path produced by exactly one step.
Write patterns by asking three questions for each producing step:
- What output or outcome does this step produce?
- Which
ifroutes each value, absence, or failed outcome? - What pattern should run next, including
nothingwhen nothing should run next?
A dead agent is a failed step outcome, not a missing value. A file that was not written is absence, routed by else, not a malformed value. A malformed value is the producer's failed outcome. Keeping those three facts separate is how a pattern avoids substituting a default for a bad session.
Gantry, not the agent, owns Git and step boundaries. A pattern may name structural boundaries for restore or diff, and never runs Git operations as pattern steps.
2. Reading your first pattern
A runnable file has a [header] naming pattern_language_compatibility = "3", its entry pattern, and a human description. Pattern definitions live under [patterns.<name>] and contain ordered steps.
The smallest useful work loop has this form:
[header]
pattern_language_compatibility = "3"
entry = "main"
description = "Run one task directory through an executor and gate."
[patterns.main]
[[patterns.main.parameters]]
name = "tasks"
kind = "path"
description = "Directory of task files."
[[patterns.main.steps]]
name = "run_tasks"
type = "for"
list = { directory = "{{tasks}}" }
body = "one_task"
[patterns.one_task]
[[patterns.one_task.steps]]
name = "execute"
type = "agent"
prompt = "{{task.prompt}}"
[[patterns.one_task.steps]]
name = "verify"
type = "gate"
command = "{{task.gate}}"
[[patterns.one_task.steps]]
if = { verify = "red" }
then = "stop_red"
else = "nothing"
[patterns.stop_red]
[[patterns.stop_red.steps]]
type = "stop"
note = "The gate went red; stopping for inspection."
The for step is sequential bounded iteration over a directory of task files. The directory may be a bound input or a directory output written by an earlier step. The body sees the current task through {{task}}, {{task.name}}, {{task.prompt}}, and {{task.gate}}.
The gate step runs Gantry's authoritative project check package. A command can compute a verdict, but it does not become a gate. The gate carries Gantry's baseline checks, red/green verdict handling, merge re-gates, and recovery semantics. A task may carry a gate command through {{task.gate}}; that changes the command the package runs, not the package's authority.
3. Producing values
Declare every file or directory a step produces on that step:
[[patterns.review.steps]]
name = "review"
type = "agent"
prompt = "review"
outputs = [
{ name = "review_verdict", path = "state/review-verdict.txt", values = ["complete", "retry", "blocked"] },
{ name = "retry_note", path = "state/retry-note.md" },
]
An output with values is a branchable value. The producing prompt or command writes exactly one of
those values to the declared file. An output without values is a data or prose record. A path ending
in / is a directory output, and a directory output is a list, so it never declares values.
A branch on a declared output is exhaustive over the authored vocabulary:
[[patterns.review.steps]]
if = "review_verdict"
then = { complete = "verify_review_edits", retry = "retry_sprint", blocked = "stop_review_blocked" }
else = "stop_missing_review"
else is the absence arm. Use it deliberately when the producer may complete without writing the
file. If absence should be terminal, route it to a stop pattern with an explicit note. If the branch
should do nothing, route to nothing, the reserved engine-provided identity pattern. Do not define
nothing yourself.
4. Routing failure
A producing step with a name also has a branchable outcome: done or failed. Gates additionally allow the color aliases green and red.
Route a failed outcome with an if, which must be the step immediately after the producing step it tests. With no else, a done outcome continues at the next step:
[[patterns.one_task.steps]]
name = "execute"
type = "agent"
prompt = "{{task.prompt}}"
[[patterns.one_task.steps]]
if = { execute = "failed" }
then = "repair_dead_session"
[[patterns.one_task.steps]]
name = "verify"
type = "gate"
command = "{{task.gate}}"
An unrouted failed outcome reaches the engine-forced stop and may enter the header on_stop hook. If a failed step should be recoverable, say so with an if.
A reviewer that writes no verdict does not silently become retry; its missing verdict routes through else, and its failed session routes through the step outcome. Test the outcome first, because a value branch with both then and else always routes and no step after it runs:
[[patterns.after_review.steps]]
if = { review = "failed" }
then = "retry_sprint"
else = "route_review_verdict"
[[patterns.route_review_verdict.steps]]
if = "review_verdict"
then = { complete = "verify_review_edits", retry = "retry_sprint", blocked = "stop_review_blocked" }
else = "retry_sprint"
5. Commands are deterministic data producers
Use command for deterministic reads of the tree, the run record, or authored files. A command does
not branch by stdout text directly. It writes declared outputs, and later if steps route those
outputs or the command's own outcome.
[[patterns.check_roster.steps]]
name = "check_roster"
type = "command"
run = "bin/check-roster \"$roster\" > \"$roster_verdict\""
outputs = [
{ name = "roster_verdict", path = "state/roster-verdict.txt", values = ["valid", "invalid"] },
]
[[patterns.check_roster.steps]]
if = "roster_verdict"
then = { valid = "run_roster", invalid = "rewrite_roster" }
else = "rewrite_roster"
Command text receives whole values through environment variables. Parameters, readable outputs, and
task references are exported with their names; dots become underscores, so {{task.name}} becomes
$task_name. The orchestration directory is $orchestration. Do not put {{...}} substitutions in
run; whole-value references remain legal in typed slots such as command = "{{task.gate}}" or
list = { directory = "{{roster}}" }.
This is how the language expresses every numeric predicate, list-empty condition, goal check, roster validation, and other deterministic decision. Derive the fact at the moment you need it, reduce it to an authored value, write the value, and route on the value. Numbers are never stored as pattern state.
6. Iteration and recursion
Use for when the work is bounded by a directory of task files:
[[patterns.run_roster.steps]]
name = "run_tasks"
type = "for"
list = { directory = "{{roster}}" }
body = "one_task"
fail = "continue"
for is sequential. It is not a parallel map and has no concurrency option. The directory is re-read
before each iteration, so a previous task can add more task files if that is the authored behaviour.
When a body invocation falls off its end, the task is complete; no pattern declares a commit, DONE-row, ledger, or task-completion operation.
fail = "continue" handles only failed iterations the body did not route: a dead session, a gate
that could not run, or another unrouted failed outcome. A red gate that the body routes to repair is
not a for.fail event. An authored stop or engine stop inside the body is still a run stop; it is
not converted into a continued iteration.
Use recursion through call for unbounded loops:
[patterns.until_green]
[[patterns.until_green.steps]]
name = "check"
type = "command"
run = "bin/check-goal > \"$goal_verdict\""
outputs = [
{ name = "goal_verdict", path = "state/goal-verdict.txt", values = ["green", "red"] },
]
[[patterns.until_green.steps]]
if = "goal_verdict"
then = { green = "nothing", red = "close_gap" }
else = "write_handover"
[patterns.close_gap]
[[patterns.close_gap.steps]]
name = "build_cycle"
type = "call"
pattern = "build"
[[patterns.close_gap.steps]]
name = "again"
type = "call"
pattern = "until_green"
There is no repeat, until, loop, cycle count, retry budget, depth guard, or max_cycles pattern word. Recursion ends when ordinary data routes somewhere that does not call back. An unattended recursive run is unbounded; operators bound it with lifecycle controls or with an authored entry parameter the selected pattern declares and consumes. The bundled sprint pattern declares attempt_ceiling with the default unbounded; the build entry passes 2.
Recursive calls that participate in a cycle must be in tail position. In practice: do not put more steps after a recursive call that can return through the cycle. The loader enforces this because otherwise a retry can unwind into stale post-retry work.
7. Composition and includes
Every callable definition is a pattern. call invokes another pattern in the composed namespace:
[[patterns.build.steps]]
name = "build_milestone"
type = "call"
pattern = "milestone"
[patterns.build.steps.parameters]
plan_source = "{{plan_source}}"
Files compose through the header include list, not through an include step:
[header] pattern_language_compatibility = "3" entry = "build" include = ["milestone.toml"] description = "Build each milestone in order."
Definitions from the entry file and every included file form one flat pattern namespace. Included files may also be runnable on their own; when included, their run-level header keys are inert. The entry file's header alone selects the run's entry pattern and stop hooks.
Treat a called pattern's interface as its parameters and declared outputs. Do not rely on its name
for behaviour. milestone, sprint, map, until, and any other familiar word are author-owned
names carrying no engine meaning. There is no level key and no display label: a for or a call
opens a depth, the depth is read from the composed task path, and the interface renders a position
as numbers rather than as a noun.
8. Plan-change handling
Plan-change observation is an agent-step property:
[[patterns.review.steps]] name = "review" type = "agent" prompt = "review" on_plan_change = "review_plan_change"
on_plan_change = "<handler>" names a handler pattern. Gantry derives the watched plan-file set from the declared plan source and files derived from it, observes the diff after the agent step, calls the handler, and re-observes until the plan-file diff is clean. Use this policy for executor, reviewer, and recovery steps.
on_plan_change = "nothing" observes declared plan-file changes and accepts the observed diff without a handler. Use this policy for planner steps whose product is plan files.
An omitted on_plan_change key leaves the step unwatched for plan-file edits. The format has no author-owned plan-change scope vocabulary.
9. Prompt and reference discipline
Prompt ids are whole typed values. A prompt field may name a literal prompt id, use prompt = "{{task.prompt}}", or use a whole {{prompt-id-parameter}} reference. A prompt id never selects a role, stage, branch, or recovery policy.
Prompt prose placeholders live in the agent's text, not in the pattern namespace. A step's text
table supplies prose values to those placeholders:
[[patterns.repair.steps]] name = "repair" type = "agent" prompt = "troubleshoot" [patterns.repair.steps.text] POSITION_CLAUSE = "Repair the red gate and write the troubleshoot verdict."
Whole-value references are structural only when they occupy the entire TOML value, except in prose text and declared output paths. They cannot supply identifiers. Do not build a prompt id, pattern name, output name, branch target, parameter name, or step name by string interpolation.
Task-carried prompts and gates are typed task references. {{task.prompt}} is usable where an agent
prompt slot is expected; {{task.gate}} is usable where a gate command slot is expected. Neither
grants behaviour because of its name or file path.
10. Stop hooks and compatibility
on_stop in the entry header installs hooks for engine stops only. A single pattern value is the
catch-all form:
on_stop = "write_handover"
A table can choose hooks by stop class:
[header.on_stop] step = "repair_unrouted_step" gate = "repair_gate_stop" any = "write_handover"
The closed stop classes are step, absence, plan, gate, record, and merge. any is only the hook-table catch-all key, and a class-specific hook takes precedence over it. record is a stop class for unsafe run records and resumes; a record hook may be declared, but record hooks never dispatch and record stops never fall through to any. merge is typed for reporting finalization stops, but it is not a hook-table key in this release and merge stops do not fall through to any.
A matching hook runs before the stop is final. When it returns, Gantry re-derives the stop condition.
If the condition has cleared, the run continues. If the same stop class recurs at the same position
with no new witnessed boundary since the hook ran, Gantry records a final stop from the freshly
re-derived facts. Authored stop steps, operator pauses, and operator aborts are already terminal and
do not enter on_stop.
Keep two compatibility surfaces separate:
pattern_language_compatibility = "3"in a pattern file is the grammar contract for the reader. A file declaring the retiredversionkey instead is read only far enough to be refused with an incompatible-version error.- The run pin also records a pattern-language compatibility marker for the frozen closure it resumes. That marker protects reuse of an existing run under a different interpreter contract; it is not a second grammar.
11. Running, inspecting, resuming
- Run:
gantry --pattern <name|file.toml> [inputs…]. Name resolution walks the project tier, the global tier, and the bundled tier, taking the first that has a file. - Freeze: at start, Gantry freezes the resolved pattern closure, the selected merge pattern closure, and bound inputs. Edit the source pattern freely while a run executes; the run keeps its frozen copy.
- Inspect: the run directory under
.gantry/is a book repository holding the run pin,merge-pattern.toml, the journals, thePROGRESS.mdledger, the frozen pattern closure underpatterns/, state files, and step and session records. - Resume:
gantry resume [<name>]. Gantry resumes from the frozen closure after checking the run pin's compatibility marker; older incompatible pins stay inspectable but are not parsed as patterns. A run recorded beforemerge-pattern.tomlexisted is backfilled once with the bundledmergeclosure.
12. Conventions
- Name patterns for the author's intent, not for engine behaviour.
- Fork the nearest bundled pattern; do not edit bundled files in place.
- Comment policy choices and invariants, not TOML syntax.
- Keep prompts and patterns harness-neutral.
- Branch with
ifon the produced value or step outcome that decides the route. - Keep commands narrow: deterministic process text, environment variables in, declared outputs out.
- Use
nothingfor an identity arm andstopfor a terminal authored note.
docs/patterns/manual.md in the Gantry repository, rendered as it stands