← cd ~ writing/

Workflows: Deterministic Fan-Out for Coding Agents

July 27, 2026

There are two ways to get an agent to do a large piece of work in parallel. The common one is to ask it nicely: “spawn some subagents and review these files.” The model decides how many, in what order, and when to stop. Sometimes that’s fine. Often you get four agents where you wanted twelve, a second wave that starts before the first finished, and no way to run the same thing twice.

The other way is to write the fan-out down as a program, and let the model fill in only the parts that need judgment. That’s what a workflow is: deterministic control flow around non-deterministic workers.

I’ve been using the second approach heavily on phux, and this is the reference I wish I’d had — the primitive set, the one mistake everybody makes first, what actually breaks once you’re running a dozen agents against a shared repo, and what it would take to build the same thing on a different stack.

The primitive set

The whole thing is four functions. That’s genuinely all.

agent(prompt, opts?)              // spawn a worker, await its result
parallel([() => …, () => …])      // run thunks concurrently, await all
pipeline(items, stage1, stage2)   // run each item through stages independently
phase(title)                      // group the next agents in the progress UI

agent() is the only one that touches a model. Everything else is scheduling. The script runs in a sandboxed JS context with those injected; the model doesn’t choose what runs next, the script does.

The minimum viable workflow is a loop:

const bugs = []
while (bugs.length < 10) {
  const result = await agent("Find bugs in this codebase.", { schema: BUGS })
  bugs.push(...result.bugs)
  log(`${bugs.length}/10 found`)
}

Note what that gets you for free: a hard stopping condition. “Find ten bugs” asked conversationally means “find some bugs and then decide you’re done.” Here it means ten.

Structured output is the actual unlock

The schema option is the feature that makes the rest usable. Pass a JSON Schema and the worker is forced to call a structured-output tool; validation happens at the tool-call layer, so a mismatch makes the model retry rather than handing you malformed text.

const FINDINGS = {
  type: 'object',
  required: ['summary', 'facts'],
  additionalProperties: false,
  properties: {
    summary: { type: 'string' },
    facts: {
      type: 'array',
      items: {
        type: 'object',
        required: ['claim', 'evidence'],
        additionalProperties: false,
        properties: {
          claim: { type: 'string' },
          evidence: { type: 'string', description: 'file:line plus a quote' },
        },
      },
    },
  },
}

const report = await agent(prompt, { schema: FINDINGS })
report.facts.filter(f => f.evidence.includes('.rs'))   // just an object

Without this you are regex-ing prose, and every downstream stage inherits the ambiguity. With it, agent results compose like any other data. The description fields are not decoration either — they’re prompt surface. 'file:line plus a quote' is where you enforce that a claim comes with evidence.

A subtlety worth internalizing: tell workers their output is a return value, not a message. Left alone, models write you a friendly summary with a preamble. A worker whose result feeds another worker should return data and nothing else.

The mistake everyone makes first

Here’s the one that matters. You have items, and two stages of work per item. The obvious shape:

const reviews = await parallel(DIMENSIONS.map(d => () => agent(d.prompt, {…})))
const findings = reviews.flatMap(r => r.findings)
const verified = await parallel(findings.map(f => () => agent(verify(f), {…})))

This is a barrier. Nothing in stage two starts until everything in stage one finishes. If your slowest reviewer takes 3× the fastest, the fast ones sit idle for two thirds of that window, and you pay it again on the next stage.

The fix is to pipeline, so each item flows through all stages independently:

const results = await pipeline(
  DIMENSIONS,
  d      => agent(d.prompt, { label: `review:${d.key}`, schema: FINDINGS }),
  review => parallel(review.findings.map(f => () =>
    agent(`Adversarially verify: ${f.title}`, { schema: VERDICT })
      .then(v => ({ ...f, verdict: v }))
  )),
)

Now dimension A’s findings are being verified while dimension B is still reviewing. Wall clock goes from sum of slowest-per-stage to slowest single chain.

A barrier is correct in exactly one situation: when stage N needs cross-item context from all of stage N−1. Deduplicating findings across the full set before expensive verification. Early-exiting if the total count is zero. A prompt that literally says “compared to the other findings.” That’s the list.

A barrier is not justified by “I need to flatten and filter first” — do that inside a pipeline stage. It’s not justified by “the stages are conceptually separate,” which is what pipeline already models. Separate stages are not synchronized stages.

The smell test: if you wrote await parallel(...), then a pure transform, then await parallel(...), the middle transform didn’t need the barrier.

What it looks like on real work

