← blog.wrbriggs.cloud
DYNAMIC WORKFLOWS IN CLAUDE CODE · PART 2 OF 6

Anatomy of a Workflow Script

How to read and write a complete workflow script: typed data out of agents, parameters in, the sandbox rules, and how to iterate on a script across runs.

Will Briggs·August 2026·14 min read

BEFORE YOU START

Read part one for the mental model. You'll want everyday JavaScript (await, arrow functions) and passing familiarity with JSON Schema. If you've never written a schema, the official Creating your first schema guide covers everything this post uses, type through enums, in about five minutes.

Part one ended with a ten-line script and a promise to take it apart. Here's the same workflow grown into its working form: about 30 lines, and every concept in this post lives somewhere in it. Line numbers matter; I'll refer back to them throughout.

 1  export const meta = {
 2    name: 'audit-routes',
 3    description: 'Audit route handlers for missing auth checks',
 4    whenToUse: 'Run before release when route handlers changed',
 5  }
 6
 7  const dir = args?.dir ?? 'src/routes/'
 8
 9  const found = await agent(`List every .ts file under ${dir}.`, {
10    schema: { type: 'object', required: ['files'],
11      properties: { files: { type: 'array', items: { type: 'string' } } } },
12  })
13  log(`Auditing ${found.files.length} files under ${dir}`)
14
15  const audits = await pipeline(found.files, file =>
16    agent(`Audit ${file} for missing authentication checks.`, {
17      label: file,
18      schema: { type: 'object', required: ['file', 'findings'],
19        properties: {
20          file: { type: 'string' },
21          findings: { type: 'array', items: { type: 'object',
22            required: ['line', 'issue', 'severity'],
23            properties: { line: { type: 'integer' }, issue: { type: 'string' },
24                          severity: { enum: ['low', 'medium', 'high'] } } } },
25        } },
26    }))
27
28  const results = audits.filter(Boolean)
29  return { audited: results.length,
30           findings: results.flatMap(r => r.findings.map(f => ({ ...f, file: r.file }))) }

Three things changed since part one. The auditing agents now return typed findings instead of prose; every field the script touches downstream has a declared shape. The target directory arrives as a parameter instead of being hardcoded. And the script returns a structured summary rather than raw output. Still one discovery agent, still one auditor per file. What's new is that everything crossing the script boundary is typed: parameters in, findings out.

The rest of this post walks the file top to bottom: meta, agent(), schemas, parameters, the sandbox, and then the part that changes how you use the feature day to day, iterating on a script across runs.

The meta block: read, not run

Lines 1–5 look like configuration, and mostly are. name and description are required. whenToUse is optional and shows up in the workflow list. There's also a phases field for grouping agents in the progress view; single-phase scripts like this one don't need it, and multi-phase discipline is a part-three topic.

The rule that trips people up: meta must be a pure literal. No variables, no function calls, no spreads, no template strings. This fails before the run starts:

export const meta = { name: PREFIX + 'audit', ... }   // parse error

The reason is more interesting than the rule. When you launch a workflow, Claude Code shows an approval dialog (name, description, planned phases) before executing anything. The runtime gets that information by statically extracting the meta block, the same way a bundler reads package.json without running your app. Meta is the part of the script the runtime reads rather than runs, and a computed value would mean executing untrusted code just to render the approval prompt. Pure literal, no exceptions.

OBJECTION

“Meta is just documentation.” It's interface. name is the identity future invocations resolve; in part four it becomes a slash command, then a plugin namespace suffix. description is what a human reads before approving the run. Write both for someone who's never seen the script body, because that's exactly who the approval dialog is for.

agent(): the only way work happens

Every unit of actual work in a workflow is an agent() call. The script itself can't read files, run commands, or touch the network (more on that in the sandbox section), so anything that inspects or changes the world goes through an agent. The call takes a prompt and an options object, and resolves to whatever the agent produced.

The prompt is a contract. Subagents in a workflow are told their final output is the return value, so write prompts that ask for data. “List every .ts file under src/routes/” produces a file list. “Take a look at the routes directory and share your thoughts” produces an essay, and now your script is parsing an essay. If you wouldn't accept the output shape from a function, don't accept it from an agent.

Here's the full option surface. This post covers three; the rest get one line and a pointer, because they only make sense once concurrency is on the table:

OPTIONWHAT IT DOESCOVERED
labelNames the agent in the /workflows viewhere
schemaForces validated, structured outputnext section
phaseAssigns the agent to a progress grouphere, briefly; then part 3
model, effortRoute this agent to a different model or reasoning tierpart 3
isolation: 'worktree'Gives the agent its own git worktree for parallel editspart 3
agentTypeRuns a custom subagent type instead of the defaultpart 3

