Control flow
Conditions, the two forms of iteration and the rule for choosing between them, filtering an iteration, grouping, caching, retries, and the step that runs whatever happened.
By default a workflow runs its actions top to bottom, once each. Control flow changes that. A step can be skipped, run once per element of a collection, cached, retried, or held back until everything else has stopped, and a group of steps can be treated as one.
Conditions
Any action can carry an if. When the expression is falsy the step is skipped. An optional else action runs in its place.
- name: translated
component: aweehub/translations/translate-batch
if: '{{ missing_rows.items | default:[] | length | gt:0 }}'
vars:
rows: '{{ missing_rows.items }}'
else:
name: translated
component: var
vars:
var: rows
value: []Note that else is a single action, not a list. The convention is to give the if branch and the else branch the same name, so downstream steps read one value ({{ translated.rows }}) regardless of which branch ran. When you catch yourself writing {{ a.x | default:b.x }} to paper over two branch names, give both branches the same name instead.
A condition is an ordinary expression, so the decision rule applies to it in full. Guard the value before the verdict.
Two ways to iterate
Iteration has two forms and they are not interchangeable.
The each: field runs one step once per element and collects that step's outputs into lists. The each component runs a block of child actions once per element and collects nothing.
The each: field
Write each: beside an action's vars: and the step iterates on its own.
- name: published
component: aweehub/translations/publish
each:
items: '{{ channel_rows.items }}'
vars:
project_id: '{{ item.project_id }}'
channel: '{{ item.channel }}'
only_if_changed: trueThe current element is bound as item and its position as index. For a list index is an integer, and for a map it is the key, with a map iterated in sorted key order so the run is stable.
| Sub-field | Meaning |
|---|---|
items | The collection to iterate. A list, a map, or a string. Required. |
as | Renames the element binding, which defaults to item. index is not renameable. |
where | Keeps only the elements it answers true for. |
skip | A list of indexes or keys, written as text, to pass over. |
limit | How many elements the step takes at a time. 1 is one at a time, and above one the element is a chunk and index is that chunk's list of positions. |
type | How a string is split, chars or runes. |
What the step publishes is one list per result field, under the step's own name. A step named published whose component returns release_id publishes published.release_id as the list of every release id across the elements that ran. That is the whole point of the field. One step does what a seed, a loop and an appending var step take three to do.
An element the where rejects contributes nothing at all rather than a null, so the list a later step reads never has to be filtered a second time. A collection that expands to nothing, a where that rejects everything, and a condition that skipped the whole step all publish an empty list per field, which is one shape downstream and no default at any of the references.
where gates one element and the step's own if gates the whole step, so neither word has to mean the other. Locals resolve per element, which is where the sub-expression a where is written against belongs.
- name: scoped_rules
component: var
each:
items: '{{ context_rows.items }}'
where: '{{ has_pattern | and:language_ok }}'
locals:
any_language: '{{ item.language | default:'''' | eq:'''' }}'
has_pattern: '{{ item.key_pattern | default:'''' | length | gt:0 }}'
language_ok: '{{ any_language | or:this_language }}'
this_language: '{{ item.language | default:'''' | eq:var.language }}'
vars:
var: items
value: '{{ item }}'Two things an iterated step does not do. It carries no child actions, because per-element children on top of collected outputs is where paired-item confusion comes from. And its cache is not honoured, because a key computed over one step's inputs cannot speak for a run that had different inputs every time round.
To a watcher it is one step. It starts once and completes once however many elements it ran, and the elements' costs and retries are summed into that one completion. Elements run one at a time, in order, and the first element that fails stops the step.
The each component
Set component: each and put the body under actions. The block runs once per element, and the element is read under the step's own name rather than as item.
- name: row_each
component: each
vars:
items: '{{ output.rows }}'
actions:
- name: row_key
component: var
vars:
var: value
value: '{{ row_each.output.key }}'
- name: checked
component: llm:generate
vars:
provider: '{{ var.ai_provider }}'
model: '{{ var.ai_model }}'
prompt: '{{ var.check_prompt }}'Inside the block, {{ row_each.output }} is the current element and {{ row_each.index }} is its position. Both forms read the same items, as, where, skip, limit and type config, so the split is identical and the two cannot drift into meaning different things by the same words.
The block form collects nothing. Each iteration overwrites the last, so what survives the loop is the final iteration's values. Where every result matters, accumulate as the loop runs with a var step carrying append: true and an append_source.
- name: inserted_acc
component: var
vars:
var: rows
value: '{{ row_insert.record }}'
append: true
append_source: '{{ inserted_acc.rows | default:[] }}'Choosing between them
The work per element is one step, so use the each: field. The work per element is several steps, so use component: each.
That is the whole rule, and everything else follows from it. One step per element wants its outputs collected, which the field does for free and the block form makes you build by hand out of a seed, an append and a default:[] at every reference downstream. Several steps per element need somewhere to nest, which the field deliberately does not offer.
Two corollaries are worth writing down. A step that already iterates cannot carry a second iteration inside it, so an inner loop is a reason to reach for the block form, or better, to move the inner work into a sub-workflow the outer step calls once per element. And a step calling a sub-workflow with an each: field is the fan-out shape in full, since the callee is as many steps as it likes and the caller still gets one collected list back.
Grouping
group wraps a set of actions under one named step. It runs no logic of its own. Use it to organize a workflow, or to hang one if, retry, or cache on a whole block.
- name: enrich
component: group
actions:
- name: profile
component: http:request
vars: { url: '{{ env.API }}/users/{{ user_id }}' }
- name: history
component: http:request
vars: { url: '{{ env.API }}/users/{{ user_id }}/history' }Reshaping with map
Where each runs steps, map reshapes an object in place. It projects fields from a source into a new structure using a mapping of output keys to dotted paths.
- name: flat
component: map
vars:
value: '{{ strapi.body | from_json }}'
mapping:
model_id: data.0.documentId
cost_id: data.0.ai_model_costs.0.documentIdThe paths in a mapping index into nested maps and lists, and a * wildcard fans out across a list. This is the one place a numeric path segment indexes a list, which a dotted path in an ordinary template does not do. An optional per-key template applies an expression after the mapping.
Caching a step
A cache block stores a step's result and reuses it while the entry is fresh, keyed by the step and a hash of its resolved inputs. A cache hit skips the step entirely.
- name: summary
component: llm:generate
cache:
for: 24h
vars:
provider: openai
model: gpt-5
prompt: '{{ var.prompt }}'Warning
The for duration uses its own units. min is minutes, h hours, d days, w weeks, and m is months. Tokens combine with spaces, so 1h 30min and 3w 2d are valid. Write minutes as min, since a bare m means a month.
Retries, delays, and explicit failure
retry re-runs a failing step. count is the number of attempts and delay is the pause between them in seconds.
- name: translated
component: llm:generate
retry:
count: 3
delay: 10
vars:
provider: openai
model: gpt-5
prompt: '{{ var.prompt }}'Two small control components round this out. sleep pauses for a for duration given in milliseconds, useful for pacing an API. fail stops the workflow on purpose with a message, which pairs with an if to enforce a precondition or a budget guard.
- name: budget_guard
component: fail
if: '{{ estimate.cost_input_converted | default:0 | gt:5 }}'
vars:
message: estimated cost exceeds the 5.00 budgetRunning whatever happened
A top-level action carrying always: true is held out of the ordinary sequence and runs once the rest of the plan has stopped, whether it finished or a step failed. It is what lets a workflow that stamped a row as in progress stamp the outcome itself, rather than leaving that to a caller who may have closed the tab.
An always step reads the verdict as run.failed and run.error, which are ordinary names in scope and are spelled like a step's own outputs.
- name: stamp
component: app:record:update
always: true
vars:
schema: '{{ var.schema_id }}'
table: imports
id: '{{ var.import_id }}'
record:
status: '{{ run.failed | when:''failed'',''done'' }}'
error: '{{ run.error }}'The plan still fails with the error that stopped it, so an always step reports the outcome rather than swallowing it, and an always step that fails on top of a failure is logged rather than replacing the reason the run stopped. Only a top-level action carries it, since a nested one would have to define what stopping means for one loop iteration.
With the flow settled, inference covers the component most workflows are built around.