How to Build Custom Workflows in Claude Code: Scripting API, Constraints, and Real-World Examples
The Problem: Orchestration Is Hard to Get Right
I’d been reading the Dynamic Workflows docs for a while, but reading and building are two different things. When I sat down to write my first workflow script, I hit a wall immediately.
The docs showed the agent() primitive. I could figure out how to spawn a subagent. But I had no idea how to structure a real workflow — how to chain stages, pass data between agents, handle errors, or manage concurrency. The examples were all toy snippets. I needed to build something that analyzed 100+ files, and I kept guessing at the shape of the API.
After a lot of trial and error (and a few CI failures from workflow scripts that ran fine locally but timed out in production), I figured out the patterns that work. This post covers what I learned.
The Mandatory Meta Block
Every workflow script starts the same way. You must export a literal meta object. No variables, no function calls, no template interpolation — it has to be a pure literal.
export const meta = { name: "my-workflow", description: "Does something useful", schema: { type: "object", properties: { files: { type: "array", items: { type: "string" } }, severity: { type: "string", enum: ["low", "high"] } }, required: ["files"] }};The schema field is optional but useful. When you provide it, Claude will ask you for the parameters before running the workflow. The values end up in the args parameter of your run function.
If you get the meta block wrong — say you use a variable for the description field — the runtime throws a parse error. I learned this the hard way.
The Five Core Primitives
The workflow runtime exposes five primitives that handle different orchestration patterns. Here’s what each one does and when to use it.

