An agent used to be one loop.
Now it can write a program that runs a few hundred more.
On October 9, 2026, Anthropic added dynamic workflows to Claude Managed Agents, in beta under the managed-agents-2026-04-01 header. The release notes put it plainly:
"A workflow is a program that runs many agents in phases and combines their results."
The agent writes the workflow. The server runs it in the background as a workflow run. You turn it on with one field on the agent:
"multiagent": { "type": "multiagent_20261001", "workflows": { "type": "enabled" } }
Then you follow each run through workflow_run.* events on the session's event stream.
That distinction matters.
The model is no longer only doing the work. It is deciding how many workers to hire.
Anthropic's Workflow runs docs list the limits: 64 threads working at once in one run (a number the docs say can change), and 1,000 agents over a run's whole life. A run has no price of its own. Its model requests count toward the session's budget.
So the hard question is not "can it fan out?"
It's "who decided it should fan out that wide?"
It's not just Anthropic.
On October 7, Anthropic launched Claude Haiku 5.5, saying it "pairs well with Opus 5.5 and Sonnet 5.5 as a subagent" and costs around 75% less to run than Haiku 4.5 on average.
On October 8, Google Cloud introduced the Gemini agent. Google says it plans the work, chooses the best model for the job, and has built-in cost controls.
Different products. Same direction.
When you fan work out, you need a budget and a concurrency cap, not hope.
Let's build a tiny version.
By the end, you'll run one command:
npx tsx fanout.ts
And watch 300 example contracts go through three plans: no budget, a capped fan-out, and a tight budget that refuses a worker mid-run and picks a cheaper plan.
No API key. No model. Just TypeScript.
One honesty note: this is my small model of the idea, not Anthropic's workflow runtime. Their server runs the workflow and pauses open runs at the session budget. Mine refuses a worker before it starts.
Code: github.com/bobbyhalljr/tiny-fanout-budget
Table of Contents
- What We Are Building
- Project Setup
- Step 1: Price a Worker
- Step 2: The Fan-Out Budget
- Step 3: Mock Workers and a Capped Fan-Out
- Step 4: A Lead Agent With a Phased Plan
- Step 5: Three Runs, Same 300 Contracts
- Where It Breaks Down
- The Bigger Idea
What We Are Building
A lead agent gets one job: find which of 300 contracts auto-renew.
It plans two phases. I borrowed the names from the example in Anthropic's docs:
- Read the contracts. One cheap worker per contract, fanned out.
- Reconcile the findings. One bigger worker merges the results.
Between the plan and the workers sits a FanOutBudget. It checks four limits before any worker starts: concurrent workers, total workers, dollars, and tokens.
If a worker can never fit, it gets refused with a reason. If it only needs to wait, it waits.
It's also a small version of the question behind Roster: when an AI employee can hire helpers, who owns the spend?
Project Setup
You will need Node.js 18 or newer.
mkdir tiny-fanout-budget
cd tiny-fanout-budget
npm init -y
npm install --save-dev typescript tsx @types/node
Save the following blocks, in order, as fanout.ts.
Step 1: Price a Worker
// tiny-fanout-budget: a fan-out budget for phased multi-agent workflows.
// No API key. No model. Mock workers with example token counts.
type Tier = "haiku-class" | "sonnet-class";
// USD per million tokens, from Anthropic's Claude Haiku 5.5 pricing table
// (Oct 7, 2026). Token counts in this file are example inputs.
const RATES: Record<Tier, { input: number; output: number }> = {
"haiku-class": { input: 0.1, output: 0.5 },
"sonnet-class": { input: 2.0, output: 10.0 },
};
type Estimate = { tier: Tier; inputTokens: number; outputTokens: number };
const usd = (e: Estimate) =>
(e.inputTokens * RATES[e.tier].input + e.outputTokens * RATES[e.tier].output) / 1e6;
const tokens = (e: Estimate) => e.inputTokens + e.outputTokens;
const money = (n: number) => `$${n.toFixed(3)}`;
The rates come from the pricing table on Anthropic's Haiku 5.5 page: $0.10 in and $0.50 out per million tokens for Haiku 5.5 (prompts up to 100k), and $2.00 and $10.00 for Sonnet 5.5.
The token counts later in the file are mine. They are example inputs.
usd() is the whole pricing model. Boring on purpose.
Boring math is what lets you refuse a worker before it costs anything.
Step 2: The Fan-Out Budget
type Limits = { maxConcurrent: number; maxWorkers: number; maxUsd: number; maxTokens: number };
type Ticket = { usd: number; tokens: number };
type Refusal = { refused: string };
class FanOutBudget {
started = 0;
peak = 0;
refusals = 0;
spentUsd = 0;
spentTokens = 0;
private inFlight = 0;
private heldUsd = 0;
private heldTokens = 0;
private waiters: Array<() => void> = [];
constructor(readonly limits: Limits) {}
async acquire(e: Estimate): Promise<Ticket | Refusal> {
const cost = usd(e);
const size = tokens(e);
for (;;) {
const usdLeft = this.limits.maxUsd - this.spentUsd;
const tokensLeft = this.limits.maxTokens - this.spentTokens;
// Hard refusal: it can never fit, even if every in-flight worker finishes.
let reason = "";
if (this.started >= this.limits.maxWorkers) reason = `worker cap reached`;
else if (cost > usdLeft) reason = `needs ${money(cost)}, only ${money(usdLeft)} left`;
else if (size > tokensLeft) reason = `needs ${size} tokens, only ${tokensLeft} left`;
if (reason) {
this.refusals++;
return { refused: reason };
}
// Soft wait: it fits once in-flight workers settle.
const busy = this.inFlight >= this.limits.maxConcurrent;
const held = cost > usdLeft - this.heldUsd || size > tokensLeft - this.heldTokens;
if (!busy && !held) break;
await new Promise<void>((wake) => this.waiters.push(wake));
}
this.started++;
this.inFlight++;
this.peak = Math.max(this.peak, this.inFlight);
this.heldUsd += cost;
this.heldTokens += size;
return { usd: cost, tokens: size };
}
settle(ticket: Ticket, actual: Estimate): void {
this.inFlight--;
this.heldUsd -= ticket.usd;
this.heldTokens -= ticket.tokens;
this.spentUsd += usd(actual);
this.spentTokens += tokens(actual);
this.waiters.splice(0).forEach((wake) => wake());
}
}
There are two kinds of no.
A hard refusal means the worker can never fit, even if every in-flight worker finished right now.
A soft wait means it fits later. The concurrency cap is full, or other workers are holding reservations.
The important part is the reservation.
Without it, sixteen workers can each see $0.07 left and all start at once. That's how parallel spend overshoots.
Anthropic's docs describe the server side of this. At the session budget, each thread finishes the model request it already started, so a run can pass the budget by one request for each working thread.
Reasonable for a server. Also a good reason to plan on your side.
Reserve before you spawn. Settle when it returns.
Step 3: Mock Workers and a Capped Fan-Out
type Doc = { id: string; index: number };
type Finding = { docId: string; autoRenews: boolean };
type Work<R> = { result: R; actual: Estimate };
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// Example input: 6,000 tokens in and 600 out per contract.
const readEstimate = (tier: Tier): Estimate => ({ tier, inputTokens: 6_000, outputTokens: 600 });
async function readContract(doc: Doc, est: Estimate): Promise<Work<Finding>> {
await sleep(2 + (doc.index % 3));
const used = 0.7 + (doc.index % 4) * 0.1; // 70% to 100% of the estimate
return {
result: { docId: doc.id, autoRenews: doc.index % 7 === 3 },
actual: {
...est,
inputTokens: Math.round(est.inputTokens * used),
outputTokens: Math.round(est.outputTokens * used),
},
};
}
async function fanOut<T, R>(
items: T[],
budget: FanOutBudget,
est: Estimate,
work: (item: T, est: Estimate) => Promise<Work<R>>,
) {
const results: R[] = [];
const refused: string[] = [];
await Promise.all(
items.map(async (item) => {
const ticket = await budget.acquire(est);
if ("refused" in ticket) return void refused.push(ticket.refused);
try {
const { result, actual } = await work(item, est);
results.push(result);
budget.settle(ticket, actual);
} catch {
budget.settle(ticket, est); // a failed worker still costs money
}
}),
);
return { results, refused };
}
The workers are fake on purpose. Each one sleeps a few milliseconds and uses 70% to 100% of its estimate.
fanOut() is just Promise.all with the budget standing at the door.
Notice the catch. A failed worker still settles at its full estimate.
Failed work still costs money. Ask anyone who has paid for a retry storm.
Step 4: A Lead Agent With a Phased Plan
type Plan = { readTier: Tier; mergeTier: Tier; planB?: Tier };
// Example input: 2,000 tokens of instructions plus 200 per finding.
const mergeEstimate = (tier: Tier, n: number): Estimate => ({
tier,
inputTokens: 2_000 + n * 200,
outputTokens: 1_500,
});
function merge(findings: Finding[]): string {
const hits = findings.filter((f) => f.autoRenews).map((f) => f.docId).sort();
return `${hits.length} of ${findings.length} contracts auto-renew (first: ${hits.slice(0, 3).join(", ")})`;
}
async function runWorkflow(label: string, plan: Plan, limits: Limits, docs: Doc[], intended: number) {
console.log(`\n=== ${label} ===`);
const budget = new FanOutBudget(limits);
console.log(`phase: Read the contracts (${docs.length} x ${plan.readTier})`);
const read = await fanOut(docs, budget, readEstimate(plan.readTier), readContract);
if (read.refused.length) console.log(` refused ${read.refused.length}: ${read.refused[0]}`);
console.log(`phase: Reconcile the findings (1 x ${plan.mergeTier})`);
let est = mergeEstimate(plan.mergeTier, read.results.length);
let ticket = await budget.acquire(est);
if ("refused" in ticket && plan.planB) {
console.log(` refused: ${ticket.refused}`);
console.log(` lead: retry reconcile on ${plan.planB}`);
est = mergeEstimate(plan.planB, read.results.length);
ticket = await budget.acquire(est);
}
if ("refused" in ticket) {
console.log(` refused: ${ticket.refused}`);
console.log(`merge: skipped, returning ${read.results.length} raw findings`);
} else {
await sleep(5); // the mock reconcile worker
budget.settle(ticket, est);
console.log(`merge: ${merge(read.results)}`);
}
const over = budget.spentUsd - intended;
const used = budget.spentTokens.toLocaleString("en-US");
console.log(`workers ${budget.started} | peak ${budget.peak} | tokens ${used} | refusals ${budget.refusals}`);
console.log(
`spent ${money(budget.spentUsd)} of ${money(intended)} -> ` +
(over > 0 ? `OVER by ${money(over)}` : "under budget"),
);
}
The plan is just data: which tier reads, which tier merges, and an optional plan B.
When the merge worker is refused, the lead doesn't argue with the budget. It picks the cheaper tier and tries once more.
With no plan B, it returns the raw findings unmerged and says so. Honest partial work beats a silent failure.
One more honesty note. In Anthropic's runtime, an inline agent that a workflow defines uses the session agent's model. Mixing tiers per phase is my simplification.
Step 5: Three Runs, Same 300 Contracts
async function main() {
const docs: Doc[] = Array.from({ length: 300 }, (_, i) => ({
id: `contract-${String(i + 1).padStart(3, "0")}`,
index: i,
}));
const open: Limits = { maxConcurrent: Infinity, maxWorkers: Infinity, maxUsd: Infinity, maxTokens: Infinity };
const capped: Limits = { maxConcurrent: 16, maxWorkers: 400, maxUsd: 1.0, maxTokens: 3_000_000 };
const greedy: Plan = { readTier: "sonnet-class", mergeTier: "sonnet-class" };
const frugal: Plan = { readTier: "haiku-class", mergeTier: "sonnet-class" };
const withPlanB: Plan = { ...frugal, planB: "haiku-class" };
await runWorkflow("A. no budget, big model everywhere", greedy, open, docs, 1.0);
await runWorkflow("B. capped fan-out, cheap readers", frugal, capped, docs, 1.0);
await runWorkflow("C. tight budget, refuse then downgrade", withPlanB, { ...capped, maxUsd: 0.3 }, docs, 0.3);
}
main();
Run it:
npx tsx fanout.ts
Here is the real output:
=== A. no budget, big model everywhere ===
phase: Read the contracts (300 x sonnet-class)
phase: Reconcile the findings (1 x sonnet-class)
merge: 43 of 300 contracts auto-renew (first: contract-004, contract-011, contract-018)
workers 301 | peak 300 | tokens 1,746,500 | refusals 0
spent $4.729 of $1.000 -> OVER by $3.729
=== B. capped fan-out, cheap readers ===
phase: Read the contracts (300 x haiku-class)
phase: Reconcile the findings (1 x sonnet-class)
merge: 43 of 300 contracts auto-renew (first: contract-004, contract-011, contract-018)
workers 301 | peak 16 | tokens 1,746,500 | refusals 0
spent $0.369 of $1.000 -> under budget
=== C. tight budget, refuse then downgrade ===
phase: Read the contracts (300 x haiku-class)
phase: Reconcile the findings (1 x sonnet-class)
refused: needs $0.139, only $0.070 left
lead: retry reconcile on haiku-class
merge: 43 of 300 contracts auto-renew (first: contract-004, contract-011, contract-018)
workers 301 | peak 16 | tokens 1,746,500 | refusals 1
spent $0.236 of $0.300 -> under budget
Same 300 contracts. Same answer: 43 auto-renew.
Very different bills.
Run A has no budget. It starts 300 big-model workers at once and spends $4.729 against the $1.00 I meant to spend. Nobody said no, so nothing said no.
Run B uses cheap readers, 16 at a time. It spends $0.369 for the same merge.
Run C has a $0.30 cap. After phase one, $0.070 is left. The big-model merge needs $0.139, so the budget refuses it. The lead retries on the cheap tier and finishes at $0.236.
Those dollars are published rates times example token counts. They are not a measurement of any real run.
The answer didn't change. The bill did.
Where It Breaks Down
This demo is small on purpose. A few hard parts sit right outside it.
Estimates are guesses. Real workers vary, and thinking tokens count. Settle on real usage. Anthropic's docs show how: add up
usageon the threads that carry the run'sworkflow_run_id.Your budget is not the server's budget. Mine is a planning budget on the client. In Managed Agents, the session budget is the hard stop: open runs pause and the session goes idle with
budget_reached. Use both.Retries duplicate work. The docs say the server can run a failed agent again on a new thread. Make worker tools safe to call twice.
Concurrency is also a rate limit. A run's model requests count toward your Messages API rate limits, along with the rest of your traffic. Cap the fan-out below the server limit.
"Completed" is not "correct." A run can end
completedeven though work on its threads failed. Count refusals and failures in the merge.
The Bigger Idea
Lead agent
↓
Plan (phases, tiers, plan B)
↓
Fan-out budget (reserve before spawn)
↓
Workers (capped, in parallel)
↓
Merge (settled spend, honest gaps)
The model plans.
The workers execute.
The budget decides how wide the plan is allowed to go.
The merge tells you what you got for the money.
This part is prediction, not history.
I think the next hard problem in agents isn't intelligence. It's headcount.
An agent that can start 1,000 helpers needs what any new manager needs: a budget, a cap on hires, and someone willing to say no before the work starts.
Try Roster
If the same follow-ups, handoffs, and waiting loops keep eating your week, give them to an AI employee.



Top comments (1)
The reserve-before-spawn bit is the part most people skip. I've watched parallel workers each "see" enough budget left and all start at once. If you don't hold the estimate until the job settles, the math lies.
Hard refuse vs soft wait is the right split too. Soft wait is just a queue. Hard refuse is the honest "this plan doesn't fit," and that's when Plan B earns its keep.
One thing I'd add from burned fingers: settle failed workers at the estimate, or the actual if you got it. A timeout that retries still costs money, and dashboards that only count successes stay green while the bill climbs.