Akshay Parkhi's Weblog

Claude Code Dynamic Workflows: Inside Out

29th May 2026

Dynamic workflows look like a feature. Mechanically they are something more interesting: a tiny JavaScript runtime that the orchestrator model writes a program for, mid-turn, and then runs deterministically.

The one-line frame

A dynamic workflow is JavaScript that Claude writes during your turn, persisted to a file, and executed by a small deterministic scheduler whose only real primitive is agent(). Everything else — parallel, pipeline, phase, schemas, the journal — is glue around that one call.

If that lands, skip to the execution model. If not, the rest of the post earns it.

Why I went looking

Anthropic shipped dynamic workflows in Claude Code. The marketing answer is: “Claude fans out hundreds of subagents and converges on a single answer.” True, but unhelpful. I wanted to know:

  1. What does “dynamic” actually mean? Is there a compiler? A planner? Where does the script come from?
  2. What does the runtime look like underneath — is this real concurrency, or a coroutine pretending?
  3. How does “resume from where it left off” work, and what does it cost you in expressivity?
  4. What can — and can’t — you encode in a workflow?

I asked Claude Code to build me one (food → cardiovascular risk platform, six expert teams, adversarial review, consensus). It generated a 280-line JS file, the parser rejected the first attempt, I iterated against the file, it ran. Below is what the engine is actually doing — the parts that matter and the parts that explain the constraints.

1. The “build” step is token generation, not compilation

The single most important thing to get straight: there is no code generator, planner, or template engine that builds the workflow file. The main-loop model writes JavaScript as ordinary tokens — the same way it would write any other code — and passes the source as the script argument of the Workflow tool.

your prose prompt
      │
      ▼
main-loop Claude  ── "does a workflow fit?" ──► yes
      │
      ▼
emits JavaScript as a string  (token-by-token generation)
      │
      ▼
Workflow tool:
  1. parse source as plain JS   ── fail ──► InputValidationError
  2. on success: persist to a file under the session dir
  3. start the run in the background
  4. return { runId, scriptPath }

Nothing pre-exists. The meta block, the phases, the team list, the JSON schemas — all synthesised on the spot from your prose. A different prompt produces a structurally different script. That is what “dynamic” means here. (There is a static counterpart — named workflows in .claude/workflows/ — but those are pre-saved files, not what we’re looking at.)

Two practical consequences:

  • It must be plain JS. There is no transpile step in front of the parser. TypeScript annotations, generics, and interfaces fail immediately. My first attempt died on a parse error at line 208 — I rewrote with longhand function(){} and dropped a couple of spread operators to dodge the offending token.
  • The script is the durable artifact. Once persisted, it’s what the journal/replay machinery re-executes on resume. The conversation that produced it can be summarised away; the file is the source of truth for the run.

2. The runtime in one diagram

Your script body runs inside a generated async function. Globals are injected into that scope — not imported.

InjectedWhat it is
agent(prompt, opts)The only primitive that actually does work. Spawns a context-isolated subagent. Returns a Promise.
parallel(thunks)Barrier. Runs all thunks concurrently (gated), awaits everything, returns the array.
pipeline(items, ...stages)No barrier between stages. Each item flows independently through all stages.
phase(title) / log(msg)Pure UI. Mutates progress-tree grouping; emits a narrator line.
workflow(name, args)Run another workflow inline (one level deep).
args, budgetInputs: the value you passed in, and the shared token pool.

What’s removed: Date.now(), Math.random(), argless new Date(), filesystem, network. Those throw on call. Reason in §6.

3. parallel() is a counting semaphore, not threads

Your JavaScript runs single-threaded on one event loop. parallel() does not give your script threads. The concurrency is in the subagents — separate processes, which from your script’s point of view are just I/O.

A faithful model of the scheduler:

permits = min(16, cores - 2)
active  = 0
queue   = [...thunks]

