A formula step computes one or more named values from earlier step outputs, input, or context — using a small function library, not a general-purpose expression language.
No infix operators
Formulas are function-call syntax only. price + tax does not parse — write ADD(price, tax). There is no +, -, ==, or && — every operation is a function call.
Syntax
- Literals — strings (
'...'or"...", with\n/\tescapes), numbers,true/false,null. - Refs — a dotted path with no braces:
input.email,context.orgCode, orget_course.name(any earlier step's output, by itsoutputAsname — nosteps.prefix, no{{ }}). - Function calls —
FN(arg1, arg2, ...). Function names are case-insensitive (if(...)andIF(...)are the same call) but are always written upper-case by convention. - Nesting — arguments can themselves be calls or refs:
ROUND(DIVIDE(total, count), 2).
Config shape
{
"type": "formula",
"outputAs": "pricing",
"config": {
"expressions": [
{ "name": "subtotal", "formula": "MULTIPLY(unit_price, qty)", "type": "number" },
{ "name": "label", "formula": "CONCAT(UPPER(sku), ' - ', TEXT(qty))", "type": "string" }
]
}
} Each expression's type (string/number/boolean/date) coerces the result after evaluation — e.g. an object coerced to string is JSON-stringified, a numeric-looking value coerced to number is cast with Number(). Reference a result downstream as {{pricing.subtotal}}.
Builder currently supports one expression
The engine supports an array of named expressions per formula step, but the Formula tab in the logic-block builder only edits a single expression today. If you need several derived values, use separate formula steps for now.
Function reference
This is the complete, current list — an unknown function name fails the step at execution time.
- Logical —
IF(cond, a, b),AND(a, b),OR(a, b),NOT(a),ISBLANK(a)(true fornull/undefined/""). - Math —
ADD(a, b),SUBTRACT(a, b),MULTIPLY(a, b),DIVIDE(a, b)(returnsnullon divide-by-zero, not an error),ROUND(x, n),ABS(x),MAX(a, b),MIN(a, b). - String —
CONCAT(...args),UPPER(x),LOWER(x),TRIM(x),LEN(x),SUBSTRING(x, start, end?),REPLACE(x, find, rep),TEXT(x)(stringifies anything, including objects via JSON). - Utility —
GUID(),RAND()(0-1 float),RAND_INT(min, max),RAND_STRING(length)(alphanumeric, max 256). - Collection aggregates —
COLLECTION_MAX(col, field),COLLECTION_MIN(col, field),COLLECTION_AVG(col, field),COLLECTION_FIND(col, field, matchValue).colis any{items:[...]}-shaped value — a collection variable, or alist_entities/ai_call liststep referenced directly. - Date —
NOW()(ISO timestamp),TODAY()(ISO date),DATEADD(date, n, unit)(unit:days|months|years),DATEDIFF(d1, d2, unit),FORMAT_DATE(date, format)(tokens:YYYY MM DD HH mm ss).
Formula refs don't get the {{...}} shorthands
Every other step's {{...}} templates support .first/.last/.min/.max/.avg/.sum shorthands on a collection. A formula's own bare refs (myVar.field, no braces) use a simpler resolver that only reads real stored properties — .items and .count work directly, but .first/.last/.min/.max/.avg/.sum do not. That's exactly why COLLECTION_MAX/MIN/AVG/FIND exist: they're the formula-context equivalent of those shorthands.
Example — aggregate a collection, then format it
s1: list_entities entity=order_line filters=[{field: order_id, op: eq, value: {{input.order_id}}}]
s2: formula expressions=[
{ name: total, formula: COLLECTION_AVG(s1, amount), type: number },
{ name: summary, formula: CONCAT('Avg line: $', TEXT(ROUND(total, 2))), type: string }
] COLLECTION_* reads .items directly
s1 above is a list_entities output, which is already {items:[...], count} — pass it straight into COLLECTION_AVG as the first argument, no need to reference s1.items.