Graph engineering: when a fleet of agents beats a single loop

Every few weeks the agent crowd finds a new noun. For a while it was loops. Now it is graphs. Peter Steinberger poked at this on X in July with a one liner, asking whether we were still talking loops or had shifted to graphs yet, and a chunk of the timeline that had only just learned loops declared them obsolete inside a day.

The pushback arrived just as fast, and it was fair. None of this is new. Directed acyclic graphs have been running build systems and job schedulers since long before anyone attached a language model to them. If you have written an Airflow DAG, a Nix derivation, a Bazel build or a CI pipeline with a needs: block, you have already done this. I find that reassuring rather than deflating. The topology is well understood and the failure modes are documented, so we are mostly rediscovering them with a much larger bill attached.

So this is the part I think actually transfers to agent work: the vocabulary, a test that finds wasted latency in whatever you run today, the one pattern worth memorising, the ways these systems break once they are real, and the case for not building one at all.

From one loop to a graph of loops

A loop is one agent improving one thing on repeat. Try, check, adjust, run it again. It is a control system with a single feedback path, and for a great deal of work it is still the right shape.

flowchart LR
  T["try"] --> C["check"]
  C --> A["adjust"]
  A --> T

What people mean by a graph is not a better loop. It is a set of loops wired together, where the checking is done by something other than the thing that did the work, and where independent jobs stop queueing behind each other for no reason.

flowchart TB
  P["plan the angles"] --> W1["worker loop"]
  P --> W2["worker loop"]
  P --> W3["worker loop"]
  W1 --> V["independent verifier"]
  W2 --> V
  W3 --> V
  V --> S["synthesise one answer"]

That second picture is most of the subject. What is left is knowing when to draw it, and what it costs you when you draw it badly.

The vocabulary is two words

A graph is a plan for your agent work, written down so the dependencies are visible. It answers two questions: which jobs need to happen, and which job has to wait on which other job.

There are two parts to it.

A node is one job. One agent, one bounded task, one input, one output. Researching a single competitor, writing a draft, checking a claim. If you cannot describe a node in a single sentence without using the word "and", it is two nodes.

An edge is a data dependency. It means the job on the receiving end needs something the job on the sending end produced, so it has to wait. The qualifier that matters is that an edge only exists when real data travels along it. Ordering you happened to type is not an edge.

flowchart LR
  N1["node: research pricing"] -->|"edge carries { price, source, date }"| N2["node: write comparison"]

Nodes do the work and edges carry the results, and that is the whole vocabulary. Almost every muddle in this space comes from treating a habit of sequence as though it were a dependency.

Node contracts are what make a graph wireable

What turns a prompt into a node is a contract: a bounded job with a defined input and a defined output shape. A node that emits a wall of prose can only be read by a human. A node with a schema on its output can be read by the next node without guessing, which is the difference between a diagram and a running system.

NODE CONTRACT
  JOB     research one competitor's pricing, nothing else
  IN      { competitor: string, url: string }      passed in, never assumed
  OUT     { price: number, plan: string, source: url, date: "YYYY-MM-DD" }
  SCHEMA  enforced. free text is rejected and the node retries
  WHY     a defined output is what lets the next node consume this one
          with no human in the middle. that is what makes it wireable.

Schema enforcement is not decoration. When validation happens at the tool call layer, a malformed response becomes a retry rather than a corrupted downstream node, and you get that retry for free instead of writing parsing code that guesses at intent. This is where the analogy to build systems holds up best. A Bazel rule declares its inputs and outputs precisely so the scheduler can work out what is safe to run concurrently, which is the same job you are doing here with considerably less reliable workers.

The fake edge test

This is the part of graph engineering I would learn first, because it costs nothing and it applies to whatever you are running today.

Walk your current agent workflow step by step. At each step, ask whether it consumes the output of the step before it.

If it does, the edge is real and the ordering stays. If it does not, there is no edge, and the wait is waste. Those two jobs can run at the same time.

The canonical example is a review task: check file A for bugs, then check file B for bugs. It reads as a sequence, but the review of B never looks at what the review of A returned. They run one after the other only because that is the order they were typed in. Run them side by side and the wall clock is the slower of the two, not the sum.

flowchart TB
  subgraph seq["typed as a sequence: 40s + 40s = 80s"]
    direction LR
    A1["review file A"] --> B1["review file B"]
  end
  subgraph par["no data on that edge: 40s total"]
    direction LR
    S(["start"]) --> A2["review file A"]
    S --> B2["review file B"]
    A2 --> M(["merge"])
    B2 --> M
  end

Most workflows have a couple of these sitting in them, and each one is latency you are paying for and getting nothing back on.

Your linear workflow is already a graph

When you write an agent as "do A, then B, then C, then D", you have already drawn a graph. It is just the worst one available: a single chain where every node has one edge in and one edge out.

It runs correctly. It also runs slowly and breaks easily, because a chain has no independent paths. If C stalls, D never starts, and the work A produced sits upstream with nowhere to go.

The first real skill here is redrawing that chain. Take the linear workflow, apply the fake edge test to every arrow, delete the arrows carrying no data, and the line collapses into something wider: a handful of independent jobs running at once, feeding the one job that genuinely needs all of them.

The arithmetic is worth sitting with. A forty step linear workflow has forty sequential failure points and latency equal to the sum of all forty. The same forty jobs drawn honestly usually have three to five real dependencies, and they finish at the speed of the slowest layer rather than the sum of everything. On identical work that is the gap between five minutes and fifteen seconds, which is almost never a model problem. It is the line you drew.

The diamond: fan out, reduce, synthesise

You do not need a library of topologies. Watch any serious agent system and the same shape keeps surfacing. Work splits, workers dig in parallel, something independent checks what came back, and the survivors merge into one answer.

That shape is the diamond, and the name for it is worth remembering: fan out, reduce, synthesise. Fan out for breadth, reduce with plain code to compress, then hand a final agent the job of writing the answer.

flowchart TB
  Q["question"] --> P["lead plans the angles"]
  P --> R1["researcher: pricing"]
  P --> R2["researcher: complaints"]
  P --> R3["researcher: feature gaps"]
  P --> R4["researcher: direction"]
  R1 --> D["reduce: dedupe in plain code, zero tokens"]
  R2 --> D
  R3 --> D
  R4 --> D
  D --> V["verify: fresh sceptic per finding"]
  V --> S["synthesise: one ranked report"]

The research feature inside Claude runs this in production. A lead plans the angles, workers gather concurrently, findings get checked, and only then does a single report reach you. Once you can see the diamond you stop asking how to make an agent do more steps and start asking where the split is and where the merge is. The second question is the one that scales.

Underneath it is a short orchestration script. Because the coordination is code rather than conversation, passing results between agents costs no additional context in your own session.

// a market scan graph: the diamond, as an orchestration script
const angles = [
  'pricing against the top three competitors',
  'what buyers complain about in reviews',
  'feature gaps across the category',
  'where the category moves over the next 12 months',
];

// FAN OUT: one researcher per angle, all concurrent
const raw = await parallel(
  angles.map((a) => () =>
    agent(`research: ${a}. every claim needs a source url and a date.`, {
      schema: FINDING_SCHEMA,   // validated output, not free text
      model: 'haiku',           // mechanical node, cheap model
    })
  )
);

// REDUCE: plain code, so it spends no tokens and adds no latency
const findings = dedupeBySource(raw.filter(Boolean).flatMap((r) => r.findings));

