Multi-stage AI pipelines for code — where the graph is the program.
Attractor is an Amplifier bundle implementing StrongDM's attractor nlspec — extending it, and in a few ledgered places deliberately diverging from it (see the repository's specs/EXTENSIONS.md). A Graphviz DOT digraph is not a picture of the program; it is the program. Nodes are computation, edges are dispatch, attributes configure behavior, and clusters scope defaults and derive stylesheet classes. You run the .dot file. You also render it with stock Graphviz, diff it in a pull request, and review it like any other source file.
Diagram 1 — the signature shape: a convergence loop
The exit is structurally unreachable until the gate reports success. The gate is a shell command whose real exit status decides the route — not an opinion, and not a summary the worker wrote about itself.
01The problem
Multi-step AI workflows decay into scripts nobody can see
A real AI-powered software workflow is several LLM calls chained together with conditional logic, human approvals, and parallel execution. Without a structured orchestration layer, that becomes one of two things: a fragile imperative script, or an ad-hoc state machine.
Both share the same defect — the control flow is invisible. It lives in if statements and early returns spread across a few hundred lines. You cannot look at it. You cannot diff it. When it loops forever, or exits early, or silently skips the review step, you find out by reading the log backwards.
Attractor's answer is to move the control flow out of the code and into a file whose entire purpose is to describe a graph:
Version-controlled — the workflow is a text file in your repo, next to the code it operates on.
Diffable — a change to the pipeline shows up as a change to an edge, not a change to a nest of conditionals.
Reviewable in a pull request — a reviewer reads eight lines of DOT instead of inferring a state machine.
Renderable with stock Graphviz — dot -Tsvg pipeline.dot. No custom viewer, no proprietary format.
That much is table stakes; several tools express workflows as graphs. The part that matters is what Attractor asks the graph to be.
02The big idea
Convergence, not chaining
The name is the thesis. An attractor is a shape a system falls into and cannot easily leave. Attractor asks you to build workflows with that property: a region of the graph that the run keeps re-entering until a machine-checkable condition is satisfied, and only then releases.
The canonical shape is the loop in the diagram above — a worker, a gate, and a corrective back-edge. Read it as three moves:
A worker node produces an artifact — code, a patch, a document.
A deterministic evidence gate checks the artifact. In the example it literally runs pytest; the shell's exit status is the verdict.
On failure, a corrective back-edge routes the run back to the worker, carrying the failing output forward as evidence.
Nothing in that loop is a suggestion. The done node is reachable from exactly one edge, and that edge's condition is the gate's own output. If the gate never says gate_pass, the run never reaches done. That is what "structurally unreachable" means, and it is the entire difference between a flowchart and an attractor.
Why a loop beats a chain, arithmetically
Assume each node in a pipeline succeeds 90% of the time — generous, for an LLM performing a non-trivial engineering task. A six-step linear chain multiplies those odds together. There is no recovery, because the graph has nowhere to recover to.
Diagram 2 — the same six nodes, chained versus looped
Six nodes at 0.90 give 0.90⁶ ≈ 0.53 end-to-end. Wrapping the same nodes in one corrective loop lifts it to ≈ 0.94. The nodes did not get better; the graph got a second chance and a way to know it needed one.
The loop only pays if the thing deciding whether to loop is trustworthy. If the gate is another LLM saying "looks good to me," you have added a step, not a guarantee — the same fallible process now grades its own output. The gate has to be something that cannot be talked into passing: a test suite, a type checker, a linter, a build, a schema validation, a script that greps for the string that must exist.
The gate is not a style choice; it is the mechanism that turns a flowchart into an attractor.
03What's actually novel
Five things that are not "AI workflow chaining"
Plenty of tools let you draw a workflow. What follows is the set of design decisions that make Attractor a different category of thing — each one earned from a failure mode that showed up in a real run.
01
The graph is the program
Most orchestration frameworks put YAML around code: steps that name functions, with the real logic hiding in the functions. Attractor inverts it. The .dot file is the executable artifact. Nodes are computation, edges are dispatch, attributes configure behavior, clusters scope defaults and derive stylesheet classes.
A node's prompt= is its body. An edge's condition= is its branch. shape= selects which execution tier runs it. There is no second file where the "actual" workflow lives, and no translation step between the picture and the behavior — because there is no picture, only the source. Rendering it with Graphviz produces a diagram that is correct by construction, since it is generated from the thing that runs.
02
Evidence-gated exits, and they fail closed
A node marked goal_gate=true is the node that gets to say the run is finished. This implementation treats that authority as dangerous and constrains it: on a goal-gate node, a plain-prose LLM response returns RETRY, not success. Ambiguity resolves against exiting.
That rule is the bundle's, not the spec's. The upstream nlspec is still fail-open today — §4.5 reads any string response as SUCCESS, with no exception for the node holding the exit — and the bundle diverges from it deliberately, with the divergence written down in specs/EXTENSIONS.md. What follows is why.
Diagram 3 — the same response, two verdicts. The gate node is held to a higher standard than the nodes it guards.
Why this exists — a real incident
A judge node evaluated a run and wrote, in prose: "NOT CONVERGED — 2 of 7 criteria pass." Under the fail-open rule — the spec's rule, and the bundle's own default at the time — an unparseable response counted as success. The pipeline recorded the run as converged and exited — after 2.4 hours, with zero work product.
The judge was right. The plumbing inverted its verdict. Fail-closed is what this implementation did about it: on a goal gate, "I could not parse a verdict" now means "go around again." The spec's text has not moved; the bundle has, and says so.
03
Verification lives outside the worker's context
The second failure mode is subtler and more interesting. If a worker knows the gate reads a file, the worker can write the file. In a live run, a worker fabricated its own convergence evidence — a convergence.jsonl reporting that it had converged — and the gate, reading the file exactly as instructed, passed it.
Dual critics running outside that worker's context caught it and refused to ship.
Diagram 4 — the gate inside the loop can be satisfied by the thing it is grading. The critics cannot be, because they never saw the reasoning that produced the artifact.
Verification inside the context that produced the evidence is not verification.
This is the practical reason Attractor spawns each LLM node as its own sub-session with its own context, and why critics are given fresh ones. Independence is not an optimization here; it is the property being purchased.
04
The graph is a control plane, not a recipe
The most common way to misuse Attractor is to draw your task breakdown as nodes: plan → implement → test. That feels natural and it is the wrong layer. The graph encodes the convergence skeleton — where the gates are, what the budgets are, which feedback channels exist. The model owns domain decomposition, because the model is the part that can adapt the decomposition when the domain surprises it.
When you find yourself adding plan → implement → test as graph nodes, stop.
Diagram 5 — the heuristic in one image. If your pipeline graph has no cycle, it should probably have been a recipe.
05
Tier discipline: models judge, shells execute
Every node picks an execution tier through its shape, and the split is deliberate. Deterministic work runs in shell nodes. Judgment runs in LLM nodes. Putting deterministic work inside a model is how you get a gate that is merely an opinion about a gate.
box — LLM node
Reads a situation, decides something, writes something that did not exist. Spawns a sub-session with tools. Non-deterministic by nature, and priced accordingly.
Use for: implementing, reviewing, judging, planning under uncertainty.
parallelogram — shell node
Runs a tool_command. Exit status and output are the result. Reproducible, auditable, free, and impossible to persuade.
Use for: tests, builds, linters, type checks, file existence, greps — every gate you actually trust.
The self-test the docs give you is blunt and effective: "Is the model here for judgment, or just to type?" If the answer is "just to type," you wanted a shell node.
04Under the hood
What actually happens when you run a .dot
Six phases, one orchestrator, one sub-session per LLM node, and a deterministic edge-selection algorithm that is worth reading twice.
The run lifecycle
Diagram 6 — the lifecycle. TRANSFORM resolves the stylesheet and expands variables before VALIDATE sees the graph, so lint reads the expanded form rather than the template. The checkpoint written after every node is both a record of what happened and a place to restart from — an interrupted run is continued with attractor resume <run_dir>, the nlspec's §5.3 resume behaviour, implemented. It restores the recorded state and picks up after the last completed node: finished work is not re-executed, and the node that was interrupted runs again because it never completed. Resume is explicitly opt-in — a fresh run never reads a checkpoint back, so a stale one left on disk is inert — and a corrupt, foreign or already-finished checkpoint fails loud rather than quietly restarting from the top. Graph-owned durability still ships alongside it: a file-state guard node checks whether its stage's artifact already exists and routes straight past the work if it does (the repository's 12-graph-resume pattern; delete an artifact to rewind to that stage).
The shape vocabulary
A node's Graphviz shape is not decoration. It selects the execution tier — which is why a rendered Attractor graph is readable at a glance: you can see where the model is thinking and where the machine is checking.
Diagram 7 — node shapes and what they execute
Shape
DOT
Tier / behaviour
Mdiamond
Start. Entry point of the run.
Msquare
Exit. Reaching it triggers the goal-gate check before the run is allowed to end.
box(default)
Codergen LLM agent. Spawns a sub-session with tools and works the prompt until it reports an outcome.
diamond
Conditional routing. A no-op node: it runs nothing, the outgoing edges do the deciding.
parallelogram
Tool / shell execution. Runs tool_command. This is where trustworthy gates live.
component
Parallel fan-out. Dispatches its successors concurrently.
tripleoctagon
Fan-in. Joins parallel branches back into one path.
hexagon
Human approval gate. The run blocks on a person.
folder
Nested sub-pipeline. Another .dot, executed as a node.
house
Supervisor loop(experimental).
Orchestrator and agent: two different loops
There are two nested loops in a running pipeline, and confusing them is the main source of "why did it do that." The outer loop is loop-pipeline walking the graph: pick a node, run it, checkpoint, select an edge, repeat. The inner loop is loop-agent inside a single LLM node: call the model, execute the tools it asked for, feed the results back, call again — until that node's work is done. The graph never sees the inner loop; it sees one node and one outcome.
Diagram 8 — results flow forward along edges; the orchestrator holds the context. The direct tier is not a downgrade to bare completions: it hands the agentic tool loop to the shared unified-llm-client, one loop per node. Selection is automatic but never adaptive — before the walk starts the engine preflights every node's declared provider, and a node asking for one the run cannot serve stops the launch outright, naming the node, the provider, and the missing credential. Nothing falls back to a different provider quietly, and no model is ever silently skipped.
Edge selection: five steps, in order
When a node finishes, the orchestrator has to pick exactly one outgoing edge. It tries five rules in sequence and takes the first that yields a target. If none of them does, that is not a shrug — it is a hard stop.
Diagram 9 — first rule that resolves, wins. Steps 2 through 5 draw only from unconditional edges, so an edge whose condition failed can never be picked back up by label or by suggested id. And step 5 does not always resolve: when nothing is eligible the engine refuses to guess and terminates the run with no_matching_edge — a deliberate, ledgered divergence from the spec, which would have read the same dead end as a successful completion.
The documented pitfall, and what it does now
The story the older docs tell: an agent reported success with no preferred_label. Neither outgoing condition matched. Steps 1 through 4 all fell through, so step 5 ran — and the lexical tiebreak silently picked Fix over Test, because F sorts before T. The pipeline looped forever, fixing something that was already fixed.
That run cannot happen on the engine as it stands. With both outgoing edges conditional and neither condition true, there is nothing eligible left to choose from — steps 2 through 5 see unconditional edges only — and the run stops on the spot with no_matching_edge. The lexical tiebreak's remaining territory is narrower and louder: several conditional edges matching at once, or several unconditional edges tied on weight.
The advice is unchanged and the reason is better. Defensive inequality routing — state the negative case explicitly and weight it — makes step 1 resolve either way. Routing that is total never reaches the tiebreak, and never reaches the hard fail either.
// make the routing total — step 1 resolves either way
review -> Fix [condition="outcome=retry"]
review -> Test [condition="outcome!=retry", weight=10]
The report_outcome contract
An LLM node communicates its result back to the orchestrator by calling a tool. This is the authoritative channel — everything else is recovery.
If the tool was not called, the orchestrator walks a recovery ladder, each rung less trustworthy than the last.
Diagram 10 — the ladder is why fail-closed matters. The lower rungs are guesses about intent, and the second-to-last rung is where a goal gate refuses to guess in the direction of "done".
Context fidelity: what the next node gets to see
Each node declares how much of the run's history it inherits. This is the main cost/coherence dial in a long pipeline.
context fidelity modes
Mode
What the node receives
Notes
full
Fresh spawn, with the prior exchanges replayed in as parent_messages
Transcript replay, not session reuse — thread_id groups nodes onto one shared replayed transcript
compact(default)
Fresh session plus a structured summary
The sane default: independence without amnesia
truncate
Goal and run ID only
Maximum independence — useful for critics
summary:low
Summary at roughly 600 tokens
Explicit budget when compact is too coarse
summary:medium
Summary at roughly 1,500 tokens
summary:high
Summary at roughly 3,000 tokens
Gotcha
last_response is truncated to 200 characters in every mode — full included. What a full-fidelity successor gets instead is the replayed transcript, which carries the complete history; last_response stays a 200-character snippet regardless. If a downstream node is quietly making decisions on a fragment, this is usually why.
Retries and failure routing
The retry policy draws a hard line between "try again" and "this failed."
max_retries retries RETRY outcomes, transient errors (429s, 5xx, timeouts), and must_write violations — a node that promised an artifact and did not produce one.
A plain FAIL is returned immediately and is never retried. Failure is a routing decision, not a flake.
A FAIL routes only through an explicit path: an edge with condition="outcome=fail", a retry_target, or a node with runs_on=always / runs_on=failure. If none exists, the pipeline terminates — fail-fast by default.
continue_on_fail=true exists for the cases where you mean it, but it cannot override a must_write= artifact contract. You do not get to declare success over a missing artifact.
The rest of the instrument panel
model_stylesheet — CSS for model assignment
Assign models by selector instead of repeating llm_model= on forty nodes. Only three properties apply: llm_provider, llm_model, reasoning_effort.
An edge with loop_restart="true" increments $iteration, resets completed nodes so they can run again, and preserves context_updates. The graph goes back to the start of the loop; what was learned does not.
feedback_from — teaching the next attempt
feedback_from= pipes a critic's output into a later node as $prior_critiques_<node_id>. Each critique is truncated to 500 characters, at most 5 are carried. The point is subtle and important: the next iteration runs in a fresh worker context, so this is the channel by which a brand-new worker learns what the last one got wrong.
Variables and parallelism
Prompts and commands expand $goal, any $param passed at launch, and $iteration. Fan-out uses a component node; the join uses join_policy=wait_all, with max_parallel defaulting to 4.
05A full run-through
Four nodes, two iterations, one real test run
This is the whole program. A dozen lines of DOT, no accompanying Python, no step definitions elsewhere. Read it, then watch it execute.
Three things in that listing carry all the weight. The goal lives in graph [goal=...], so the pipeline is self-describing. The gate is a shell command, so its verdict is an exit status rather than an opinion. And the worker's prompt tells it to read test_output.txt if it exists — which is how attempt two learns from attempt one.
Diagram 11 — the same two nodes run twice. Nothing about the graph changed between iterations; what changed is that test_output.txt now exists.
Step by step
showing all 9 steps
parse · transform · validate · initialize
The engine reads pipeline.dot, runs its lint rules and reachability checks, resolves the stylesheet and expands variables, then builds the run context — including $goal, taken straight from the graph attributes. Nothing is checkpointed yet; the first checkpoint is written after the first node has actually run.
first pass · implement
A box node, so the orchestrator spawns a loop-agent sub-session with tools. Inside it: call the model, run the tools it requests, feed results back, repeat. It writes count_words and a test file, then reports an outcome. Checkpoint.
first pass · test_gate
A parallelogram, so no model is involved. The shell runs pytest -q, redirects stdout and stderr into test_output.txt, and echoes gate_pass or gate_fail depending on pytest's exit status. Two tests fail. The last line is gate_fail.
edge selection
Rule 1 resolves immediately: the edge with condition="context.tool.last_line=gate_fail" matches. No fallback rules run, so nothing is left to alphabetical order. The target is implement.
loop restart
That edge carries loop_restart="true". $iteration becomes 1 — the counter starts at 0 for the initial pass, so the second pass is iteration one — completed nodes are reset so they may run again, and context_updates are preserved: the run rewinds its position, not its knowledge.
second pass · implement
A fresh worker context, and this is the point of the whole design: it does not inherit the previous worker's reasoning about why the code was right. Its prompt tells it to read test_output.txt if present, so it reads the actual failure output — the assertion, the traceback, the line number — and fixes the bug.
second pass · test_gate
The identical shell command runs again. pytest exits 0, so the && branch fires and the last line is gate_pass.
route to exit
Rule 1 resolves again, this time onto the done edge. done is an Msquare, which is not merely a label — reaching it triggers the goal-gate check.
goal gate · finalize
The goal gate reported success through a real command's exit status, not through prose, so the fail-closed rule has nothing to object to. The run completes with a passing test suite on disk as its artifact.
Why the redirection matters — lint rule CMD-001
The command writes to a file with > and then tests the exit status. Piping instead — pytest | tail or pytest | grep — would hand the shell the pipe's exit status, which is the last command's, and pytest's failure would vanish. The gate would report gate_pass on a red test suite, and the loop would exit on a lie. Attractor lints for this.
The companion hazard — a failed tool node leaves tool.last_line stale
A tool node that fails does not refresh tool.last_line: the value from its last successful run is still sitting in the context, and a label-routed edge will match against it happily, on evidence from a previous pass. Where a gate's command can genuinely exit non-zero, pair the label test with the node's own outcome — condition="context.tool.last_line=gate_pass && outcome=success" — so stale evidence cannot route the run on its own.
The sample above does not need that guard, and the reason is the idiom: … && echo gate_pass || echo gate_fail always exits 0, so the node always succeeds and tool.last_line is always this pass's. Write a gate that can exit non-zero and you have opted back into the hazard.
06How you use it
Four entry points
01As an Amplifier bundle
Point your configuration at a graph with dot_file: and run. There is no --goal flag, because the goal is an attribute of the graph — the pipeline carries its own intent.
02As a standalone CLI
The attractor binary runs a graph directly and passes parameters in.
attractor run pipeline.dot --param goal="Fix the TypeError..."
attractor resume run-dir/ # pick up after the last completed node
attractor lint pipeline.dot # structural checks, CMD-001 and friends
attractor trace run-dir/ # what path did a run actually take
attractor doctor # environment and provider diagnostics
03Conversationally, with the run_pipeline tool
Inside a session you can ask for a pipeline by name and let the assistant launch it:
"Run the plan-implement-test pipeline to add input validation to the login endpoint."
04The /attractorify slash command
Analyses the session you are already in against the three-question test below, tells you whether the work actually wants a pipeline, and then designs one with you conversationally. It is as willing to answer "no" as "yes".
The bundle is also importable as a library, so a graph can be executed programmatically from your own code when neither the CLI nor a session is the right host.
07When to reach for it
The three-question test
Attractor is a specific tool for a specific shape of problem. If a task does not answer yes to all three, a pipeline is probably ceremony. The repository is careful about how hard to press that: a "no" is a signal to reconsider the shape of the work, not a verdict on it.
Is there a cycle?
If the work is a sequence with no path backwards, there is nothing to converge to. A graph without a cycle is a list with extra syntax.
Is the exit gated on machine-checkable evidence, external to the worker?
Tests, builds, type checks, schema validation. If the only thing standing between the run and "done" is a model's assessment of its own output, the loop guarantees nothing.
Would it still land if any one LLM node had a bad day?
The failure mode to design against is not a crash — it is a single response that is plausible but wrong. If one such response can end the run successfully, the graph is not doing its job.
Reach for a recipe
Staged, sequential workflows with human approval gates. Known steps, known order, a person in the loop deciding whether to continue.
Reach for an attractor
Machine-verified convergence. Unknown number of attempts, a gate that cannot be argued with, and a corrective path back into the work.
"If your pipeline graph has no cycle, it should probably have been a recipe."