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:
- Background by default. The workflow returns a task ID immediately and notifies on completion. You keep working. Anything that blocks a session for twenty minutes doesn’t get used.
- The script is persisted. Every invocation writes itself to disk and hands back the path. Iterating means editing that file, not re-sending 300 lines.
- Resume replays the unchanged prefix. Re-invoke with
{scriptPath, resumeFromRunId}and everyagent()call whose prompt and options are unchanged returns instantly from cache; the first edited call and everything after runs live. Fixing your last stage doesn’t re-run the first eight. - There’s a journal. One line per agent with its full return value. When a notification truncates a 100KB result, that file is where the real thing is — and it’s how you debug an empty return instead of guessing.
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.
Breadcrumbs
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.