// VERIFY: a fresh sceptic per finding, prompted to kill it
const verdicts = await parallel(
  findings.map((f) => () =>
    agent(`try to refute this finding. return keep or drop, with why.\n${JSON.stringify(f)}`, {
      schema: VERDICT_SCHEMA,
      effort: 'high',           // judgement node, spend here
    })
  )
);
const survivors = findings.filter((_, i) => verdicts[i]?.verdict === 'keep');

// SYNTHESISE: one agent writes the answer from what survived
return agent('one report, ranked by confidence, sources attached.', {
  input: survivors,
  effort: 'high',
});

Everything about the shape is in that script. Fan out where the work is genuinely independent, reduce in ordinary code rather than burning a model on deduplication, verify on a fresh context, put the cheap model on the mechanical nodes and spend where judgement lives, then synthesise once at the end. The same skeleton sits behind a market scan, a code review or a research report. You swap the angles and the prompts.

One refinement the diagrams usually skip. A barrier, where every worker must finish before the next stage starts, is only correct when the next stage needs all of the previous results together, for example to deduplicate across the full set. When each item can flow through verification independently, pipeline it instead so item A can be in verification while item B is still being researched. A barrier nobody needed is wasted wall clock, and it is the most common inefficiency I run into in hand written orchestration.

The verifier is the whole trick

Every serious evaluation of model self review lands in the same place. Models are poor judges of their own output, and a model grading its own work grades generously. The rule that falls out of that is unambiguous: the agent that did the work never checks the work.

Instead you put a separate node on the edge whose only job is to try to kill the finding before it moves downstream. If it survives, it passes. If it does not, it dies there and never reaches the report.

The catch that rarely gets stated is that the verifier needs a clean context. Hand it the worker's transcript and it is not really verifying, it is reading a conclusion it already agrees with. A set of agents sharing one context is functionally still a single loop, and it fails the same way, just later and at greater expense.

So the verifier gets its own context, and it checks a real signal: not whether the agent claimed success, but whether the test actually passes.

It also helps to split the checking across different questions rather than repeating the same one. Three distinct lenses catch what ten identical sceptics miss.

flowchart LR
  F["finding from a worker"] --> L1["is it correct?"]
  F --> L2["is it current?"]
  F --> L3["does the source resolve?"]
  L1 --> M{"majority say keep?"}
  L2 --> M
  L3 --> M
  M -->|"yes"| K["passes to synthesis"]
  M -->|"no"| X["dropped here"]
VERIFIER NODE
  INPUT    one finding, never the worker's transcript
  CONTEXT  fresh and empty. it has not seen the work it is judging
  CHECKS   three sceptics in parallel, each asking something different
             1. is the claim correct
             2. is the source current rather than stale
             3. does the link actually resolve to the claim it is cited for
  PASS     keep only if a majority let it through
  FAIL     drop it before it can reach the final answer

If there is one rule to take from this, it is that a worker and its verifier must never share a context. The moment they do you are back to one loop marking its own homework, with a larger invoice.

This is also the lesson I keep relearning on Skylark, where the agent drives a real browser. Most of the quality lives in the harness, in what the model is shown and what gets checked, rather than in the prompt.

Where graphs actually break

Context collapse

Fan out to a thousand nodes, then attempt to feed a thousand outputs into one synthesis step, and you exhaust the context window before synthesis begins.

The fix is a layered fan in. Batch the results, summarise each batch, then combine the summaries. Never pour the raw pile into one node.

flowchart LR
  R["1,000 raw outputs"] --> C["chunk into 25 batches of 40"]
  C --> S1["summarise batch 1"]
  C --> S2["summarise batch n"]
  C --> S3["summarise batch 25"]
  S1 --> F["final agent reads 25 summaries"]
  S2 --> F
  S3 --> F
// layered fan in: never pour 1,000 raw outputs into a single step
const batches = chunk(results, 40);
const summaries = await parallel(
  batches.map((b) => () => agent('summarise this batch', { input: b }))
);
return agent('write the answer from these summaries', { input: summaries });

False independence

