Loop until
Repeat a body of steps, feeding each pass's output back in, until a stop condition is true. Max iterations, the _loop_exhausted flag, poll and refine.
The cycle primitive: repeatedly run the steps wired to its loop port, feeding each pass's output back in, until the stop condition over that output becomes true. Then the final payload leaves the done port. This is retry-until-success, poll-until-ready, generate-and-refine.
What it does
Everything wired off the loop port is the body. On the first
pass the body receives the upstream payload; on every later pass
it receives the previous pass's final output. After each pass the
stop condition is evaluated against that output. True → the output
leaves through done. False → another pass, until the cap.
The semantics are do-until: the body always runs at least once
before the condition is checked. If nothing is wired to loop,
the step passes its input straight through to done.
A loop until has two output ports and no concept of items, unlike
loop: body steps see a plain payload, not
payload.item. The compiler treats the body as its own sub-graph,
so a body can branch and merge internally, but it can't contain
another loop or loop until (nested loops are rejected at build
time).
Config
The stop condition plus the safety cap.
- Stop when: an expression over the body's output (same
dialect as if):
payload["status"] == "done". Checked after each pass, so the body always runs at least once (do-until). Required. - Max iterations: safety cap, default 50, ceiling 1000. If
the condition never turns true, the loop stops and the payload
carries
"_loop_exhausted": true.
The whole loop also sits under a 60-minute orchestration budget per step. Each body step keeps its own normal time limit on top.
Output
The final body output, on done. A body whose last step emitted
{ "status": "done", "job_id": "j_42", "result_url": "…" } hands
exactly that map to the step after done, so
{{ payload.result_url }} works there.
On exhaustion the last pass's output plus the marker:
{ "status": "pending", "job_id": "j_42", "_loop_exhausted": true }
Branch on payload["_loop_exhausted"] with an if
when hitting the cap means something different than finishing;
when the key is absent the if reads nil, which is falsy.
A test run from the Inspector doesn't iterate. It emulates one
pass from the body's pinned sample and adds a _test_note saying
whether a live run would have exited or kept going.
Patterns
- Poll until ready: call a job-status API →
wait → loop until
status == "complete". - Refine: generate with an AI step → validate → loop until the check passes.
- Drain a queue: get from store in
listmode → process the batch → loop untilpayload["count"] == 0. - Retry with backoff: a flaky web request
with on-failure
continue→ wait → loop untilpayload["ok"] == true, cap set to the number of attempts you're willing to make.
Gotchas
- The condition reads the body's output, not the original
input, so make sure the last body step actually emits the field
you're checking. A body that ends in a step whose output
doesn't carry
statuscan never satisfypayload["status"] == "done". - Put a wait inside polling bodies; a tight loop against an API burns the iteration cap in seconds.
- Each pass replaces the payload. If the tail of the workflow
needs something from the original input, carry it through the
body with set fields in
mergemode. - A body step that fails fails the loop; there's no per-pass
failedcount the way loop has.
Related
- Loop for once-per-item work.
- Wait to pace a poll.
- Waits and resumes for how long pauses are scheduled.