Destaris
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 typeWhy
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 input node is the Loop's only entry point; a second trigger inside nodes: is ambiguous.

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.references is 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.