function pump() {
  while (active < permits && queue.length) {
    const thunk = queue.shift()
    active++
    Promise.resolve()
      .then(thunk)                              // invoke = spawn now
      .then(r => results[i] = r,
            e => results[i] = null)             // throw → null
      .finally(() => { active--; pump() })
  }
}

Two consequences fall out of this:

  • You pass thunks, not Promises. If parallel() took agent(...) calls directly, all of them would have already spawned by the time the array was built — the semaphore would have nothing to gate. () => agent(...) is what lets the engine control when each spawn happens.
  • A failure becomes null, not a rejection. The .then(ok, err) swallows the throw and writes null into the result slot. That’s why parallel() never rejects, and why every result needs .filter(Boolean).

The silent-partial trap: if 2 of 6 agents fail, the downstream code sees an array of 4 with no indication anything went wrong. If that matters, assert the length before proceeding. The engine won’t do it for you.

4. parallel vs pipeline: where the barriers sit

This is the single biggest design decision in a workflow script, and the docs lead with the right default: pipeline by default; reach for parallel only when you genuinely need all prior-stage results together.

parallel([t1..tn]) — n independent nodes, one join barrier at the end

t1 ─┐
t2 ─┼─► (await ALL) ─► return [r1..rn]
tn ─┘

pipeline(items, stageA, stageB) — one chain per item, no cross-item barrier

item0:  A0 ─► B0 ─┐
item1:  A1 ─► B1 ─┼─► return [r0..rn]   (only THIS collection is a barrier)
item2:  A2 ─► B2 ─┘

In a pipeline, the instant A0 resolves, B0 hits the semaphore — even while A1 is still running. Wall-clock ≈ the slowest single chain. Parallel-between-stages would force every A to finish before any B starts, wasting the fast items’ head start.

The rule I keep in my head:

Use parallel when…Use pipeline when…
Stage N+1 needs cross-item context (dedup, “the other findings,” early-exit on zero)Each item has an independent downstream chain
Genuine fan-in → fan-out (e.g. all designs serialised into a package every reviewer reads)Verification is per-finding, transform is per-result, fix is per-file
You need a programmatic aggregation step between phasesYou don’t — later stages just consume one prior result at a time

Smell test: if you wrote const a = await parallel(...); const b = transform(a); const c = await parallel(b.map(...)) and the middle transform doesn’t actually need all of a together — that’s a pipeline wearing a parallel mask. Rewrite as pipeline(items, A, r => transform([r])[0], B) and stop paying the barrier latency.

5. Schema enforcement happens at the tool boundary, not post-hoc

When you call agent(prompt, { schema }), the engine doesn’t run the subagent freely and then JSON.parse its output. It does something stronger:

  1. Appends a directive to the subagent’s system prompt: your final answer must come via the StructuredOutput tool.
  2. Synthesises that tool, using your JSON Schema as its input schema.
  3. When the model calls it, the args are validated at the tool-call boundary. On mismatch — missing required, wrong enum, type error — the call is rejected with a validation error fed back to the model, and it retries. Only a conforming call ends the agent.
  4. agent() resolves to the validated args.

By the time your const designs = await parallel(...) resolves, every element provably matches the schema. additionalProperties: false and required are enforced by the model’s tool loop — not by hopeful parsing on your side. That’s why structured workflows feel sturdy: the contracts are real.

Without a schema, the agent’s resolution is just its last assistant message verbatim. The subagent is explicitly told “your final text is the return value,” so you get raw data — no “Here is your document:” preamble.

6. Subagent isolation: string-threading is your only memory

Every agent() call spawns a fresh subagent that is context-isolated — it cannot see your script, the other agents, or the parent conversation. Its world is exactly:

  • The prompt string you pass
  • Its own system prompt and toolset

If you want information to flow between agents, you serialise it into the next prompt yourself. In a fan-in/fan-out workflow, this looks like:

