Skip to content

Repeating work

There is no loop keyword. Which shape of repetition you want depends on whether you know the list up front, whether the iterations are independent, and how long the whole thing runs.

You know You want Reach for
The combinations, as you write One job per combination matrix:
The list, at run time All of it, in parallel fan_out
The whole shape, at run time Work you couldn’t name up front A computed graph
Only a stopping condition One pass, then decide, nesting at most 20 deep A graph that calls itself
Neither - it’s a conversation A turn at a time A script while loop
Nothing; just “again later” A fresh run on a cadence A schedule

Combinations you know while writing: matrix:

Section titled “Combinations you know while writing: matrix:”

In a pipeline, when the axes are fixed as you write, declare them and the job runs once per combination. exclude: carves combinations out of the full grid:

test:
matrix:
values:
db: [postgres, sqlite, mysql]
shard: ["1", "2"]
exclude:
- { db: sqlite, shard: "2" }
- { db: mysql }
steps:
- name: test
run: ./run-tests.sh --db ${{ matrix.db }} --shard ${{ matrix.shard }}

Six combinations become three. An exclude: entry is a partial combination: the dimensions it names must all match, and the ones it omits are wildcards, so { db: mysql } drops the whole mysql row. Values match as written, so a quoted "2" in values: stays quoted in exclude:. A misspelled dimension or value, an empty entry, or an exclude list that removes every combination fails the compile instead of quietly running the whole grid.

It stays one job, so depends_on: [test] is one edge that waits for all of them. See matrix: for max_parallel:, fail_fast:, rate: and how retry: applies per combination.

The axes have to be literal lists. A ${{ … }} hole in matrix.values is refused, because a run’s job set is fixed when it starts. For axes known only at run time, use fan_out if you have a list, or a computed graph if you don’t yet have a shape.

The list arrives from an upstream step and every item is independent: build ten targets, notify twelve people, resize a directory of images. fan_out runs the same body once per item:

- id: build
type: fan_out
config:
items: { $expr: nodes.targets.output.list }
max_parallel: 4
body:
type: shell
config:
run: { $template: "make {{ item }}" }

The body sees each element as item and its position as index. The step’s output is the array of body outputs in item order, whatever order they finished in.

A hundred items is the ceiling, for fan_out and matrix: alike. A literal list over that is refused by spool workflow check and on save; a computed items fails the step when it resolves. A workflow body doesn’t lift it, since it’s one effect per item either way. Past a hundred, fan out over batches, or drive the list from a script loop: spool activity submit has no such cap.

max_parallel caps how many run at once. on_error decides what a failing item does to the rest: halt (the default) fails the step once the in-flight window settles; continue leaves failed items as null slots and lets the step succeed. The run-scoped state cell _spool/graph-fan-out-items records why each slot is null: per fan-out, each item’s index, status and error. Read it with spool state get _spool/graph-fan-out-items --run <id>.

max_parallel bounds how many items are in flight. A service that answers 429 counts requests per minute, and four in flight is still hundreds an hour when each takes a second. rate bounds that:

- id: judge
type: fan_out
config:
items: { $expr: nodes.rows.output.list }
rate: { limit: 20, period: 1m }
max_parallel: 4
body:
type: http
config: { url: "https://api.example.com/score" }

The first twenty items go out, four at a time. Once that window has settled the step waits one full period, then sends the next twenty. A max_parallel larger than limit does nothing, since a window only holds limit items.

In a pipeline the same knob sits on matrix: as rate:. Here a five-shard matrix runs at two per six seconds, as the timestamps the shards print show:

spool run on a pipeline whose matrix has rate limit 2 per 6s: shards 1 and 2 start together, then 3 and 4 six seconds later, then 5.

The last window gets no wait after it, so a list no longer than limit never waits at all.

The wait between windows is a durable timer: the run is asleep and holds no agent, and a restart mid-wait resumes where it was instead of sending a window again. Eighty items at twenty a minute cost three one-minute waits on top of the time the windows take.

The budget is per step, per run: two runs fanning out to the same API each get their own twenty. When the ceiling has to hold across runs, use a top-level throttle: on the pipeline or graph, which counts run starts across the project. Pacing doesn’t raise the hundred-item ceiling either.

One activity per item, or one run per item

Section titled “One activity per item, or one run per item”

The body is either an activity kind (http, shell, code) or workflow, depending on what one item’s work is. An activity body is one cheap call per item, and everything lands in this run’s history. A workflow body starts a child run per item:

- id: release
type: fan_out
config:
items: { $expr: nodes.plan.output.services }
max_parallel: 4
body:
type: workflow
config:
graph: { name: service-release }
input: { service: { $expr: item } }

