Expressions
Expressions are how data moves through a workflow: they take what one step produced and put it where the next step needs it.
How to write an expression
Expressions live inside ${{ ... }}. When the whole value is one expression, the result keeps its type: a number stays a number, an array stays an array. When it's embedded in text, the result becomes part of the string:
inputs:
findings: "${{ steps.detect_drift.outputs.findings }}" # stays an array
title: "Deprecated AMI on scope ${{ inputs.finding.scope_name }}" # becomes text
Inside an expression you get the usual operators: arithmetic, comparisons, &&, ||, !, and the ternary cond ? a : b. Use || as a fallback: ${{ workflow.inputs.due_days || vars.DUE_DAYS }} uses the run input when it was provided.
What you can reference
| Reference | What it is |
|---|---|
workflow.inputs.X | What the trigger or caller passed into the run |
steps.X.outputs.Y | Field Y of step X's first output item |
steps.X.items | All of step X's output items |
variables.X | Per-run variables, seeded by initialValue, written by set-variable |
secrets.X / vars.X | Config entries: credentials and settings |
execution.id, execution.startedAt | Metadata about the current run, plus workflowId, revision, correlationKey, organizationId |
signal.payload.X | The payload that resumed a signal-wait, readable by the step right after it |
trigger.payload, trigger.source | The raw trigger context, on trigger-started runs |
$item, $itemIndex, $itemsLength | The current element, its position, and the batch size, when a step processes items one by one |
$items | The whole input batch, only in a step that takes the batch at once |
inputs.X | The step's own inputs block, resolved |
The last row is the most common source of confusion: ${{ inputs.X }} is not ${{ workflow.inputs.X }}. Bare inputs is how a step's config reads what its own inputs block declared, including forEach item variables.
Here is each reference as a real line from published suites like AMI drift:
due_days: "${{ workflow.inputs.due_days || vars.AMI_DRIFT_DUE_DAYS }}" # run input, falling back to a config entry
rows: "${{ steps.lake_deployments.outputs.rows }}" # one field of a step's output
decisions: "${{ steps.decide.items }}" # all of a step's output items
drift_set: "${{ variables.drift_set }}" # a per-run variable
apiKey: "${{ secrets.NP_API_KEY }}" # a secret config entry
category_slug: "${{ vars.AMI_DRIFT_CATEGORY_SLUG || 'engineering' }}" # a variable config entry, with fallback
correlationKey: "deployagent:${{ execution.id }}" # run metadata, namespacing a signal key
description: "${{ $item.description }}" # the item currently being processed
action_item_id: "${{ inputs.id }}" # the step's own declared inputs, read from config
Example: wiring three steps
Three steps: a lake query produces rows, code turns them into findings, and a log message reports the total. Each step declares what it consumes and the expressions do the wiring:
- id: detect
type: module
plugin_type: code-exec
inputs:
rows: "${{ steps.lake_query.outputs.rows }}" # consume the query's rows
config:
code: |
var rows = inputs.rows || []; # read them as bare inputs
var findings = rows.filter(function (r) { return !r.owner; });
return { findings: findings, total: findings.length };
- id: log_summary
type: module
plugin_type: log
config:
message: "Scan finished: ${{ steps.detect.outputs.total }} findings."
Built-in functions
Sixteen functions are available inside any expression.
| Function | Returns |
|---|---|
len(x) | Length of a string or array, key count for an object, 0 for null |
contains(haystack, needle) | Substring test on a string, strict-equality membership on an array |
concat(a, b, ...) | One array from any number of arrays. Nulls count as empty, and a non-array argument is wrapped, so concat(variables.results, $items) works |
default(x, fallback) | x unless it is null or undefined |
upper(s), lower(s), trim(s) | The obvious string operations. Non-strings are stringified first, so upper(42) is "42" |
floor(n), ceil(n), round(n), abs(n) | The matching Math operations |
json(x) | x serialized to a JSON string |
parseJson(s) | s parsed. Throws on invalid JSON |
now() | The current time, ISO 8601 |
uuid() | A fresh UUID v4 |
random() | A float in [0, 1) |
The last three look random, but inside a run they aren't: the engine uses replay-safe versions, so a replayed execution gets the same values it got the first time.
Use default() instead of || when zero or an empty string are valid values, because || treats them as missing.
What counts as true in a condition
Deciders and connection conditions follow JavaScript rules:
| Falsy | Truthy |
|---|---|
null, undefined, false, 0, NaN, "" | [], {}, and everything else |
An empty array counts as true, so a decider that tests an array to ask "did we find anything?" fires on every run. Compare the length instead: steps.detect.items.length > 0.
Common mistakes
- Deciders and connection conditions take bare expressions, without
${{ }}:expression: "steps.detect.outputs.total > 0". - A manual input's
defaultis form hint text, not a runtime value. For real defaults, use||fallbacks or a variable withinitialValue. - There are no array or object literals.
contains(["a","b"], x)won't parse; put the array in a variable'sinitialValueand writecontains(variables.ok_values, x). - Read other steps through
inputs. Declare what a step reads in itsinputsblock instead of referencingsteps.*from insideconfig.
Next steps
- Secrets and variables: where
secrets.*andvars.*come from - Items and data flow: what
$itemand$itemsmean, and when each exists - Nodes: the inputs and outputs these expressions wire together