Mastering /workflows — Claude Code
Claude Code · Internal Reference

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.

plain JavaScript async/await deterministic resumable explicit opt-in required

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.

Core mental model

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

PieceWhat it isWhen 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 await directly at the top level — no wrapper needed.
  • Always returns immediately. The Workflow tool 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.
Cost awareness

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

meta must be a pure literal — no exceptions

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:

Never use inside a workflow script
  • Date.now() — use args.timestamp passed from outside
  • Math.random() — use args.seed or vary agent labels by index
  • new Date() (no args) — use new 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).

OptionTypeDefaultDescription
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.
Always filter nulls

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 null and 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 null in 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
phase() inside pipeline/parallel

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.

PropertyTypeMeaning
budget.totalnumber | nullnull if no target was set
budget.spent()numberOutput tokens spent this turn (live)
budget.remaining()numbermax(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 ▸ name group 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.

pipeline() — non-barrier, items flow independently
Item A
Stage 1
Stage 2
Stage 3 ✓
finishes first
Item B
Stage 1
Stage 2 ···
still running while A is done
Item C
Stage 1 ···
still in stage 1
parallel() — barrier, all must complete before next proceeds
Item A
Stage 1 ✓
Item B
Stage 1 ✓
Stage 2 (blocked)
Item C
Stage 1 ···
← barrier: all wait for this

When to use each

ScenarioUse
Most multi-stage workpipeline() — default, no barrier overhead
Dedup across full result set before downstream workparallel() — need ALL results at once
Early-exit if total count is zeroparallel() — check count before spawning next phase
Stage N prompt references "the other findings"parallel() — genuine cross-item dependency
Flatten/map/filter between stagespipeline() — do it inside a stage

The smell test

Unnecessary barrier smell

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')
Design schemas for downstream use

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.

⚔️
Adversarial Verify
Kill plausible-but-wrong findings before they ship
// 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))
🔄
Loop-Until-Dry
Unknown-size discovery — keep going until K empty rounds
// 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`)
}
🔭
Multi-Modal Sweep
Parallel agents with different search angles — each blind to the others
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`)
⚖️
Judge Panel
N independent approaches → scored synthesis → best of all worlds
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 }
)
💰
Budget-Gated Loop
Scale depth to the user's +500k token directive
// 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.

1
Run the workflow.

Tool result includes both the runId (wf_abc123) and the scriptPath. Note both.

2
Edit the script at scriptPath.

Change an agent prompt, add a new stage, modify a schema. Only edited/new calls will re-run.

3
Stop the prior run first.

Use TaskStop on the run before resuming. Running two instances against the same journal causes corruption.

4
Resume with the run ID.

Workflow({ scriptPath: '...', resumeFromRunId: 'wf_abc123' })

Cache mechanics
  • 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

min(16, cpu−2)
Max concurrent agents at once
1,000
Max total agents per workflow (backstop)
shared
Token budget — main loop + all workflows combined

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

isolation: 'worktree' is expensive
  • ~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

Gotcha 1 · Date.now() throws

Date.now(), Math.random(), and new Date() (no args) are banned inside workflow scripts. They would break resume determinism.

✅ Pass args: { timestamp: Date.now() } from the caller before calling Workflow. Read args.timestamp inside.
Gotcha 2 · TypeScript syntax fails

Scripts are plain JS. No : string[], no interface Foo {}, no generics like Array<Bug>. All are parse errors.

✅ Write plain JS. Use JSDoc comments for type hints if needed — they don't affect execution.
Gotcha 3 · parallel(promises) fails

parallel takes thunks (() => Promise), NOT promises directly. Passing raw promises starts them all immediately and loses laziness.

✅ Always wrap: parallel([() => agent(...), () => agent(...)])
Gotcha 4 · Not filtering nulls

An agent returns null when the user skips it. Calling .bugs on null throws and crashes the workflow.

✅ Always: results.filter(Boolean) before accessing properties.
Gotcha 5 · Unnecessary barrier

Using parallel() where pipeline() suffices means every fast item waits for the slowest. Wall-clock doubles or triples for no benefit.

✅ Default to pipeline(). Only use parallel() when you genuinely need ALL results together.
Gotcha 6 · meta with computed values

name: getWorkflowName() or phases: PHASES_VAR silently fail or hard-error. The parser reads meta before running the script.

✅ meta must be a pure literal — all values inline, no variables, no spreads.
Gotcha 7 · Nested workflow() calls

Calling workflow() inside a child workflow throws. Nesting is one level only by design.

✅ Compose at the top-level script. Use sub-functions inside a single script instead of nested workflows.
Gotcha 8 · Global phase() inside pipeline()

When items run concurrently, a global phase('Scan') call races — items in different stages overwrite each other's phase assignment.

✅ Pass { phase: 'Scan' } as an opt on each agent() call inside pipeline/parallel.
Debugging a stuck workflow
  • 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 seen or confirmed? (should be seen)
  • Budget exhaustion: check budget.spent() against budget.total

10 When to Use What

Explicit opt-in rule

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
Hybrid pattern — scout then orchestrate

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.