label earns its place on line 17. A 38-agent fan-out with no labels is 38 rows named after truncated prompts; labeled by filename, the progress view becomes a checklist. Label by the item being processed, always. phase does for groups what label does for individuals: it names the box an agent appears under. That's all you need until part three.

Then there's the failure model, the thing to internalize before writing anything real. agent() resolves to null in exactly two cases: you stopped the agent mid-run from the /workflows view, or it died on an unrecoverable API error after retries. It does not throw. Failure is a value here, and line 28, audits.filter(Boolean), is the reflex that follows. After any fan-out, filter before you touch the results, or the first dead agent takes down your flatMap thirty agents into an otherwise good run.

OBJECTION

“I'll wrap agent() in try/catch.” Backwards. agent() doesn't throw on agent failure. It returns null, and code that catches exceptions while never checking for null has error handling pointed at the wrong hazard. Exceptions do exist in this model, but they come from elsewhere: part five's workflow() call throws on a bad reference, and that contrast is deliberate.

Schemas: the load-bearing feature

Part one claimed the schema option is what lets code, not a model, drive the loop. Time to cash that in.

The mechanism takes three sentences. When you pass a JSON Schema, the subagent must deliver its answer through a structured-output tool instead of free text. Validation happens at the tool-call layer: output that doesn't match bounces back and the model retries, up to five attempts before the call gives up and resolves like a failure. Your script receives a validated object or null. It never receives a string to parse.

Now look at what the spine script does with that guarantee. Line 13 calls found.files.length: code doing arithmetic on model output. Line 24 declares severity as an enum, so findings.filter(f => f.severity === 'high') is safe today and on every future run. Line 29 flatMaps across nested structures guaranteed to exist because required says so. None of this survives contact with untyped output. A model that returns “I found 3 files: ...” one run and a markdown table the next gives your control flow nothing to stand on, and the whole premise of a workflow (the script decides what runs next) collapses back into prompt-and-pray.

So the schema isn't a convenience. It's the joint where model output becomes program state, and it repays design attention the way any public interface does.

Some hard-won guidance on that design. Keep schemas small and flat; every level of nesting multiplies the ways a retry can fail. Mark every field required, because optional fields invite the model to omit them and litter downstream code with existence checks. Use enums for anything code branches on: free-text severity gives you “High”, “high”, and “HIGH” across three files. And only ask for fields the script actually uses. Every extra field costs tokens on every call and retry surface on every validation.

One boundary to keep in view: schemas constrain shape, never truth. A hallucinated vulnerability with a valid line number and a well-formed severity sails through validation. Structure makes output processable. Whether it's correct is a different problem. That problem is verification, it's structural rather than syntactic, and it's the entire subject of part three.

args in, structured results out

A workflow script is a function. Its signature is the args global; its return value is what lands back in the session. Both ends repay the care you'd give a public API, because in part four that's literally what they become.

Line 7 shows the receiving end: const dir = args?.dir ?? 'src/routes/'. args arrives as structured data, so pass a list and you can call .map on it directly, no parsing. When the caller omits it, the global is undefined, which is what the ?. and ?? on line 7 handle. From the invoking side it reads like conversation: “run the audit on src/api/” and Claude passes {dir: 'src/api/'}.

Design the surface like a function signature. Few parameters. Defaults for all of them. And validate at the top: a throw on line 8 costs nothing, while a malformed input discovered twenty agents into a run costs twenty agents:

if (args?.files && !Array.isArray(args.files)) {
  throw new Error('files must be an array of paths')
}

The return side has one rule: return summaries, never transcripts. The return value is the only part of the run that enters Claude's context, and keeping that footprint small is the entire architectural point. Line 29 returns a count and a flattened findings list, maybe two kilobytes. Returning forty raw audit outputs would stuff a hundred kilobytes of intermediate state into the context the workflow existed to protect.

log(), back on line 13, is the third channel. It emits a progress line to the human watching /workflows: cheap narration, so use it generously at stage boundaries. It also has an honesty function. If your script caps, samples, or truncates anything, log what got dropped, because a run that silently audited 50 of 200 files reads as “audited everything” to whoever gets the report. Part three turns that into a named pattern.

The sandbox, explained by its purpose

Scripts run under restrictions that look arbitrary in a list and coherent from one sentence up. Everything traces to a single design commitment: a run must be journaled and deterministically replayable. Hold that, and every rule below derives.

First: plain JavaScript only. Type annotations fail to parse, since the runtime executes your script directly with no build step. import() fails before the run starts. If a stage needs a library, that work belongs in an agent's task, where the agent can install and run whatever it needs under the session's permission rules.

