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
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 |
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.
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.
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 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, 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.