Concretely, from a feature I shipped last week — a terminal session recorder, about 4,000 lines across a new crate, two existing ones, docs, and CI.

I ran it as three workflows in sequence, reading the output between each:

Understand. Six scouts in parallel, one per subsystem — the client frame path, CLI conventions, the wire protocol, the rendering stack, the release process, and the on-disk file format I had to interoperate with. Each returned the same {summary, facts[], recommendation, risks[]} shape. Then one architect agent got all six dossiers and produced an implementation plan against a schema with work_items[], each with files, detail, tests, depends_on.

Implement. The work items, respecting their declared dependencies: one foundation agent that created every module as a compiling stub with exact public signatures, then four in parallel filling in bodies, then two more on top.

Finish. Docs, tests, the demo asset.

The foundation-agent trick is worth stealing. Parallel agents editing the same crate will conflict — unless one of them first lays down every file with the final signatures, so the rest are filling in function bodies inside disjoint files. Merge conflicts drop to roughly zero. The cost is one serialized agent up front, and it’s cheap because stub-writing is fast.

What actually breaks

Everything above is the happy path. These are the things that bit.

A shared filesystem is shared state. Subagents run against one working tree. Two agents editing the same file is a lost update. Two agents running cargo fmt --all while a third is mid-edit is worse. The mitigations, in increasing order of cost: assign disjoint file lists and say so in the prompt; tell workers to format only files they touched; give each agent its own git worktree (isolation: 'worktree') when they genuinely must mutate the same paths. Worktrees cost real setup time and disk per agent — reach for them when agents conflict, not by default.

Determinism is load-bearing, so time and randomness are banned. Date.now(), Math.random(), and argless new Date() throw inside the script. That looks hostile until you want to resume: replay depends on the same script plus the same args producing the same call sequence. Stamp timestamps after the workflow returns; vary “random” agents by index instead.

Silent truncation reads as completeness. If your workflow takes the top 20 of 80 files, the report says “reviewed the codebase.” Log what you dropped. This is the single easiest way for a workflow to lie to you, and it’s entirely self-inflicted.

Concurrency is capped whether you plan for it or not. Roughly cpu - 2 agents run at once; passing 400 items to parallel() doesn’t run 400 agents, it queues them. That’s fine — but if each also shells out to a compiler, they serialize on that lock instead, and your beautiful fan-out is now a queue with extra steps.

Cost is real and worth scripting against. A budget handle exposes spent() / remaining(), which turns depth into a variable:

while (budget.total && budget.remaining() > 50_000) {
  const round = await agent("Find bugs.", { schema: BUGS })
  bugs.push(...round.bugs)
}

Two full waves on that phux feature ran about 2M output tokens. Worth it for the result, but you should decide that on purpose.

Verification, or: the war story

The thing nobody tells you is that fan-out multiplies confident wrongness. Twelve agents each returning a plausible finding is twelve plausible findings, not twelve true ones.

The pattern that works is adversarial: spawn N verifiers per claim, prompted to refute, and kill anything a majority knocks down.

const votes = await parallel(Array.from({ length: 3 }, () => () =>
  agent(`Try to refute: ${claim}. Default to refuted=true if uncertain.`,
        { schema: VERDICT })))
const survives = votes.filter(Boolean).filter(v => !v.refuted).length >= 2

Better still, give each verifier a different lens — correctness, security, does-it-actually-reproduce. Three identical skeptics are correlated; three different ones catch failure modes redundancy can’t.

And then the part that no amount of orchestration fixes. On that recorder feature, every agent reported green. Seventeen unit tests passed. Clippy was clean at pedantic level. I ran the binary against a real server and every recording came back as an empty 0×0 grid — the feature was completely non-functional.

The cause: the server pushes its priming snapshot before the command acknowledgement. The client waited for its ack and discarded everything else, including the snapshot, which is never re-sent. The tests passed because the hand-written fake server in the test file answered ack-first — the opposite of the real one. The fake encoded the same wrong belief that produced the bug, so it could never catch it.

The code even carried a comment reading “the loop below must not consume one” directly above the line that consumed it.

No verification pass would have found that, because every agent was internally consistent and so were the tests. What found it was running the thing against reality for ninety seconds. Budget for a smoke test outside the agent loop. A green suite is evidence about the tests.

Ergonomics that turn out to matter

Small things, disproportionate effect:

The failure mode of not having these: people write one enormous workflow, watch it fail at stage nine, and start over from zero. Then they stop using workflows.

Building this on your own stack

Say you’re on TypeScript with Effect and want the same thing. The mapping is unusually clean, because Effect already has the hard parts — structured concurrency, typed errors, interruption, retries.

