Composition
Building large automations out of small ones. Calling a workflow as a step, fanning one out over a collection, convergent naming across branches, and the reuse patterns that keep a workflow tree readable.
A workflow is itself a component. Any workflow can call another by its id, passing variables in and reading its declared outputs back. This is what keeps a large automation manageable. You build small, single-purpose workflows and compose them, rather than growing one enormous file.
Calling a workflow as a step
Set an action's component to the id of another workflow. The caller's vars become the child's inputs, the child runs as a nested plan, and its outputs become this step's output, addressed by the step's name.
- name: translated
component: aweehub/translations/translate-batch
vars:
rows: '{{ missing_rows.items | default:[] }}'
language_from: '{{ var.language_from }}'
language_to: '{{ var.language_to }}'Here translate-batch is a whole workflow, reused as one step. Its outputs are read as {{ translated.rows }}, exactly as if it were a built-in component. A child sees only the variables the parent passes it, so a composed workflow stays isolated and testable on its own.
Because a called workflow is resolved by id, the same building block runs unchanged whether the top-level automation is launched from the command line, over the REST API, or from the web app. The composition is part of the definition, not the runner.
One thing a sub-workflow step does not have is a static result type, so a skipped one publishes <step>.skipped and nothing else. That is the one place a downstream default is still carrying real weight rather than papering over a shape.
Fanning a sub-workflow out
An each: field on a sub-workflow call is the fan-out shape in full. The callee is as many steps as it likes, the caller runs it once per element, and the caller gets one collected list back per output the callee declares.
- name: published
component: aweehub/translations/publish
each:
items: '{{ channel_rows.items }}'
vars:
project_id: '{{ item.project_id }}'
channel: '{{ item.channel }}'
only_if_changed: trueThis is also the answer to nesting. An iterated step carries no children, so work that needs several steps per element either moves into a component: each block or, better, into a sub-workflow the outer step calls once per element. The second reading keeps the collected outputs, and it keeps the inner work testable on one element.
Declaring what a call needs
A sub-workflow declares its own connections, and a caller satisfies that declaration by naming one of its own on the calling step's connection field. The credential is chosen where the run is, not written into the document, so the same workflow runs against whichever vendor an organization connected for that capability.
Convergent naming
The single habit that keeps a branching workflow readable is naming the value, not the operation, and reusing that name across branches. When an if and its else produce the same logical result, give both the same name. Downstream reads one expression.
- name: strapi
component: http:request
if: '{{ existing.items | default:[] | length | gt:0 }}'
locals:
body_map:
title: '{{ var.model_title }}'
vars:
method: PUT
url: '{{ env.API }}/models/{{ existing.items | first | key:''id'' }}'
body: '{{ body_map | wrap:''data'' | json }}'
else:
name: strapi
component: http:request
locals:
body_map:
title: '{{ var.model_title }}'
vars:
method: POST
url: '{{ env.API }}/models'
body: '{{ body_map | wrap:''data'' | json }}'Whether the record already existed or was just created, later steps read {{ strapi.body }}. If you find yourself writing {{ existing.x | default:created.x }}, that is the signal you skipped a convergent name.
Reuse patterns
A handful of shapes recur across real automations.
- Pipeline. A chain of steps where each names the value it produces and the next reads it. A
localsblock gives a reshaped value a readable name without a step of its own. - Idempotent upsert. A lookup step is the parent. Its
ifupdates an existing record and itselsecreates one, both converging on the same name, so the workflow is safe to run twice. - Fan-out. An
each:field on a sub-workflow call, once per element, with the callee's outputs collected into lists. This is how a batch job reuses a single-item workflow, and it needs no accumulator. - Orchestrator. A top-level workflow whose steps are almost all calls to other workflows, wiring them together and shaping the final output.
- Chunk, process, unchunk. Split a large input into chunks with
each.limit, run a workflow over each chunk, then reassemble. This keeps a model call or an API request within its size limits. - Three-way merge.
data:list:comparesorts an incoming list, a stored list and a baseline intoonly_in_a,only_in_b,same,different,a_moved,b_movedandboth_moved, so each bucket becomes one branch of the merge policy rather than a rule you re-derive.
Stamping the outcome
A composed automation that marks a row as in progress should mark the outcome itself. A top-level step carrying always: true runs once the rest of the plan has stopped, whether it finished or failed, and reads the verdict as run.failed and run.error. That keeps the record honest without asking the caller to stay connected, which control flow covers.
Events for milestones
Long automations emit their own milestones with event:emit, publishing a named event other systems or steps can react to. A correlation id threaded through the run's variables ties every emission back to one execution, so a workflow driving real work stays observable from the outside.
A composed automation can span many workflows. Lifecycle shows how the engine folds that whole tree into one static plan, runs it, and accounts for every step it touches.