DEV Community

Cover image for Anthropic Launched Dynamic Workflows. Let's Build a Tiny Fan-Out Budget in TypeScript.
Bobby Hall Jr
Bobby Hall Jr

Posted on

Anthropic Launched Dynamic Workflows. Let's Build a Tiny Fan-Out Budget in TypeScript.

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" } }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  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
Enter fullscreen mode Exit fullscreen mode

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)}`;
Enter fullscreen mode Exit fullscreen mode

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());
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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"),
  );
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Run it:

npx tsx fanout.ts
Enter fullscreen mode Exit fullscreen mode

Same contracts, three plans, three bills

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
Enter fullscreen mode Exit fullscreen mode

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.

  1. Estimates are guesses. Real workers vary, and thinking tokens count. Settle on real usage. Anthropic's docs show how: add up usage on the threads that carry the run's workflow_run_id.

  2. 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.

  3. 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.

  4. 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.

  5. "Completed" is not "correct." A run can end completed even 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)
Enter fullscreen mode Exit fullscreen mode

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.

Try Roster →

Top comments (1)

Collapse
 
flamingape profile image
Ken @ Flaming Ape •

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.