An agent is an Effect. Model spawn as an effect with a typed error channel and decode the result through Schema, which gives you the same validate-and-retry contract:

import { Effect, Schema, Duration } from "effect"

const Finding = Schema.Struct({
  claim: Schema.String,
  evidence: Schema.String,
})
const Report = Schema.Struct({
  summary: Schema.String,
  facts: Schema.Array(Finding),
})

const agent = <A, I>(prompt: string, schema: Schema.Schema<A, I>) =>
  Effect.gen(function* () {
    const client = yield* AgentClient          // a Layer-provided dependency
    const raw = yield* client.run(prompt, schema)
    return yield* Schema.decodeUnknown(schema)(raw)
  }).pipe(
    Effect.retry({ times: 2 }),
    Effect.timeout(Duration.minutes(10)),
  )

parallel is Effect.all with a concurrency bound. The cap you’d otherwise hand-roll is a parameter:

const reviews = yield* Effect.all(
  dimensions.map((d) => agent(d.prompt, Report)),
  { concurrency: 10 },
)

pipeline is Effect.forEach over the per-item chain. This is the part people get wrong when they hand-roll it — the point is that the composition is per item, so there’s no barrier between stages:

const results = yield* Effect.forEach(
  dimensions,
  (d) =>
    agent(d.prompt, Report).pipe(
      Effect.flatMap((review) =>
        Effect.all(
          review.facts.map((f) => agent(verifyPrompt(f), Verdict)),
          { concurrency: "unbounded" },
        ),
      ),
    ),
  { concurrency: 10 },
)

Because each item’s stages are composed before forEach schedules them, item A reaches stage two while item B is still in stage one. That’s pipelining, and you got it from function composition rather than a scheduler.

Partial failure needs a decision. Effect.all fails fast by default, which is usually wrong here — one flaky worker shouldn’t kill a 40-agent sweep. Either { mode: "either" } for Either results, or per-item Effect.either and filter after. Whatever the equivalent is on your stack, decide it explicitly; the default is rarely what you want for agent fan-out.

Backpressure is Semaphore. If workers hit a rate-limited API or a build lock, a global concurrency number isn’t enough — you want a semaphore around the contended resource specifically, so ten agents can think while two compile.

Resume is the genuinely hard part, and it’s not an Effect problem — it’s a design constraint. You need a stable key per call site (prompt + options hash, or an explicit step id), an append-only journal, and a rule that the script cannot observe anything the journal doesn’t capture. That last one is why Date.now() is banned upstream. If you’re building this, ban it early; retrofitting determinism onto a scheduler that reads the clock is miserable.

If you’re not on Effect: the shape survives translation. Python’s asyncio.Semaphore plus gather(..., return_exceptions=True) gets you 80% of it. The pieces that actually matter are (1) schema-validated worker output, (2) per-item composition instead of stage barriers, (3) a bounded worker pool, and (4) a journal keyed for replay. Everything else is ergonomics.

Things I’d think hard about before building this yourself, roughly in order of how much pain they caused:

Isolation model. Shared working tree, worktree-per-agent, or container-per- agent — this is the decision everything else hangs off. Shared is fast and conflicts; isolated is clean and expensive to merge. There’s no universal answer, but there is a per-task one, so make it a parameter rather than an architecture.

Prompt-as-contract. When one agent’s output feeds another’s prompt, you have an interface. Version it, schema it, and give downstream agents the upstream deviations list explicitly — mine returns {status, files_changed, verification, deviations[]} and the deviations field has caught more integration breakage than the status field.

The verification asymmetry. Generating is parallel; verifying is often serial and always cheaper to skip. Budget for it up front or it silently disappears from the workflow, and you’ll ship a green wave of confidently wrong work.

Observability before scale. At three agents you read the transcripts. At thirty you cannot, and if the only artifact is a final summary, you’re trusting a model’s account of a model’s work. The journal is not a nice-to-have.

Know what it’s for. Workflows earn their cost on breadth — sweeps, audits, migrations, multi-perspective review, anything where the work decomposes and the pieces don’t need to talk. They’re a bad fit for tasks with a long serial dependency chain and a small context, which is most ordinary programming. The honest heuristic: if you can hold the whole task in one head, one agent is better and much cheaper.


The runtime described here is Claude Code’s Workflow tool; the primitives and their semantics are its actual contract, and the war story is from phux, a libghostty-backed terminal control plane. The Effect mapping is my own — if you build it, the ordering constraint in “resume is the hard part” is the one I’d get right first.