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.

Table of Contents

  1. What We Are Building
  2. Project Setup
  3. Step 1: Price a Worker
  4. Step 2: The Fan-Out Budget
  5. Step 3: Mock Workers and a Capped Fan-Out
  6. Step 4: A Lead Agent With a Phased Plan
  7. Step 5: Three Runs, Same 300 Contracts
  8. Where It Breaks Down
  9. The Bigger Idea

What We Are Building

Lead agent, plan, fan-out budget, workers, merge

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:

  1. Read the contracts. One cheap worker per contract, fanned out.
  2. 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

Four limits, reserve before spawn, settle on return

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 greedySource:https://dev.to/bobbyhalljr/anthropic-launched-dynamic-workflows-lets-build-a-tiny-fan-out-budget-in-typescript-af6

Related posts