Second: no filesystem, no shell, no network from the script itself. Agents do all I/O; the script coordinates. This is separation of concerns with teeth. The litmus test when writing: any line that does something rather than deciding something probably belongs in an agent prompt. What the constraint buys you is an orchestrator whose every effect on the world is an inspectable agent call sitting in the /workflows view. There is no side channel to audit.

Third, the one that surprises everyone: Date.now(), Math.random(), and new Date() without arguments all throw.

Here's why. The runtime journals every agent() call and its result as the run progresses. When a run resumes (after a pause, a stop, or an edit), the runtime re-executes your script from the top and serves completed calls from the journal instead of spawning agents again. That replay only works if re-running the code deterministically reproduces the same sequence of calls. A branch on Date.now() might take a different path on replay than it did live, and now the journal describes a run that no longer exists. Determinism isn't a style preference here; it's the mechanism resume is built on.

The workarounds are all one-liners. Timestamps come in through args. Result stamping happens after the workflow returns, in the session. Variation across a fan-out derives from the array index. Awkward for about a minute, and then you stop noticing.

That journal earns its keep beyond resume. Each run writes journal.jsonl under its session directory, recording what every agent actually returned, which makes it the first place to look when a run produces something empty or strange. My debugging order used to be “assume an agent misbehaved, re-run everything.” Now it's “read the journal,” and about half the time the agents were fine and my filter on line 28 was the problem. Full replay semantics (the ordering rules, why stopping mid-fan-out costs more than it looks) are a deep dive I'm saving for after the main series.

OBJECTION

“The Date.now() ban means this is a toy.” Each constraint is the price of a guarantee, and you get to see the receipt. Banned nondeterminism buys resumable runs. No script I/O buys an orchestrator with no side channels. Pure-literal meta buys an approval dialog that can't be gamed by computed values. Loosen any of them and the corresponding guarantee goes with it. Temporal and every other replay-based system makes the same trade, for the same reason.

The authoring loop

Everything so far is reading knowledge. This section is the practice that makes it pay: you almost never write a workflow script from scratch, and you should almost never let one be regenerated from scratch either.

Start by describing, not writing. Give Claude the task (part one's on-ramps) and review what it wrote via the approval dialog's raw-script view. A first pass is usually 80% right, and the remaining 20% concentrates in exactly the places this post covered: a schema looser than it should be, a prompt that invites prose, a missing default on args.

Then find the artifact. Every run persists its script to a file under your session's directory, and Claude gets the path when the run starts, so just ask for it. Open the file. It looks like the spine script, because the spine script is one of these, cleaned up for print.

Fix it there. Tighten the enum, sharpen the discovery prompt, add the line-8 validation, then relaunch from the edited file. This is the part people miss: relaunching from the file preserves your working parts exactly, while re-describing the task hands Claude license to regenerate everything, including the three things you'd already gotten right. Scripts are code. Edit code; don't re-prompt it into existence.

Resume is what makes the loop cheap. Relaunch an edited script with resume and the runtime replays the journal up to your first changed call: everything before it returns instantly from cache, and only the edited call and what follows runs live. Concretely, fixing the return statement on a 40-agent run costs zero agents. Fixing the last stage costs one stage. The economics of iteration change completely when a tweak doesn't mean paying for the whole run again, and that's why polishing a script beats tolerating a mediocre one.

One habit ties the loop together: scout first. The work-list rarely needs a workflow to discover it. Ask in plain conversation, “which route files changed this sprint?” That's cheap, interactive, and you can eyeball the answer before committing agents to it. Then hand the vetted list to the workflow through args. The pattern from part one holds: you need the shape of the orchestration step before the script exists, and conversation is the cheapest place to find shape.

What to do next

Take a workflow Claude wrote for you, this week's or one you run today for the occasion, and open its persisted script. Find the loosest schema in it and tighten it: add required, turn a free-text field into an enum. Relaunch from the edited file with resume and watch the cached prefix fly by. That single exercise touches every mechanism in this post, and it costs almost nothing.

Where you now stand: you can read any single-phase script Claude generates, and you can see where it's weak. Untyped output, missing defaults, transcript-sized returns. The spine script also begs the question this post can't answer. The audit produced 60-odd findings across 38 files, every one shaped correctly, and some of them are wrong anyway. Deciding which (skeptic agents, judged in code, at scale) is orchestration structure. That's part three.

← Part 1: Your Agent Can Write Its Own Harness
Part 3: Pipelines, Barriers, and Verification: Orchestration Patterns That Actually Converge →

Written against Claude Code v2.1.239 and the Agent SDK's Workflow tool reference. The changelog tracks what's moved since.