Public beta — not for production use. Data may be wiped at any time. Questions? Contact us.
Documentation menu

CEL expressions

Every place a CEL expression appears in Axiom — the compose-mapping formula bar, the edge-adapter cel() transform, a gate condition's CEL op, and a HITL node's interrupt_payload_template — the value/srcField bindings, the inputs.<edge>.<path> multi-source syntax, the built-in string functions, and Axiom's two custom functions toUpper/toLower.

View as Markdown

Axiom uses Common Expression Language (CEL) — via cel-go — anywhere a mapping needs more than a straight field pick: computing a target field from one or more source fields, or gating an edge with a boolean condition. This page documents the CEL surface available to you: what identifiers are bound, the multi-source inputs.<edge>.<path> syntax, the built-in string functions, and Axiom's two custom functions.

Where you write CEL

  • The compose-mapping formula bar. When a consumer node has more than one incident edge (a join), select a target field and the ƒx formula bar becomes the one place to type its binding. Typing a bare field name (e.g. email) collapses to a plain pick if it is unambiguous; typing anything else — email != "" ? email : full_name, a multi-source expression, a string-function call — is stored and evaluated as a CEL expression. Click the ? next to ƒx to reopen this page from the panel.
  • The single-source edge adapter. On a single-source edge, each mapping's right-hand side is a single bare CEL expression evaluated against the source message (or a plain field pick when it names a source field). Write the expression directly — int(value), text + "!", toUpper(text) — with no pipe wrapper. See the error catalog for how a failing adapter reports its error.
  • An edge's gate condition, scoped to one field. Set op: CEL and field: <path> on a flow.yaml condition (or the equivalent graph JSON) to evaluate the expression against that field's own value, bound to value (same convention as the single-source edge adapter above) — the condition passes iff the expression returns true. There is no field-less/whole-message CEL condition today: a CEL op with no field set fails to compile (field "" not found in message ...) rather than evaluating against the whole input — so a flow.yaml op: CEL block always needs field too. (The canvas gate editor's own "Use CEL expression" toggle doesn't currently expose a field to scope to, so a condition authored that way hits the same compile failure — use the row-based comparison operators in the canvas editor instead until that toggle is field-aware.)
  • A HITL node's interrupt_payload_template. Evaluated once when the pause fires, with value bound to the whole node input message (not a single field) — value alone reproduces the whole input, value.text extracts one field, and normal CEL string concatenation ("approve order " + value.id) builds a custom string. No source-field alias here since there is no single source field to alias.

The value binding and source-field aliases

Every CEL expression has a variable named value bound to its input — the field (or, in a pipeline, the running value after any prior transforms). When the adapter or binding also knows the source field's name, that name is declared as a second variable bound to the same value, so an expression written naturally against the field's own name works without having to know about value at all:

value == ""            # always works
email == ""            # works too, when the source field is named `email`

If the source message happens to have a field literally named value, the real field wins — Axiom does not silently return the wrong type just to keep the placeholder name meaningful.

Picking a whole message-typed field

A field pick doesn't have to name a scalar — it can name a whole message-typed field, singular or repeated, and copy it across the edge verbatim. This is the basis of the canonical-envelope pattern: a node that outputs a shared Image (or Document, Graph, …) envelope wires straight into a node that consumes the same envelope with a one-line pick.

# source node outputs `annotated` (an Image message); consumer takes `image` (also Image)
adapter:
  annotated: image        # whole-message pick — copies the Image verbatim

The one rule: a whole-message pick is a byte-for-byte copy, so it is only valid when the source field and the destination field are the same message type. Picking one message type into a different message field — even a structurally identical one from another package that doesn't resolve to the same type — is a compile-time error, not a silent misread:

adapter: field pick "image" maps message type "pkgA.Image" into differently-typed
message field "annotated" (type "pkgB.Image") … reconstruct the destination in CEL
by mapping each leaf field explicitly (e.g. {"annotated.<field>": "image.<field>"})

To bridge two different message types, reconstruct the destination in CEL, field by field (annotated.width: image.width, …) or with a CEL object literal — the same explicit-reshape rule every cross-type mapping follows. A cross-type message pick never compiles green and then fails at run.

Multi-source composition: inputs.<edge>.<path>

A join's consumer can have several incident edges, each identified by its canvas edge id. A CEL expression in the compose-mapping formula bar addresses any field on any incident edge with:

inputs.<edge_id>.<field_path>

<field_path> is dotted for nested fields (inputs.edge-a.reviewer.email). Axiom builds the CEL type environment from each edge's declared source message, so referencing an edge id that isn't actually incident to this join, or a field path that doesn't exist on that edge's message, is a compile-time error — it fails before any execution runs, not partway through one. An edge id that isn't a legal CEL identifier (for example one containing a hyphen from the canvas) can't be referenced this way; wire it with a plain pick instead.

The formula bar accepts a bare field name as shorthand — email — and qualifies it to the full inputs.<edge>.<path> form automatically as long as exactly one incident edge has a field with that name. An expression that touches more than one source field always displays and stores the fully qualified form, since that is the only form cel-go's checker (and Axiom's wire-recovery parser) can resolve unambiguously.

If an incident edge hasn't produced a payload yet, every field on it reads as its type's zero value (empty string, 0, false, …) rather than failing the expression.

String functions (cel-go ext.Strings())

Axiom enables the full cel-go strings extension, called on a string value with member-call syntax (value.trim()), plus the two-argument join/ split/replace/substring overloads:

FunctionSignatureExample
charAtstring.charAt(int) -> stringvalue.charAt(0)
indexOfstring.indexOf(string) -> int, string.indexOf(string, int) -> intvalue.indexOf("@")
lastIndexOfstring.lastIndexOf(string) -> int, string.lastIndexOf(string, int) -> intvalue.lastIndexOf(".")
lowerAsciistring.lowerAscii() -> stringvalue.lowerAscii()
upperAsciistring.upperAscii() -> stringvalue.upperAscii()
replacestring.replace(string, string) -> string, string.replace(string, string, int) -> stringvalue.replace("-", "_")
splitstring.split(string) -> list<string>, string.split(string, int) -> list<string>value.split(",")
substringstring.substring(int) -> string, string.substring(int, int) -> stringvalue.substring(0, 5)
trimstring.trim() -> stringvalue.trim()
joinlist<string>.join() -> string, list<string>.join(string) -> stringvalue.split(",").join(" | ")
reversestring.reverse() -> stringvalue.reverse()
formatstring.format(list<dyn>) -> string"%s wins".format([name])

lowerAscii/upperAscii only affect ASCII bytes — for Unicode-aware uppercasing/lowercasing, use Axiom's toUpper/toLower below instead.

Axiom custom functions: toUpper / toLower

Axiom registers two custom global CEL functions alongside the cel-go extension — called as plain functions, not member calls:

toUpper(value)              # "hello" -> "HELLO"
toLower(value) + " world"   # "HELLO" -> "hello world"

Both take exactly one string argument and return a string; calling either with a non-string argument is a runtime CEL error (toUpper requires string / toLower requires string).

These are Go's strings.ToUpper/strings.ToLower under the hood (full Unicode case folding, not the ASCII-only ext.Strings() equivalents above).

toUpper and toLower are the only two custom functions Axiom adds on top of the standard CEL library documented below.

Function catalog

Everything below is enabled everywhere CEL runs in Axiom — the ƒx formula bar, the cel() adapter transform, gate conditions, and HITL templates all share one environment (env_version: v3), so this catalog is the single surface. The same list drives the ƒx bar's autocomplete/function palette and the axiom flow mapping-scope CLI verb, both fed live from GET /api/cel/manifest. Only pure, deterministic functions are enabled: nothing here does I/O, produces randomness, or reads the clock (that guarantee is what keeps preview-equals-runtime and durable replay sound).

Named functions are grouped by family; operators (+, ==, in, ?:, …) are CEL syntax and are not listed.