Two nodes look independent because their prompts never reference each other, while both write to the same file or hammer the same rate limited API. That is a hidden edge, and it is the one that corrupts results rather than merely slowing them.

When the Bun team first fanned a large migration across many agents, the workers shared a single workspace and overwrote each other. The fix is isolation plus an audit for shared resources, not just shared data.

// isolate the workers: no shared file, no shared workspace
await parallel(
  files.map((f) => () => agent(`refactor ${f}`, { isolation: 'worktree' }))
);
// rule: any two nodes writing the same file need an edge, not parallelism

Silent node failure

In a chain, one failure halts everything, which is irritating but at least it is loud. In a graph, one dead node out of two hundred slips quietly into a report that looks complete, and nothing in the output tells you it happened.

Every fan in should count what it received against what it expected and flag the gap rather than silently synthesising on partial data.

// fan in guard: catch the node that quietly died
const results = (await parallel(jobs)).filter(Boolean);
if (results.length < jobs.length) {
  log(`WARNING: ${jobs.length - results.length} of ${jobs.length} nodes returned nothing`);
}
// never synthesise on a partial set and call the report complete

Anyone who has done observability work will recognise the failure. It is the same class of problem as a span that never records a status code, where the trace looks healthy because nothing wrote down that it was not.

When a graph is the wrong tool

A graph buys you breadth, not judgement. It is a tool for width, for independent work executed concurrently. When the work is not wide, the shape of your workflow was never the problem.

flowchart TB
  Q1{"can you name two jobs with no data passing between them?"}
  Q1 -->|"no"| L["use a loop"]
  Q1 -->|"yes"| Q2{"is the work wide enough to pay for coordination?"}
  Q2 -->|"no"| L
  Q2 -->|"yes"| Q3{"do you need to approve every step?"}
  Q3 -->|"yes"| L
  Q3 -->|"no"| G["build the graph"]

Skip the graph when the task is small or isolated, such as adding one function or fixing one bug, where coordination is pure overhead and a single agent is faster and cheaper. Skip it when you want to approve each step, because running wide without you is the entire point and a tight leash negates it. Skip it when you do not yet know what you are looking for, since exploratory work wants one agent you can steer rather than a fleet committed to a plan. And skip it when the steps genuinely depend on each other, where forcing a graph onto sequential work adds cost for no speedup.

The tell is the fake edge test. If you cannot find two jobs with no edge between them, there is no graph to build. It is a loop, and a loop is fine.

Anchors: topology does not buy truth

There is a subtler trap waiting once the graph gets sophisticated.

Picture the full build. Paired checkers, audit nodes, meta nodes tuning the other nodes. Every node watches another node, and every one of them reads a report. The audit reconciles the numbers against the finance numbers, which came out of the same system in the first place.

Everything is consistent. Nothing is verified.

That graph fails the way the single loop did, only later, more expensively, and with far more green lights along the way.

What a graph needs is anchors, meaning nodes that cannot be argued with. Tests that actually executed rather than tests that should pass. Revenue that landed in the bank. Some form of ground truth that exists outside the system doing the reasoning.

Some rules also have to be frozen, specifically the ones an optimiser would be tempted to weaken, kept off limits precisely because they are what it would bend to win. Judge the graph against numbers that cannot argue back and it stays grounded. Let it grade its own reports and it will be confidently wrong.

Building one in Claude Code

Claude Code ships the tooling for this directly, as dynamic workflows, and it comes down to one word: workflow.

Put it in the prompt and Claude stops working through a single line of steps. It writes a short orchestration script and spawns a coordinated fleet of subagents to run it. The coordination is code rather than conversation, so passing results between agents does not re-spend your context the way a chat handoff does. That is what lets a single run scale to a fleet without drowning the session.

Open a repository you know and paste this:

