Blocks

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 loop-until step in the Inspector 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 list mode → process the batch → loop until payload["count"] == 0.
  • Retry with backoff: a flaky web request with on-failure continue → wait → loop until payload["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 status can never satisfy payload["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 merge mode.
  • A body step that fails fails the loop; there's no per-pass failed count the way loop has.

Related