From Subagents to Dynamic Workflows — What Changed in May 2026
If you have already read our guide on orchestrating Claude Code subagents in production, you know the core fan-out model: the main Claude agent spawns child agents via the Task tool, each child runs in its own context window, and results flow back to the orchestrator. That pattern works well for many cases. But it has a structural limitation that becomes painful as workflows scale.
The fundamental issue is that static subagents are model-driven. The main agent decides when to spawn a child, how many to spawn, and in what order — at runtime, as part of its inference. There is no source-of-truth control flow definition you can inspect, version, or replay. If the workflow runs for three hours and fails at hour two, you start over. There is no concurrency cap you can configure. There is no progress display beyond whatever the agent happens to say. And there is no cost-visibility layer — the main agent just spawns until it's done.
Dynamic Workflows, introduced with Claude Opus 4.8 on 28 May 2026, address all of these gaps with a single new primitive: a plain JavaScript script that defines orchestration deterministically. The script describes phases, which agents run in each phase, whether they run in series or parallel, and what structured output they should return. The Claude Code runtime executes the script — it does not interpret it through a model.
The gap that static subagents leave
To make the comparison concrete, consider what happens when you ask a static subagent orchestrator to review 40 changed files:
- The main agent decides how many reviewer subagents to spawn — it might spawn 10, or 40, or 3, depending on its inference at that moment.
- If the session is interrupted, you get nothing. Resume is not a concept.
- Concurrency is uncapped from the script's perspective — Claude Code's internal scheduler handles it, but you cannot express "run at most N at a time" in the subagent model.
- There is no phase display in the /workflows UI because no phase structure was defined.
Dynamic Workflows fix all four of those. The concurrency cap is min(16, cpu_cores - 2) concurrent agents by default, with excess calls queuing automatically. The phase structure appears in the /workflows UI as the script executes. Resume via resumeFromRunId means completed agent calls are cached and re-used on re-invocation. And the control flow is plain JavaScript — readable, diffable, testable.
Comparison: static subagents vs Dynamic Workflows
| Dimension | Static subagents (Task tool) | Dynamic Workflows (Opus 4.8+) |
|---|---|---|
| Control flow | Model-driven, non-deterministic | JavaScript script, deterministic |
| Who decides spawning? | Main Claude agent at inference time | Script author at authoring time |
| Resume on failure | Not supported — restart from zero | resumeFromRunId — reuses completed calls |
| Concurrency control | Internal scheduler, not configurable | min(16, cores-2) cap; excess queues |
| Progress visibility | Agent prose only | Named phases in /workflows UI; log() messages |
| Structured output | Parse agent response string manually | schema option — agent() returns validated object |
| Cost visibility | Total session tokens post-hoc | budget.spent() / budget.remaining() live |
| Max agents/run | No documented cap | 1,000 agents per workflow lifetime |
| Multi-model routing | One model per session by default | model option per agent() call |
| Skills integration | No native routing to named skill types | agentType option routes to a named Skill |
The practical upshot: if your workflow runs for more than a few minutes, involves more than a handful of agents, or needs to be debugged and re-run without starting over, Dynamic Workflows are the right tool. For quick, one-off fan-outs where you do not care about resume or cost visibility, static subagents still work fine.
The Workflow Architecture — Phases, Agents, and Orchestrators
A Dynamic Workflow is a .js file — plain JavaScript, not TypeScript. The runtime executes it in an async context, so you can await directly at the top level without wrapping everything in an IIFE. The file must begin with a meta export that the runtime reads to display the workflow name and phase list in the UI.
The meta block
The meta export must be a pure object literal with no computed values. The runtime parses it statically before executing the script, so any dynamic expression — a variable reference, a function call, a ternary — will cause a parse error. Keep it simple:
export const meta = {
name: 'code-review',
description: 'Review changed files for bugs and style issues',
phases: [
{ title: 'Review' },
{ title: 'Synthesise' },
],
}
The phases array controls what appears in the /workflows UI progress display. You do not need to declare every phase up-front if your workflow has dynamic branching — but named phases make progress tracking much easier for long-running jobs.
Core primitives
The runtime exposes four orchestration primitives as globals:
agent(prompt, opts?)- Spawns a single subagent with the given prompt. By default returns a string (the agent's response). If you pass a
schemaoption, the runtime validates the response against that JSON Schema and returns a parsed object. Options includelabel(display name in UI),phase(which phase this belongs to),model(override the model for this call),agentType(route to a named Skill), andschema. phase(title)- Marks the start of a named phase in the progress display. Has no effect on execution — it is purely for UI grouping. Call it immediately before the agents that belong to that phase.
parallel(thunks)- Takes an array of zero-argument functions (thunks), executes them concurrently, and waits for all to resolve before returning. This is a barrier: nothing after the
parallel()call runs until every thunk completes. Use this when the next step genuinely requires all results from the current step. pipeline(items, ...stages)- Takes an array of items and one or more stage functions. Runs each item through all stages in order — but critically, there is no barrier between stages. Item A can be in stage 2 while item B is still in stage 1. This gives you better wall-clock time on large item sets. Use this as the default for multi-stage fan-out work.
Two additional globals are available:
log(message)- Emits a progress message visible in the /workflows UI and in the terminal. Use liberally — it is the primary way to track what a long-running workflow is doing.
workflow(nameOrRef, args?)- Invokes a sub-workflow by name or file reference. Sub-workflows can use all the same primitives, but nesting is limited to one level: a child workflow cannot call
workflow()itself.
The pipeline() vs parallel() mental model
This is the most important architectural distinction to internalise. Consider reviewing 5 files through two stages: per-file analysis and per-file summarisation.
With parallel(), the execution looks like this — a barrier after stage 1 means all 5 analyses must complete before any summarisation begins:
Stage 1 (analyse): [A] [B] [C] [D] [E] ← all run, then barrier
↓
Stage 2 (summarise): [A] [B] [C] [D] [E] ← all run after barrier
With pipeline(), there is no barrier. As soon as file A finishes analysis, its summarisation starts — while B, C, D, E are still being analysed:
Stage 1 (analyse): [A→] [B→] [C→] [D→] [E→]
Stage 2 (summarise): [A→] [B→] [C→] [D→] [E→]
↑ starts as soon as A leaves stage 1
On a set of 20 files, the wall-clock difference can be significant — especially when individual agent calls have variable latency. The rule is: default to pipeline(); reach for parallel() only when you have a genuine synchronisation requirement (for example, "synthesise all 20 reviews into one report" must wait for all 20).
A quick smell test: if you would naturally write array.map().filter() between stages, that is pipeline() work. If you would write array.reduce() across all results before the next stage, that is a parallel() barrier.
Writing Your First Dynamic Workflow — A Step-by-Step Walkthrough
The best way to understand the primitives is to walk through a complete, working example. The workflow below reviews a list of changed files, fans out per-file reviews via pipeline(), filters for critical issues, and synthesises a final summary. This is the shape of workflow you would use in a CI gate or a pre-merge review step.
export const meta = {
name: 'code-review',
description: 'Review changed files for bugs and style issues',
phases: [
{ title: 'Review' },
{ title: 'Synthesise' },
],
}
const REVIEW_SCHEMA = {
type: 'object',
properties: {
file: { type: 'string' },
issues: { type: 'array', items: { type: 'string' } },
severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },
},
required: ['file', 'issues', 'severity'],
}
phase('Review')
const reviews = await pipeline(
args.files,
(file) => agent(
`Review ${file} for bugs and style issues. Be specific about line numbers.`,
{
label: `review:${file}`,
phase: 'Review',
schema: REVIEW_SCHEMA,
}
),
)
phase('Synthesise')
const critical = reviews.filter(Boolean).filter(r => r.severity === 'critical')
log(`${critical.length} critical issues found across ${args.files.length} files`)
const summary = await agent(
`Summarise these code review findings and suggest a merge decision: ${JSON.stringify(critical)}`,
{ phase: 'Synthesise' }
)
return { reviews, summary, criticalCount: critical.length }
Let us walk through each section.
The meta block
The meta block is a pure literal — no variables, no function calls. The runtime parses it before executing the script. phases must list every named phase you intend to call; unlisted phase names still work but will not appear in the progress display. The name field is used to identify the workflow in workflow() calls from parent workflows.
The schema definition
REVIEW_SCHEMA is a JSON Schema object defined outside the meta block. Passing it as the schema option to agent() does two things: it appends a StructuredOutput instruction to the agent's system prompt, and it validates the returned JSON against the schema before returning. If validation fails, the runtime throws — so your downstream code can trust the type. Note the schema is plain JavaScript, not TypeScript — no interface or type annotation syntax.
pipeline() with per-file reviews
pipeline(args.files, stageFunction) fans out one agent call per file. args is the global that receives the input passed to the workflow — in this case, an object with a files array. The stage function receives each item and returns a promise (the agent() call). The runtime executes up to min(16, cores-2) concurrent agents; the rest queue.
The label option gives each call a human-readable name in the UI. The phase option groups it under the correct phase in the progress display — even when items are in different stages simultaneously, they always show under their correct phase.
Filtering with .filter(Boolean)
The line reviews.filter(Boolean).filter(r => r.severity === 'critical') is a two-step pattern you will use constantly. The first .filter(Boolean) removes any null or undefined results — individual agent calls can return null if the agent encounters an error or the schema validation fails and the runtime is configured to return null rather than throw. Never assume every call returned a value; always filter first.
The synthesis agent
The final agent() call does not use a schema — it returns a human-readable string that will be part of the workflow's output. Notice it only receives critical issues, not all 40 reviews. Passing the full result set to a single agent when you only need the critical subset wastes tokens and degrades response quality. Filter aggressively before synthesis.
The return value
A Dynamic Workflow returns structured data, not a human-facing message. The return value is available to the parent (if invoked via workflow()) or to the caller that triggered the workflow via the API. Keep it a plain object — the runtime serialises it to JSON.
Do not use Date.now(), Math.random(), or new Date() with no arguments anywhere in a workflow script. These break the resume system because they produce different values on re-execution, making it impossible for the runtime to match calls from the prior run. Pass timestamps as part of args instead: args.runDate.
Skills Integration — Reusable Agent Types as Building Blocks
Dynamic Workflows compose naturally with Claude Code Skills. A Skill is a custom subagent type defined by a YAML file plus a system prompt. The YAML specifies the Skill's name, description, when it should be used, which model to use, what tools it has access to, and the contents of its system prompt. Skills are the right abstraction when you want to reuse a specialised subagent across multiple workflow runs without copy-pasting its system prompt into every agent() call.
What a Skill definition looks like
A Skill file typically lives in your project's .claude/skills/ directory. A minimal Skill definition for an SEO validator might look like this:
name: seo-validate
description: Validate an HTML article against AITC SEO requirements
when_to_use: When you need to check an article file for meta tags, canonical URLs, hreflang, JSON-LD, and heading structure
model: claude-opus-4-8
tools:
- Read
system_prompt: |
You are an SEO validator for AI Tech Connect articles.
Run 27 deterministic checks across meta tags, URL hygiene,
hreflang, Open Graph, Twitter Card, JSON-LD structured data,
heading hierarchy, and internal links.
Return a structured report with pass/fail per check.
Invoking a Skill from a workflow
To route an agent() call to a named Skill, pass agentType matching the Skill's name field:
const seoReport = await agent(
`Validate /tips/claude-code-dynamic-workflows-opus-48-2026.html`,
{ agentType: 'seo-validate' }
)
The runtime loads the Skill's YAML, sets the specified model, grants it only the tools listed in the Skill definition, and prepends the Skill's system prompt before the user-facing prompt. This means your workflow script stays clean — no system prompts embedded in JavaScript strings.
Composing Skills with structured output
You can combine agentType with schema to get validated structured output from a Skill:
const SEO_REPORT_SCHEMA = {
type: 'object',
properties: {
passed: { type: 'boolean' },
checksPassed: { type: 'number' },
checksFailed: { type: 'number' },
failures: {
type: 'array',
items: {
type: 'object',
properties: {
check: { type: 'string' },
detail: { type: 'string' },
},
required: ['check', 'detail'],
},
},
},
required: ['passed', 'checksPassed', 'checksFailed', 'failures'],
}
const seoReport = await agent(
`Validate /tips/claude-code-dynamic-workflows-opus-48-2026.html`,
{
agentType: 'seo-validate',
schema: SEO_REPORT_SCHEMA,
}
)
if (!seoReport.passed) {
log(`SEO validation failed: ${seoReport.checksFailed} checks failed`)
seoReport.failures.forEach(f => log(` FAIL: ${f.check} — ${f.detail}`))
}
When schema is set alongside agentType, the runtime appends a StructuredOutput instruction to the Skill's system prompt. The Skill's own system prompt remains intact — the StructuredOutput instruction is appended, not prepended. This means a Skill can be invoked both with and without a schema from different parts of the same workflow.
When to use a Skill vs an inline agent() call
The decision is straightforward: use a Skill when the subagent's system prompt is long enough that you would not want to repeat it inline, or when the same specialised agent runs in multiple different workflows. Use an inline agent() call when the prompt is short and the agent's role is specific to this one workflow. A rule of thumb: if the system prompt would be more than 200 words, extract it to a Skill.
For the AITC content pipeline, Skills like edit-news, seo-validate, and compliance-check are all candidates for Skill-based routing from a workflow — each has a substantial system prompt that should not live in a workflow script. The plan-first workflow guide covers how to structure these definitions before writing the orchestration script.
Token Budget and Cost Management
Every Dynamic Workflow has access to a budget global that provides live token accounting. As of May 2026, the budget object exposes three members:
budget.total— the token target set when the workflow was invoked (ornullif no target was set)budget.spent()— tokens consumed so far across all agent calls in this workflow runbudget.remaining()—budget.total - budget.spent()(orInfinitywhenbudget.totalis null)
These are live values — they update after each agent call completes. This makes budget-gated loops possible: you can run a discovery loop that keeps spawning agents until you are close to your budget target, rather than pre-computing how many agents to spawn.
Budget-gated loop pattern
const ISSUES_SCHEMA = {
type: 'object',
properties: {
issues: {
type: 'array',
items: {
type: 'object',
properties: {
path: { type: 'string' },
description: { type: 'string' },
},
required: ['path', 'description'],
},
},
done: { type: 'boolean' },
},
required: ['issues', 'done'],
}
const results = []
let iteration = 0
while (budget.total && budget.remaining() > 50_000) {
iteration += 1
log(`Iteration ${iteration} — ${Math.round(budget.remaining() / 1000)}k tokens remaining`)
const batch = await agent(
`Find more security issues in the codebase. Focus on authentication and input validation. Skip files already covered: ${results.map(r => r.path).join(', ')}`,
{ schema: ISSUES_SCHEMA }
)
if (!batch || batch.issues.length === 0 || batch.done) {
log('Agent signalled completion — exiting loop')
break
}
results.push(...batch.issues)
log(`${results.length} total issues found so far`)
}
log(`Final: ${results.length} issues found in ${iteration} iterations`)
Note the budget.total && guard at the top of the while condition. If budget.total is null (no target was set), budget.remaining() returns Infinity, and the loop would never exit on the budget condition alone. Always guard with the null check, and always include a secondary exit condition (here: batch.done or empty batch.issues).
Model cost awareness and per-agent routing
As of June 2026, the pricing difference between Fable 5 and Opus 4.8 is meaningful at scale:
| Model | Input (per MTok) | Output (per MTok) | Best for |
|---|---|---|---|
| Claude Opus 4.8 | $5 | $25 | Analytical tasks, code review, structured extraction |
| Claude Fable 5 | $10 | $50 | Creative synthesis, complex reasoning, multi-step planning |
In a workflow with 40 agent calls, routing 30 analytical calls to Opus 4.8 and 10 creative synthesis calls to Fable 5 versus routing all 40 to Fable 5 can halve the total token cost. Use the model option to route per-agent:
// Analytical / extraction — Opus 4.8 is adequate and cheaper
const fileReview = await agent(
`Extract all TODO comments from ${file} and categorise them`,
{ model: 'claude-opus-4-8', schema: TODO_SCHEMA }
)
// Creative synthesis — Fable 5 if you need its additional capability
const narrative = await agent(
`Write an executive summary of these findings for a non-technical audience`,
{ model: 'claude-fable-5' }
)
Set model: 'claude-opus-4-8' as the workflow default for all agents, then explicitly override to Fable 5 only where you have verified the uplift is worth the 2× cost. Start with Opus 4.8 everywhere and promote to Fable 5 based on output quality review — not instinct.
Fable 5 + Opus 4.8 Safety Routing in Multi-Model Workflows
Claude Fable 5 launched on 9 June 2026 — roughly two weeks after Opus 4.8. The two models are designed to work together, and Dynamic Workflows are the natural place to route between them.
Automatic safety routing
Fable 5 includes a safety routing layer that automatically escalates certain query types to Opus 4.8. As of June 2026, the three categories that trigger automatic routing are:
- Cybersecurity queries — exploit development, penetration testing techniques, vulnerability analysis
- Biology and chemistry queries — synthesis routes, dual-use biological research, chemical weapon precursors
- Model distillation queries — attempts to replicate Fable 5's weights or training data
When Fable 5 routes a query to Opus 4.8, the caller sees the response arrive from the original Fable 5 endpoint — the routing is transparent. Approximately 95% of Fable 5 sessions run entirely on Fable 5's own responses; the routing is for the narrow set of safety-sensitive topics where Opus 4.8's guardrails are authoritative.
In a Dynamic Workflow, this automatic routing still applies when model: 'claude-fable-5' is set on an agent call. You do not need to implement safety routing yourself; it is handled at the inference layer.
Explicit per-agent model routing
Beyond the automatic safety routing, you may want to set the model explicitly for workflow design reasons rather than safety ones. A common pattern in multi-stage content pipelines is to use Fable 5 for the creative stages and Opus 4.8 for the validation stages:
export const meta = {
name: 'article-pipeline',
description: 'Write and validate a technical article',
phases: [
{ title: 'Draft' },
{ title: 'Validate' },
],
}
phase('Draft')
const draft = await agent(
`Write a 2,000-word technical guide about ${args.topic}. Use British English. Include code examples.`,
{
model: 'claude-fable-5',
phase: 'Draft',
label: 'draft:article',
}
)
log('Draft complete — moving to validation')
phase('Validate')
const [seoResult, complianceResult] = await parallel([
() => agent(
`Run SEO checks on this article:\n\n${draft}`,
{ model: 'claude-opus-4-8', phase: 'Validate', label: 'validate:seo', schema: SEO_SCHEMA }
),
() => agent(
`Check this article for compliance issues (EU AI Act, UK Frontier AI Bill):\n\n${draft}`,
{ model: 'claude-opus-4-8', phase: 'Validate', label: 'validate:compliance', schema: COMPLIANCE_SCHEMA }
),
])
return { draft, seoResult, complianceResult }
Here the draft stage uses Fable 5 — the creative uplift justifies the cost for the article-writing step. The two validation stages use Opus 4.8 — they are analytical and extractive, not creative, so Fable 5 provides no meaningful uplift. The two validation agents run in parallel() rather than pipeline() because there is only one item (the draft) being processed through two independent checks — there is no per-item fan-out here, so pipeline() would add no benefit.
Cost discipline in multi-model workflows
The cost implication of Fable 5 at 2× Opus 4.8 is worth modelling before you build a workflow rather than after. If a workflow makes 30 agent calls and 10 of them are on Fable 5, and each call consumes 2,000 output tokens on average, the Fable 5 calls alone cost: 10 × 2,000 × $50/MTok = $1.00. The same calls on Opus 4.8 would cost $0.50. At 10,000 workflow runs per month, that is $5,000/month in difference from those 10 calls. Model selection is a cost-engineering decision, not just a capability one.
Dynamic Workflows are fully available on Claude Code regardless of region — there is no geographic restriction on the Anthropic API. For Indian teams using Claude Code on IndiaAI Mission GPU credits, Dynamic Workflows run against the same Anthropic API endpoint and carry no extra latency penalty. For UK teams subject to data residency requirements under the UK AI Bill's emerging guidance, note that all inference remains on Anthropic's US infrastructure as of June 2026; if data localisation is a hard requirement, evaluate whether your workflow payload (prompts and tool results) can be pseudonymised before being passed to agent() calls.
Building production agentic systems?
Builders on AI Tech Connect are shipping multi-model workflows, custom Skills pipelines, and Claude Code integrations at scale. Browse profiles and find someone who has done it before.
Browse AI Builders →Debugging, Resume, and Common Pitfalls
Dynamic Workflows introduce a new class of debugging challenges. The good news is the runtime provides excellent tooling — log() messages, the /workflows UI, and the resume system. The bad news is that several categories of mistake will fail silently or produce confusing errors if you are not aware of them.
Resume with resumeFromRunId
Resume is the most operationally significant feature of Dynamic Workflows for production use. When a workflow is interrupted — by a network error, a budget exhaustion, a process restart, or a manual kill — you do not need to restart from zero. Pass the prior run's ID as resumeFromRunId when re-invoking:
// Invoking via the SDK
const result = await claudeCode.workflows.run({
name: 'code-review',
args: { files: changedFiles },
resumeFromRunId: 'run_abc123', // ID from the interrupted run
})
On resume, the runtime replays the script from the top. For each agent() call it encounters, it checks whether there is a completed result from the prior run with identical prompt and options. If there is, it returns the cached result instantly — no tokens are spent. Calls that did not complete (or were not reached) in the prior run execute normally.
Resume has three requirements:
- Same session — the resume invocation must occur within the same Claude Code session that produced the original run ID.
- Unchanged script — any change to the workflow script invalidates the resume cache. The runtime compares script content, not just the name.
- Identical agent() calls — the prompt string and options object for each call must be identical across runs. This is why
Date.now()andMath.random()are prohibited: they change between runs, making cached result lookup impossible.
The TypeScript trap
Workflow scripts are plain JavaScript. TypeScript syntax — type annotations, interface declarations, generic type parameters, as casts — will throw a parse error at the script level. The error message is not always obvious about the cause. If you are seeing a syntax error in a workflow script that looks valid to you, check for accidental TypeScript:
// WRONG — TypeScript syntax — will throw
const review: ReviewResult = await agent(prompt, { schema: SCHEMA })
// CORRECT — plain JavaScript
const review = await agent(prompt, { schema: SCHEMA })
workflow() nesting limit
The workflow() primitive supports one level of nesting. A parent workflow can call a child workflow. The child workflow can use all primitives including agent(), pipeline(), and parallel() — but it cannot call workflow() again. Attempting to nest a third level throws at runtime. Design deeply nested orchestration as flat multi-stage pipelines rather than recursive workflow calls.
Null returns from agent()
When an agent call fails schema validation, or when the agent returns an empty response, the runtime may return null rather than throwing. This is the right behaviour for large pipeline fan-outs where you do not want a single failing agent to abort the entire workflow — but it means you must filter defensively:
const reviews = await pipeline(args.files, (file) =>
agent(`Review ${file}`, { schema: REVIEW_SCHEMA })
)
// Always filter before iterating
const validReviews = reviews.filter(Boolean)
const critical = validReviews.filter(r => r.severity === 'critical')
log(`${validReviews.length}/${args.files.length} reviews succeeded`)
Iterating over a pipeline() result without .filter(Boolean) first is the most common runtime bug in Dynamic Workflow scripts. If even one file is too large for the agent's context and returns null, a later r.severity access will throw a TypeError that aborts the entire workflow run — potentially after hours of completed work.
Log strategy for long-running workflows
Use log() aggressively. Every significant transition — phase start, batch completion, budget checkpoint, early exit — should emit a log message. The messages appear in the /workflows UI in real time and in the terminal. For workflows that run longer than a few minutes, a log message every 30–60 seconds of wall time is appropriate. Without logs, a stuck workflow is indistinguishable from a slow one until it times out.
A structured log pattern that reads well in both terminal and UI:
log(`[${phase}] ${label}: ${count} items processed — ${Math.round(budget.remaining() / 1000)}k tokens remaining`)
For more on the foundational orchestration patterns that Dynamic Workflows build on, see the static subagent orchestration guide. For a deeper look at how to structure the planning stage before writing a workflow script, the plan-first workflows guide covers the pre-coding phase that saves the most debugging time. If you are building custom Skills to compose with workflows, the MCP server guide covers the tool-server patterns that complement Skills-based routing. For the Agent SDK context that Dynamic Workflows operate within, see the Claude Agent SDK production guide. And for the multi-session orchestration features that sit alongside Dynamic Workflows in the Claude Code UI, see the Agent View and multi-session orchestration overview.