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.

  • An edge's gate condition over the WHOLE message. Set op: CEL and leave field unset (ADR-133 C7). Every declarable scalar field of the source message is bound by its own name — proto3-absent scalars are zero-filled, so an unset field compares as its zero rather than erroring — and value is bound to the whole message context. This is the form to use when one decision depends on several fields at once:

    priority > 8 && category == "billing"

    The expression must return a bool; anything else is reported as a TYPE_MISMATCH repair error. The canvas gate editor's "Use CEL expression" toggle emits exactly this field-less shape.

  • 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:
  image: annotated        # dest-keyed: destination `image` <- source `annotated` (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 "annotated" maps message type "pkgA.Image" into differently-typed
message field "image" (type "pkgB.Image") … reconstruct the destination in CEL
by mapping each leaf field explicitly (e.g. {"image.<field>": "annotated.<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).

A compose cel_expr is the one CEL site NOT compiled at graph compile. Every other expression on this page — edge adapters, conditions, HITL templates — is type-checked against real message descriptors when the graph compiles. A compose_plan binding's cel_expr is stored verbatim: the CLI validates only that the source alias is incident to the join, so a field path that doesn't exist on that source fails at run time, not at compile. Prefer a field pick (src_edge_id + src_path), which is checked at compile, and dry-run any cel_expr with axiom flow preview --node <consumer> before relying on it.

An edge id has to be a legal CEL identifier to be addressable this way; ids generated from a flow.yaml are (edge_retrieve_answer_0), and in a flow.yaml you write the SOURCE NODE ALIAS — inputs.retrieve.text — which the CLI rewrites to the edge id at compile. A canvas-authored id containing a hyphen can't be referenced in an expression; wire that edge with a plain pick instead.

A CEL identifier is [A-Za-z_][A-Za-z0-9_]*. The reserved facade endpoint @flow_input (ADR-172), and any node alias containing a hyphen, therefore cannot be written in dotted form at all. In a flow.yaml compose expression, address those with bracket syntax, which the CLI rewrites exactly like the dotted form:

inputs["@flow_input"].text == inputs.decode.text
inputs["my-node"].value

Single and double quotes both work. This is the supported way for a compose expression to read the flow's own input — for instance to compare an input against a node's output — so no throwaway node is needed just to give that value a referenceable alias. inputs.@flow_input is rejected at compile with a message naming the bracket form. A field pick needs no quoting either way: src_edge: '@flow_input' has always addressed the endpoint directly.

Reaching repeated, nested, and cross-source fields

Each incident edge is bound two ways at once: as typed scalar leaves (inputs.<edge>.count is checked as an int, so a cross-edge type mismatch fails at compile) and as the whole message under inputs.<edge> (a dynamic value). The whole-message binding is what makes a repeated field, a repeated-message element, or a deep nested path reachable — inputs.<edge>.tags[0], inputs.<edge>.items[0].id, size(inputs.<edge>.items), and a cross-source merge like inputs.a.title + " / " + inputs.b.tags[0]. This lets a fan-in consumer combine its branches in a single compose expression rather than serializing each branch and merging it in a downstream node. (Field names here are proto snake_case — the same casing toJson(value) emits, axiom flow mapping-scope advertises, and an axiom invoke / flow-run response returns; the whole surface is snake_case.)

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, every field on it reads as its type's zero value (empty string, 0, false, …) rather than failing the expression — but note what happens one layer up: on an AND join (every incident edge required) a compose binding whose source produced no payload fails the JOIN, naming that edge, instead of dispatching the consumer with the field missing. A QUORUM_K_OF_N join, or one with failure_policy: TOLERATE_FAILURES, opens with a partial set by design and keeps the zero-value reading.

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

Axiom registers six custom CEL functions alongside the cel-go extension. Note the call form: toUpper/toLower/toJson are plain global functions, while parseJSON/padStart/padEnd are member (receiver) calls.

FunctionSignatureExample
toUppertoUpper(string) -> stringtoUpper(text)"HELLO"
toLowertoLower(string) -> stringtoLower(text)"hello"
parseJSONstring.parseJSON() -> dynstring(body).parseJSON().status
toJsontoJson(dyn) -> stringtoJson(value)
padStartstring.padStart(int) -> string, string.padStart(int, string) -> stringmonth.padStart(2, "0")"07"
padEndstring.padEnd(int) -> string, string.padEnd(int, string) -> stringcode.padEnd(8)

toUpper/toLower 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). They are Go's strings.ToUpper/strings.ToLower under the hood (full Unicode case folding, not the ASCII-only ext.Strings() equivalents above).

parseJSON is a receiver on a string — write string(body).parseJSON(), casting first when the source field is bytes. It decodes with UseNumber(), so a JSON integer becomes a precise CEL int (beyond 2^53) and only non-integral numbers become doubles. Guard the result with has(x.k) && x.k != null: a JSON key can be absent or present-and-null.

toJson serializes any value — a parseJSON result, a message-shaped dyn, a CEL map/list literal — to a JSON string with sorted object keys, so the output is deterministic. Keys are proto names (snake_case).

padStart/padEnd mirror JavaScript's String.prototype.padStart/padEnd, taking a target width and an optional pad string (default " "). They exist because cel-go's string.format() supports no width or zero-pad clause at all ("%04d".format([m]) is a hard parse error), which made assembling a date or time string a hand-rolled ternary per component. Width is bounded at 1024 runes.

These six are the only 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: v4), 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/toJson/padStart/padEndpadEnd, padStart, parseJSON, toJson, toLower, toUpperAxiom's custom functions: Unicode-correct toUpper/toLower; 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); and <string>.padStart(width[, pad]) / .padEnd(width[, pad]) for width and zero padding, which cel-go's own format() has no clause for.

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.

