DocsWorkflowsCookbook

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. vars declares defaults for the model, the languages, and the rows. A caller overrides them. The prompt points 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:generate renders its prompt against that map and nothing else, so a prompt looping over rows needs rows there or the loop variable is resolved in the planning pass and reported as missing.
  • One inference does the work. tool_choice: specific forces the translate_rows tool, 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, and retry: { count: 3, delay: 10 } rides out a transient provider error.
  • The credential is not in the document. connections declares the capability and connection: llm selects it, so whichever vendor the organization connected answers the call.
  • The result is read out of tools. The output step reaches the translate_rows entry by dotted path, parses it from JSON, and takes its rows field. The trailing default:[] 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:
            - key

A 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: variant

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

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