const designs = (await parallel(TEAMS.map(t => () =>
  agent(designPrompt(t), { schema: DESIGN_SCHEMA })
))).filter(Boolean)

const designPackage = serialize(designs)   // 6 designs → one string

const reviews = await parallel(REVIEWERS.map(r => () =>
  agent(reviewPrompt(r, designPackage), { schema: CRITIQUE_SCHEMA })
))

designPackage is the entire shared memory of phase 2. There is no implicit context, no shared blackboard, no inter-agent message bus. If a fact didn’t make it into the string, the reviewer doesn’t know it. That constraint sounds limiting, but it’s what makes the system tractable: every cross-agent dependency is visible in your script as a function from upstream results to downstream prompts.

7. The journal — and why Math.random is banned

Every agent() call is journaled: keyed by (prompt, opts), written to a per-run agent-<id>.jsonl. Resume works by re-executing your script from the top and consulting the journal.

runId journal:  { hash(prompt, opts) → cachedResult }

on resume, inside agent():
  if key in journal:  return cachedResult instantly  (no spawn)
  else:               spawn live, append to journal

Because the engine replays the whole script, the longest unchanged prefix of agent() calls is served from cache; the first call whose (prompt, opts) changed — and everything downstream — runs live. Same script + same args ⇒ 100% cache hit ⇒ instant.

This is the entire reason for the determinism rules:

  • If Math.random() or Date.now() could vary the prompts, every cache lookup would miss on resume, and worse, the control flow could diverge from the original run.
  • If meta were not a static literal, the engine couldn’t read it without executing the body — which it must, to draw the progress tree and the permission dialog before the run starts.

Need randomness? Vary by index — label: `review:${i}` — which is stable across replays. Need a timestamp? Pass it via args so it’s frozen, or stamp it after the workflow returns.

One thing not to conflate: this journal is the workflow’s replay cache. It is unrelated to the Anthropic prompt cache (5-minute TTL, token-cost). One is about which agent calls to skip; the other is about how cheaply the LLM serves the tokens it actually does generate.

8. Budget is a shared pool, with a hard ceiling

budget is shared across the main loop and every workflow this turn — not per-workflow.

  • budget.total — the turn’s target (null if the user set none).
  • budget.spent() — output tokens consumed so far, pooled across everything.
  • budget.remaining()max(0, total − spent()), or Infinity when total is null.

The ceiling is hard: once spent() ≥ total, further agent() calls throw. Inside a parallel(), those throws become nulls — so you can quietly get back half-reviewed results without realising it.

This is why budget-aware loops always guard on budget.total &&:

const findings = []
while (budget.total && budget.remaining() > 50_000) {
  const round = await agent(findPrompt, { schema: BUGS_SCHEMA })
  findings.push(...round.bugs)
}

Without that guard, a turn with no budget set has remaining() = Infinity and the loop runs to the 1000-agent lifetime backstop. (Yes, there is a 1000-agent backstop. It’s a runaway-loop safety, not a real ceiling.)

9. The execution trace, end to end

For a workflow with two parallel phases and a consensus step, here’s what actually happens on the timeline:

t0   parse file, read meta, build progress tree
t0   body starts.  phase('Domain Design') ─ UI only
t0   TEAMS.map → array of 6 thunks  (no spawns yet)
t0   parallel() drains thunks through semaphore (~10 in flight max)
     await ─ ENTIRE SCRIPT suspended
t1   6 design agents run, each with its own schema-retry loop, independently
t2   last design settles → parallel resolves → script resumes
t2   .filter(Boolean), serialize() → designPackage (one string)
t2   phase('Adversarial Review');  build 6 cross-exam + 2 red-team thunks
t2   parallel(concat) ─ await ─ suspended;  8 critique agents run
t3   resume;  flatMap → allFindings;  filter → blockers
t3   phase('Consensus')
t3   await agent(RESOLUTION_SCHEMA) ─ single agent, suspended
t4   resume; build resolutionsText
t4   await agent()  ── writer produces final markdown (no schema)
t5   return { ... }  → journaled, delivered to the main loop
                       via <task-notification>

