Loops
The hard part of agent work is rarely a single prompt. It's keeping an agent on a goal across many steps: finding the next piece of work, doing it, checking the result, and going again until the job is actually finished. A loop is how you hand that whole cycle to the workflow instead of prompting each step by hand. You define the goal once, and the run iterates until it's reached.
Boardwalk doesn't have a "loop" feature to configure, because a loop is just control flow in your workflow program: a while or for around an agent() call. What the platform gives you is the durable, bounded shell around that loop: triggers to start it, budget caps and verifiers to stop it, persistent state to carry between runs, and human gates for the moments that need a person.
What a loop is
Every agent loop is some version of the same cycle: look at the current state, decide the next action, take it, check the result, and repeat until a goal is met. The value is that you stop being the thing in the middle. Rather than re-prompting an agent until it gets there, you write the goal and the stopping condition once and let the run drive itself.
A loop is just code
The smallest useful loop keeps working until the work runs dry instead of for a fixed number of passes. Track what you've seen in plain code, ask the agent only for what's new, and stop after a couple of empty rounds.
const seen = new Set<string>();
let dry = 0;
let round = 0;
while (dry < 2 && round < maxRounds) { // two layered exits, see below
round += 1;
const fresh = (await findWork(seen)).filter((w) => !seen.has(w.id));
fresh.forEach((w) => seen.add(w.id));
dry = fresh.length === 0 ? dry + 1 : 0; // stop after 2 empty rounds, not a fixed count
}That's the entire idea. Everything below is about making a loop you can leave running: giving it exits, choosing how it's shaped, checking its output, and carrying state between runs.
Always give a loop 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 < maxRoundsbound, so a loop that never converges still terminates. - A budget. Set
budget.max_usd(andmax_duration_seconds) inmeta. Breaching a budget terminates the run; it doesn't truncate silently. - 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.
export const meta = {
slug: "nightly-cleanup",
triggers: [{ kind: "manual" }],
budget: { max_usd: 5, max_duration_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.
A bounded goal loop is one run. The whole effort is a single run that iterates and then stops. State lives in memory for free (your seen set, counters). Reach for this when the work converges and ends: comb this diff until no new issues turn up, drain this queue, hit this target. This is the shape in the example above.
An open-ended cadence is many runs.When the loop should run indefinitely (every night, forever), don't write one run that never ends. Add a cron triggerand let each tick be its own fresh run. Each run is independently observable, billable, and capped, and a crash on one tick doesn't kill the schedule. State carries between runs in persistent workspace or is re-derived from the source of truth.
// Open-ended: a fresh run every night, each one bounded.
export const meta = {
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 meta 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.
Verify what the loop produces
An agent that decides its own work is done is the goal-drift failure mode in one sentence. The biggest reliability win when you loop toward a goal is to split the maker from the checker: one agent produces, a separate agent grades, prompted to refute rather than to agree. Only what survives the check counts. (This is adversarial verification applied to a loop.)
// Loop 1 found candidates; Loop 2 keeps only the ones a strict, separate agent confirms.
const verdicts = await parallel(
candidates.map((c) => () =>
agent<Verdict>(`Be skeptical. Is this a REAL defect, not a nice-to-have? "${c.title}"`, {
schema: VERDICT,
})),
);
const confirmed = candidates.filter((_, i) => verdicts[i].real);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.
Remember across runs
A model remembers nothing between runs, so a recurring loop has to keep its own record of what it has already done, or it repeats itself. Turn on a persistent workspace and write progress to a file the next run reads back.
export const meta = {
slug: "backlog-groomer",
triggers: [{ kind: "cron", expr: "0 * * * *" }],
workspace: { persist: ["state"] }, // this dir survives across runs
concurrency: { mode: "serial" }, // persisted state is last-writer-wins
};
// Your program runs IN the workspace, so a relative path is the workspace.
const donePath = "state/done.json";
const done: string[] = existsSync(donePath) ? JSON.parse(readFileSync(donePath, "utf8")) : [];Name the directories that carry, rather than reaching for persist: true, which keeps the wholeworkspace — a loop that clones a repo would ship all of it to storage on every tick. A loop's compounding state is usually small; say which part it is. Each environmentkeeps its own workspace, so staging never reads production's progress.
Whenever a loop shares persisted state, pin concurrency to serial so two runs can't overwrite each other. For work that must never be redone on a restart, put it behind workflows.call, which re-attaches to the existing result instead of running again. If the state a loop built up ever goes wrong, clear it with boardwalk workspace reset <workflow> — the workflow, its triggers, and its history stay.
Don't pay to wait
Loops spend a lot of time waiting: between polls, for a build, for a person to approve. Never burn a machine on a busy-wait. A long sleepreleases the run's machine and re-acquires one when it wakes, and a human gatedoes the same while it waits for an answer. Idle time isn't billed, so a loop can park overnight on an approval or poll every ten minutes for a week and cost nothing in between.
for (;;) {
if (await isHealthy(url)) break;
await sleep("5m"); // releases the machine; resumes in 5 minutes, idle time free
}
const ok = await humanInput({
prompt: "Open the PR?",
input: { kind: "choice", options: ["approve", "skip"] },
}); // the run suspends here until a person answers; not billed while it waitsStart from an example
Two copyable templates put the whole thing together:
- loop-until-done — the bounded goal loop: keep spawning finders until a couple of rounds turn up nothing new, with a budget and a round ceiling.
- loop-with-verify — the same loop plus a separate checker that keeps only the findings it can confirm. The output shows what was verified and what was thrown out, and why.
From there, give the finder real eyes with a tools or MCP connection, swap the stop condition for one that fits your goal, and add a trigger to run it on a schedule. The loop is yours; the exits are non-negotiable.