An F# implementation of the StrongDM Attractor spec — a DOT-based pipeline runner that orchestrates multi-stage AI workflows using directed graphs.
Write a .dot file. Each node is a stage (LLM call, shell command, human gate, conditional branch). Edges define the flow. The engine executes it, calls real LLMs, checkpoints after every stage, and lets you resume if anything fails.
Built from three NLSpecs:
- Attractor Specification — the pipeline engine
- Coding Agent Loop Specification — agentic loop with tool execution
- Unified LLM Client Specification — multi-provider LLM client
~1,020 tests (unit + 208-test conformance suite). Zero warnings. One binary.
# Set at least one API key
export ANTHROPIC_API_KEY=sk-ant-...
export OPENAI_API_KEY=sk-...
export GEMINI_API_KEY=AI...
# Learn the DOT schema
attractor schema
# List known models (and their aliases) for llm_model=
attractor models
# See a working example
attractor example
# Validate a pipeline
attractor --validate my-pipeline.dot
# Run it
attractor my-pipeline.dot
# Run fully autonomous (no human prompts)
attractor my-pipeline.dot --auto-approve
# Run without API keys (mock LLM responses)
attractor my-pipeline.dot --simulate
# Resume from checkpoint after interruption
attractor my-pipeline.dot --resume attractor-logs/20260217-160000Pipelines are Graphviz DOT digraphs. Nodes have shapes that determine their behavior:
digraph my_pipeline {
graph [goal="Build and test a new feature"]
start [shape=Mdiamond] // entry point
done [shape=Msquare] // exit point
plan [shape=box, prompt="Plan how to: $goal"] // LLM call
implement [shape=box, prompt="Write the code",
goal_gate=true, retry_target="plan"] // must succeed to exit
test [shape=parallelogram, // shell command
tool_command="dotnet test 2>&1 | tail -10",
timeout="60s"]
review [shape=hexagon, label="Approve?"] // human gate
gate [shape=diamond] // conditional branch
start -> plan -> implement -> test -> gate
gate -> review [condition="outcome=success"]
gate -> implement [condition="outcome=fail"]
review -> done [label="[A] Approve"]
review -> plan [label="[R] Revise"]
}Run attractor schema for the complete attribute reference.
| Shape | Type | What it does |
|---|---|---|
Mdiamond |
start | Entry point (exactly one required) |
Msquare |
exit | Terminal node (exactly one required) |
box |
codergen | Single-turn LLM call — sends prompt to the model, writes response to logs. Auto-promoted to coding agent when agent attributes are present (max_turns, cwd, thread_id) |
tab |
coding_agent | Multi-turn LLM agent with tool execution (file I/O, shell, grep). Use for any node that needs to read/write files or run commands |
parallelogram |
tool | Runs tool_command in a shell, captures stdout/stderr |
hexagon |
wait.human | Pauses for human input, shows options from outgoing edge labels |
diamond |
conditional | Pass-through — engine evaluates edge conditions to pick next node |
component |
parallel | Fan-out — executes all branch targets concurrently |
tripleoctagon |
fan_in | Fan-in — consolidates parallel results |
house |
manager_loop | Bounded supervisor loop (observe/steer/wait cycles) |
graph [goal="...", model_stylesheet="* { llm_model: claude-sonnet-4-6; }",
default_fidelity="compact", default_max_retry=2]
node [prompt="...", goal_gate=true, retry_target="node_id", timeout="30s", auto_status=true]
edge [condition="outcome=success", label="[A] Approve", weight=10, loop_restart=true]
Coding agent nodes (tab, or box with auto-promotion) additionally support:
node [max_turns=40, max_tool_rounds=25, cwd=".", thread_id="review", system_prompt="...",
acp_preset="codex", command_timeout=120000]
LLM nodes can declare a post-stage contract via success_criteria_command. When a
stage would otherwise succeed, the engine runs the command via /bin/sh -c in the
node's working directory. Exit 0 keeps Success and captures stdout into
contract_check_output; non-zero exit or success_criteria_timeout_ms expiry
overrides the outcome to Fail with the command's combined output recorded. The
subprocess inherits ATTRACTOR_* env vars plus every graph-level attribute.
node [prompt="...", success_criteria_command="dotnet test ./MyPackage.Tests",
success_criteria_timeout_ms=60000]
thread_id is a session partition key — nodes sharing the same thread_id share a persistent session (conversation history, tool state). Nodes with different thread_id values get independent sessions. Give late-pipeline nodes their own thread_id to avoid cumulative turn exhaustion.
condition="outcome=success" // status check
condition="outcome=fail"
condition="outcome=partial_success" // partial success
condition="outcome=success && context.tests=passed" // compound
condition="context.ready=true" // context variable
condition="internal.loop_restart_count=3" // bail after 3 restarts
condition="tool_exit_code=0" // tool output alias
Edges with loop_restart=true restart the pipeline with fresh logs and context. All context keys are cleared except graph.* keys, which persist across restarts. This is useful for retry-from-scratch loops.
Tool nodes with auto_status=true automatically synthesize a Success outcome if the tool exits successfully but doesn't write a status.json file. This simplifies pipelines where tools don't need to communicate structured results back to the engine.
tool_command subprocesses inherit every graph-level attribute as an environment variable, so you can parameterize pipelines by declaring config at the top and referencing it as $my_var in shell commands (built-in ATTRACTOR_* vars win on collision):
graph [goal="...", target_package="Billing", planning_dir=".ai/billing"]
init [shape=parallelogram, tool_command="mkdir -p \"$planning_dir\" && echo $target_package"]Assign different LLM models per node using CSS-like selectors:
model_stylesheet="* { llm_model: claude-sonnet-4-6; }
.critical { llm_model: claude-opus-4-6; }
#review { llm_model: gemini-3.1-pro-preview-customtools; }"
Specificity: * < shape < .class < #id. Node attributes override stylesheet.
A few conventions that keep pipelines readable and maintainable as they grow.
Collect graph attributes, node defaults, edge defaults, and the model stylesheet into a single block at the top. Readers should see the pipeline's goal, model routing, and defaults before any node declarations.
digraph my_pipeline {
// --- Configuration ---
graph [
goal="Implement the billing API",
label="Billing Sprint",
model_stylesheet="* { llm_model: claude-sonnet-4-6; }
.critical { llm_model: claude-opus-4-6; }
#final_review { llm_model: gemini-3.1-pro-preview-customtools; }",
default_fidelity="truncate",
default_max_retry=2
]
node [shape=box]
edge [weight=1]
// --- Lifecycle ---
start [shape=Mdiamond]
done [shape=Msquare]
// --- Stages ---
...
}Use // comments to separate phases visually. Name nodes by what they do, not what they are (orient not llm_step_1).
// Phase 1: Discovery
gather_context [shape=parallelogram, tool_command="..."]
orient [shape=box, class="critical", prompt="..."]
// Phase 2: Planning
decompose [shape=box, prompt="..."]
critique [shape=box, class="critical", prompt="..."]
// Phase 3: Execution
implement [shape=tab, class="deep", cwd=".", max_turns="60", prompt="Implement the plan"]
run_tests [shape=parallelogram, tool_command="dotnet test ...", timeout="120s"]Separate node declarations from edge declarations. This makes the flow scannable at a glance — nodes describe what, edges describe when.
// --- Flow ---
start -> gather_context -> orient -> decompose -> critique
critique -> implement [condition="outcome=success"]
critique -> decompose [condition="outcome=fail", label="Revise"]
implement -> run_tests -> doneAttractor parses bare durations like timeout=60s, but Graphviz's dot renderer doesn't. Always quote them so the same file works with both attractor --validate and dot -Tpng:
timeout="60s" // works everywhere
timeout=60s // breaks Graphviz renderingThe parser respects quoted strings — // and /* inside "..." are preserved, not stripped as comments. URLs, globs, and shell patterns work correctly:
tool_command="curl https://example.com/api" // works
tool_command="find . -name '*.go'" // works
tool_command="find . -path '*/vendor/*'" // worksDon't scatter llm_model across every node. Use model_stylesheet to assign models by class, shape, or ID. This makes it easy to change models in one place.
// BAD — model repeated on every node
plan [shape=box, llm_model="claude-opus-4-6", prompt="..."]
implement [shape=box, llm_model="claude-opus-4-6", prompt="..."]
review [shape=box, llm_model="claude-sonnet-4-6", prompt="..."]
// GOOD — one stylesheet, nodes just declare their class
graph [model_stylesheet=".deep { llm_model: claude-opus-4-6; } * { llm_model: claude-sonnet-4-6; }"]
plan [shape=box, class="deep", prompt="..."]
implement [shape=box, class="deep", prompt="..."]
review [shape=box, prompt="..."]Mark critical stages with goal_gate=true so the pipeline can't exit until they succeed. Pair with retry_target to create automatic recovery loops:
implement [shape=box, prompt="...", goal_gate=true, retry_target="plan"]
// If implement fails, pipeline redirects to plan instead of exitingAlways validate before execution. The validator catches structural issues (unreachable nodes, dead ends, missing terminals), syntax errors, and warns about common misconfigurations. The synopsis tells you whether the pipeline will actually produce code changes or just generate documents.
attractor --validate my-pipeline.dot$ attractor --validate sprint-execute.dot
Nodes: 21 | Edges: 27 | Goal: Implement a new feature
Synopsis:
EXECUTION pipeline — will invoke coding agents and run commands to produce code changes
Capabilities: [LLM | TOOLS | CODE_CHANGES | HUMAN_GATES | GOAL_GATES | FEEDBACK_LOOPS | CONDITIONALS]
Stages: 7 LLM, 7 tool, 2 human, 3 conditional, 0 parallelThe validator checks structure (reachability, dead ends, terminal paths), syntax (conditions, stylesheet), and classifies the pipeline:
- EXECUTION — invokes coding agents (
tabnodes or ACP presets) to produce code - PLANNING — LLM-only, generates docs/plans but no code changes
- HYBRID — runs tool commands but no coding agent detected
The examples/ directory contains 17 reference pipelines imported from Swift OmniKit, ranging from a zero-LLM vulnerability scanner to a 52-node megaplan with 6-branch cross-model critique:
| Start with | What it shows |
|---|---|
vulnerability_analyzer.dot |
Pure tool pipeline, no LLM. Best "hello world" |
consensus_task.dot |
Multi-model planning with goal gate and retry |
fix_sheets.dot |
Simplest instance of the 9-phase execution template |
cedar_spec_port.dot |
Advanced: model stylesheet, nested critique, complex retry |
megaplan_quality.dot |
Advanced: human gates, 6-branch fan-out, quality gates |
Also available:
attractor example # print a template pipeline to stdout
attractor schema # print the complete DOT attribute referenceEach run writes to attractor-logs/<timestamp>/:
manifest.json // pipeline name, goal, start time
checkpoint.json // crash recovery (resume with --resume)
<node_id>/prompt.md // LLM prompt sent
<node_id>/response.md // LLM response received
<node_id>/status.json // outcome, context updates
<node_id>/tool_output.txt // full tool stdout
<node_id>/tool_stderr.txt // tool stderr (if any)
Six F# libraries targeting .NET 10.0:
src/Attractor/ 16 modules — pipeline engine, DOT parser, handlers, validation
src/UnifiedLlm/ 16 modules — multi-provider LLM client (Anthropic, OpenAI, Gemini)
src/CodingAgent/ 10 modules — agentic loop, tool execution, provider profiles
src/AcpRuntime/ 6 modules — Agent Client Protocol (stdio/WebSocket/HTTP+SSE)
src/McpClient/ 5 modules — Model Context Protocol client
src/JsonRpc/ 3 modules — JSON-RPC transport layer
src/Attractor.Cli/ 1 module — CLI binary
tests/ 812 tests across 6 test projects
conformance/ 208 tests — black-box CLI conformance suite
examples/ 17 reference DOT pipelines
dotnet build Attractor.slnx # build everything
dotnet test Attractor.slnx # run all unit tests
make install # publish + install to ~/bin/attractor
make format # Fantomas formatter (auto-fix)
make lint # FSharpLint (style/quality rules)
make analyze # G-Research.FSharp.Analyzers
make conformance # full 208-test conformance suiteCI gates format-check, lint, and analyze in addition to build + test; PRs with unformatted code, lint warnings, or analyzer findings fail.
ATTRACTOR_LLM_INACTIVITY_TIMEOUT_SECONDS SSE idle timeout per stream.
Default 120. Also aliased as
ATTRACTOR_AGENT_INACTIVITY_TIMEOUT_SECONDS.
If you're a coding agent and need to generate an Attractor pipeline:
- Run
attractor schemato get the complete DOT schema reference - Run
attractor modelsto see supportedllm_model=values and aliases - Run
attractor exampleto see a valid pipeline that exercises most features - Write your
.dotfile - Run
attractor --validate your-file.dotto check it - Run
attractor your-file.dot --auto-approveto execute it
The schema output is designed to fit in your context window and covers every attribute, shape, condition syntax, edge selection algorithm, validation rule, and artifact path.
MIT. See LICENSE.
Built on the StrongDM Attractor specification.