Patterns
A single agent working a long, massively parallel, or adversarial task tends to drift: it declares a half-finished job done, it trusts its own answers when you ask it to check them, and it loses the original goal across a long context. The fix is structural: spawn separate agents, each with its own clean context window and one narrow job, and let deterministic code hold the plan together.
These are the recurring shapes for doing that. You compose them in an ordinary program with the primitives from Writing workflows: agent(), parallel(), workflows.call(), and plain control flow. Each one below links to a copyable template.
Classify and act
Use a classifier agent to label the task, then route in code to a handler tuned for that label. Some branches spend a tailored agent; the routine ones are just code, so the cheap path stays cheap. Good for triage, intake, and intelligent routing.
const { category } = await agent<Label>(`Classify: ${message}`, { schema: LABEL });
switch (category) {
case "bug": return draftBugAck(message); // a tailored agent
case "spam": return { action: "drop" }; // routine: no model at all
// ...
}Template: classify-and-act.
Fan out and synthesize
Split a task into many smaller pieces, run an agent on each in its own context so they don't cross-contaminate, then merge the structured results in a final step. The synthesize step is a barrier: it waits for everyone, then combines.
const drafts = await parallel(
angles.map((angle) => () => agent(`${task}\n\nStyle: ${angle}`)),
);
const best = await agent<Verdict>(`Pick the best of these drafts:\n${drafts.join("\n---\n")}`, {
schema: VERDICT,
});Template: fan-out-judge.
Adversarial verification
For each thing an agent produces, run a separateagent to check it, prompted to refute it, not to agree. A claim survives only if its own skeptic can't knock it down. Because the writer never grades its own work, self-preferential bias has nowhere to hide. This is how you check work before it reaches you.
const verdicts = await parallel(
claims.map((claim) => () =>
agent<Verdict>(`Try to REFUTE this claim; assume it is wrong: "${claim}"`, {
schema: VERDICT,
})),
);
const unsupported = verdicts.filter((v) => !v.supported);Template: adversarial-verify.
Generate and filter
Generate many ideas from different angles, dedupe the pile in plain code, then keep only the highest-quality survivors by a rubric. Diverge hard, narrow hard: better than asking one agent for its single best answer.
const ideas = (await parallel(angles.map((a) => () => brainstorm(a)))).flat();
const unique = dedupe(ideas); // plain code, never trust the model not to repeat itself
const best = await agent<Picks>(`Keep the top 3 by this rubric:\n${unique.join("\n")}`, {
schema: PICKS,
});Template: generate-and-filter.
Tournament
Rank by pairwise comparison instead of absolute scoring, asking which of these two is better?stays far steadier than asking a model to score 1–10. A deterministic sort holds the bracket; one fresh agent judges each matchup, so only two items are ever in context. Take the top item for a single winner, or the whole order for a ranked list of 1,000+.
// One fresh agent per matchup; a deterministic merge sort holds the running order.
async function aBeatsB(a: string, b: string): Promise<boolean> {
const { winner } = await agent<Compare>(
`Which better fits "${criterion}"?\nA: ${a}\nB: ${b}`,
{ schema: COMPARE },
);
return winner === "a";
}
const ranked = await mergeSort(items, aBeatsB);Template: tournament.
Loop until done
When you don't know how much work there is (bugs, edge cases, missing tickets), loop spawning agents until a stop condition is met instead of a fixed number of passes. Dedupe what's new against everything seen and stop after a couple of empty rounds. A budget and a hard ceiling keep it bounded.
let dry = 0;
while (dry < 2) {
const fresh = (await findIssues(seen)).filter((f) => !seen.has(f.title));
fresh.forEach((f) => seen.add(f.title));
dry = fresh.length === 0 ? dry + 1 : 0; // stop after 2 empty rounds, not a fixed count
}Template: loop-until-done.
Quarantine untrusted input
When the input is untrusted (public tickets, user reports, scraped pages), the agents that read it must hold no privileges. Reader agents classify the raw content with no tools and no secrets and emit only a structured summary; deterministic, trusted code (the only place secrets.get()lives) then acts on those summaries, never the raw text. Prompt injection can't cross the boundary, because the side that reads untrusted content can do nothing but describe it.
// Reader agents see untrusted content but hold NO tools and NO secrets.
const summaries = await parallel(
items.map((it) => () => agent<Summary>(classify(it.content), { schema: SUMMARY })),
);
// Trusted code acts on summaries only, never the raw content. secrets.get() lives here.
for (const s of summaries) await act(s);This is Boardwalk's security model in miniature: the agent() leaf is the untrusted edge, the program around it is trusted. Template: quarantine-triage.
When to reach for one
Patterns spend real compute: a panel of five verifiers is five times the tokens of one answer. Reach for them when a task is genuinely large, parallel, or adversarial: a codebase-wide audit, a migration across thousands of files, research you can't afford to get wrong. For an everyday task that one focused agent handles well, a single agent() call is the right tool.