Every await is a script-wide suspension point. Between them your code is ordinary synchronous JavaScript — map, flatMap, filter, template strings. All real cost and all real concurrency lives in the agent spawns; the engine is a deterministic, replayable, budget-accounted, concurrency-bounded scheduler wrapped around agent().

10. Quality patterns: what to compose with this

Once you internalise that agent() is the only primitive, the “workflow” mental model collapses into a small algebra of shapes you compose:

ShapeWhen it earns its keep
Adversarial verify — N skeptics per finding, kill on majority refutePlausible-but-wrong findings; you need to filter noise before acting
Perspective-diverse verify — correctness / security / repro / perf, one eachFinding can fail in multiple ways; redundancy can’t catch what diversity can
Judge panel — N independent attempts, parallel judges score, synthesiser picksSolution space is wide; one-attempt-iterated misses
Loop-until-dry — keep finding until K consecutive empty roundsUnknown-size discovery (bugs, issues, dead code); fixed N misses the tail
Multi-modal sweep — finders that each search differentlyOne search angle won’t surface everything
Completeness critic — a final pass that asks “what’s missing?”You want the run to flag its own blind spots

The launch post’s Bun-rewrite story (Zig → Rust, ~750k lines, 11 days) is the same algebra at industrial scale: one workflow mapped Rust lifetimes for every struct field, the next ported every .zig.rs in parallel with two reviewers each, a fix loop drove build + tests to green, an overnight workflow opened PRs for redundant data copies. Four orchestrations, each a one-page script around agent().

11. What “dynamic” actually buys you

Strip the marketing layer and the value proposition is precise:

  1. The orchestration shape is generated per task. You don’t pick a workflow from a menu. A bug-hunt script and a migration script have different topologies because they have different barriers, different schemas, different fan-out. The model writes both.
  2. The shape is durable. Once persisted, the file is replayable, journaled, and resumable across context resets. The conversation that produced it is incidental.
  3. The contracts are real. Cross-agent hand-offs go through schemas enforced at the tool boundary, not vibes.
  4. The coordination is outside the conversation. Tens to hundreds of agents converging on a single answer don’t need to share a context window — they share a JS scheduler instead.

When NOT to reach for a workflow

Be honest with yourself:

  • One subagent would do. A single Agent call with a focused prompt is cheaper and quicker. Workflows pay for their structure with tokens.
  • The task is sequential. If there is genuinely no parallelism — every step depends on the last — you’re paying scheduler overhead to run one agent at a time.
  • You need true determinism. Workflows are replayable; they are not deterministic in the sense that LLM outputs vary run-to-run. If you want bit-stable behaviour, write code.
  • The user hasn’t opted in. Token cost is meaningful. The runtime is designed to ask before launching for that reason.

Closing

Most posts about dynamic workflows will describe what they do. The mechanical answer is smaller and more elegant than the marketing makes it sound:

  1. A model writes JavaScript on the fly.
  2. A parser accepts it.
  3. A scheduler runs agent() calls under a semaphore.
  4. A journal lets it resume.

Everything else — the schemas, the phases, the fan-outs, the adversarial passes, the consensus syntheses — is patterns you compose on top of that core. The reason it’s powerful isn’t that the runtime is clever; it’s that the runtime is small enough to get out of the way, so the orchestrator model is free to encode the topology your problem actually needs.

Once you see that, the next question stops being “how do I use workflows?” and becomes “what shape does this task want?” That’s the right question. The script writes itself.

More recent articles

This is Claude Code Dynamic Workflows: Inside Out by Akshay Parkhi, posted on 29th May 2026.

Next: Hermes Agent: The Self-Improving Story

Previous: AgentCore Harness, Inside Out