BUILD · PHASE 12 · LESSON 79

`parallel()` vs `pipeline()`

They look like two speeds of one thing. They are two shapes of input, and both

Type
Build
Prerequisites
Lesson 78 (Dynamic Workflows)
Time
~40 minutes

Learning Objectives

  • Choose between agent(), pipeline(), and parallel() from the shape of the work
  • Predict what a failed or stopped agent does to the returned array, per call shape
  • Make a result slot nameable with label instead of matching on position alone
  • State what the runtime does not give you, and what you would build instead

The Problem

A migration workflow fanned out over 412 files and returned an array of results. Sixteen of them were null.

The user could not say which sixteen files had failed. The script had built its tasks by mapping over a list, and the returned array had one entry per file — in the right order, in fact. But the entries were bare strings from the agents, and twenty of the strings were empty, and the retry note from one agent had been logged by Claude Code rather than returned, and the mapping from slot back to file lived in a variable the script had thrown away.

Nobody had thought about what a slot in that array was supposed to mean, because the choice had been made by reaching for the combinator that sounded right: parallel(), because "parallel" was the word in the feature's name.

Two things would have fixed it in one line each. Neither is about parallelism.


The Concept

Three call shapes, and the difference between the first two is small — much smaller than the names suggest.

CallTakesReturnsWhen you reach for it
agent(prompt, opts)One taskThat agent's result, or nullOne job, done once
pipeline(list, fn)A list, and a function from item → taskOne entry per item, in the list's orderThe item varies, the job is the same
parallel(tasks)A set of tasksOne entry per taskThe job varies, and there is no list
flowchart LR
    subgraph P["pipeline(files, f => agent(...))"]
      L1["file A"] --> T1["task A"]
      L2["file B"] --> T2["task B"]
      L3["file C"] --> T3["task C"]
      T1 --> RA["[ rA, rB, rC ]"]
      T2 --> RA
      T3 --> RA
    end
    subgraph Q["parallel([agent(p1), agent(p2)])"]
      S1["check authors"] --> TB["[ r1, r2 ]"]
      S2["check licences"] --> TB
    end
Mermaid source — renders as a diagram where mermaid.js is available

What actually differs

pipeline() is for one job across many items. The file list is the input; the per-file task is the function. Change the item, and you have asked a different question of the same machinery.

parallel() is for many jobs at once. A security review, a licence audit, and a dependency check are not three iterations of anything. They are three tasks, and there is no list behind them.

That is it. That is the whole distinction the docs draw.

What does not differ, and this is the trap

Both combinators wait for all of them. Neither is "give me whichever finishes first." If you need a race, you do not have a combinator for it in the documented surface — check the script-writing reference for your version before assuming one exists, and otherwise build it from agent() calls yourself.

Both combinators are subject to the same runtime concurrency cap — sixteen agents by default, fewer when the machine has fewer CPUs. The cap belongs to the runtime, not to the combinator you picked. If you believe pipeline() serialises and parallel() does not, measure it in your own session before you design around it; nothing in the docs promises you that difference.

Both put a null in the slot of an agent that was stopped, stalled out, or hit an unrecoverable error. The contrast that actually changes your code is not between the two combinators — it is between either of them and a bare agent():

Where the agent call sitsWhat a lost agent does
Inside pipeline() or parallel()The run carries on, with null in that slot
Awaited directlyThe whole run ends with the error

So the structure question is bigger than the combinator question. Is this agent one of many, or is it load-bearing? Put load-bearing calls outside the fan-out, where failing loudly is what you want.

Make a slot nameable

An agent() call takes a label. The docs' own example passes the file being worked on:

const audits = await pipeline(found.files, file =>
  agent(`Audit ${file} for missing authentication checks.`, { label: file }),
)

Without it, a slot is a position. With it, a slot is a name — and since sixteen nulls in a list of anonymous strings is exactly the failure above, this is not decoration. It is the difference between a result you can act on and a result you have to re-derive.

The reframe: parallel() and pipeline() are not two speeds of the same

thing — they are two shapes of input, and both wait for everything, so the

choice is about what varies and what a result slot is allowed to mean.


Build It

You need a recent Claude Code with workflows available (Lesson 78 covers turning them on). If your plan does not have them, read the steps and run the equivalent by hand; the shapes are the lesson.

1. One job, one call

Start with the boring shape. Ask for a workflow over a single file:

use a workflow to summarize src/routes/auth.ts and list its exported symbols

Read the generated script. It should be one agent() call and a return.

✅ You pass when you can say what the script would do if that one agent returned null, without running it.

2. The item varies

Now the pipeline shape. Point it at a list:

