Browse the docs
Building Loops
How to author a Destaris Loop — the pinned input and brain nodes, the success condition, stage-addressed feedback, and the traps worth knowing before your first run.
This page is the practical companion to Loops. If you haven't read that yet, start there — it's what a Loop is. This is how you build one.
A Loop lives in a single file at .destaris/loops/<slug>.yaml, read fresh off disk on every run.
You can hand-write it, ask an AI to write it, or build it on the canvas — they're the same file.
The smallest Loop that works
Only two things are required: a name, and a brain with a success condition. Everything else is
optional. A Loop with no stages at all is legal — the brain just judges from its references:
name: spec-is-done
brain:
successCondition:
text: Every acceptance criterion in the spec is implemented and its tests pass.
references: [docs/specs/feature-x.md]
edges:
- { from: input, to: brain }
A Loop with stages
The useful shape adds your own graph in between:
name: implement_spec
title: Implement a spec
input: # the pinned START node — text, or a file
kind: artifact
ref: docs/specs/feature-x.md
brain: # the pinned END node — who judges, and against what
model: claude-opus-5
effort: high
successCondition:
text: |
Every acceptance criterion in the spec is implemented and its tests pass.
references:
- docs/specs/feature-x.md
maxIterations: 5 # optional — 5 if omitted
maxTokens: 10000000 # optional — this IS the default; "unlimited" removes the cap
nodes: # your stages — everything between input and brain
- id: research
type: agent.run
config: { prompt: "Read the spec and the touched modules. {{input}}" }
- id: implement
type: agent.run
config: { prompt: "Implement the ACs.", access: broad }
- id: verify
type: task.command
config: { command: "pnpm test", captureExit: true }
edges:
- { from: input, to: research }
- { from: research, to: implement }
- { from: implement, to: verify }
- { from: verify, to: brain }
input and brain are reserved, pinned node ids. They're declared by their own top-level
blocks — never as entries in nodes: — because they're configuration, not stages you author.
They're still legal edge endpoints, which is why from: input and to: brain work above.
Declaring a stage with either id is a validation error.
Give every other stage an id matching /^[A-Za-z_][A-Za-z0-9_]*$/ — letters, digits, underscores,
no hyphens. Nothing hard-enforces it, but {{path}} interpolation only matches word characters, so
a hyphenated id like verify-tests silently fails to resolve in a prompt. It's left as literal
text rather than raising an error, which means a correction can look wired up on the canvas while
never actually reaching the model.
Writing a success condition that works
brain.successCondition.text is the bar. Write the thing a person would check, not the command
you'd run:
- Good: "Every acceptance criterion in the spec is implemented and its tests pass."
- Bad: "Run
pnpm test." That's a stage, and it's already in your graph.
references is an optional list of workspace paths, re-read fresh before every pass. Order
matters. Each reference is capped at 20,000 characters and the total across all of them at
60,000, charged in the order you declared — so put your most important reference first. Nothing is
dropped silently: an over-budget reference is replaced with a visible placeholder, an over-long one
is truncated with a visible marker, and an unreadable path tells the brain it was unreadable so it
can act on that as information.
The two rules the graph has to satisfy
A path must exist from input to brain. This is real graph reachability, not just "both ids
appear in an edge somewhere" — a stage wired off to the side, contributing nothing, is caught too.
A file that fails is rejected before it runs, naming every unreachable node.
The brain is terminal. It can be an edge's to, never its from. It judges after a pass has
finished, so nothing can run after it — each pass is built from input plus your stages and no
brain node at all. An edge like { from: brain, to: cleanup } used to lint clean and silently
drop cleanup every single pass; it's now rejected outright, naming the stage it would have
stranded.
What a stage can be
Any agent.run, any task.*, or any integration.* — the same palette as a workflow. That
includes task.run-workflow, which is how a Loop composes deterministic work it already has.
Four node types are banned inside a Loop, and using one fails validation by name:
| Banned type | Why |
|---|---|
agent.invent-workflow | Drafts and runs a new workflow at runtime — the uncontrolled, self-modifying behaviour a Loop exists to avoid. That belongs to an Agentic Workflow. |
agent.switch | Hands process selection back to a model mid-run — same problem, same destination. |
trigger.cron / trigger.webhook / trigger.command | The pinned |
Watching a foreach or loop stage run
When one of your stages is task.foreach or task.loop, its live progress shows right on the
canvas: the region's chip becomes the current lap (3 of 7, or a bare count when the loop is
unbounded), and every stage completed inside that lap wears the iteration's own colour instead of
plain success-green — so the canvas never shows an older lap's status once a new one has started.
Open the gate's own run detail and, for a task.foreach, you get an Items list: one row per item,
a swatch in its iteration's colour, a checkmark once it's done, a pulsing dot on the one running
now, and a red cross on the item a mid-loop failure actually died on. An item that looks like a
readable file (.md, .txt, .json, .yaml, .yml, .csv, .log) is clickable — it opens the
file's full contents in the canvas's viewer, rather than the truncated label shown in the list.
Feed your stages
This is the single biggest thing that decides what a Loop costs to run, and it is a design choice, not a setting.
An agent.run stage is a conversation, and every turn re-sends the whole
conversation. A stage that takes two hundred turns to find something is billed for
two hundred turns of context. A task.command that computes the same fact costs
cents. So: anything a deterministic stage can know, hand over. Keep agent.run
for judgment and for writing things, not for looking things up.
| Hand the stage | Don't make it |
|---|---|
| the exact file list the work touches | search the repo to find them |
| the diff, already computed | run git diff and page through it |
| the failing test's name and its assertion | run the whole suite and read thousands of lines of passes |
| the gate result, trimmed to the failure | re-run the gate to find out whether it passes |
| the one section of the spec that applies | read the whole spec to locate it |
Three things follow from that, and they are easy to miss:
Trim what you hand back, by marker not by tail. A monorepo test run prints one
summary per package in sequence, so tail -60 gives you whichever package finished
last — not the one that failed. Anchor on the runner's own failure banner instead.
On a real one-test failure: full output 20,119 characters, tail -60 3,902 and not
guaranteed to contain the assertion, marker-anchored 1,510 and always exact.
One stage, one job. If a deterministic gate runs the full suite, the stage before it must not. Duplicating the sweep inside an AI stage is the most common way a Loop wastes its budget, and it usually shows up as timeouts before it shows up as cost.
timeoutSeconds bounds the clock, not the spend. Thirty minutes of a stage going
in circles is tens of millions of tokens. If a stage can loop, the fix is a narrower
job or richer input, not a longer timeout.
Finally: carry state, not conversation. The files an earlier pass wrote are how a Loop remembers. A stage that starts fresh with a curated brief is both cheaper and more accurate than one carrying every assumption it has ever made — because a wrong assumption from two passes ago will otherwise still be steering it.
The trap: a failing verifier is not a failed run
This is the one that bites first.
A red test suite is the normal, expected state of a Loop mid-work. It's the whole reason
there's a second pass. But task.command rejects on a non-zero exit by default, so a plain
"run the tests" stage turns the ordinary case — tests still red — into a hard failed stop on pass
1, before the brain ever sees anything. No feedback, no second pass, just a dead run every time.
Set captureExit: true on that stage. A non-zero exit then becomes data — an exitCode field on
the stage's own output — instead of a stage error. Only then can the brain read "the tests failed"
out of the stage outputs and route a correction. Remember it has no tools of its own; if the
failure isn't in a stage output or a reference, it doesn't exist as far as the brain is concerned.
onError: continue is the general-purpose version of the same escape hatch, and works on any
stage. It turns whatever error the stage hit into an { error: "..." } output rather than failing
the pass — at the cost of the richer stdout/stderr/exitCode shape captureExit gives you
specifically for task.command.
What a Loop is allowed to run on
A Loop can declare inputRules — constraints on the input it's willing to run against. Every
field is optional, and an absent field constrains nothing:
inputRules:
kinds: [artifact] # "text", "artifact", or both — absent means either
extensions: [md, txt] # artifact inputs only, matched on the basename, case-insensitively
name: "spec-*.md" # a filename glob — "*" is the only wildcard
Declare it and Destaris enforces it in three places, all blocking:
- The "Run on an input…" dialog filters the kind tabs and the file picker to match, and refuses a violating pick inline.
- The editor flags a declared default
inputthat breaks the Loop's own rules — a lint error on the canvas, not a trap that only surfaces at run time. - Run start refuses a violating keyed run before the engine is even asked — no outcome row, no broken graduation streak.
A Loop with no inputRules is unconstrained, exactly like before this field existed. The same
group is editable from the canvas: with the pinned input node selected, the inspector's Accepts
group edits kinds, extensions, and name directly.
Running a Loop on a specific input
The input: block is the Loop's default, not its only input. You can also run a Loop on an input
supplied per run — right-click it, pick "Run on an input…", and choose text or a file. That
overrides the declared block for that run alone; the file itself is never rewritten.
This is what makes one Loop worth authoring for spec-driven development. Write implement_spec
once, then run it on docs/specs/a.md today and docs/specs/b.md tomorrow, instead of copying the
Loop once per spec. Runs on different inputs are concurrent and independent: each gets its own row,
its own canvas, and its own Stop, and one run's failure is its own.
If the input is a markdown file, the keyed run's label is drawn from the document itself — its
frontmatter title: field, or failing that its first # heading — instead of a generic filename
snippet. The Run-on-input dialog previews this label the moment you pick the file. This is display
only: the run's identity key is always the file's own path, never the title.
Three things do not follow the per-run input, so author around them:
successCondition.referencesis a fixed list of paths. It's re-read fresh every pass, but it's always the same paths whatever input the run is on. A Loop you intend to run on many specs must not name one spec there, or every run gets judged against that one file. List what's true for every run — an AC checklist, a house standard — and let this run's own spec reach the brain through a stage's output.- The brain is shown no input.
{{input}}goes to your stages, never to the brain. If which input this run is on matters to the verdict, a stage has to say so in its output. - Memory pools at the Loop, not per input. Every run on every input feeds one belief history, because what it proposes is a change to the graph — and the graph is shared.
Building it on the canvas instead
Everything above has a GUI. Open a Loop and you get the same canvas as a workflow, pinned at both
ends: input and brain render as undeletable cards, and you drag ordinary stages in between them
from a palette that omits the banned types.
The inspector edits the pinned nodes directly — input's source and its Accepts group on one side;
the brain's success condition, its references list, and its model/effort/runner on the other — plus
every stage's own config, same as a workflow. With no node selected, the inspector becomes the
Loop's own settings: title, maxIterations, maxTokens (the token-budget safeguard, next to the
pass ceiling), and the memory opt-out. A YAML pane sits alongside for raw editing, with the same
reachability, banned-type, and terminal-brain checks live on both views.
Save writes the file. Run always reads what's on disk — so an unsaved canvas edit is never what actually runs.
Related
- Loops — what a Loop is, and how it differs from the other two objects.
- Node reference — every node type, and its config.
- Expressions — the
{{path}}and${NAME}reference syntaxes. - Building agentic workflows — the
task.loopshapes you build inside a single pass.