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/\t escapes), numbers, true/false, null.
  • Refs — a dotted path with no braces: input.email, context.orgCode, or get_course.name (any earlier step's output, by its outputAs name — no steps. prefix, no {{ }}).
  • Function callsFN(arg1, arg2, ...). Function names are case-insensitive (if(...) and IF(...) 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

json
{
  "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.

  • LogicalIF(cond, a, b), AND(a, b), OR(a, b), NOT(a), ISBLANK(a) (true for null/undefined/"").
  • MathADD(a, b), SUBTRACT(a, b), MULTIPLY(a, b), DIVIDE(a, b) (returns null on divide-by-zero, not an error), ROUND(x, n), ABS(x), MAX(a, b), MIN(a, b).
  • StringCONCAT(...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).
  • UtilityGUID(), RAND() (0-1 float), RAND_INT(min, max), RAND_STRING(length) (alphanumeric, max 256).
  • Collection aggregatesCOLLECTION_MAX(col, field), COLLECTION_MIN(col, field), COLLECTION_AVG(col, field), COLLECTION_FIND(col, field, matchValue). col is any {items:[...]}-shaped value — a collection variable, or a list_entities/ai_call list step referenced directly.
  • DateNOW() (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

text
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.