skip to content

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:

  • pattern defines a callable sequence.
  • agent, gate, and command are the only producing steps.
  • for, if, stop, and call are the only composition steps.
  • output is the value word: a file or directory path produced by exactly one step.

Write patterns by asking three questions for each producing step:

  1. What output or outcome does this step produce?
  2. Which if routes each value, absence, or failed outcome?
  3. What pattern should run next, including nothing when 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:

toml
[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:

toml
[[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:

toml
[[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:

toml
[[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:

toml
[[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.

toml
[[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:

toml
[[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:

toml
[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:

toml
[[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:

toml
[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:

toml
[[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:

toml
[[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:

toml
on_stop = "write_handover"

A table can choose hooks by stop class:

toml
[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 retired version key 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, the PROGRESS.md ledger, the frozen pattern closure under patterns/, 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 before merge-pattern.toml existed is backfilled once with the bundled merge closure.

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 if on the produced value or step outcome that decides the route.
  • Keep commands narrow: deterministic process text, environment variables in, declared outputs out.
  • Use nothing for an identity arm and stop for a terminal authored note.

docs/patterns/manual.md in the Gantry repository, rendered as it stands