Templating language
How workflows use sintax, the template expression language. Reading step outputs by dotted path, modifier chains, why a decision on missing data refuses, 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, in an each.where, 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.body }}'| Reference | Reads |
|---|---|
{{ var.<name> }} | A workflow variable declared under top-level vars, or a global the caller supplied. |
{{ 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.
Dotted paths
A name goes deeper than one dot, and it resolves in a specific order worth knowing.
A step publishes its outputs as flat names spelled step.field, so the whole name is tried first and a flat hit always wins. Only when nothing is stored under the whole name is the name read as a path, trying successively shorter dot-prefixes, longest first, and walking whatever is left into the value the prefix found. So file_row.record.key_nesting reaches the flat name file_row.record and reads key_nesting out of it. The first prefix that hits owns the answer, and a walk that runs out from there is a miss rather than a retry with a shorter prefix, so one name resolves through one path no matter what else is in scope.
status: '{{ source_release.record.status }}'
kind: '{{ look.pagination.kind }}'
depth: '{{ var.project.language }}'The same rule covers globals, so var.project.language finds the flat var.project and reads language out of it.
A dotted segment reads a map key. It does not index a list, so rows.0.name is not how you reach the first row. Use the modifiers for that, | first, or | key:0, and use | key: for a walk whose segment names come from data rather than from the document.
key: '{{ stored.lookups | key:''key_id'' | key:key_id | key:''key'' | default:'''' }}'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. An unquoted argument names a variable rather than a literal, which is how one local is compared against another.
slug: '{{ title | slug | lower }}'
first_email: '{{ users | filter:''active'',true | pluck:''email'' | first }}'
amount: '{{ page.body | from_json | key:''orders'' | pluck:''total'' | sum }}'The last chain reads as what it does. Parse the body as 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.
| Kind | Modifiers | Typical chain |
|---|---|---|
| Text | slug, trim, lower, upper, replace, split, join, length | {{ title | slug }} |
| Collections | key, first, last, pluck, filter, find, sort, sum, flatten, wrap | {{ users | pluck:'email' | first }} |
| Convert | from_json, from_yaml, from_csv, from_date, json, yaml | {{ page.body | from_json | key:'id' }} |
| Decide | not, and, or, eq, neq, has, is, when | {{ flag | default:false | not }} |
| Compare | gt, gte | {{ 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.
A miss is fine as a value and never fine as a condition
This is the single rule that decides whether a workflow runs correctly or runs confidently on nothing.
A missing key and a key holding an explicit null arrive as the same nothing by the time a modifier sees them. Reading that nothing stays soft. It can be caught, so {{ sub.body.currency | default:'''' }} renders empty, an if over a bare miss reads false, and a loop over a missing list iterates nothing. What it cannot do is become a verdict.
Eight modifiers exist to turn a value into a decision, and each of them refuses nothing rather than answering it.
not and or eq neq has is whenHanded nil, any of them fails the step with a message naming the path and saying it is missing or null with no earlier default. gt and gte are not in that set, and neither is filter, and neither is length.
The reason is a real defect this prevents. A translation import wrote this to decide whether a row needed a reviewer.
settled: '{{ row.record.reviews_enabled | not | or:approver }}'A project row carrying no reviews_enabled column, or carrying null in it, read as nothing. not inverted the nothing to true, or kept it, and every imported translation approved itself. Nothing failed, nothing logged, and the wrong answer looked exactly like the right one. The corrected line says what absence means before the decision is taken.
settled: '{{ row.record.reviews_enabled | default:true | not | or:approver }}'default:true here is the fail-safe reading. A project row carrying no review flag, or carrying null, reads as review on.
The escape hatch is a default earlier in the same chain, and only earlier. default is an ordinary modifier that substitutes its fallback when the piped value is nil or an empty string, so a decision downstream of one never sees nothing at all. Written after the decision it is too late, because it would be answering the verdict rather than the missing value.
works: '{{ row.status | default:''draft'' | eq:''published'' }}'
fails: '{{ row.status | eq:''published'' | default:false }}'The refusal is terminal. Nothing further down the chain rescues it, and the step fails rather than the run continuing on a guess.
Warning
An uncaught miss is loud in an output position too. {{ page.missing }} on its own fails the render rather than printing nothing, and an if: condition the engine cannot resolve fails the run naming the action and the condition. Soft means catchable, not silent. Where absence is legitimate, say so with a default.
Where this bites hardest is a related read. app:record:query with a related.lookups block carries the related rows back beside the page, and a source row whose relation is null, or whose related row this run's grant does not reach, contributes no entry at all. Both cases read as nothing, identically and on purpose, so a lookup feeding a decision needs its default exactly as a column does.
The default fallback
default supplies a value when the input is nil or an empty string. An empty list, an empty object, the number zero and the boolean false are all real values and pass through untouched. It guards against nothing being there, not against a value that happens to be empty.
rows: '{{ var.rows | default:[] }}'
context: '{{ var.context | default:'''' }}'
model: '{{ var.model | default:''gpt-5'' }}'Tip
Guard a list before you measure it or loop over it, and guard a column before you decide on it. {{ items | default:[] | length | gt:0 }} and {{ row.flag | default:false | not }} are the two shapes worth learning. The first prevents a failure, the second prevents a wrong answer, and the second is the one that will not tell you it happened.
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.
Words that mean more than one thing
Four words in this dialect are spelled the same in unrelated places. Each pair below is worth reading once, because the wrong reading produces a document that parses and does not work.
var is four things. Top-level vars: declares the workflow's own variables. A step's vars: is not variables at all, it is that component's configuration. {{ var.<name> }} is the namespace prefix every workflow variable and every caller-supplied global is read under. And var is a component whose own config field is also called var, naming the output the value is published under.
- name: total
component: var # the component
vars: # this step's component config
var: total # the name it publishes under
value: '{{ invoices.items | pluck:''amount'' | sum }}'item is three. item is the binding the current element is read under inside a step's each: field, renameable with as:. items is the collection an each iterates, and it is also the result field a great many components publish for a page of rows, which has nothing to do with iteration. And item is a field on the entries data:list:compare puts in only_in_a and only_in_b, which is why a real document contains {{ item.item.key }} and that is not a typo. Note that the each component does not bind item at all. Its per-element names are <step>.output and <step>.index.
skipped is three. <step>.skipped is the boolean a step that did not run publishes. step.skipped is a lifecycle event on the bus, and it carries the reason, disabled or condition, which the output flag deliberately does not. And each.skip is an input, the list of indexes or keys an iteration passes over, which produces no flag of any kind.
run.failed is two. It is a value an always step reads, true when a step stopped the run, beside run.error holding the text. It is not an event. The run-level events are run.started and run.finished, the failure event is the per-step step.failed, and the status a run.finished carries is the string ok or failed.
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: storage:local:file:read
vars:
path: ./request.json
- name: sent
component: http:request
vars:
url: '{{ env.API }}/orders'
'...': '{{ read_json.content }}'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 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:request
locals:
body_map:
title: '{{ var.model_title }}'
provider: '{{ strapi.provider }}'
enabled: true
vars:
method: POST
url: '{{ env.STRAPI_API_URL }}/api/models'
body: '{{ body_map | wrap:''data'' | json }}'body_map is a local holding a map. The body field wraps it under a data key and JSON-encodes it in one chain, so the readable structured form and the encoded string sit side by side and only the encoded string reaches the component. This is what locals is for. An intermediate value nothing else reads belongs in a local rather than in a step of its own.
Next, control flow shows how conditions and loops decide which steps run and how often.