GRAPH SPEC
  GOAL      audit every route file under src/routes/ for missing auth checks
  FAN OUT   one agent per file, all in parallel
  VERIFY    an independent checker per finding, fresh context
  CAP       20 files on this first run
  ON FAIL   flag any file that does not return, never skip it silently
  REPORT    one merged list of the routes missing auth

  (start the prompt with the word "workflow")

Claude shows the plan before doing anything, you approve it, and the fleet runs. What lands at the end is one report rather than twenty transcripts to sift through, because the intermediate results lived inside the script and never entered your context.

Note the cap. Twenty files keeps the first run cheap, and it points at the thing most demonstrations leave out, which is the bill.

Specs you can paste

Each of these is the same diamond aimed at a different job. Swap the bracketed parts. Keep yourself as the last approval before anything ships.

A decision grade research desk, where the question splits into angles, researchers work concurrently, a sceptic attacks each finding, and only survivors reach the report:

GRAPH SPEC
  GOAL        decision grade research on [your question]
  FAN OUT     5 distinct angles, one researcher per angle, in parallel
  RULE        every finding carries a source link and a date
  VERIFY      a sceptic tries to disprove each finding, drop what fails
  MERGE       survivors into one report ranked by confidence
  SAVE        research-report.md, then show me the top findings
  HUMAN GATE  change nothing after that without asking me

A repository wide refactor sweep, covering breadth no single context could hold:

GRAPH SPEC
  GOAL     find every function over 100 lines and propose a refactor for each
  FAN OUT  one agent per file, in parallel
  VERIFY   an independent checker per proposal, fresh context
  DEDUPE   proposals against everything already seen
  CAP      50 files on this first run
  REPORT   how many files came back, so nothing fails silently

A discovery loop of unknown size, for jobs where finding one issue reveals three more:

GRAPH SPEC
  GOAL     hunt this repo for [security issues / broken error handling]
  FAN OUT  run finders in parallel
  DEDUPE   check each new find against everything already seen
  VERIFY   an independent checker on the survivors
  LOOP     continue until two consecutive rounds find nothing new
  CAP      a hard limit on total agents so it cannot run away
  REPORT   final list ranked by severity

Run one scoped, watch what it costs, then widen. When a run proves itself, save it and it becomes a command you launch by name.

Cost and supervision

A graph costs more than a normal chat. Substantially more. Coordination is what gets cheaper, not the work. The agents still burn tokens and a fleet of them burns a pile.

The clearest public example is the Bun runtime rewrite, where an engineer used this approach to translate roughly 535,000 lines of one language into over a million lines of another in about eleven days, work that would take close to a year by hand. It reportedly ran around 50 workflows with up to 64 agents concurrently, cost roughly 165,000 US dollars in usage, required a human designing and supervising throughout, and drew legitimate criticism about whether that volume of generated code can be reviewed safely at all.

Both things are true at once. A graph can chew through work no single context could hold, and it can quietly spend a fortune in the background if you point it at the wrong task or skip the anchors. The heavy version belongs to teams with the budget, the caps and the monitoring to run it. If that is not you yet, you are not missing much. Start small, measure what a run costs, and widen only once a run has earned it.

How I decide now

My default is to start with a loop and let the work argue me into a graph. That ordering matters. Coordination is the expensive part, and no amount of clever topology improves the quality of any single decision happening inside it.

The heuristic I keep returning to is a ratio: count the steps, then count the genuine dependencies between them. Thirty steps with four real dependencies has width worth exploiting. Thirty steps with twenty eight dependencies is sequential work, and wrapping it in a fleet buys nothing except more surface area for something to fail quietly.

I get that ratio wrong fairly often, almost always by overestimating it. Plenty of jobs that looked wide turned out to be four sequential steps in a trench coat, and I only worked that out after paying for the fleet.

Which is why the counting is the useful habit here, not the tooling. Take whatever you have in flight, mark the arrows that carry no data, and you will usually find a few minutes you did not realise you were spending.


← Back to all writing