Parallel and loops
Control flow in a workflow is plain TypeScript, and two shapes come up in almost every one: agents side by side, such as three reviewers each looking for one kind of problem, and one agent checking another, round after round, until it approves. The first is wf.parallel, the second a for. They combine: several reviewers in one round is a parallel inside the loop.
Side by side
wf.parallel runs a function over a list of items, with a concurrency limit, shows the group in the progress view, and gives the results back in the order of the items:
const reviews = await wf.parallel(
LENSES,
async (lens): Promise<Review> => {
const reviewer = await wf.agents.open({
key: `review-${lens}`,
runtime: "codex",
});
const { outcome } = await reviewer.run({
prompt: lensPrompt(lens),
schema: FINDINGS,
});
return outcome.answered
? { lens, findings: outcome.value.findings }
: {
lens,
findings: [],
failed: `${outcome.kind}: ${outcome.reason}`,
};
},
{ label: "lenses", concurrency: 2 },
);Each item gets its own agent, keyed by its lens, and its own turn. Two run at a time; the third starts when one finishes. reviews has one entry per lens, in the order of LENSES, whichever finished first. Fanning in is plain code over that array:
const findings = reviews
.flatMap(({ lens, findings }) =>
findings.map((finding) => ({ lens, ...finding })),
)
.toSorted(
(a, b) => Number(b.severity === "high") - Number(a.severity === "high"),
);
const failed = reviews.flatMap(({ lens, failed }) =>
failed ? [{ lens, reason: failed }] : [],
);
return { findings, failed };The whole workflow is in examples/site/guide/parallel.ts, with its test beside it.
The options
wf.parallel(items, (item, index) => …, { concurrency?, label?, timeout? })concurrencyis how many items run at once. It defaults to all of them, and must be a positive integer. Each call has its own limit, so aparallelof 4 inside aparallelof 3 can have 12 agents working.labelnames the group in the run's progress view, where it shows aslenses 0/3 · 1 queuedwhile the first two work, thenlenses 1/3, with how many of its agents failed. An unlabelledparallelinside a labelled one counts as part of it.timeout,"20m"or aDate, bounds the whole group. When it passes,parallelrejects and drops what it had gathered; A group of agents has how to keep it.
The function gets each item and its index. An empty list resolves to [] at once.
When one fails
An agent that doesn't answer is not an error. Its turn ends with an outcome such as failed or timed-out, and run resolves with it. So the fan-out above keeps going when a reviewer fails: that lens comes back with no findings and a reason, the others with theirs.
A branch that throws is different. parallel is fail-fast:
return wf.parallel(LENSES, async (lens) => {
const reviewer = await wf.agents.open({
key: `review-${lens}`,
runtime: "codex",
});
const { outcome } = await reviewer.run({
prompt: lensPrompt(lens),
schema: FINDINGS,
});
if (!outcome.answered) throw new Error(`${lens}: ${outcome.kind}`);
return outcome.value.findings;
});When one branch throws, parallel cancels the turns still running in the others (they end cancelled), starts no more items, waits for those turns to end, and then rejects with that first error. The results of the branches that had finished are lost with it.
So choose per workflow. Return the outcome as data when the other agents' work is still worth having; throw when one missing answer makes the rest pointless. To keep going past a throw from your own code, such as opening an agent, catch it inside the branch and return it as data, as catalogue-review does: each failure becomes an entry in the result's failures.
Round after round
An implementer works, a reviewer on another model reads the change and answers with a typed verdict, and the implementer makes the changes it asked for, until the reviewer approves or the rounds run out:
const implementer = await wf.agents.open({
key: "implementer",
runtime: "claude",
});
const reviewer = await wf.agents.open({
key: "reviewer",
runtime: { harness: "codex", model: "gpt-6-luna", placement: "headless" },
});
let prompt = `Implement this task: ${task}`;
for (let round = 1; round <= 3; round++) {
const work = await implementer.run({ prompt, schema: DONE });
if (!work.outcome.answered)
return wf.stop(`implementer: ${work.outcome.reason}`);
const review = await reviewer.run({
prompt: "Review the change.",
schema: VERDICT,
});
if (!review.outcome.answered)
return wf.stop(`reviewer: ${review.outcome.reason}`);
if (review.outcome.value.approved)
return { approved: true, rounds: round };
prompt = `Make these changes:\n${review.outcome.value.changes.join("\n")}`;
}
return { approved: false, rounds: 3 };- Each agent keeps its session. The implementer's second turn continues from its first, so the prompt says only what is new: the changes asked for.
- The verdict is typed.
review.outcome.value.approvedis a boolean the engine checked againstVERDICT, so theifdecides on a value, not on prose. - Every turn ends with an outcome, and the loop looks at it before it reads a value.
- The loop has a bound. Three rounds, then it returns
approved: falseinstead of going on.
The rest of this section builds that loop out, in examples/site/guide/loops.ts and its test.
Who checks whom
Put the reviewer on another harness or model than the implementer, so it doesn't share the implementer's blind spots: above, claude implements and codex reviews. The reviewer reads the change in the working tree itself; the author's summary is context, not evidence.
- Keep the reviewer from fixing. Say so in its
instructions, or open it in a sandbox withoutwrite, where it sees the working directory read-only. - Keep the verdict small. A boolean and a list of changes is all the loop needs; A typed answer has how the agent hands it back.
- Several reviewers are a
parallelinside the round, one agent per lens, and the round approves when they all do. - A check that needs no agent, such as routing a change before anyone works on it, can go to a decision model.
Branch on the outcome
outcome.answered is enough when every non-answer ends the loop the same way. When one deserves another try, branch on outcome.kind. Each round starts with the implementer's turn:
let work = await implementer.run({
prompt,
schema: DONE,
timeout: TURN,
});
const closed =
work.outcome.kind === "failed" || work.outcome.kind === "timed-out";
const login =
work.outcome.kind === "failed" && work.outcome.login !== undefined;
if (closed && !login && !replaced) {
// The agent is closed. Once, a new one under its own key takes over.
replaced = true;
implementer = await wf.agents.open({
key: "implementer-2",
runtime: "claude",
});
const handover = [
`Take over from an agent that stopped. The task: ${task}`,
"Its change so far is in the working tree.",
].join("\n");
work = await implementer.run({
prompt: round === 1 ? handover : `${handover}\n${prompt}`,
schema: DONE,
timeout: TURN,
});
}
if (!work.outcome.answered) {
return wf.stop(
`implementer ${work.outcome.kind}: ${work.outcome.reason}`,
);
}A failed or timed-out turn closes its agent, so the replacement is opened under a key of its own, implementer-2, and starts with no session: its prompt gives it the task and the changes still asked for. A missing login fails every agent on that harness alike, so the loop stops on it rather than open another. Any other non-answer, and a second failure, stops the run with the kind and the reason.
What each kind leaves of the agent is on Agents. How the loop ends is yours: return a value that says what happened, return wf.stop(reason) so the run can be continued, or let an error throw and fail it (Stopping with a reason).
Bound the loop
const ROUNDS = 3;
const TURN = "20m";- Rounds, in code. A reviewer can always find something more.
ROUNDSends the loop, and the result says it wasn't approved and what is still open, for a person or the next workflow to pick up. Never write the loop aswhile (true). - Time, per turn.
timeoutends a turntimed-outwhen it passes, andawf run --timeoutbounds the whole run: Timeouts and nudges.
Without either, a turn whose agent stops without answering still ends: the engine nudges it, and the turn ends unanswered if no answer comes. What nothing stops is a loop that keeps getting answers.
In a run with stages, the whole loop goes inside one stage. A continue then redoes the loop from its first round.
Carry feedback between rounds
The rest of the round is the review, and what the next round is told:
const review = await reviewer.run({
prompt:
round === 1
? `Review the change. Its author says: ${work.outcome.value.summary}`
: `The author says it made your changes: ${work.outcome.value.summary}\n` +
"Check each one, then the rest of the change.",
schema: VERDICT,
timeout: TURN,
});
if (!review.outcome.answered) {
return wf.stop(
`reviewer ${review.outcome.kind}: ${review.outcome.reason}`,
);
}
if (review.outcome.value.approved)
return { approved: true, rounds: round };
open = review.outcome.value.changes;
prompt = `Make these changes:\n${open.join("\n")}`;- Send only what is new. The implementer's session holds the task and its own earlier work, so a round's prompt is the changes, not the history.
- Ask the reviewer about its own changes. It keeps its session too, so from the second round it is told what the author says it did and asked to check each change, then the rest.
- Keep what matters in variables.
promptandopenlive in the workflow, not in an agent, so they survive a replaced agent, andopenends up in the result when the rounds run out. - Long loops grow the context. Between rounds,
agent.compact({ prompt })shrinks it with the harness's own compaction: Agents.
Catches
- Give each branch its own agent key. Opening a key that is already open returns the same agent, and an agent takes one turn at a time, so branches that share one wait for each other.
- Await what a branch starts.
parallelresolves only once every turn started inside it has settled. Work started from a branch after itsparallelhas resolved is refused. - A
parallelgoes inside a stage, not stages inside aparallel. A run has one stage open at a time, so parallel branches can't each enter one (Runs). Promise.allworks too. It has no limit, no deadline and no progress line, and when one promise rejects, the others keep running.- A non-answer is not an exception. Reading
outcome.valuebefore checking the kind doesn't type-check. unansweredcomes after a nudge. The engine already asked the agent for its answer, so asking again in the same session is a choice, not a retry the engine skipped.- A replacement agent knows nothing. Its prompt and the working tree are all it has.
Testing it
A test scripts each agent by key, so parallel branches are read by key, not by position; to check item order, the parallel sample's test makes the first item's answer wait for the last's. A scripted noAnswer.failed(), noAnswer.timedOut() or noAnswer.silent() drives each branch of a loop for free; the loop sample's test covers approval, the bound, a replacement and a stop. Testing has the rest.
See also
parallel, Outcomes and Sessions in the workflow API.- Review loop, the pattern: a reviewer that asks for each fix by message.
examples/catalogue-review: lenses chosen by the files a diff touches, each finding verified by its own agent, aparallelafter aparallel.