use a workflow to check every file under src/routes/ for missing auth checks,
one agent per file

Find the pipeline() line. Note what the function takes — one item — and what it returns for that item.

✅ You pass when the length of the input list and the length of the returned array are the same number, and you can point at the line that made them match.

3. The job varies

Now the other shape. Three checks that share nothing but the repository:

use a workflow to audit this repo for (1) unused exports, (2) missing licence
headers, and (3) dependencies with no lockfile entry

This should come back as a parallel() — or as three awaited agent() calls, if Claude judged them load-bearing. Both are defensible. What is not defensible is three separate pipeline() calls over the same file list, one per check, each called in sequence.

✅ You pass when you can name, from the script, whether the three audits run concurrently or in sequence, and why that choice was right for this task.

4. Add a label, and prove it matters

Take the pipeline from step 2. Add { label: file } to the agent call and rerun. Then read the progress view with /workflows.

✅ You pass when each agent in the view is identified by the file it is working on rather than by an index, and you can say which line in the results array the label does not change.

5. Break it in both places

This is the step that pays for the lesson. Find a fan-out and stop one agent mid-run by selecting it in /workflows and pressing x. Let the run finish.

Show me the array the script returned, including any null entries.

Now take a single agent() call that the script awaits directly — the kind at the top of the script, not inside a combinator — and stop that one the same way.

✅ You pass when you have one run that finished with a null in a slot and one run that ended with an error, and you can say which structural difference caused which outcome.

6. Find the line that would have lost the file names

Go back to step 2's script and look for the join between the input list and the result array. If it is .filter(Boolean), you have found the line from the opening story.

✅ You pass when you can write down the index of a dropped entry and the file that was at that index — and say what a program reading the filtered array afterwards believes about the two.

Workflows ship weekly. Verified against

the workflows page on 2026-10-15.

The call shapes and the 4,096-item cap are current as of that date; the

runtime's concurrency number is Lesson 81. Check

https://code.claude.com/docs/llms.txt before quoting either.


Use It

SituationCallWhy
One job, onceagent() awaited directlyFailures stop the run, which is what you want for a prerequisite step
One job, many itemspipeline(list, fn)The item is the axis of variation
Many jobs, one repoparallel(tasks)There is no shared list
You need the file names back{ label: ... } on each agentA position is not a name
One step must succeed or the run is pointlessAwait it outside the fan-outA null slot hides a broken prerequisite
One step is best-effortPut it inside the fan-outA null slot is a hole you can detect
You want the first successBuild it yourself from agent()Neither combinator returns early
Long listSplit it4,096 items per call is a hard limit, and it is rejected rather than truncated

Exercises

  1. Take a task you have run as a workflow and rewrite its orchestration twice: once as pipeline() where you had parallel(). Run both. ✅ You pass when you can describe one thing that changed and one thing that did not.
  1. Write a workflow where a prerequisite agent() is awaited directly, then move it inside a pipeline() and stop it mid-run in both versions. ✅ You pass when one version dies and one continues, and your note names the structural difference rather than the tool you clicked.
  1. Build the join by hand, without .filter(Boolean). Produce a list of "<file>: <result or MISSING>" lines. ✅ You pass when a stopped agent shows up as MISSING against the correct file name, and you can say what the filtered version would have printed instead.
  1. You need "run these five checks and tell me as soon as any one of them succeeds." Describe what you would write, and why the answer is not a combinator. ✅ You pass when your answer names the runtime's wait-for-all behaviour and says where you would put the early return.

Key Terms

TermWhat people sayWhat it actually means
pipeline()"the parallel loop"A list plus a function. One agent per item, results in list order
parallel()"run them at once"A set of tasks. No list behind them. Waits for all
agent()"one subagent"One task. Resolves to its result, or null if it was lost
awaited directly"outside the fan-out"The run ends if that agent fails. The opposite of a null slot
null in a slot"it failed"The placeholder for a stopped, stalled-out, or unrecoverable agent. The slot stays where it was
label"a nicety"The only name a fan-out slot has. Without it you have an index
wait-for-all"how it works"Both combinators return after every agent finishes. There is no early return
4,096"a big number"The items-per-call cap. A longer list is rejected, not trimmed

What this lesson skipped

Ordering and identity. This lesson says a slot keeps its position. Why that is the only thing you can trust, what resume does to it, and how a filtered array misleads you is Lesson 80 — and it is the reason the opening story happened.

The runtime's numbers. Sixteen concurrent agents by default, 1,000 agents per run, stall windows, restarts, the prompt-cache stagger in a fan-out. Lesson 81.

