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

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

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

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

| 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: `toUpper` / `toLower`

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

```text
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 -->
| 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` | `parseJSON`, `toJson`, `toLower`, `toUpper` | Axiom'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:

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

## 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).
