Workflow expressions
Steps can reference inputs and previous step outputs using {{ expression }} syntax:
| Namespace | Description |
|---|---|
inputs.spec |
Workflow input values |
steps.specify.output.file |
Output from a previous step |
item |
Current item in a fan-out iteration |
fan_in |
Fan-in aggregation context |
context.run_id |
Current workflow run ID |
context.workflow_dir |
Resolved absolute path to the workflow source directory. Empty string for string-loaded workflows. |
Available filters: default, join, contains, map, from_json, to_json, upper, lower, split, length.
Syntax
| Form | Example |
|---|---|
| Dot-path lookup | {{ inputs.name }}, {{ steps.test.output.exit_code }} |
| Literals | Strings, numbers, true, false, none/null, and lists such as [1, 2] |
| Comparisons | ==, !=, >, <, >=, <= |
| Boolean logic | and, or, not |
| Membership | in, not in |
| Filters | {{ inputs.tags \| split(',') \| length }} |
This is a dedicated expression language, not full Jinja2 or Python.
Filters
| Filter | Example | Behavior |
|---|---|---|
default |
{{ val \| default('fb') }} |
Fallback for None or an empty string |
join |
{{ list \| join(', ') }} |
Join list elements into a string |
contains |
{{ text \| contains('sub') }} |
Substring or membership check |
map |
{{ list \| map('attr') }} |
Extract an attribute from each item |
from_json |
{{ out \| from_json }} |
Parse a JSON string into a typed value |
to_json |
{{ obj \| to_json }} |
Serialize a value to a JSON string — the inverse of from_json; mapping keys must be strings |
upper |
{{ text \| upper }} |
Uppercase a string |
lower |
{{ text \| lower }} |
Lowercase a string |
split |
{{ csv \| split(',') }} |
Split a string on a separator into a list of strings |
length |
{{ items \| length }} |
Number of elements in a list, or characters in a string |
default falls back only for None and the empty string. Other falsy values — 0, false, [], {} — are passed through unchanged, so {{ count | default(10) }} still yields 0 for a zero count. Falsy is not the same as empty here.
Notes on the newer filters:
Types are validated, not coerced.
upperandloweraccept strings only,splitrequires both a string value and a non-empty string separator, andlengthaccepts lists and strings but rejects mappings. Anything else raises aValueErrornaming the problem. Coercion is deliberately not performed: a type mismatch nearly always means the workflow is wired to the wrong variable, and a coerced result would hide that. A filter given the wrong number of arguments (| upper('x'),| splitwith no separator,| split(',', 1)) is reported as a known filter misused, which is distinct from an entirely unknown filter name: a call carrying more than one argument falls through to that same unsupported-form error rather than being evaluated as a single expression. The older filters (join,map,contains) are more permissive and unchanged:joinstringifies unsupported values andmap/containsreturn fallbacks rather than raising.to_jsonoutput is deterministic. Object keys are sorted and non-ASCII characters are left as-is rather than escaped, so the same value always serializes to the same bytes. That buys reproducibility only — it does not make the result safe to pass through a shell, because interpolation adds no quoting or escaping. Do not interpolate unconstrained JSON into arunfield.to_jsonrejects non-finite floats.NaN,Infinity, and-Infinityraise aValueErrornamingto_jsoninstead of serializing to bare tokens. None of the three is valid JSON, so emitting them would hand a standards-compliant downstream parser a string it must reject.to_jsonrequires string mapping keys. JSON objects have string keys, so a mapping with any other key type —{1: "a"},{True: "a"}— raises aValueErrornaming the key type instead. Left tojson.dumps, an integer key would be coerced to"1"and collide with an existing"1"key, while mixed key types would failsort_keyswith an orderingTypeErrorreported only as "not JSON-serializable".Comparing filtered counts requires parentheses. A filter chain cannot be an operand of an operator, so an unparenthesized trailing comparison such as
{{ inputs.items | length > 0 }}raises an "ambiguous filter precedence"ValueError. Parenthesize the filtered operand to compare its result:{{ (inputs.items | length) > 0 }}. For a threshold of at least two:condition: "{{ (inputs.items | length) >= 2 }}"For an emptiness check, use the filtered count's truthiness directly:
condition: "{{ inputs.items | length }}" # 0 is False, any non-zero count is True
and and or return true or false and evaluate their right side only when the left side does not decide the result: and skips it when the left side is false, and or skips it when the left side is true. A condition can therefore guard a filter that would fail on an empty value. {{ inputs.payload != '' and (inputs.payload | from_json | length) }} is false for an empty optional payload instead of failing the run on invalid JSON. When the right side is evaluated, its errors still fail the run.
Operators bind from loosest to tightest as or, and, not, then the comparisons. All comparisons (==, !=, >, <, >=, <=, in, not in) share one level and are evaluated left to right; none has priority over another. {{ a == b > 0 }} therefore means {{ (a == b) > 0 }}. To combine comparisons, use parentheses or and/or.
The body of every {{ }} block must be a valid expression. Text that is not one, such as prose ({{ some text }}), a key with characters other than letters, digits, _ and - ({{ inputs.weird:key }}), or arithmetic ({{ count + 1 }}), raises a ValueError naming the offending token. Numbers may be written as 5, -5, +5, 1_000, 0.5, .5, 5., or 1.5e3.
Compatibility: Earlier releases could resolve an expression such as
{{ inputs.weird:key }}when the key existed; this expression now fails at runtime. Rename keys you control (for example,weird:keytoweird_key) and update their references. Quoted-key lookup is not supported. Workflow validation may not detect these expressions before execution.
Example:
condition: "{{ steps.test.output.exit_code == 0 }}"
args: "{{ inputs.spec }}"
message: "{{ status | default('pending') }}"
tag_count: "{{ inputs.tags | split(',') | length }}"
shell_flag: "{{ inputs.branch | upper }}"
Interpolation and shell safety
Expressions are resolved by plain string substitution — the value of {{ ... }} is spliced into the surrounding text exactly as-is, with no quoting or escaping added. That is convenient for building args and message strings, but it has an important consequence for shell steps: a run field is handed to the system shell (/bin/sh -c on POSIX), so any interpolated value is interpreted as shell syntax, not just data.
If an interpolated value can contain characters like ;, |, &, $( ), backticks, or quotes, it can change or extend the command that actually runs. This matters most when the value is not fully under the workflow author's control:
- Workflow
inputs.*— supplied by whoever runs the workflow. - A prior step's output, e.g.
{{ steps.plan.output.stdout }}— for acommandorpromptstep withcapture: truethis is text produced by the AI agent, which can in turn be influenced by files, tickets, or web content the agent read. Treat agent output as untrusted when it flows into ashellstep.
There is no shell-escaping filter in the expression language and no sandbox around a shell step. Prefer strict: true with env bindings to keep dynamic values out of shell source. Strict mode does not sanitise data, inspect commands, or prevent an authored script from interpreting data as code. Do not use eval, build another sh -c command from a variable, or pass untrusted values through shell startup variables such as BASH_ENV. On Windows, cmd.exe expansion of %VALUE% can introduce shell syntax; read untrusted values inside a program instead. On POSIX, quote variable expansions and use the target program's argument validation or -- where appropriate to prevent option injection.
For compatibility, raw interpolation remains enabled when strict is omitted or false. Its injection risk remains unchanged. Keep values you cannot constrain out of run fields entirely. Scrutinise every non-strict run field that interpolates a value you do not control, and at minimum:
Constrain the value at the source with
enum/an allowlist. Wheninputs.*feeds a non-strictrunfield, restrict it to a fixed set of known-safe values so a caller cannot supply arbitrary shell text at all.inputs: target: type: string enum: [staging, production] # caller cannot inject arbitrary textKeep unconstrained values out of
run. If a value cannot be constrained to an allowlist — most agent/promptoutput — do not interpolate it into arunfield. Branch on it withif/switchagainst fixed conditions, or act on it in acommand/promptstep rather than a shell command built from it.Quoting is not a security boundary. Surrounding a substitution with quotes (
'{{ inputs.x }}') helps the shell treat a trusted value as a single argument and avoids word-splitting on spaces, but a value that itself contains the matching quote character can still break out and inject shell syntax. Quote for correctness on constrained values; never rely on quoting to make an unconstrained substitution safe.Gates do not inspect the next step, and
messageis printed verbatim. Agatestep renders only its ownmessage/show_file— it does not display, resolve, or sanitise the command that follows it, and approval never neutralises an injectable interpolation. Do not interpolate raw untrusted data intomessage: it is printed as-is with no control-character stripping, so agent or caller output could inject terminal/ANSI escapes that alter or hide the approval prompt. Keepmessageto trusted, constrained text, and surface untrusted material for review viashow_fileinstead — its path and contents are control/ANSI-stripped before display.
A shell step is an arbitrary-command primitive by design; these practices reduce exposure and keep which command runs under the author's control, but they do not eliminate the risk of interpolating values you do not fully control.
Runtime Context
{{ context.* }} exposes engine-managed runtime metadata for the
current run:
| Variable | Description |
|---|---|
context.run_id |
The current workflow run id (the same value Spec Kit prints as Run ID: at the end of workflow run). Auto-generated runs are 8-character hex from uuid4; operator-supplied ids may be any alphanumeric string with hyphens or underscores. Empty string outside a run context. |
context.workflow_dir |
The resolved absolute path to the directory containing the workflow source file. For file-loaded workflows this is the parent directory of the YAML file; for installed-by-ID workflows it is the absolute path to the installation directory (e.g. <project>/.specify/workflows/<id>/); for string-loaded workflows it is an empty string. On resume the original source directory is preserved from the first execution. |
# Stamp telemetry events with the run id for cross-system join.
- id: emit-event
type: shell
strict: true
env:
RUN_ID: "{{ context.run_id }}"
run: 'printf ''{"run_id":"%s","event":"started"}\n'' "$RUN_ID" >> events.jsonl'
# Per-run scratch directory.
- id: prep-scratch
type: shell
strict: true
env:
RUN_ID: "{{ context.run_id }}"
run: 'mkdir -p "/tmp/run-$RUN_ID"'
# Pass run id into a command for artifact metadata.
- id: tag-artifact
command: speckit.specify
input:
args: "{{ context.run_id }}"
# Reference a sibling file shipped alongside the workflow definition.
- id: apply-config
type: shell
strict: true
env:
SOURCE_DIR: "{{ context.workflow_dir }}"
run: 'cp "$SOURCE_DIR/defaults.yml" ./config.yml'
These shell examples use POSIX syntax. For portable scripts, read the environment inside the called program.
Typed values
A single expression ({{ expr }} by itself) returns its value without string conversion. Mixed templates (prefix {{ expr }}) return text; an embedded null becomes empty text. Use to_json when a structured value must cross a text boundary.