agent(prompt, opts?) — Spawn a Subagent
This is the workhorse. It spawns a Claude Code subagent with the given prompt and returns a string result.
export default async function run({ agent }) { const result = await agent("Analyze the error logs in ./logs/app.log"); return result;}If you pass a JSON Schema via opts, the runtime validates the agent output and retries automatically on mismatch.
export default async function run({ agent }) { const result = await agent({ prompt: "Extract all error codes from the log file", schema: { type: "object", properties: { errorCodes: { type: "array", items: { type: "object", properties: { code: { type: "string" }, count: { type: "number" }, severity: { type: "string" } } } } } } }); // result is a validated object, not a string return `Found ${result.errorCodes.length} unique error codes`;}parallel(thunks) — Concurrent Fan-Out With Barrier
When you need multiple agents to run at the same time and you need all results before proceeding, use parallel(). It takes an array of functions, not promises.
export default async function run({ agent, parallel }) { const files = ["auth.js", "api.js", "db.js", "utils.js"];
const results = await parallel( files.map(file => () => agent(`Review ${file} for security issues. Return findings as bullet points.`) ) );
// All agents finished. results is an array of strings. const combined = results.join("\n\n---\n\n"); const summary = await agent(`Based on these security reviews:\n${combined}\n\nWrite a prioritized fix list.`); return summary;}Key detail: you pass function thunks (() => agent(...)) not promises. parallel() handles the scheduling and barrier internally.
pipeline(items, stages…) — Barrier-Free Multi-Stage Processing
This was the hardest primitive for me to understand. pipeline() processes each item through all stages without a per-stage barrier. Item A can be in stage 3 while item B is still in stage 1.
export default async function run({ agent, pipeline }) { const files = ["file1.js", "file2.js", "file3.js"];
const results = await pipeline( files.map(name => ({ name, data: name })), [ async (item) => { item.analysis = await agent(`Analyze ${item.name}`); return item; }, async (item) => { item.score = await agent(`Score the analysis: ${item.analysis}`); return item; }, async (item) => { item.recommendation = await agent(`Make recommendation for: ${item.score}`); return item; } ] );
return results.map(r => `${r.name}: ${r.recommendation}`).join("\n");}Each stage function transforms the item and passes it to the next stage. No waiting for the full batch.
phase(title) — Progress Grouping
This is a display primitive. It groups subsequent agents under a labeled phase in the progress tree.
export default async function run({ agent, phase, parallel }) { phase("Analysis"); const analyses = await parallel( files.map(f => () => agent(`Analyze ${f}`)) );
phase("Synthesis"); const summary = await agent(`Synthesize: ${analyses.join("\n")}`);
phase("Reporting"); await agent(`Format this as a report: ${summary}`);}The user sees three expandable groups in the terminal. Makes long workflows readable.
log(msg) — Show Progress
Straightforward. Prints a message above the progress tree.
export default async function run({ agent, log }) { log(`Starting analysis of ${files.length} files`); // ... workflow logic ... log(`Completed. Found ${bugCount} bugs.`); return `Found ${bugCount} bugs`;}How Data Flows Between Agents
This is the part that tripped me up the most. There is no message bus. No shared state. No pub/sub. Data flows through plain string concatenation and JSON.stringify.
export default async function run({ agent, parallel }) { // Step 1: collect raw results const raw = await parallel( files.map(f => () => agent(`Read ${f} and list its imports`)) );
// Step 2: data flows through string concatenation const combined = raw.map((r, i) => `File ${files[i]}:\n${r}`).join("\n\n");
// Step 3: pass the combined string to the next agent const dependencyGraph = await agent( `Build a dependency graph from:\n\n${combined}\n\nList circular dependencies.` );
// Step 4: final result goes back to Claude return dependencyGraph;}Each agent’s prompt is a string. Each agent’s result is a string. You build the next prompt by concatenating and formatting previous results. It’s primitive but it works, and it keeps the complexity linear.

When to Use Barriers (and When Not To)
A barrier forces all concurrent agents to finish before the next step executes. parallel() has a built-in barrier. pipeline() does not. I found three cases where barriers are necessary:
1. Deduplication before the next phase. You need the full set to remove duplicates.
// Barrier needed: cannot deduplicate until all agents finishconst results = await parallel( urls.map(u => () => agent(`Scrape ${u}`)));const unique = [...new Set(results.flat())];const filtered = await agent(`Filter these unique items: ${unique.join(", ")}`);2. Early exit based on total count. Stop processing if the batch already found enough.
// Barrier needed: need total count to decideconst results = await parallel( chunks.map(c => () => agent(`Search ${c} for critical bugs`)));const criticalFindings = results.filter(r => r.includes("CRITICAL"));if (criticalFindings.length > 10) return "Too many critical issues, aborting";3. Cross-referencing. One agent’s prompt needs to reference other agents’ results.
const analyses = await parallel( files.map(f => () => agent(`Analyze ${f}`)));const crossRef = await agent( `Compare these analyses and find conflicting recommendations:\n\n${analyses.map((a, i) => `File ${files[i]}:\n${a}`).join("\n\n")}`);If none of these apply, default to pipeline(). It runs faster because items don’t wait for stragglers.
Real Case 1: Bun Migration (Three Workflows)
I worked on a project migrating Bun from Zig to Rust. This required analyzing lifetime annotations across hundreds of struct fields. We built three workflows:
Workflow 1: Lifetime Mapping. Analyze each struct field and map the Zig lifetime to the equivalent Rust lifetime. This ran as a single large agent with a structured prompt.
Workflow 2: Parallel Migration. Spawn hundreds of parallel agents, one per file, and pair each with two reviewer agents.
export const meta = { name: "bun-migration-parallel", description: "Migrate Zig structs to Rust with review"};
export default async function run({ agent, phase, parallel }) { phase("Migration");
const results = await parallel( files.map(file => () => agent( `Migrate ${file} from Zig to Rust. Apply these lifetime mappings:\n${lifetimeMappings}\n\nAfter migration, review for:\n1. Lifetime correctness\n2. Unsafe block safety\n3. Memory leaks` )) );
phase("Compile Check"); const compileResult = await agent(`Check compilation of migrated files. Fix any compile errors.`); return compileResult;}Workflow 3: Compile & Test Fix Loop. A while loop that iterates: fix compile errors, compile again, run tests, fix test failures, repeat. This ran overnight.
Real Case 2: 133 Session Analysis
I analyzed 133 Claude Code conversation sessions to find common failure patterns. The workflow had three phases: preprocessing, parallel analysis with JSON schema, and synthesis.
export const meta = { name: "session-analysis", description: "Analyze conversation sessions for failure patterns", schema: { type: "object", properties: { sessionsPath: { type: "string" } }, required: ["sessionsPath"] }};
export default async function run({ agent, parallel, phase, log, args }) { phase("Preprocessing"); log(`Reading sessions from ${args.sessionsPath}`);
// Sessions grouped into batches of ~13 each const batches = chunk(sessions, 13);
phase("Analysis"); const analyses = await parallel( batches.map((batch, i) => () => agent({ prompt: `Analyze these ${batch.length} sessions for failure patterns:\n\n${batch.join("\n---\n")}\n\nExtract: error types, frequency, root causes.`, schema: { type: "object", properties: { errorTypes: { type: "array", items: { type: "string" } }, frequencies: { type: "object" }, rootCauses: { type: "array", items: { type: "string" } } } } })) );
phase("Synthesis"); const synthesizedAnalyses = analyses.map(r => JSON.stringify(r)).join("\n\n"); const finalReport = await agent( `Synthesize these analyses into a unified failure pattern report:\n\n${synthesizedAnalyses}` );
return finalReport;}Results: 11 total agents (10 analysis + 1 synthesis), 818K tokens consumed, 254 seconds wall time. Only the final report entered my context.
Minimal Workflow: Bug Scanner
Here’s a complete, minimal workflow script that scans files for common bug patterns. This is the one I actually use in my day-to-day work.
export const meta = { name: "bug-scanner", description: "Scans files for common bug patterns", schema: { type: "object", properties: { targetDir: { type: "string" }, filePattern: { type: "string" }, severity: { type: "string", enum: ["all", "critical", "high"] } }, required: ["targetDir", "filePattern"] }};
export default async function run({ agent, parallel, pipeline, phase, log, args }) { const { targetDir, filePattern, severity } = args;
phase("Discovery"); log(`Finding ${filePattern} files in ${targetDir}`); const fileList = await agent(`List all ${filePattern} files in ${targetDir}. Return one file per line.`); const files = fileList.trim().split("\n").filter(Boolean);
if (files.length === 0) { return "No matching files found"; }
log(`Found ${files.length} files to scan`);
phase("Scanning"); const scanResults = await parallel( files.map(file => () => agent(`Scan ${file} for bug patterns:\n1. Null pointer dereferences\n2. Resource leaks\n3. Race conditions\n4. SQL injection vulnerabilities\n\nFor each finding, report: file, line number, severity, description, fix suggestion.`) ) );
phase("Filtering"); const resultsWithSeverity = scanResults.map((r, i) => ({ file: files[i], findings: r }));
const severityFilter = severity === "all" ? resultsWithSeverity : resultsWithSeverity.filter(r => r.findings.toLowerCase().includes(severity));
if (severityFilter.length === 0) { return `No ${severity} severity findings in ${files.length} files`; }
phase("Reporting"); const combinedResults = severityFilter .map(r => `## ${r.file}\n${r.findings}`) .join("\n\n"); const report = await agent( `Generate a summary report from these scan results:\n\n${combinedResults}\n\nInclude: total findings, severity breakdown, top 3 critical issues, fix priority order.` );
log(`Scan complete. Checked ${files.length} files.`); return report;}Run it with:
>claude workflow run bug-scanner-workflow.jsClaude will ask for targetDir, filePattern, and severity.The progress tree shows Discovery → Scanning → Filtering → Reporting.Hard Constraints
The runtime has hard limits. Knowing them upfront saves guesswork.
Max 16 concurrent subagents. The actual limit is min(16, CPU cores - 2). On an 8-core machine, you get 6 concurrent agents. On a 32-core machine, you get 16.
Max 1000 agent calls per run. After that, the runtime stops new agent calls. Plan your batches accordingly.
No direct filesystem or shell access. The workflow script runs in a sandbox. All filesystem operations must go through subagents.
Permissions inherited from the main session. If you haven’t granted a permission in your Claude Code session, the workflow subagents won’t have it either. Pre-allowlist permissions before large runs.
Resume is session-only. If you restart Claude Code, a running workflow starts from scratch. There is no persistent checkpoints.
Summary
Building custom workflows is about knowing which primitive fits which pattern. Use agent() for single tasks, parallel() when you need all results before proceeding, pipeline() for multi-stage processing without barriers, phase() to keep the progress tree readable, and log() to show status.
Data flows through string concatenation and JSON.stringify. There is no message bus, and that’s by design — it keeps the runtime simple and the overhead low.
The real constraint isn’t the API surface. It’s knowing when to use a barrier and when to let items flow freely through a pipeline. Get that right, and everything else falls into place.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Claude Code Dynamic Workflows Documentation
- 👨💻 Building Effective Agents - Anthropic
- 👨💻 Claude Code Workflow Script API Reference
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments