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

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 stageDon't make it
the exact file list the work touchessearch the repo to find them
the diff, already computedrun git diff and page through it
the failing test's name and its assertionrun the whole suite and read thousands of lines of passes
the gate result, trimmed to the failurere-run the gate to find out whether it passes
the one section of the spec that appliesread 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 input that 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.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 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.