Skip to content

Template Syntax

QuickFlo has two template languages, used in different places:

  • LiquidJS is the template engine for step configuration — use it to reference step outputs, environment variables, connections, and utility values anywhere in a step’s input fields.
  • JSONLogic is the expression language for control-flow conditions and data-pipeline predicates. It’s used by core.if, core.switch, data.filter, data.reduce, and data.classify. JSONLogic operands accept Liquid {{ template }} form for data access, so the two languages compose naturally — see Conditionals & Data-Pipeline Logic below.
Template autocomplete showing available variables and step outputs

Step outputs are referenced by their step ID directly — not nested under a steps prefix:

{{ my-step-id.someOutputField }}

For example, if you have a step with ID fetch-users that returns a list of users:

{{ fetch-users.data }}
{{ fetch-users.data[0].name }}
{{ fetch-users.totalCount }}

Every step exposes a $meta object with execution metadata:

{{ fetch-users.$meta.success }} // boolean — true if the step ran without throwing AND wasn't an error-severity operational failure
{{ fetch-users.$meta.skipped }} // boolean — true if the step's skipCondition matched
{{ fetch-users.$meta.durationMilliseconds }} // number — execution time in ms
{{ fetch-users.$meta.stepType }} // string — step type name
{{ fetch-users.$meta.operationalStatus }} // 'ok' | 'warning' | 'error' (see Operational Errors)
{{ fetch-users.$meta.error }} // object — present when the step *threw* (execution error)
{{ fetch-users.$meta.operationalErrors }} // array — present when classifyOutput() flagged the result (operational error)

$meta.error vs $meta.operationalErrors vs $errors — three different surfaces, three different jobs:

  • $meta.error (per-step, singular object) — only set when the step’s code threw. Use step.$meta.error.message to read the thrown message.
  • $meta.operationalErrors (per-step, array) — only set when the step ran successfully but the result was a failure (HTTP 4xx, SMTP rejection, file not found, LLM rate limit). The original output is still readable. Use step.$meta.operationalErrors[0].code and .message.
  • $errors (workflow-global, array) — every error from every step in the run, aggregated. Use it to ask “did anything fail anywhere?” — see Operational Errors below.

For “did this specific step succeed?” — step.$meta.success is the simplest check. It’s false for both thrown and operational failures. See the Error Handling cheat sheet for the full mapping.

$meta.error is an object, not a string. When present, it has these fields:

FieldDescription
.messageHuman-readable error message
.nameError class name (e.g., WorkflowError, Five9Error)
.codeOptional machine-readable code (e.g., HTTP_CLIENT_ERROR)
.severityOptional severity level
.isRetryableOptional boolean — true if the engine considers the error transient
{{ fetch-users.$meta.error.message }}
{{ fetch-users.$meta.error.code }}

For operational errors specifically, use $meta.operationalErrors (an array) or the global $errors variable described below.

These global variables are available in every template expression:

VariableDescription
{{ initial.* }}Data passed when the workflow execution started
{{ step-id.* }}Output from a completed step, referenced by its step ID
{{ $env.* }}Environment variables
{{ $connections.* }}Connection credential objects
{{ $vars.* }}Workflow variables set by set-variable steps
{{ $util.* }}Utility generators (UUIDs, timestamps, etc.)
{{ $errors.* }}Array of operational and execution errors that have accumulated during the run — see Operational Errors below

The initial object contains the data that triggered the workflow execution. Its structure depends on the trigger type.

When a workflow is triggered via webhook, the request body fields are spread to the root of initial, and a webhook context object provides access to the full request:

{{ initial.someFieldFromBody }}
{{ initial.webhook.query.page }}
{{ initial.webhook.query.filter }}
{{ initial.webhook.headers['content-type'] }}
{{ initial.webhook.body }}
{{ initial.webhook.params }}

The webhook object contains:

FieldDescription
webhook.bodyRaw request body
webhook.queryURL query parameters (?key=value)
webhook.paramsURL path parameters
webhook.headersRequest headers (lowercase keys)
webhook.filesUploaded files (multipart/form-data) with base64 buffers

For JSON request bodies, all top-level fields are also accessible directly:

// POST body: { "userId": 123, "action": "sync" }
{{ initial.userId }} // 123
{{ initial.action }} // "sync"
{{ initial.webhook.query.debug }} // query param ?debug=true

Form submissions spread the submitted field values to the root and include a form context:

{{ initial.name }}
{{ initial.email }}
{{ initial.form.formName }}
{{ initial.form.submittedAt }}
{{ initial.form.authenticatedUser.username }}

The form object contains:

