DocsWorkflowsControl flow

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: true

The 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-fieldMeaning
itemsThe collection to iterate. A list, a map, or a string. Required.
asRenames the element binding, which defaults to item. index is not renameable.
whereKeeps only the elements it answers true for.
skipA list of indexes or keys, written as text, to pass over.
limitHow 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.
typeHow 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.documentId

The 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 budget

Running 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.