Skip to main content
Two step types hold other steps: foreach runs its body once per item, and parallel runs its branches at the same time. Both are ordinary steps otherwise — they take an id, a condition and a retry block like anything else.
A condition belongs on the container, not on a step inside it. A condition on any step within a foreach body or a parallel branch — at any depth — is refused at save time with condition_in_container_body. Gate the whole container instead, or filter the items list you feed it.The container’s own condition is fine while the container is top-level. A container nested inside another container’s body is itself a body step, so it cannot carry one either.

foreach

A definition carries exactly one of step and steps. Both, or neither, is refused. Inside the body, ${item} is the current element (${item.title} walks into it) and ${index} is its 0-based position. Both are legal anywhere in the body subtree and illegal in items, which is evaluated before there is an iteration. Output: an array in items order, whatever order the iterations actually finished in. Under failure_mode: "continue" a failed slot holds {"_error": {"code": …, "message": …}} instead of a result. An empty items array produces [] and costs nothing. Read one iteration with ${steps.captions.output.0.…} and all of them with ${steps.captions.output.*.…}.

parallel

A parallel is a race: its branches are alternatives, they run at the same time, and the first one to succeed is the step’s result. A branch that fails is not a problem — surviving a failing alternative is the whole point.
Output: a single-key object, keyed by the winning branch’s first step id, whose value is that branch’s last step’s output. For a branch of one — the common case — those are the same step, which is why the older bare-branch spelling reads identically.
Only the winner appears, so a downstream binding has to tolerate either key. The usual spelling for that is a fallback chain:
A losing branch is stopped, but not instantly, and what it already started is billed.There is no cancellation: a step that has already been dispatched runs to completion at the provider and settles its own credits, whatever the race decides afterwards. What stops is the next step. So a losing branch costs the steps it had already dispatched when the winner returned — typically the one it was in the middle of — and never the rest of its sequence. A three-way race over 20-step branches bills roughly three steps, not sixty.Cost estimates and the credit hold reserve the worst case: every branch, every step. The hold is released down to what was really spent when the run settles.
You do not need a parallel to get concurrency. Independent top-level steps already run at the same time, because the engine reads the dependency graph — see overview. Reach for parallel when you want alternatives, not when you want speed.
There is no projection form for a parallel: it publishes an object, and * only projects over arrays. Name each branch explicitly.

join — deprecated

join used to be required and had two values. join: "all" says what the dependency graph already says. Three sibling steps that read the same input and nothing of each other are independent, so the engine runs them together without being told to — and as ordinary top-level steps they publish under their own ids (${steps.wide.output.url}) instead of under a fan-out (${steps.variants.output.wide.url}), which is the shape everything else in a definition already uses. Definitions that already carry join: "all" keep working exactly as they do today; nothing stored is rewritten and nothing is silently reinterpreted. New definitions should omit join and hoist an all-branches fan-out to top-level steps.

Scope

A container body is its own scope. The rules are short and they are enforced when the definition is saved: Three consequences worth stating outright:
  • A body step publishes nothing under its own id. A foreach’s results land under steps.<foreachId>.output.<i> and a parallel’s under steps.<parallelId>.output.<branchId>. Naming the inner step from outside is refused with binding_out_of_scope.
  • A container’s own id is unreadable from inside its own body — the aggregate is published only after the whole body has finished.
  • One parallel branch cannot read another’s steps. Every branch starts from its own copy of the caller’s scope. Refused with binding_out_of_scope; read the fan-out instead, as ${steps.<par>.output.<branchId>…}.
The container publishes its last body step’s output — per iteration for a foreach, and for the winning branch of a parallel (for every branch, under the deprecated join: "all") — with credits and duration summed over the body. There is no selector for “which body step is the result”: put the step whose output you want last.

A body runs in array order

There is no reordering pass inside a container. A body is walked exactly as written, and the scope grows by one entry per finished step. A body step may bind an earlier sibling and may not bind a later one.
This matters because the failure is silent and expensive. A reference to a later sibling resolves to undefined at run time with no error raised — and the step is dispatched and billed anyway, with a missing input. It therefore has a refusal code of its own, forward_step_ref, separate from binding_out_of_scope, because the fix is to reorder, not to rename or re-scope.
explain binds fetch, which runs before it — legal. Swap the two and the save is refused. The top level is the one exception, and it stays one: a top-level step may reference a top-level step declared after it, because the runner sorts them by dependency. The refusal arrives as invalid_pipeline_definition whose message begins forbidden_nested_foreach, with the JSON path of the offending step.

Limits

What a container refuses at run time

These four are raised before any step of the body is dispatched, so they cost nothing — but they are run-time errors, not write-time ones, so a stored definition can carry them: None of them is retryable in a useful sense — listing one in retryable_errors buys retries that cannot succeed.