FieldDescription
form.triggerIdThe trigger ID
form.formNameName of the form
form.submittedAtISO 8601 timestamp of submission
form.authenticatedUserPresent if the form requires authentication
form.authenticatedUser.usernameThe authenticated user’s username
form.authenticatedUser.connectionNameThe form-auth connection used
form.authenticatedUser.metadataCustom metadata from the form-auth connection

Scheduled workflows receive the initialData configured in the trigger settings, spread directly to the root:

// initialData config: { "reportType": "daily", "emailTo": "admin@example.com" }
{{ initial.reportType }} // "daily"
{{ initial.emailTo }} // "admin@example.com"

Event payloads from connected services are spread directly to the root of initial. The structure depends on the event provider:

{{ initial.eventType }}
{{ initial.data.agentId }}
{{ initial.data.newState }}

When executing a workflow manually or via API, the provided initial data is spread to the root:

// API call: POST /workflow-templates/:id/execute { "initial": { "name": "John" } }
{{ initial.name }} // "John"

The set-variable step writes values into $vars. Each set-variable step merges its output into the existing $vars object, so values persist and accumulate across the workflow:

{{ $vars.customerName }}
{{ $vars.retryCount | default: 0 }}

Reference connection credential objects by their connection name:

{{ $connections.my-salesforce }}
{{ $connections.postgres.host }}

See Connections for setup guides.

Reference encrypted environment variables. Variables from the workflow’s default environment are available at the root level:

{{ $env.API_KEY }}
{{ $env.DATABASE_URL }}

Variables from other environments are scoped by environment name:

{{ $env.staging.API_KEY }}

See Environments for details on setup and connection redirection.

$errors is a running array of every operational and execution error that has occurred in the workflow up to the current step. Use it in Continue on Error recovery steps to inspect what went wrong without having to remember which step failed. See the Error Handling guide for the full model.

Each entry has this shape:

{{ $errors | size }} // total entries so far
{{ $errors[0].stepId }} // step that produced the error
{{ $errors[0].type }} // 'operational' or 'execution'
{{ $errors[0].message }} // human-readable error message
{{ $errors[0].code }} // machine code (e.g., HTTP_CLIENT_ERROR)
{{ $errors[0].severity }} // 'error' or 'warning'

$errors is populated even when Continue on Error isn’t set — but in that case the workflow halts on the first error, so only the failing step’s entry is visible. With Continue on Error enabled on a step (or globally), downstream steps can branch on $errors to react to upstream failures.

$util provides zero-input generators — each call produces a fresh value:

UtilityTypeDescription
$util.uuidstringRandom UUID v4
$util.nowstringCurrent ISO 8601 timestamp
$util.timestampnumberUnix timestamp (milliseconds)
$util.timestampSecondsnumberUnix timestamp (seconds)
$util.todaystringToday’s date (YYYY-MM-DD)
$util.fileTimestampstringFile-safe date/time (YYYY-MM-DD_HHmmss)
$util.randomnumberRandom integer 0–100
$util.randomFloatnumberRandom float 0–1
$util.passwordstringSecure random 16-character password
$util.randomHexstring32 random hex characters
$util.randomBase64string32 random bytes as base64
$util.emptyObjectobjectEmpty {}
$util.emptyArrayarrayEmpty []
$util.truebooleanBoolean true
$util.falsebooleanBoolean false
$util.nullnullNull value

Example:

{{ $util.uuid }}
// → "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
{{ $util.now }}
// → "2026-02-18T14:30:00.000Z"

Filters transform values using the pipe (|) syntax:

{{ fetch-users.name | upcase }}
{{ fetch-users.items | size }}
{{ some-step.createdAt | date: "%Y-%m-%d" }}

QuickFlo supports all standard LiquidJS filters (string, math, array, date, etc.) plus the custom filters listed below.

