Skip to main content

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

ReferenceWhat it is
workflow.inputs.XWhat the trigger or caller passed into the run
steps.X.outputs.YField Y of step X's first output item
steps.X.itemsAll of step X's output items
variables.XPer-run variables, seeded by initialValue, written by set-variable
secrets.X / vars.XConfig entries: credentials and settings
execution.id, execution.startedAtMetadata about the current run, plus workflowId, revision, correlationKey, organizationId
signal.payload.XThe payload that resumed a signal-wait, readable by the step right after it
trigger.payload, trigger.sourceThe raw trigger context, on trigger-started runs
$item, $itemIndex, $itemsLengthThe current element, its position, and the batch size, when a step processes items one by one
$itemsThe whole input batch, only in a step that takes the batch at once
inputs.XThe 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.

FunctionReturns
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:

FalsyTruthy
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 default is form hint text, not a runtime value. For real defaults, use || fallbacks or a variable with initialValue.
  • There are no array or object literals. contains(["a","b"], x) won't parse; put the array in a variable's initialValue and write contains(variables.ok_values, x).
  • Read other steps through inputs. Declare what a step reads in its inputs block instead of referencing steps.* from inside config.

Next steps