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:
- What does “dynamic” actually mean? Is there a compiler? A planner? Where does the script come from?
- What does the runtime look like underneath — is this real concurrency, or a coroutine pretending?
- How does “resume from where it left off” work, and what does it cost you in expressivity?
- 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.
| Injected | What 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, budget | Inputs: 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()tookagent(...)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 writesnullinto the result slot. That’s whyparallel()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 phases | You 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:
- Appends a directive to the subagent’s system prompt: your final answer must come via the
StructuredOutputtool. - Synthesises that tool, using your JSON Schema as its input schema.
- 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.
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()orDate.now()could vary the prompts, every cache lookup would miss on resume, and worse, the control flow could diverge from the original run. - If
metawere 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()), orInfinitywhentotalis 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:
| Shape | When it earns its keep |
|---|---|
| Adversarial verify — N skeptics per finding, kill on majority refute | Plausible-but-wrong findings; you need to filter noise before acting |
| Perspective-diverse verify — correctness / security / repro / perf, one each | Finding can fail in multiple ways; redundancy can’t catch what diversity can |
| Judge panel — N independent attempts, parallel judges score, synthesiser picks | Solution space is wide; one-attempt-iterated misses |
| Loop-until-dry — keep finding until K consecutive empty rounds | Unknown-size discovery (bugs, issues, dead code); fixed N misses the tail |
| Multi-modal sweep — finders that each search differently | One 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:
- 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.
- The shape is durable. Once persisted, the file is replayable, journaled, and resumable across context resets. The conversation that produced it is incidental.
- The contracts are real. Cross-agent hand-offs go through schemas enforced at the tool boundary, not vibes.
- 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
Agentcall 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:
- A model writes JavaScript on the fly.
- A parser accepts it.
- A scheduler runs
agent()calls under a semaphore. - 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
- Hermes Agent: The Self-Improving Story - 12th June 2026
- AgentCore Harness, Inside Out - 24th April 2026