Cookbook
Three real workflows dissected end to end. A structured-output batch translation, the three-way merge that imports a translation file, and the fan-out that composes them.
Every idea in these guides comes together in a complete workflow. Here are three real ones, walked through from the first action to the final output.
1. Translate a batch of rows
This workflow takes a list of rows and translates every value with a single forced tool call. The tool's JSON schema is the output contract, so the model returns structured data, not prose.
id: aweehub/translations/translate-batch
name: Translate a batch of translation rows
vars:
ai_model: gpt-5
ai_provider: openai
language_from: en
language_to: lt
context: ''
prompt: file:./translate_rows.md
rows: []
connections:
- capability: llm
actions:
- name: translated
component: llm:generate
connection: llm
cache:
for: 24h
retry:
count: 3
delay: 10
vars:
provider: '{{ var.ai_provider }}'
model: '{{ var.ai_model }}'
prompt: '{{ var.prompt }}'
prompt_vars:
language_from: '{{ var.language_from }}'
language_to: '{{ var.language_to }}'
context: '{{ var.context }}'
rows: '{{ var.rows | default:[] }}'
tools:
- translate_rows
tool_choice: specific
- name: output
component: var
vars:
var: rows
value: '{{ translated.tools.translate_rows | from_json | key:''rows'' | default:[] }}'
outputs:
rows: '{{ output.rows }}'Reading it top to bottom.
- Variables are the inputs.
varsdeclares defaults for the model, the languages, and the rows. A caller overrides them. Thepromptpoints at a file, so the tool definition and the system and user prompts live in their own template. - The prompt's collection is named in
prompt_vars.llm:generaterenders its prompt against that map and nothing else, so a prompt looping overrowsneedsrowsthere or the loop variable is resolved in the planning pass and reported as missing. - One inference does the work.
tool_choice: specificforces thetranslate_rowstool, so the model must return data matching that tool's schema.cache: { for: 24h }means an identical batch is free to re-run within a day, andretry: { count: 3, delay: 10 }rides out a transient provider error. - The credential is not in the document.
connectionsdeclares the capability andconnection: llmselects it, so whichever vendor the organization connected answers the call. - The result is read out of
tools. Theoutputstep reaches thetranslate_rowsentry by dotted path, parses it from JSON, and takes itsrowsfield. The trailingdefault:[]covers a call that returned nothing.
2. Import a translation file as a three-way merge
The interesting real workflow is not a chain, it is a decision. This one takes an uploaded file, works out what the project already holds, and asks git's question. Which side moved away from the baseline the file was exported against.
- name: stored
component: app:record:query
description: What the project holds now, each row carrying the name of the key it belongs to
vars:
schema: '{{ var.schema }}'
table: '{{ var.table }}'
query:
filters:
- field: project_id
op: eq
value: '{{ parsed.project_id }}'
- field: language
op: eq
value: '{{ var.language }}'
limit: 0
related:
lookups:
- field: key_id
columns:
- keyA stored row is addressed by key id and the file names its keys by name, so related.lookups brings the name back beside the page rather than walking a join against a second listing. The related rows arrive under stored.lookups, keyed by the source field and then by the related row's id, so one key row pointed at fifty times is carried once.
- name: ours
component: var
each:
items: '{{ stored.items }}'
locals:
key_id: '{{ item.key_id }}'
vars:
var: items
value:
id: '{{ item.id }}'
key: '{{ stored.lookups | key:''key_id'' | key:key_id | key:''key'' | default:'''' }}'
key_id: '{{ item.key_id }}'
value: '{{ item.value | default:'''' }}'That is the each: field doing what it exists for. One step, one output field, one list back, with no seed step and no accumulator. The local names the row's own key id so the lookup chain reads it as a variable rather than repeating the expression, and the default:'' covers a row whose related key the run's grant does not reach, which comes back as nothing exactly as a null relation does.
- name: plan
component: data:list:compare
vars:
a: '{{ parsed.entries }}'
b: '{{ ours.items }}'
base: '{{ baseline_rows.items | default:[] }}'
compare:
- a: value
b: value
keys:
- a: key
b: key
- a: variant
b: variantdata:list:compare sorts the two lists into buckets. only_in_a and only_in_b are the keys one side has and the other does not, same is the pairs that agree, and a pair that disagrees is classified against the base. The side that still equals the base did not move, so a disagreement lands in a_moved, b_moved or both_moved, and a pair with no base row for its key stays in different because nothing there says which side moved. counts carries the size of every bucket.
That naming is the whole point. The merge policy is read straight off the bucket rather than re-derived a step later.
- name: taken
component: var
description: Pairs the file moved away from the baseline, plus pairs no baseline covers
locals:
parts:
- '{{ plan.a_moved }}'
- '{{ plan.different }}'
vars:
var: items
value: '{{ parts | flatten }}'
- name: accepted
component: var
description: Pairs the file moved and wrote a value for, which the merge takes
each:
items: '{{ taken.items }}'
where: '{{ given }}'
locals:
incoming: '{{ item.a.value | default:'''' }}'
given: '{{ incoming | length | gt:0 }}'
vars:
var: items
value:
id: '{{ item.b.id }}'
key: '{{ item.a.key }}'
value: '{{ incoming }}'
- name: conflicts
component: var
description: Keys both the file and the project moved, which nothing here decides
vars:
var: items
value: '{{ plan.both_moved | pluck:''a'' | pluck:''key'' }}'taken builds a list out of two buckets with a local, which is a list literal and a flatten rather than a step per bucket. accepted then iterates it, and its where is written against a local that resolves per element, so an element the file left blank contributes nothing at all rather than a null the next step has to filter out again. both_moved is surfaced rather than decided, because a value both sides moved is a question for a person.
The step that decides whether a written row needs a reviewer is the one worth reading twice.
- 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'' }}'Without the default:true, a project row carrying no reviews_enabled column, or carrying null in it, read as nothing. not inverted the nothing to true and every imported translation approved itself. The engine now refuses that outright, and the default is where the document says what absence means. Here absence means review is on, which is the fail-safe reading.
Finally, the run stamps its own outcome rather than trusting the caller to still be there.
- name: mark_outcome
component: app:record:update
always: true
if: '{{ var.dry_run | not }}'
vars:
schema: '{{ var.schema }}'
table: '{{ var.files_table }}'
id: '{{ var.file_id }}'
record:
status: '{{ run.failed | when:''failed'',''imported'' }}'3. Fanning one workflow out over many
The third shape is the smallest. A refresh workflow re-publishes every continuous channel by calling the publish workflow once per channel row.
- name: published
component: aweehub/translations/publish
each:
items: '{{ channel_rows.items }}'
vars:
project_id: '{{ item.project_id }}'
channel: '{{ item.channel }}'
dry_run: false
only_if_changed: trueThat is the whole automation. One step, a whole workflow behind it, and one collected list per output the callee declares. The callee is as many steps as it likes, and the caller does not care.
The reason this is a sub-workflow call rather than a component: each block is the rule from control flow. The work per element is several steps, so it needs somewhere to live, and a sub-workflow is a better home than a nested block because it keeps the collected outputs and stays testable on one channel.
That is the shape of the engine. Small workflows, wired by their outputs, composed into larger ones, each one inspectable and priced. Browse the component reference for the full menu of components, or start back at workflow anatomy.