Mastering /workflows
The authoring reference for the Workflow tool — deterministic multi-agent orchestration,
every API function (agent(), pipeline(), parallel(), schemas),
all quality patterns, and every sharp edge. For triggering, watching, saving, and cost-controlling
workflows from the CLI, see
Part 2 · Running Workflows.
01 What is a Workflow
A Workflow is a deterministic multi-agent orchestration script. You write plain JavaScript,
Claude Code executes it, spawning subagents for each agent() call — potentially dozens or hundreds
of them — with control flow you define: loops, conditionals, fan-out, fan-in.
A workflow is a harness, not a conversation. You write the structure; the agents do the work. The harness is deterministic — same script, same args → same execution graph. The agents are not.
The two pieces: Workflow tool vs /workflows command
| Piece | What it is | When you use it |
|---|---|---|
Workflow tool |
Launches the script. Returns immediately with a run ID like wf_abc123. |
Claude calls it when you ask for a workflow or say "ultracode". |
/workflows |
Live monitor. Shows running agents, phases, tree, progress in real-time. | Type it while a workflow is running to watch it. Also shows completed runs. |
Key properties
- Scripts are plain JS, not TypeScript. No type annotations, no interfaces, no generics.
- Runs in an async context. Use
awaitdirectly at the top level — no wrapper needed. - Always returns immediately. The
Workflowtool call returns the run ID; agents run in the background. - Resumable. Every run is journaled. Edit the script and re-invoke with
resumeFromRunId— unchanged agents return cached results instantly. - Explicit opt-in required. Claude will NOT launch a workflow unless you say "use a workflow", "ultracode", or name a specific workflow type.
Workflows can spawn dozens of agents. They are the expensive path. A task that "would benefit from parallelism" is NOT a reason to launch one unprompted. Always opt in explicitly.
02 Anatomy of a Workflow Script
Every script must begin with export const meta = {...} — a pure literal — followed by the script body.
export const meta = {
name: 'find-dead-code', // kebab-case slug, required
description: 'Find unused exports across the codebase', // shown in permission dialog, required
phases: [ // optional — titles must match phase() calls exactly
{ title: 'Scan', detail: 'grep for exports and imports' },
{ title: 'Verify', detail: 'confirm each candidate is truly unused' },
{ title: 'Fix', detail: 'remove dead exports', model: 'haiku' }, // per-phase model override
],
}
// ── Script body — async context, await directly ──
const FINDINGS_SCHEMA = {
type: 'object',
properties: { exports: { type: 'array', items: { type: 'string' } } },
required: ['exports']
}
phase('Scan')
const found = await agent('Find all exported symbols with no import anywhere', {
label: 'scan:exports',
phase: 'Scan',
schema: FINDINGS_SCHEMA
})
log(`Found ${found.exports.length} candidates`)
The meta literal rule — CRITICAL
No variables, no function calls, no spreads, no template strings. The parser reads meta before executing the script. Any dynamism causes a hard error or silent breakage.
| ❌ Breaks | ✅ Works |
|---|---|
name: getWorkflowName() |
name: 'find-dead-code' |
phases: PHASES_VAR |
phases: [{ title: 'Scan', detail: '...' }] |
description: `${PREFIX} audit` |
description: 'full audit' |
...baseConfig |
Inline all fields explicitly |
The resume-breaking functions
These three are banned in workflow scripts because they would make the same script produce different results on resume, breaking the cache:
Date.now()— useargs.timestamppassed from outsideMath.random()— useargs.seedor vary agent labels by indexnew Date()(no args) — usenew Date(args.timestamp)
// ✅ Correct — stamp time BEFORE calling Workflow, pass it in
// In the main loop (not inside the script):
await Workflow({ script: '...', args: { timestamp: Date.now() } })
// Inside the workflow script:
const ts = new Date(args.timestamp) // ✅ deterministic — same value on resume
03 Full API Reference
agent(prompt, opts?)
Spawns a subagent with the given prompt. The subagent's final text is its return value (not a human-facing message — so it returns raw data).
| Option | Type | Default | Description |
|---|---|---|---|
label |
string |
first 40 chars of prompt | Display name in /workflows tree. Use scope:item convention for readability. |
phase |
string |
current phase | Groups this agent under a named phase box. Always pass explicitly inside pipeline/parallel to avoid races. |
schema |
JSON Schema object | none | Forces structured output. agent() returns validated object. Model auto-retries on mismatch. |
model |
'haiku' | 'sonnet' | 'opus' |
inherits session model | Override model for this agent only. Omit by default — only set when you have strong reason. |
isolation |
'worktree' |
none | EXPENSIVE (~200–500ms + disk per agent). Use ONLY when agents mutate files in parallel and would conflict. Auto-removed if unchanged. |
agentType |
string |
default workflow agent | Use a registered custom agent (e.g. 'Explore', 'code-reviewer'). Composes with schema. |
agent() returns null if the user skips it. After any parallel/pipeline, always: results.filter(Boolean) before using results.
pipeline(items, ...stages)
Runs each item through all stages independently — no barrier between stages. Item A can be in stage 3 while item B is still in stage 1.
- Wall clock = slowest single-item chain (not sum of slowest per stage)
- Each stage receives
(prevResult, originalItem, index) - A stage that throws drops that item to
nulland skips its remaining stages - This is the default. Use it unless you genuinely need a barrier.
parallel(thunks)
Runs tasks concurrently. This is a barrier — awaits all thunks before returning.
- Takes
Array<() => Promise<any>>— thunk functions, not promises - A thunk that throws resolves to
nullin the result — the call itself never rejects - Use ONLY when stage N needs cross-item context from all of stage N-1
phase(title)
Starts a new named phase. Subsequent agent() calls are grouped under this phase box in /workflows.
phase('Scan') // opens "Scan" box in /workflows
const a = await agent('...') // appears under Scan
phase('Verify') // opens "Verify" box
const b = await agent('...') // appears under Verify
The global phase() call races when items run concurrently. Pass { phase: 'Scan' } as an opt to each agent() call instead.
log(message)
Emits a narrator line above the progress tree in /workflows. Use for progress milestones and loop counters.
log(`Round ${round}: ${bugs.length} found, ${dryStreak} dry`)
args
The value passed as Workflow's args input, verbatim. Pass arrays/objects as actual JSON values — not JSON-encoded strings.
// Caller (main loop):
await Workflow({ script: '...', args: ['src/auth.ts', 'src/user.ts'] })
// NOT: args: JSON.stringify(['src/auth.ts', ...]) ← breaks args.map()
// Inside script:
const results = await pipeline(
args, // ← the array of file paths
path => agent(`Review ${path}`)
)
budget
Token budget from the user's +500k-style directive. Shared across main loop and all workflows.
| Property | Type | Meaning |
|---|---|---|
budget.total | number | null | null if no target was set |
budget.spent() | number | Output tokens spent this turn (live) |
budget.remaining() | number | max(0, total - spent()), or Infinity if no target |
// Guard: only loop when a budget was set AND tokens remain
while (budget.total && budget.remaining() > 50_000) {
const r = await agent('Find more issues', { schema: SCHEMA })
results.push(...r.items)
log(`${results.length} found, ${Math.round(budget.remaining()/1000)}k remaining`)
}
workflow(nameOrRef, args?)
Runs another workflow inline as a sub-step. Returns whatever the child returns.
// By name (saved workflow):
const map = await workflow('codebase-mapper')
// By script file:
const r = await workflow({ scriptPath: '.claude/workflows/my-scan.js' }, { target: 'src/' })
- Child shares concurrency cap, agent counter, abort signal, token budget
- Child's agents appear under a
▸ namegroup in/workflows - One level only — calling
workflow()inside a child throws
04 pipeline() vs parallel() — The Critical Distinction
This is the most commonly misunderstood part of the API. The names suggest similarity; the behavior is fundamentally different.
When to use each
| Scenario | Use |
|---|---|
| Most multi-stage work | pipeline() — default, no barrier overhead |
| Dedup across full result set before downstream work | parallel() — need ALL results at once |
| Early-exit if total count is zero | parallel() — check count before spawning next phase |
| Stage N prompt references "the other findings" | parallel() — genuine cross-item dependency |
| Flatten/map/filter between stages | pipeline() — do it inside a stage |
The smell test
If you wrote parallel() → transform (map/filter/flatten) → parallel(), the middle transform does NOT need the barrier. The transform has no cross-item dependency. Rewrite as a pipeline() with the transform inside a stage.
// ❌ Unnecessary barrier — transform doesn't need all results first
const a = await parallel(items.map(x => () => agent(x)))
const b = a.filter(Boolean).flatMap(r => r.items) // just a transform
const c = await parallel(b.map(x => () => agent(x)))
// ✅ Pipeline — A can verify while B and C are still scanning
const results = await pipeline(
items,
item => agent(`Scan ${item}`, { schema: FINDINGS }),
scan => [scan].flatMap(r => r.items), // transform inside a stage — no barrier
(flat, _orig, i) => agent(`Verify item ${i}: ${JSON.stringify(flat)}`)
)
// ✅ Barrier IS justified — dedup needs all results before verification
const scans = await parallel(FINDERS.map(f => () => agent(f, { schema: FINDINGS })))
const deduped = dedupeByKey(scans.filter(Boolean).flatMap(r => r.items)) // needs ALL
const verified = await parallel(deduped.map(b => () => agent(`Verify: ${b}`)))
05 Structured Output — The Schema Pattern
When you pass a schema option to agent(), the agent is forced to call a StructuredOutput tool. Validation happens at the tool-call layer — if the output doesn't match the schema, the model retries automatically. agent() returns the validated object directly. No JSON.parse, no try/catch.
// Define the schema once as a plain JS object (not TypeScript)
const BUGS_SCHEMA = {
type: 'object',
properties: {
bugs: {
type: 'array',
items: {
type: 'object',
properties: {
file: { type: 'string' },
line: { type: 'number' },
desc: { type: 'string' },
severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }
},
required: ['file', 'line', 'desc', 'severity']
}
}
},
required: ['bugs']
}
// Use it
const result = await agent('Find all null pointer dereference bugs in src/', {
schema: BUGS_SCHEMA
})
// result.bugs is a validated Bug[] — no parsing needed
const critical = result.bugs.filter(b => b.severity === 'critical')
Include all fields you'll need in subsequent stages. Include a key field (file + line hash) if you plan to dedup across agents. Include a confidence field if you'll use adversarial verification.
06 Quality Patterns
These are composable recipes. Mix them freely — e.g. multi-modal sweep → dedup (barrier) → adversarial verify (pipeline) → loop-until-dry.
// Spawn N independent skeptics per finding — each is prompted to REFUTE
// Majority rules: ≥2 of 3 must fail to refute for the finding to survive
const VERDICT_SCHEMA = {
type: 'object',
properties: {
refuted: { type: 'boolean' },
reason: { type: 'string' }
},
required: ['refuted', 'reason']
}
async function adversarialVerify(claim) {
const votes = await parallel(
Array.from({ length: 3 }, (_, i) => () =>
agent(
`Try to REFUTE: "${claim}". Default refuted=true if uncertain.`,
{ label: `refute:${i}`, phase: 'Verify', schema: VERDICT_SCHEMA }
)
)
return votes.filter(Boolean).filter(v => !v.refuted).length >= 2
}
// Usage in pipeline — each finding verified as soon as it's found
const confirmed = await pipeline(
findings,
f => adversarialVerify(f.desc),
(survives, f) => survives ? f : null
).then(r => r.filter(Boolean))
// Dedup against `seen` (not `confirmed`) — else rejected findings reappear every round
const seen = new Set()
const bugs = []
let dry = 0
while (dry < 2) {
const found = await agent(
`Find bugs. Already know about: ${JSON.stringify([...seen])}`,
{ schema: BUGS_SCHEMA }
)
const key = b => `${b.file}:${b.line}`
const fresh = found.bugs.filter(b => !seen.has(key(b)))
if (!fresh.length) { dry++; continue }
dry = 0
fresh.forEach(b => seen.add(key(b)))
bugs.push(...fresh)
log(`${bugs.length} total, dry streak reset`)
}
const FINDERS = [
{ key: 'by-file', prompt: 'Scan every modified file for security issues' },
{ key: 'by-import', prompt: 'Trace import chains for vulnerable dependencies' },
{ key: 'by-test', prompt: 'Find issues surfaced by failing test patterns' },
{ key: 'by-history', prompt: 'Read recent git commits for regressions' },
]
phase('Sweep')
const sweeps = await parallel(FINDERS.map(f => () =>
agent(f.prompt, { label: f.key, phase: 'Sweep', schema: FINDINGS_SCHEMA })
))
// Barrier justified: need ALL sweep results to dedup before verification
const all = sweeps.filter(Boolean).flatMap(r => r.items)
const deduped = dedupeByKey(all)
log(`${all.length} raw → ${deduped.length} unique after dedup`)
const APPROACHES = ['MVP-first', 'risk-first', 'user-first']
phase('Design')
const proposals = await parallel(APPROACHES.map(angle => () =>
agent(`Design the auth system from a ${angle} perspective`, {
label: angle, phase: 'Design', schema: PROPOSAL_SCHEMA
})
))
phase('Judge')
const synthesis = await agent(
`Score these proposals, pick the winner, graft the best ideas from runners-up: ` +
JSON.stringify(proposals.filter(Boolean)),
{ phase: 'Judge', schema: SYNTHESIS_SCHEMA }
)
// Guard on budget.total first — without a target, remaining() is Infinity
// and the loop runs to the 1000-agent cap
const findings = []
while (budget.total && budget.remaining() > 50_000) {
const r = await agent('Find more edge cases not already in the list: ' +
JSON.stringify(findings), { schema: FINDINGS_SCHEMA })
findings.push(...r.items)
log(`${findings.length} found, ${Math.round(budget.remaining()/1000)}k tokens remaining`)
}
07 Resume & Iteration
Every Workflow invocation auto-persists its script under the session directory and returns the path in the tool result. This enables a tight edit-and-resume loop.
Tool result includes both the runId (wf_abc123) and the scriptPath. Note both.
scriptPath.
Change an agent prompt, add a new stage, modify a schema. Only edited/new calls will re-run.
Use TaskStop on the run before resuming. Running two instances against the same journal causes corruption.
Workflow({ scriptPath: '...', resumeFromRunId: 'wf_abc123' })
- Same
(prompt, opts)pair → instant cache hit, no re-run - First edited or new
agent()call runs live - Everything after the first changed call also runs live (forward dependency)
- Same-session only — run IDs don't persist across session restarts
// Typical resume invocation:
await Workflow({
scriptPath: '.claude/sessions/abc123/workflow-wf_abc123.js',
resumeFromRunId: 'wf_abc123'
})
08 Concurrency & Limits
What happens when you exceed concurrency
Excess agent() calls queue and run as slots free. You can pass 100 items to pipeline() or parallel() — all 100 will complete, just not all simultaneously. Wall-clock time scales with the slowest item, not the count.
worktree isolation cost
- ~200–500ms setup + disk space per agent
- Use ONLY when agents mutate files in parallel and would otherwise conflict
- Worktree is auto-removed if the agent makes no changes
- Most review workflows don't need it — only mutation workflows do
Token budget is a hard ceiling
When budget.spent() reaches budget.total, further agent() calls throw. Always guard loops with budget.total && to avoid this in unconstrained sessions.
09 Common Gotchas & Anti-Patterns
Date.now(), Math.random(), and new Date() (no args) are banned inside workflow scripts. They would break resume determinism.
args: { timestamp: Date.now() } from the caller before calling Workflow. Read args.timestamp inside.Scripts are plain JS. No : string[], no interface Foo {}, no generics like Array<Bug>. All are parse errors.
parallel takes thunks (() => Promise), NOT promises directly. Passing raw promises starts them all immediately and loses laziness.
parallel([() => agent(...), () => agent(...)])An agent returns null when the user skips it. Calling .bugs on null throws and crashes the workflow.
results.filter(Boolean) before accessing properties.Using parallel() where pipeline() suffices means every fast item waits for the slowest. Wall-clock doubles or triples for no benefit.
pipeline(). Only use parallel() when you genuinely need ALL results together.name: getWorkflowName() or phases: PHASES_VAR silently fail or hard-error. The parser reads meta before running the script.
Calling workflow() inside a child workflow throws. Nesting is one level only by design.
When items run concurrently, a global phase('Scan') call races — items in different stages overwrite each other's phase assignment.
{ phase: 'Scan' } as an opt on each agent() call inside pipeline/parallel.- Open
/workflows— look for agents stuck in a queue or a phase box that never advances - If an agent returns null unexpectedly, check whether the user skipped it (approval dialog)
- A loop that never exits: check your dedup key — are you deduping against
seenorconfirmed? (should beseen) - Budget exhaustion: check
budget.spent()againstbudget.total
10 When to Use What
Claude will NOT launch a workflow unless you say "use a workflow", "ultracode", or name a specific workflow type. A task that "would benefit from parallelism" is NOT opt-in. Claude should describe the workflow and ask first.
| Scenario | Tool | Why |
|---|---|---|
| Single lookup, known file path | Read / Grep directly |
Fastest path. No agent overhead. |
| 1–3 targeted research queries | Agent tool |
Subagent protects main context window from results. No orchestration needed. |
| Cross-file exploration, pattern finding | Agent(Explore) |
Explore agent is optimized for read-only codebase navigation. |
| Fan-out over N files/items with control flow | Workflow | pipeline() or parallel() over items is the core workflow use case. |
| Parallel file mutations (parallel agents writing) | Workflow + isolation: 'worktree' |
Worktree isolation prevents write conflicts between concurrent agents. |
| User said "use a workflow" or "ultracode" | Workflow | Explicit opt-in. Launch confidently. |
| Unknown-size discovery (bugs, issues, edge cases) | Workflow (loop-until-dry) | Static agent count misses the tail. Loop converges naturally. |
| Multi-phase work (understand → design → implement → review) | Multiple workflows in sequence | One workflow per phase keeps main loop in the loop between phases. |
| Quick review, audit, or code explanation | Agent(code-reviewer) |
Single specialized agent is often sufficient. Workflows reserved for scale. |
Decision heuristic
- 1 thing to look up → inline tool
- 1–3 things to research → Agent tool
- N items × M stages, or unknown N → Workflow
- N agents writing files simultaneously → Workflow + worktree
- Uncertain scope → scout inline first, then call Workflow once you know the work-list shape
You don't need to know the work-list shape before deciding to use a workflow — only before writing the orchestration step. Scout with inline tools (grep, list files, check git diff), discover the items, then call Workflow with that list as args. This avoids writing a workflow that immediately spawns an agent just to discover what to do next.