DocsWorkflowsTemplating language

Templating language

How workflows use sintax, the template expression language. Referencing step outputs and variables, modifier chains, conditional blocks, fallbacks, and injecting a whole config object with the spread operator.

Every field of every action accepts a template. A plain value (a string, a number, a boolean, a list) is passed through as written, and any {{ }} expression is resolved before the step runs, so any field can be built from the workflow's variables and the outputs of earlier steps.

This is the only glue between steps. There is no code between them, just expressions that read a value, optionally reshape it, and drop it into the next field. The language is sintax, our template expression engine, and it works the same everywhere a workflow renders text, in action fields, in if conditions, and inside prompt files. This page covers everything a workflow author reaches for, so you should rarely need to leave this docset. The sintax reference documents every modifier with worked examples when you do.

Referencing values

Three kinds of value are in scope inside an expression.

vars:
  url: "{{ env.API_URL }}/orgs/{{ var.org_id }}"
  body: "{{ page.content }}"
ReferenceReads
{{ var.<name> }}A workflow variable declared under top-level vars.
{{ env.<name> }}An environment variable.
{{ <step>.<field> }}A field of an earlier step's output, addressed by the step's name.

A step's output is visible to every step that comes after it, at any nesting depth, by its short name. You never write an ancestor chain. A grandchild reads a grandparent step the same way a sibling does.

Modifier chains

A value passes through a chain of modifiers separated by |, applied left to right. The first argument to a modifier follows a colon, any further arguments follow commas, and string arguments are single-quoted.

slug: "{{ title | slug | lower }}"
first_email: "{{ users | filter:'active',true | pluck:'email' | first }}"
amount: "{{ response | from:'json' | key:'orders' | pluck:'total' | sum }}"

The last chain reads as what it does. Parse the response from JSON, take its orders field, pull each order's total, and sum them. Most wiring problems in a workflow reduce to a chain like this.

Common modifiers

A handful of modifiers cover almost every workflow.

KindModifiersTypical chain
Textslug, trim, lower, upper, replace, split, join, length{{ title | slug }}
Collectionskey, first, last, pluck, filter, sort, sum, wrap{{ users | pluck:'email' | first }}
Convertjson, from:'json'{{ page.body | from:'json' | key:'id' }}
Compareeq, gt, gte, not{{ items | default:[] | length | gt:0 }}

Two of these do the heavy lifting around model calls. from:'json' parses a string into a structure you can index into, which is how tool output and API responses are unpacked, and json serializes a structure back into a string, which is how request bodies are built.

The full set, with every argument and edge case, lives in the sintax reference.

Conditional blocks

Inside any template, an {{ if }} block renders one branch or the other. The condition is an ordinary expression, so it can end in a comparison modifier.

prompt: "Summarise the article.{{ if var.audience }} Write it for {{ var.audience }}.{{ endif }}"
status: "{{ if failures | default:[] | length | gt:0 }}unhealthy{{ else }}ok{{ endif }}"

An action's if: field uses the same expressions to decide whether the whole step runs, which control flow covers. The block form here is for shaping text, most often prompts, where a sentence should appear only when its data exists.

The default fallback

default supplies a value when the input is missing, null, or empty. It is how optional inputs get a sane baseline.

rows: "{{ var.rows | default:[] }}"
context: "{{ var.context | default:'' }}"
model: "{{ var.model | default:'claude-opus-4-20250514' }}"

Tip

Guard every list before you loop over it or measure it. Write {{ items | default:[] | length }}, not {{ items | length }}. A missing or null value flowing into length, join, or each is the most common workflow error, and a default:[] (or default:{}) removes it.

Injecting a whole config with ...

Sometimes an action's entire configuration is computed elsewhere, for example read from a JSON file or produced by a prior step. Rather than list every field, spread the whole object into the action with the ... key.

- name: read_json
  component: file:read
  vars:
    path: ./component-config.json

- name: update_code
  component: coder
  vars:
    dir: "{{ var.project_dir }}"
    lines_after: "'use client'"
    "...": "{{ read_json.body }}"

The "..." key is a merge instruction. Its value must be a JSON document, and the engine unmarshals that document straight into the component's config, filling every field at once, then validates the result. The explicit keys alongside it (dir, lines_after) still apply. The value may be a string, raw bytes, or the byte content of a file loaded by an earlier step.

This is the one operator that populates a component from an opaque object. Reach for it when a config is data (loaded, generated, or passed through) rather than something you want to spell out inline.

Building an object, then encoding it

The inverse is just as common. Assemble a nested object in vars, then encode it in a single expression. This is how request bodies are built.

- name: create
  component: http
  vars:
    method: POST
    url: "{{ env.STRAPI_API_URL }}/api/models"
    body_map:
      title: "{{ var.model_title }}"
      provider: "{{ strapi.provider }}"
      enabled: true
    body: "{{ body_map | wrap:'data' | json }}"

body_map is an ordinary variable holding a map. The body field wraps it under a data key and JSON-encodes it in one chain. Because expressions can read sibling vars, you keep the readable structured form and the encoded string side by side.

Next, control flow shows how conditions and loops decide which steps run and how often.