FilterDescriptionExample
base64EncodeEncode string to Base64{{ "hello" | base64Encode }}aGVsbG8=
base64DecodeDecode Base64 string{{ "aGVsbG8=" | base64Decode }}hello
fromJsonParse JSON string to object{{ jsonString | fromJson }}
toJsonConvert value to JSON string{{ obj | toJson }}
FilterDescriptionExample
basicAuthBase64-encode username:password{{ "user" | basicAuth: "pass" }}
basicAuthHeaderFull Basic ... header value{{ "user" | basicAuthHeader: "pass" }}
basicAuthHeaderFromObjectBasic auth header from object with username/password fields{{ creds | basicAuthHeaderFromObject }}
FilterDescriptionExample
toStringConvert to string (objects become JSON){{ 123 | toString }}"123"
toNumberConvert to number (0 if invalid){{ "42" | toNumber }}42
toIntConvert to integer (truncates decimal){{ "12.9" | toInt }}12
toFloatConvert to float{{ "12.5" | toFloat }}12.5
toBooleanConvert to boolean ("true", "1", "yes" → true){{ "true" | toBoolean }}true
toArrayWrap value in array (parses JSON strings){{ "hello" | toArray }}["hello"]
FilterDescriptionExample
arrayCreate array from arguments{{ "" | array: 1, 2, 3 }}[1, 2, 3]
rangeCreate number range{{ 0 | range: 5 }}[0, 1, 2, 3, 4]
findFind first item where field equals value{{ users | find: "id", 42 }}
distinctByRemove duplicates by field{{ users | distinctBy: "email" }}
distinctByFieldsRemove duplicates by multiple fields{{ items | distinctByFields: "name", "email" }}
arrayConcatConcatenate arrays{{ arr1 | arrayConcat: arr2 }}
pushAdd item to end of array{{ items | push: "new" }}
FilterDescriptionExample
pickKeep only specified keys{{ user | pick: "name", "email" }}
omitRemove specified keys{{ user | omit: "password" }}
keysGet array of object keys{{ user | keys }}
valuesGet array of object values{{ user | values }}
objectMergeMerge objects together{{ defaults | objectMerge: overrides }}
FilterDescriptionExample
padStartPad string at start to target length{{ "5" | padStart: 3, "0" }}"005"
padEndPad string at end to target length{{ "hi" | padEnd: 5, "." }}"hi..."
substringExtract substring by index{{ "hello" | substring: 1, 4 }}"ell"
includesCheck if string contains substring{{ "hello" | includes: "ell" }}true
startsWithCheck if string starts with prefix{{ "hello" | startsWith: "he" }}true
endsWithCheck if string ends with suffix{{ "hello" | endsWith: "lo" }}true
strConcatConcatenate multiple values{{ "Hello" | strConcat: " ", "World" }}"Hello World"
stripDiacriticsRemove accents and diacritical marks (é→e, ñ→n, ü→u){{ "José García" | stripDiacritics }}"Jose Garcia"
FilterDescriptionExample
eqEqual{{ status | eq: "active" }}
neNot equal{{ status | ne: "deleted" }}
gtGreater than{{ age | gt: 18 }}
gteGreater than or equal{{ score | gte: 100 }}
ltLess than{{ price | lt: 50 }}
lteLess than or equal{{ count | lte: 10 }}
FilterDescriptionExample
coalesceFirst non-null value{{ value | coalesce: "fallback" }}
ternaryConditional value selection{{ isAdmin | ternary: "Admin", "User" }}
andLogical AND{{ hasAccess | and: isActive }}
orLogical OR{{ isAdmin | or: isManager }}
notLogical NOT{{ isDeleted | not }}
FilterDescriptionExample
minMinimum of values{{ 5 | min: 3, 8 }}3
maxMaximum of values{{ 5 | max: 3, 8 }}8
sumSum all numbers in array{{ amounts | sum }}
avgAverage of numbers in array{{ scores | avg }}
FilterDescriptionExample
nowCurrent ISO 8601 timestamp{{ "" | now }}
timestampCurrent Unix timestamp (ms){{ "" | timestamp }}
formatCurrentDateTimeFormat current time{{ "" | formatCurrentDateTime: "YYYY-MM-DD" }}
FilterDescriptionExample
hashCryptographic hash (sha256, sha512, md5){{ "data" | hash }} or {{ "data" | hash: "md5" }}
hmacHMAC signature{{ "message" | hmac: "secret" }}
passwordGenerate secure random password{{ "" | password }} or {{ "" | password: 24, false }}
randomHexRandom hex string{{ "" | randomHex }} → 32 hex characters
randomBase64Random base64 string{{ "" | randomBase64 }}

All telephony filters use libphonenumber-js for parsing and validation.

FilterDescriptionExample
toE164Convert to E.164 international format{{ "202-555-1234" | toE164: "US" }}+12025551234
e164ToNanpaConvert E.164 to NANPA national format{{ "+12025551234" | e164ToNanpa }}(202) 555-1234
isValidPhoneCheck if phone number is valid{{ "202-555-1234" | isValidPhone: "US" }}true
isValidE164Check if strictly valid E.164 format{{ "+12025551234" | isValidE164 }}true
formatPhoneFormat to NATIONAL, INTERNATIONAL, or E.164{{ "+12025551234" | formatPhone: "INTERNATIONAL" }}
phoneCountryGet ISO country code{{ "+12025551234" | phoneCountry }}US
phoneCallingCodeGet country calling code{{ "+12025551234" | phoneCallingCode }}1
phoneNationalNumberGet national number without country code{{ "+12025551234" | phoneNationalNumber }}2025551234