Width and zero padding: padStart / padEnd

cel-go's format() accepts no width or zero-pad clause. "%04d".format([m]) and "%02d".format([m]) are parse errors (unrecognized formatting clause "0"), as is any width like %5s; only %d/%s and float precision (%.3f) work. Use padStart/padEnd instead — they mirror JavaScript's String.prototype.padStart/padEnd:

string(month).padStart(2, "0")        # 7      -> "07"
string(year).padStart(4, "0")         # 7      -> "0007"
"code".padEnd(8, ".")                 # "code" -> "code...."
"x".padStart(5)                       # pad string defaults to " "

The pad string is optional (default " ") and repeats, truncated to fit. Width is counted in runes, not bytes, so non-ASCII text lines up as read. A string already at least that wide is returned unchanged, and width is capped at 1024.

Guarding a value that might not be there

Which guard is correct depends entirely on what kind of value you are guarding, and the three kinds behave differently. Using the wrong one is either a compile error or — worse — a guard that silently never fires.

What you are guardingCorrect guardWhy
A typed proto3 singular scalar (string, int32, bool, …)x == "" ? <fallback> : x (== 0, == false)proto3 singular scalars have no field presence. Unset and zero are the same value.
A message-typed field, or a repeated field(value.?msg).orValue(<fallback>)Message fields do have presence, so the optional guard genuinely fires.
A dyn value from parseJSON() (or any parsed JSON)has(x.k) && x.k != null ? … : <fallback>A JSON key can be genuinely absent or present-and-null; both halves are load-bearing.

The two failure modes this table exists to prevent:

1. An optional guard on a scalar never fires. A proto3 singular scalar is always readable — the CEL scope zero-fills every declared scalar so an absent field still resolves — so .? always reports it present:

(value.?raw_json).orValue("{}")     # ✗ NEVER fires; "" flows downstream
raw_json == "" ? "{}" : raw_json    # ✓

This bites hardest on a connector's error path, where a non-200 leaves the field unset: the "" travels several nodes before surfacing as somebody else's domain error (a downstream jq node answering invalid JSON input: EOF). It is invisible to preview, because a hand-built sample has the field populated. axiom flow compile now rejects this shape when the fallback differs from the field's zero value, and names the rewrite. A fallback that is the zero value (orValue(""), orValue(0)) is a no-op either way and stays legal.

2. A null comparison on a typed leaf is a type error. In a compose expression, inputs.<edge>.<path> leaves are typed, and CEL has no string/null comparison:

inputs.rates.raw_json != null   # ✗ TYPE_MISMATCH: no matching overload for '_!=_' applied to '(string, null)'
inputs.rates.raw_json == ""     # ✓

!= null is right only for genuinely nullable dyn values — the output of parseJSON(), not a typed proto leaf. Note the asymmetry: the same != null compiles harmlessly in a single-source edge adapter, where fields bind dynamically, and fails only in a compose expression. Write == "" for a scalar in both places and the question never arises.

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.