Use it when one item’s work is more than one step, or when you want each item inspectable on its own: a child has its own page, history and rerun-from-step (see One child per item). The output is still the list of per-item results, in item order.

The per-item fields (retry, requires, agent, timeout) describe how one activity runs, so a workflow body refuses them; set them inside the child, on the step that needs them.

A body that waits (wait, approval) is refused either way. One item’s approval is the same question as the next one’s; put the gate in the graph the workflow body names.

fan_out repeats one body over a list. When the work isn’t uniform (this target needs a build and a sign, that one only a build, a third waits for an approval) there is no single body to repeat. Build only what changed does this for a monorepo. Compute the graph instead: one step emits a graph document, and a workflow step runs it.

- id: build_all
type: workflow
config:
definition: { $expr: nodes.generate.output.graph }

The child is a real run with its own history and retries, validated when it starts. The cost is coarse sequencing, since the parent waits for the whole child. Composition shows a complete one, including the step that generates the document.

A stopping condition: a graph that calls itself

Section titled “A stopping condition: a graph that calls itself”

When you can’t compute the list up front (poll until the export is ready, page through an API until the cursor comes back empty, retry a flaky system with your own backoff), do one pass, decide, and call yourself again. A workflow node can name any stored graph in the project, including the one it’s in.

Each pass is a child run of the one before, and children nest at most 20 levels deep. The pass that would go deeper fails, and every pass above it fails with it. For a loop that may need more passes, use a script loop or a schedule.

A loop that pages through an API looks like this:

version: hyperspool-graph/v1
nodes:
- id: page
type: http
config:
url: { $template: "https://api.example.com/orders?cursor={{ input.cursor }}" }
- id: more
type: switch
config:
cases:
- name: again
when: "input.attempt < 20 && nodes.page.output.body.next_cursor != ''"
default: done
- id: next
type: workflow
if: "nodes.more.output.branch == 'again'"
config:
graph: { name: sync-orders }
input:
cursor: { $expr: nodes.page.output.body.next_cursor }
attempt: { $expr: input.attempt + 1 }
edges:
- { from: page, to: more }
- { from: more, to: next }

Publish it as sync-orders and start it with {"cursor": "", "attempt": 0}. Each pass fetches one page and either stops or starts the next pass as a child run with the cursor it just received.

The switch is the terminator: its default branch ends the recursion. An exit expression that can error (a missing key, a null) fails the run instead of stopping it, so make the exit the default and the continue the case. The start input carries attempt because the first pass reads it.

Keep a bound of your own in the condition. input.attempt < 20 ends the loop cleanly at the nesting limit, and stops it the day an API starts returning the same cursor.

Each pass runs the published graph. A graph: reference resolves when the child starts, to the published version unless it pins one. Save a draft and the running loop keeps the old version; publish and the next pass picks up the change.

Every pass is its own run, with its own log, nested under its parent in the console. The parent waits until the child finishes, and a recursion that pauses an hour between passes holds no agent while it waits.

To check the terminator without starting any children, press Try it on the canvas. A dry run stubs the http and workflow steps with what they returned in the last real run, runs the switch for real, and shows which branch it took.

A recursion that acts on records sees the same record twice if a pass is retried. An entity-scoped claim makes that safe; see claiming a key.

Inside a polyglot script, a loop is a loop:

Terminal window
turn=1
while [ "$turn" -le 20 ]; do
reply="$(spool step model -- ./ask-model.sh "$goal")"
[ "$reply" = "done" ] && break
spool step tool -- ./run-tool.sh "$reply"
turn=$((turn + 1))
done

The script re-runs from the top on every wake, and each completed spool step returns its recorded result instead of running again, so the loop fast-forwards to where it left off. The code between spool calls re-executes on every pass, so keep it free of side effects. Agent loops works this shape through end to end, including why every turn can reuse one step name.

Every suspend (spool sleep, wait signal, activity wait) re-runs the script from the top, returning each recorded step from its checkpoint. One lookup is cheap, but a loop of M iterations that suspends once per iteration replays 0, then 1, … then M-1 iterations: total work grows with M². A suspending loop is comfortable in the hundreds of iterations. Past that, do more per iteration (batch a minute of samples into one step) or use a schedule with an entity-scoped cursor, where each tick is a fresh run with nothing to replay. A loop that never suspends has no such limit.

If “repeat” means “every night” rather than “until done”, use a schedule. It fires a fresh run per tick, and an entity-scoped state cell tells the next tick where the last one got to.

Terminal window
spool schedule create -w sync-orders --every 1d --input '{"cursor": "", "attempt": 0}'

This is the cheapest shape, and usually the right one for anything measured in hours or days. Use recursion when the passes have to be back-to-back and carry state, and a schedule when they only have to happen again.