<!-- BEGIN generated by docs/scripts/gen-cel-catalog.go — do not edit by hand -->
FamilyFunctionsNotes
optionalsfirst, hasValue, last, optional.none, optional.of, optional.ofNonZeroValue, optional.unwrap, or, orValue, unwrapOpt, valueOptional-value plumbing — required by (and enabled with) regex.extract's optional<string> return.
stringscharAt, format, indexOf, join, lastIndexOf, lowerAscii, replace, reverse, split, strings.quote, substring, trim, upperAsciiString member helpers (value.trim()) plus list<string>.join — full signatures in the table above.
mathmath.abs, math.bitAnd, math.bitNot, math.bitOr, math.bitShiftLeft, math.bitShiftRight, math.bitXor, math.ceil, math.floor, math.isFinite, math.isInf, math.isNaN, math.round, math.sign, math.sqrt, math.truncRounding, absolute value, square root, and bitwise operations on numeric values (math.ceil(value)).
listsdistinct, flatten, lists.range, slice, sortList member helpers for de-duplicating, flattening, ordering, windowing, and generating ranges of repeated fields.
setssets.contains, sets.equivalent, sets.intersectsSet-semantics comparisons over two lists (sets.contains(value, ["a", "b"])).
encodersbase64.decode, base64.encode, json.encodeBase64 between bytes and string, and json.encode for a value → JSON string.
regexregex.extract, regex.extractAll, regex.replaceRE2 regular expressions: extract the first match (an optional), all matches, or replace matches.
toUpper/toLower/parseJSON/toJsonparseJSON, toJson, toLower, toUpperAxiom's custom functions: Unicode-correct toUpper/toLower, and the schema-free JSON pair <string>.parseJSON() (Tekton-style receiver method: parse a JSON string to a traversable value, integers kept precise) / toJson (serialize with sorted keys, byte-stable across builds).

Macros

Macros are expanded at compile time (they are not first-class functions). The token is what the manifest reports; the call form is how you write it:

MacroCall formWhat it does
alllist.all(x, pred)true iff every element satisfies the predicate (two-var list.all(i, x, pred) also available)
bindcel.bind(name, init, expr)bind a computed value to a name once and reuse it in expr
existslist.exists(x, pred)true iff at least one element satisfies the predicate
existsOnelist.exists_one(x, pred)true iff exactly one element satisfies the predicate
exists_onelist.exists_one(x, pred)legacy spelling of the exactly-one quantifier
filterlist.filter(x, pred)the sublist of elements satisfying the predicate
greatestmath.greatest(a, b, …)the maximum of the arguments (or of a single list argument)
hashas(msg.field)test whether a (possibly nested) message field is set
leastmath.least(a, b, …)the minimum of the arguments (or of a single list argument)
maplist.map(x, expr)project each element through an expression (list.map(x, pred, expr) filters first)
optFlatMapopt.optFlatMap(x, expr)like optMap but expr itself returns an optional (no double-wrapping)
optMapopt.optMap(x, expr)map an optional's value through expr if present, else stay empty
sortBylist.sortBy(x, key)the list sorted ascending by each element's computed key
transformListlist.transformList(i, x, expr)index-and-value list comprehension (list.transformList(i, x, pred, expr) filters first)
transformMapmap.transformMap(k, v, expr)rebuild a map, transforming each value (keys preserved)
transformMapEntrymap.transformMapEntry(k, v, expr)rebuild a map, transforming each entry into new key/value pairs

stdlib (always on)

<details> <summary>Standard built-in functions — conversions, collection/string membership, size, regex `matches`, and the timestamp accessors — enabled everywhere, plus the standard operators and comprehension macros above.</summary>
FamilyFunctionsNotes
stdlibbool, bytes, contains, double, duration, dyn, endsWith, getDate, getDayOfMonth, getDayOfWeek, getDayOfYear, getFullYear, getHours, getMilliseconds, getMinutes, getMonth, getSeconds, in, int, matches, size, startsWith, string, timestamp, type, uintConversions (int, double, string, bytes, bool, timestamp, duration, dyn, type), membership/size (in, contains, size, startsWith, endsWith, matches), and timestamp accessors (getFullYear, getHours, …).
</details> <!-- END generated by docs/scripts/gen-cel-catalog.go -->

Two ways to explore this catalog interactively instead of reading it here:

  • In the canvas: the ƒx bar's ƒ( ) button opens the function palette (grouped by the same families); typing in the bar autocompletes both in-scope fields and these functions.
  • From the CLI / an agent: axiom flow mapping-scope [--edge <edge-id>] [--json] prints the functions and the exact variables in scope for a specific edge's mapping.

Errors

A CEL expression that fails to parse or compile, or whose type checker rejects a reference, is reported before the flow runs. A CEL expression that raises at evaluation time (a custom function given the wrong type, a runtime error from cel-go) surfaces on the failing edge. See Edge adapter (CEL) errors and the CEL evaluation failed / CEL parse error rows in Flow run errors in the editor.