Patterns & loops

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. There is no "loop" feature to configure either: a loop is just a while or for around an agent() call, with the platform supplying the durable, bounded shell around it. Each shape below links to a copyable example.

Choosing a pattern

Patterns spend real compute, so reach for one when a task is genuinely large, parallel, or adversarial; for an everyday task that one focused agent handles well, a single agent() call is the right tool.

PatternReach for it whenRelative cost
Classify and actTriage, intake, and intelligent routingOne classifier; routine branches are just code, so the cheap path stays cheap
Fan out and synthesizeA task splits into many pieces that must not cross-contaminateOne agent per piece, plus a synthesis step
Adversarial verificationOutput you can't afford to get wrongOne checker per claim, on the order of doubling the work; five verifiers is five times the tokens of one answer
Generate and filterYou want better than one agent's single best answerOne brainstormer per angle, plus a filter call
TournamentRanking, from a single winner to a ranked list of 1,000+One fresh agent per pairwise matchup
Loop until doneYou don't know how much work there isOpen-ended; bounded by a budget and a hard ceiling
Quarantine untrusted inputThe input is untrusted: public tickets, user reports, scraped pagesOne no-tools reader per item; trusted code does the acting

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
  // ...
}

Example: 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,
});

Example: 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);

The same split matters most when you loop toward a goal: an agent that decides its own work is done is the goal-drift failure mode in one sentence, so one agent produces, a separate agent grades, and only what survives the check counts. Verification re-reads the work once per item, so it costs real tokens, on the order of doubling the loop. That's the right trade on output you can't afford to get wrong, and skippable on low-stakes work. Keep the material compact, or batch several items into one checker call, when the cost grows.

Examples: adversarial-verify, loop-with-verify (a loop plus a separate checker that keeps only the findings it can confirm).

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,
});

Example: 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 to 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);

Example: 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
}

Example: 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.
// builtins: "none" is load-bearing: the default is "all", which would hand
// a reader bash, write, and http on the strength of a hostile ticket.
const summaries = await parallel(
  items.map((it) => () =>
    agent<Summary>(classify(it.content), { schema: SUMMARY, builtins: "none" })),
);

// 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. The tool-scoping dial itself (builtins, and what "read-only" and "none" contain) is covered in Equipping agents. Example: quarantine-triage.

Give a loop layered exits

A loop with no explicit stopping logic is the single most expensive mistake you can make: it runs until your budget is gone. Don't rely on one exit. Layer them, so a failure of any single one can't run the loop away.

  • A goal check. The loop ends when the work is actually done, confirmed by a separate verifier, not by the agent grading itself.
  • A hard iteration cap. A plain round < maxRounds bound, so a loop that never converges still terminates.
  • A budget. Set budget.max_usd (and max_compute_seconds) in workflow.jsonc. Breaching a cap pauses the run for your approve-or-stop decision; it never truncates silently, and a wait costs nothing against it.
  • No-progress detection.If a round adds nothing new, count it; bail after a few in a row. A loop that's confidently spinning in place is worse than one that stops.
workflow.jsonc
{
  "slug": "nightly-cleanup",
  "triggers": [{ "kind": "manual" }],
  "budget": { "max_usd": 5, "max_compute_seconds": 1800 }, // the runaway backstop
}

One long run, or many short ones

There are two ways to shape a recurring loop, and they are not interchangeable. The question is whether you're running one converging job or an open-ended cadence.

One long runMany short runs
WhenThe work converges and ends: comb this diff until no new issues turn up, drain this queue, hit this targetThe loop should run indefinitely (every night, forever): each cron tick is its own fresh run
Where state livesIn memory for free (your seen set, counters)In persistent workspace, or re-derived from the source of truth
What a crash costsThe run restarts from the top and context.attempt incrementsOne tick; a crash on one tick doesn't kill the schedule, and each run is independently observable, billable, and capped
Descriptor implicationsbudget caps the whole effortA cron trigger; workspace.persist to carry state; concurrency: serialso two ticks can't clobber it
workflow.jsonc
// Open-ended: a fresh run every night, each one bounded.
{
  "slug": "repo-maintainer",
  "triggers": [{ "kind": "cron", "expr": "0 3 * * *", "timezone": "America/Anchorage" }],
  "budget": { "max_usd": 5 },
  "workspace": { "persist": ["state"] },   // carry progress between nightly runs
  "concurrency": { "mode": "serial" },     // never let two ticks clobber that state
}

For a fixed cadence, the cron trigger in workflow.jsonc is all you need. Reach for workflows.schedule(slug, input, { cron })only when the schedule is computed at runtime, or when one workflow schedules another. It's the dynamic version of the same pipeline, not a different one.

A recurring loop has to keep its own record of what it has already done, or it repeats itself: turn on a persistent workspace (see Workspace & state) and pin concurrency to serial in the descriptor.