The toE164, isValidPhone, and formatPhone filters accept an optional default country code (e.g., "US") for parsing numbers without a country prefix.

When using data transformation steps like map, filter, or reduce, these variables are available within each iteration:

VariableDescription
{{ $item }}Current item being processed
{{ $original }}Original item before transformation
{{ $this }}Alias for $item (useful for primitives)
{{ $index }}Zero-based iteration index
{{ $isFirst }}true for the first item
{{ $isLast }}true for the last item

Example in a map step expression:

{{ $item.firstName }} {{ $item.lastName }} ({{ $index }})

These tags work inside Liquid template strings (step config fields, return values, etc.). For if/switch steps and data-pipeline predicates, see Conditionals & Data-Pipeline Logic below.

Templates support LiquidJS control flow tags for conditional content:

{% if fetch-users.$meta.success %}
Found {{ fetch-users.data | size }} users
{% else %}
Step failed: {{ fetch-users.$meta.error.message }}
{% endif %}

See the LiquidJS tags documentation for if/elsif/else, unless, case/when, for loops, and more.

Conditionals & Data-Pipeline Logic (JSONLogic)

Section titled “Conditionals & Data-Pipeline Logic (JSONLogic)”

core.if, core.switch, and the predicates inside data.filter, data.reduce, and data.classify evaluate JSONLogic expressions — not Liquid. JSONLogic is a JSON-shaped expression language with operators as keys and operands as arrays.

The recommended way to reference data inside a JSONLogic operand is the template form — wrap a Liquid expression in a string:

{ "==": ["{{ $item.status }}", "active"] }

QuickFlo’s resolver renders the templates first, then hands the rendered tree to the JSONLogic evaluator. This is the same syntax you use everywhere else in step config, so there’s nothing new to learn.

The legacy form uses a var operator with a path string:

{ "==": [{ "var": "$item.status" }, "active"] }

Both forms still work, but the template form is preferred — it’s consistent with the rest of the platform and supports filters, default values, and the full Liquid grammar inside the operand.

CategoryOperators
Equality==, !=, ===, !==
Comparison>, <, >=, <=
Logicand, or, !, !!
Branchingif (ternary-style)
Membershipin (substring or array contains)
Existencevar, missing, missing_some
Arithmetic+, -, *, /, %, min, max
Stringcat (concat), substr, strlen
Regexmatches — case-sensitive regex match (QuickFlo-specific, with an internal LRU cache)
Debuglog

core.if — branch on a step output:

{
"stepId": "is-paid",
"stepType": "core.if",
"input": {
"condition": { ">": ["{{ fetch-customer.body.balance }}", 0] },
"then": { "steps": [ /* charge them */ ] },
"else": { "steps": [ /* skip */ ] }
}
}

core.switch — multiple cases on the same value:

{
"stepId": "route",
"stepType": "core.switch",
"input": {
"cases": [
{
"id": "north-america",
"when": { "in": ["{{ initial.country }}", ["US", "CA", "MX"]] },
"steps": [ /* ... */ ]
},
{
"id": "europe",
"when": { "in": ["{{ initial.country }}", ["UK", "DE", "FR"]] },
"steps": [ /* ... */ ]
}
],
"default": { "steps": [ /* ... */ ] }
}
}

data.filter — keep items matching a predicate:

{
"stepId": "active-only",
"stepType": "data.filter",
"input": {
"items": "{{ fetch-users.body.data }}",
"filter": {
"and": [
{ "==": ["{{ $item.status }}", "active"] },
{ ">": ["{{ $item.lastLoginDays }}", 0] }
]
}
}
}

data.reduce — conditional aggregation:

{
"stepId": "summarize",
"stepType": "data.reduce",
"input": {
"items": "{{ fetch-orders.body.data }}",
"reduce": {
"highValueCount": {
"operation": "count",
"condition": { ">": ["{{ $item.amount }}", 1000] }
},
"totalRevenue": {
"operation": "sum",
"field": "{{ $item.amount }}"
}
}
}
}

matches — regex predicate:

{ "matches": ["{{ $item.email }}", "@(gmail|yahoo)\\.com$"] }

Use {% literal %} to preserve template syntax that should not be resolved — useful when passing templates to external APIs:

{% literal %}Hello {{name}}, your order {{orderId}} is ready{% endliteral %}

This outputs the raw string Hello {{name}}, your order {{orderId}} is ready without resolving the {{ }} expressions.

Templates support up to 5 levels of recursive resolution. This means a template can resolve to another template, which resolves again:

{{ $env.MY_CONNECTION }}

If $env.MY_CONNECTION contains the string {{$connections.production-db}}, the engine resolves it further to the actual connection object. This is the foundation of environment connection redirection.