Repeating work
There is no loop keyword. Repetition is four different shapes here,
and which one 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 | 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 YAML pipeline, when the axes are fixed at authoring time - three OS versions crossed with two toolchains - declare them and let the job run once per combination:
test: matrix: values: os: [ubuntu, macos] rust: ["1.80", "1.81"] steps: - name: test run: cargo +${{ matrix.rust }} test --target ${{ matrix.os }}It stays one job, so depends_on: [test] is one edge that waits for all
four. It compiles to the same fan_out below.
The full product is rarely the grid you want. exclude: sits next to
values: and carves combinations out of it:
test: matrix: values: os: [ubuntu, macos, windows] rust: ["1.80", "1.81"] exclude: - { os: macos, rust: "1.80" } - { os: windows } steps: - name: test run: cargo +${{ matrix.rust }} test --target ${{ matrix.os }}Six combinations become three. Each entry is a partial combination: the
dimensions it names all have to match, and the ones it omits are
wildcards. So { os: macos, rust: "1.80" } drops one pairing and
{ os: windows } drops the whole windows row without naming a rust
version beside it. Values match as written, so a quoted "1.80" in
values: stays quoted in exclude:, and a dimension whose values are
objects is excluded by the whole object.
Two ways of getting it wrong fail at compile time. Naming a dimension the matrix doesn’t have is a typo that would otherwise match nothing and quietly run the whole grid, so the error names the dimensions you do have. Excluding every combination leaves a job that can never run, and that fails the compile too.
Without exclude:, leaving a combination out means guarding inside the
job body, and that combination still claims an agent and starts a job to
decide it has nothing to do.
See matrix: for
max_parallel:, fail_fast:, 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 the run starts and the
fan-out would have to exist before the job computing it has run. When the
axes are only known at run time, see the next two sections: fan_out if
you have a list, a computed graph if you don’t yet have a shape.
A list you have in hand: fan_out
Section titled “A list you have in hand: fan_out”The list arrives from an upstream node 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:
A hundred items is the ceiling, for
fan_outand for a compiledmatrix:alike.cryo checkrefuses a longer list before you submit it. Aworkflowbody doesn’t lift it - it’s one effect per item either way. Past a hundred, drive the list from a script loop:cryo activity-submithas no such cap, and a five-thousand-item batch is an ordinary shape for it.
{ "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
node’s output is the array of body outputs, in item order, so a
downstream node reads nodes.build.output as a list whatever order they
finished in.
max_parallel caps how many run at once. on_error decides what a
failing item does to the rest: halt (the default) fails the node once
the in-flight window settles, continue leaves failed items as null
slots in the output and lets the node succeed.
Pacing: rate
Section titled “Pacing: rate”max_parallel bounds how many items are in flight. A service that
answers 429 is counting a different thing - 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" } } } }Twenty items go out, then the node waits out the rest of the minute,
then the next twenty. max_parallel still applies inside a window, so
this one runs four at a time within each batch of twenty. A
max_parallel larger than limit has nothing to do - a window only
ever holds limit items.

Five shards at two per six seconds: the timestamps the shards print are what the pacing looks like from outside. The last window is short, and gets no wait after it.
The wait between windows is a durable timer: the run holds no agent across it, and a restart mid-wait resumes where it was instead of re-dispatching a window that already went out. So pacing eighty items at twenty a minute costs three minutes of wall clock and nothing else.
Three things it doesn’t do. The last window has no wait after it, so a
list shorter than limit never sleeps at all. The budget is per node,
per run - two runs fanning out to the same API each get their own
twenty; when the ceiling has to hold across runs, that is the pipeline’s
throttle:,
which counts starts per period across the project. And it doesn’t raise
the fan-out ceiling: a hundred items is the limit whatever rate you pace
them at, because each one is still an effect in this run’s history. Past
that, fan out over batches and let each item’s body handle a slice.
In a YAML pipeline the same knob sits on matrix: - see
matrix:.
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, and the choice is about what one item’s work is.
An activity body is one call per item. Cheap, and everything lands in this run’s history.
A workflow body starts a child run per item instead:
{ "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" } } } } } }Reach for it when one item’s work is more than one step, or when you want each item to be inspectable on its own: a child has its own page, its own history, and its own rerun-from-node, so re-running the one service that failed doesn’t touch the nine that worked. The node’s output is still the list of per-item results, in item order.
The per-item attrs (retry, requires, agent, timeout) describe
how one activity is dispatched, so a workflow body refuses them -
set them inside the child, on the job that needs them.
A body that parks (wait, approval) is refused either way. One item’s
approval isn’t a different question from the next one’s; put the gate in
the graph the workflow body names.
A shape you compute: a generated graph
Section titled “A shape you compute: a generated graph”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 has to wait
for an approval - there is no single body to repeat. Compute the graph
instead: one node decides what the work is and emits a
cryosleep-graph/v1 document, and a workflow node runs it.
{ "id": "build_all", "type": "workflow", "config": { "definition": { "$expr": "nodes.generate.output.graph" } } }The child is a real run: its own history, its own retries, validated when it starts. The cost is coarse sequencing - the parent waits for the whole child, since there is no edge into one generated node.
examples/computed-graph is a complete one to run;
Composition covers how
a child is named the other three ways.
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 external 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. That’s the loop:
{ "version": "cryosleep-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": "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" } } } } ], "edges": [ { "from": "page", "to": "more" }, { "from": "more", "to": "next" } ]}Publish that as sync-orders and start it with {"cursor": ""}. Each
pass fetches one page and either stops or spawns the next pass as a
child run with the cursor it just received.
Three things make this work rather than merely run:
- The
switchis the terminator. Itsdefaultbranch is what ends the recursion. A recursion whose exit depends on an expression that can error - a missing key, a null - doesn’t terminate, it fails, which is louder but not what you wanted. Make the exit the default and the continue the case. - Every pass is its own run, with its own log, nested under its parent in the UI. A hundred pages is a hundred runs, which is a readable timeline and a lot of rows. If the iteration count is in the thousands, this is the wrong shape - page in batches, or move the loop inside a script.
- The parent stays parked until the child finishes, holding no agent. So a recursion that waits an hour between passes costs nothing while it waits.
To bound it, carry a counter in the input and put the bound in the switch:
{ "id": "more", "type": "switch", "config": { "cases": [ { "name": "again", "when": "input.attempt < 20 && nodes.page.output.body.next_cursor != ''" } ], "default": "done" } }and pass { "attempt": { "$expr": "input.attempt + 1" }, … } to the
child. A loop with no bound is a loop that will one day run forever
because an API started returning the same cursor.
Only-once, across passes
Section titled “Only-once, across passes”A recursion that acts on records will see the same record twice if a pass is retried. An entity-scoped claim makes that safe - see claiming a key.
A turn at a time: a script loop
Section titled “A turn at a time: a script loop”Inside a polyglot script, a loop is a loop:
turn=1while [ "$turn" -le 20 ]; do reply="$(cryo step model -- ./ask-model.sh "$goal")" [ "$reply" = "done" ] && break cryo step tool -- ./run-tool.sh "$reply" turn=$((turn + 1))doneThe script re-runs from the top on every wake, and each completed
cryo step returns its recorded result instead of running again - so
the loop fast-forwards to where it left off. A step name is the program
point, so reaching model on turn seven records turn seven’s answer
under the same name as turn one’s, and the replay hands each turn back
what that turn got.
Agent loops works this shape through end to end, including a human gate in the middle. The same page’s warning applies to any script loop: the code between cryo calls re-executes on every pass, so keep it free of side effects.
How long a loop can be
Section titled “How long a loop can be”A loop whose iterations suspend - a cryo sleep, a wait-signal, an
activity-collect - costs more than one that doesn’t, and the reason
decides how long a loop you can write.
Each suspend re-runs the script from the top, and each completed step is a memo lookup rather than a re-execution. Those lookups are cheap and they scale linearly: a run with a thousand recorded steps replays them in a few seconds, and one with twenty-five does it in a tenth of that.
What grows is the number of times you pay it. A loop of M iterations that suspends once per iteration replays 0, then 1, then 2 … then M-1 iterations, so the total work is proportional to M². At a few hundred iterations that is seconds and nobody notices. At tens of thousands it is the dominant cost of the run - a loop sampling something every thirty seconds for a week would spend far longer replaying itself than sampling.
So: a suspending loop is comfortable in the hundreds. Past that, either do more per iteration (batch a minute of samples into one step) or stop looping and use a schedule with an entity-scoped cursor, where each tick is a fresh run with nothing to replay. A loop that does not suspend has no such limit; it is one activity doing its work.
Again later: a schedule
Section titled “Again later: a schedule”If “repeat” means “every night” rather than “until done”, it isn’t a loop at all. A schedule fires a fresh run per tick, and an entity-scoped state cell is how one tick tells the next where it got to.
cryo schedule create 1d --graph sync-orders --input '{"cursor": ""}'This is the cheapest of the four shapes and usually the right answer for anything measured in hours or days. Reach for recursion when the passes have to be back-to-back and carry state; reach for a schedule when they just have to happen again.