---
title: "CEL expressions"
description: "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."
category: reference
surfaces: [canvas]
related: [concepts/type-system, concepts/nodes-packages-flows, reference/error-catalog]
last_reviewed: 2026-07-12
---

# CEL expressions

Axiom uses [Common Expression Language](https://cel.dev) (CEL) — via
[`cel-go`](https://github.com/google/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](./error-catalog.md#edge-adapter-cel-errors) 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:

  ```text
  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:

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

```yaml
# 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:

```text
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:

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

### Sources whose alias is not a legal CEL identifier

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:

```text
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:

| Function | Signature | Example |
|---|---|---|
| `charAt` | `string.charAt(int) -> string` | `value.charAt(0)` |
| `indexOf` | `string.indexOf(string) -> int`, `string.indexOf(string, int) -> int` | `value.indexOf("@")` |
| `lastIndexOf` | `string.lastIndexOf(string) -> int`, `string.lastIndexOf(string, int) -> int` | `value.lastIndexOf(".")` |
| `lowerAscii` | `string.lowerAscii() -> string` | `value.lowerAscii()` |
| `upperAscii` | `string.upperAscii() -> string` | `value.upperAscii()` |
| `replace` | `string.replace(string, string) -> string`, `string.replace(string, string, int) -> string` | `value.replace("-", "_")` |
| `split` | `string.split(string) -> list<string>`, `string.split(string, int) -> list<string>` | `value.split(",")` |
| `substring` | `string.substring(int) -> string`, `string.substring(int, int) -> string` | `value.substring(0, 5)` |
| `trim` | `string.trim() -> string` | `value.trim()` |
| `join` | `list<string>.join() -> string`, `list<string>.join(string) -> string` | `value.split(",").join(" \| ")` |
| `reverse` | `string.reverse() -> string` | `value.reverse()` |
| `format` | `string.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**.

| Function | Signature | Example |
|---|---|---|
| `toUpper` | `toUpper(string) -> string` | `toUpper(text)` → `"HELLO"` |
| `toLower` | `toLower(string) -> string` | `toLower(text)` → `"hello"` |
| `parseJSON` | `string.parseJSON() -> dyn` | `string(body).parseJSON().status` |
| `toJson` | `toJson(dyn) -> string` | `toJson(value)` |
| `padStart` | `string.padStart(int) -> string`, `string.padStart(int, string) -> string` | `month.padStart(2, "0")` → `"07"` |
| `padEnd` | `string.padEnd(int) -> string`, `string.padEnd(int, string) -> string` | `code.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 -->
| Family | Functions | Notes |
|---|---|---|
| `optionals` | `first`, `hasValue`, `last`, `optional.none`, `optional.of`, `optional.ofNonZeroValue`, `optional.unwrap`, `or`, `orValue`, `unwrapOpt`, `value` | Optional-value plumbing — required by (and enabled with) `regex.extract`'s `optional<string>` return. |
| `strings` | `charAt`, `format`, `indexOf`, `join`, `lastIndexOf`, `lowerAscii`, `replace`, `reverse`, `split`, `strings.quote`, `substring`, `trim`, `upperAscii` | String member helpers (`value.trim()`) plus `list<string>.join` — full signatures in the [table above](#string-functions-cel-go-extstrings). |
| `math` | `math.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.trunc` | Rounding, absolute value, square root, and bitwise operations on numeric values (`math.ceil(value)`). |
| `lists` | `distinct`, `flatten`, `lists.range`, `slice`, `sort` | List member helpers for de-duplicating, flattening, ordering, windowing, and generating ranges of repeated fields. |
| `sets` | `sets.contains`, `sets.equivalent`, `sets.intersects` | Set-semantics comparisons over two lists (`sets.contains(value, ["a", "b"])`). |
| `encoders` | `base64.decode`, `base64.encode`, `json.encode` | Base64 between `bytes` and `string`, and `json.encode` for a value → JSON string. |
| `regex` | `regex.extract`, `regex.extractAll`, `regex.replace` | RE2 regular expressions: extract the first match (an optional), all matches, or replace matches. |
| `toUpper/toLower/parseJSON/toJson/padStart/padEnd` | `padEnd`, `padStart`, `parseJSON`, `toJson`, `toLower`, `toUpper` | Axiom'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:

| Macro | Call form | What it does |
|---|---|---|
| `all` | `list.all(x, pred)` | true iff every element satisfies the predicate (two-var `list.all(i, x, pred)` also available) |
| `bind` | `cel.bind(name, init, expr)` | bind a computed value to a name once and reuse it in `expr` |
| `exists` | `list.exists(x, pred)` | true iff at least one element satisfies the predicate |
| `existsOne` | `list.exists_one(x, pred)` | true iff exactly one element satisfies the predicate |
| `exists_one` | `list.exists_one(x, pred)` | legacy spelling of the exactly-one quantifier |
| `filter` | `list.filter(x, pred)` | the sublist of elements satisfying the predicate |
| `greatest` | `math.greatest(a, b, …)` | the maximum of the arguments (or of a single list argument) |
| `has` | `has(msg.field)` | test whether a (possibly nested) message field is set |
| `least` | `math.least(a, b, …)` | the minimum of the arguments (or of a single list argument) |
| `map` | `list.map(x, expr)` | project each element through an expression (`list.map(x, pred, expr)` filters first) |
| `optFlatMap` | `opt.optFlatMap(x, expr)` | like optMap but `expr` itself returns an optional (no double-wrapping) |
| `optMap` | `opt.optMap(x, expr)` | map an optional's value through `expr` if present, else stay empty |
| `sortBy` | `list.sortBy(x, key)` | the list sorted ascending by each element's computed key |
| `transformList` | `list.transformList(i, x, expr)` | index-and-value list comprehension (`list.transformList(i, x, pred, expr)` filters first) |
| `transformMap` | `map.transformMap(k, v, expr)` | rebuild a map, transforming each value (keys preserved) |
| `transformMapEntry` | `map.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>

| Family | Functions | Notes |
|---|---|---|
| `stdlib` | `bool`, `bytes`, `contains`, `double`, `duration`, `dyn`, `endsWith`, `getDate`, `getDayOfMonth`, `getDayOfWeek`, `getDayOfYear`, `getFullYear`, `getHours`, `getMilliseconds`, `getMinutes`, `getMonth`, `getSeconds`, `in`, `int`, `matches`, `size`, `startsWith`, `string`, `timestamp`, `type`, `uint` | Conversions (`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`:

```text
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 guarding | Correct guard | Why |
|---|---|---|
| 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:

```text
(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:

```text
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](./error-catalog.md#edge-adapter-cel-errors) and the
`CEL evaluation failed` / `CEL parse error` rows in
[Flow run errors in the editor](./error-catalog.md#flow-run-errors-in-the-editor).
