Workflow anatomy
The workflow config format. The top-level keys, every field an action carries, how one action can contain a whole block of others, and how a step's output flows to every step that follows it.
A workflow is a single, self-contained definition. It declares some inputs and variables, then lists the actions to run. An action runs one component, and the result of every action is available to every action that follows it.
An action is not always a single leaf step. Some components hold a block of their own actions, so a workflow is a tree, not a flat list. A branch or a group is just an action whose body is more actions, nested as deep as the work needs. This is what makes a workflow modular. You build small pieces and nest or reuse them, rather than writing one long script.
The definition is portable. It reads clearly to a person and to a language model, it runs the same on any operating system, and it is launched the same way from the command-line app, the REST API, or the web app. The examples here are written in YAML for readability, and JSON works identically. What matters is the shape below, not the serialization.
The top-level keys
id: aweehub/translations/publish
name: Publish a project's translations
vars:
ai_provider: openai
ai_model: gpt-5
inputs:
- name: project_id
type: string
actions:
- name: project_row
component: app:record:query
vars:
schema: '{{ var.schema_id }}'
table: projects
query:
filters:
- field: id
op: eq
value: '{{ project_id }}'
outputs:
project: '{{ project_row.items | first }}'| Key | Purpose |
|---|---|
id | The canonical name of the workflow, for example aweehub/translations/publish. It is how another workflow refers to this one. |
name | A human-readable title. |
vars | Default variables, read in expressions as {{ var.<name> }}. |
actions | The ordered list of steps. This is the body of the workflow. |
inputs | The typed inputs a caller supplies, also rendered as a form by the platform. |
outputs | A map of output name to a template expression, evaluated after the run. This is what the workflow returns. |
output_schema | A JSON Schema describing the output shape, so a programmatic caller or an external agent connecting over MCP knows the result contract. |
connections | The third-party credentials the steps select from. A step names one of them in its connection field. |
references | Where to read the payload of a system the workflow calls, so whoever authors against one is not guessing it. |
assets | Named blobs (their name and content) available to the run. |
tags | Labels such as table, table:row, or table:cell that classify the workflow. |
The list of steps is actions. Nothing in this format is spelled steps, at any level, and a step's output is never reached through a steps. prefix either.
The inputs and outputs blocks are the workflow's contract. outputs maps a name to an expression that reads from any step, while output_schema describes the resulting shape. Together they let a workflow be called like a function, by a person, by another workflow, or by an external tool.
Declaring a connection
A connection declaration states a capability, meaning the component contract a step needs, and whichever vendor the organization connected for that contract answers it. Naming a provider instead pins the workflow to one vendor, which is the narrow case rather than the default. The declared name defaults to the capability, or to the provider where one is pinned, and a step selects one by writing that name under connection.
connections:
- capability: llm
actions:
- name: translated
component: llm:generate
connection: llm
vars:
provider: '{{ var.ai_provider }}'
model: '{{ var.ai_model }}'
prompt: '{{ var.prompt }}'connection is a step-level concern the component never sees as an input. On a step that calls another workflow it satisfies the callee's own declaration.
Pointing at the documentation
references names the pages that describe the systems a workflow calls. Each entry carries a url, an optional title, an optional note saying what the page will not tell the reader, and an about list saying which steps the page covers. The block carries no behaviour of its own. It exists so that whoever authors the next step against a payload reads the payload.
about is matched by shape rather than read as free text, so something other than a person can answer which page covers a given step. An entry carrying a scheme is an endpoint address, and it covers a step whose request URL starts with it, which is what lets one page cover a family of endpoints. An entry carrying a colon or a slash and no scheme is a component name, built-in or sub-workflow, and it covers every step running that component. Anything else is a step name. A url with no scheme is a path relative to the workflow document, which is how a spec committed beside the workflow is named, and it resolves only for a reader holding the document on disk.
references:
- url: https://docs.stripe.com/api/charges
title: Stripe charges
note: The amount is in minor units, so a EUR charge of 12.50 is 1250.
about:
- https://api.stripe.com/v1/charges
- http:requestThe anatomy of an action
Every step in actions is one action. The only two required fields are a name and a component.
- name: page
component: http:request
vars:
url: 'https://example.com/{{ topic | slug }}'
cache:
for: 1h
retry:
count: 3
delay: 10
if: '{{ topic | default:'''' | length | gt:0 }}'| Field | Meaning |
|---|---|
name | The step's identity. Downstream steps read its output as {{ <name>.<field> }}. |
component | Which component runs, for example http:request, llm:generate, each, or the id of another workflow. |
vars | The component's configuration. Every value may contain {{ }} expressions. |
locals | Named sub-expressions visible to this step alone, resolved alongside vars and stripped before the component runs. |
each | Runs this one step once per element of a collection and collects its outputs into lists. |
actions | Child steps, for container components like each, map, and group. |
if | A condition. When it is falsy the step is skipped. |
else | A single action that runs when if is falsy. |
connection | Which of the workflow's declared connections this step uses. |
cache | A duration to cache the result under, for example { for: 1h }. |
retry | A retry policy, { count, delay } where delay is in seconds. |
always | Holds a top-level step out of the sequence and runs it once the rest of the plan has stopped, whether it finished or failed. |
description | A note describing the step, surfaced in the plan. |
disabled | When true the step is planned but never executed. |
each and locals are ordinary fields of an ordinary action. Control flow covers what each does and when to reach for it rather than for component: each.
Locals, a named sub-expression
locals binds names for the length of one step. They resolve alongside that step's vars, so one may name another in any written order, and they are stripped before the component is initialized.
- name: state
component: var
locals:
approver: '{{ var.capabilities | has:''translations:approve'' }}'
settled: '{{ project_row.record.reviews_enabled | default:true | not | or:approver }}'
vars:
var: value
value: '{{ settled | when:''approved'',''in_review'' }}'A local never enters the workflow's variable namespace. No other step can read one, the component never sees one, and it may shadow a global or an earlier step's output for the length of that step without anything downstream noticing. That is what makes it a named sub-expression rather than a variable, and it is why an expression otherwise written twice, or split out into a throwaway var step nothing else reads, stays where it is used.
A local's value does not have to be a template. Literal data is often the point, and a list written as a local is how a small membership test stays readable.
- name: admit
component: var
locals:
admitted:
- approved/approved
- approved/in_review
vars:
var: value
value: '{{ admitted | has:pair }}'Inside an iterated step, locals resolve once per element, so the element binding is in scope for them. That is the usual place to put the sub-expression an each.where is written against.
Steps that contain steps
The actions field is what makes a workflow a tree. A container component does no work of its own beyond directing its children. Set the component and put the body under actions, and the whole block becomes one step in the parent.
- name: language_each
component: each
vars:
items: '{{ language_rows.items }}'
actions:
- name: rows
component: app:record:query
vars:
schema: '{{ var.schema_id }}'
table: translations
query:
filters:
- field: language
op: eq
value: '{{ language_each.output.language }}'
- name: rendered
component: data:document:render
vars:
format: json
items: '{{ rows.items }}'Here each is one action in the parent workflow, but it runs two child actions for every language. The children nest further if they need to.
Because a block of steps is itself just a step, you can wrap related work in a group, run any block once per item with component: each, or swap a hand-written block for a call to another workflow, all without changing the shape around it. Composition takes this further, using a whole separate workflow as a single step.
Wiring steps together
An action's output is addressed by the action's name. Give a step a noun that names the value it produces, then read that value downstream.
actions:
- name: invoices
component: app:record:query
vars:
schema: billing
table: invoices
- name: total
component: var
vars:
var: total
value: '{{ invoices.items | pluck:''amount'' | sum }}'The second step reads {{ invoices.items }}, the items field of the invoices step. Naming the query step invoices (the thing it returns) rather than fetch_invoices (the act of fetching) is the house convention. It reads as data, not as a procedure.
Outputs stay visible no matter how deeply a later step is nested. A child step three loops deep can still read a top-level step by its short name. The last writer wins, so inside a component: each block each iteration overwrites the previous one's values.
A step that did not run still publishes
A step whose if was false, and a step whose disabled is true, publish the zero value of every field the component would have returned, under the step's own name. A downstream reference reads an empty string, an empty list or a zero rather than a miss, so a step downstream of an optional step needs no default to survive. Whether a step is off because the document says so or because its condition said so is the author's business, not the shape's, and two answers to the same question would only make an optional step's downstream harder to write.
An iterated step publishes an empty list per field instead of a zero value, because a real run of one collects each field across its elements. A skipped loop and a loop whose collection expanded to nothing are then one shape downstream.
Where the difference between a step that never ran and a step that ran and answered with nothing still matters, a skipped step also publishes <step>.skipped as true. A step that ran publishes no such field at all, which a template reads as a miss and answers with a default.
- name: report
component: var
vars:
var: value
value: '{{ published.skipped | default:false | when:''nothing to publish'',''published'' }}'A step whose component the registry cannot answer for, and a step calling another workflow, publish the skipped field alone. There is no static result type behind a workflow reference, so nothing else can be honestly said about it, and that is the one case where a downstream default is still doing real work.
Continue with the templating language to see what goes inside those {{ }} expressions.