Structured output. Passing a schema to an agent() call makes it return validated JSON instead of prose, with a bounded retry count. Mentioned here because the opening story's empty strings are a schema problem wearing an ordering costume. Details are on the workflows page rather than in this lesson.

Cost of a large fan-out. 400 agents is 400 times the tokens. Phase 14 instruments that; the size guideline in Lesson 78 is the crude brake.

Check yourself

5 questions. Answer before you reveal — recognising the right option is not the same as being able to produce it.

  1. A script awaits a single agent() call directly at the top — a prerequisite that must succeed before the fan-out. You stop that agent mid-run from /workflows. What happens to the run?

    1. It ends with the error, because an awaited agent that is lost is not a slot in a fan-out
    2. It carries on, with null in the place of that agent's result
    3. It restarts the agent; stopping counts as a stall
    4. It pauses until you resume, then continues from the next step
    Show answer

    A. It ends with the error, because an awaited agent that is lost is not a slot in a fan-out

    The failure behaviour depends on where the call sits, not on which combinator is nearby: inside parallel() or pipeline() the run continues with null in the slot, but a directly awaited call ends the run with the error. The null answer is tempting because it is the documented behaviour one line lower in the script — it belongs to fan-out members. A manual stop is not a stall, so no automatic restart follows; and the runtime does not quietly continue past a load-bearing failure.

  2. A pipeline() over 412 files returned 412 entries, 16 of them null. A colleague asks which files failed. What do you do?

    1. Match each null's index back to the input list, and add label so the next run names them for you
    2. Read the error message stored on each null entry
    3. Count the nulls and re-run the whole pipeline, since you cannot tell which files they were
    4. Sort the array so the nulls group together, then re-run those positions
    Show answer

    A. Match each null's index back to the input list, and add label so the next run names them for you

    A null is a placeholder that keeps its position in the array, so its index still points at the file that produced it — and adding a label to each agent() call makes the mapping readable in /workflows next time. The error-message answer is the natural instinct and is wrong: the runtime substitutes a bare null in place of the agent's result, and any reason for the loss is reported around the run rather than attached to the slot. Re-running everything or reordering the array discards the positional information that was the only thing telling you which files failed.

  3. Which statement about parallel() and pipeline() is actually supported by the documentation?

    1. Both wait for all of their agents, and neither returns as soon as one finishes
    2. pipeline() bounds concurrency while parallel() starts every task at once
    3. pipeline() runs items one after another while parallel() runs them concurrently
    4. parallel() returns results as agents finish, in completion order
    Show answer

    A. Both wait for all of their agents, and neither returns as soon as one finishes

    The docs describe parallel() as running a set of tasks at the same time and waiting for all of them, and pipeline() as running one agent per item; both are subject to the same runtime concurrency cap. Everything else here is a plausible invention. The two 'one is parallel and one is not' answers are the most seductive, because the names imply a scheduling difference the docs never draw, and the failure-handling section applies identically to both. Completion-order results are the opposite of the documented behaviour and are the subject of Lesson 80.

  4. An audit agent can legitimately find nothing and report an empty result. You must be able to tell that apart from an agent that never came back. What do you build?

    1. Give the agent a schema so 'found nothing' is a well-formed empty list, and check for null separately as 'the agent was lost'
    2. Treat an empty string and a null as the same failure, since both mean no finding
    3. Add .filter(Boolean) so the empty ones drop out and only real findings remain
    4. Ask a second verifier agent to decide whether the first one really ran
    Show answer

    A. Give the agent a schema so 'found nothing' is a well-formed empty list, and check for null separately as 'the agent was lost'

    A well-formed empty result and a null slot are different facts, and conflating them is the bug from the opening story. Passing a schema makes the first one structured — the docs describe agent() returning validated JSON against the shape, and failing the call after bounded retries when it cannot. The merge-both-into-failure answer destroys exactly the distinction you need. .filter(Boolean) removes the evidence and shifts every later slot, and a verifier cannot see an agent that never returned.

  5. A list of 5,000 files is passed to one pipeline() call. What does the runtime do?

    1. Rejects the call with an error, rather than quietly running part of the list
    2. Runs the first 4,096 and reports the count it skipped
    3. Splits the list into two batches automatically and runs both
    4. Runs all 5,000, just more slowly than usual
    Show answer

    A. Rejects the call with an error, rather than quietly running part of the list

    There is a hard cap on the number of items in a single parallel() or pipeline() call, and the runtime rejects a longer list with an error. The docs give the reason explicitly: a silent cap would drop part of the workload without telling the script. That makes the truncation answer the most dangerous wrong one — it assumes the failure mode is data loss the script can detect, when the actual failure is a loud rejection. Automatic batching and running-anyway belong to neither behaviour.