Octov0.4.2
ReferenceCEL

Language Tour

CEL by example: types, operators, strings, lists, maps, and conversions.

A tour of the CEL language as it behaves in Octo, one example at a time. Every expression on this page was evaluated with octo eval, and the results are shown exactly as it prints them — as JSON-native values. Try any of them yourself:

bin/octo eval --expr '[1, 2, 3].filter(x, x % 2 == 1)'
# {"ok":true,"result":[1,3]}

Types and literals

CEL has eight scalar types — int, uint, double, bool, string, bytes, timestamp, duration — plus list, map, and null.

ExpressionResult
4242
-7-7
0x1016
123u123 (a uint literal)
1.51.5
2.0e32000
truetrue
"hello""hello"
b'abc'"YWJj" (bytes render as base64 in JSON output)
[1, 2, "three"][1,2,"three"]
{"name": "Ada", "age": 36}{"age":36,"name":"Ada"}
nullnull

Ints, uints, and doubles are distinct types — 1, 1u, and 1.0 are three different values, and mixing them in operators is a compile error (see Equality and numeric types).

Operators

Arithmetic, comparison, logical operators, and the ternary conditional.

ExpressionResult
2 + 3 * 414
10 - 46
7 / 23 (integer division truncates)
7.0 / 2.03.5
7 % 21
1 < 2true
2 >= 2true
"a" != "b"true
"Hello" < "World"true (strings compare lexicographically)
true && falsefalse
true || falsetrue
!truefalse
1 < 2 ? "yes" : "no""yes"
"foo" + "bar""foobar"

Runtime failures like 1 / 0 surface as evaluation errors (division by zero), which in a flow fail the message into its error path.

Strings

ExpressionResult
"hello".contains("ell")true
"hello".startsWith("he")true
"hello".endsWith("lo")true
"hello".matches("^h.*o$")true
"h3llo".matches("[0-9]")true
size("hello")5
"quota: " + string(85) + "%""quota: 85%"

matches uses RE2 syntax — no backreferences or lookaheads. It is also available as a global: matches("hello", "^h") is the same as "hello".matches("^h").

Octo's CEL environment does not enable the optional strings extension: helpers such as split, lowerAscii, replace, and string indexing ("abc"[0]) are not available and fail to compile. Build strings with +, string(), and matches, or reshape data with an AI Mapping block when transformation gets complex.

Lists and maps

ExpressionResult
[10, 20, 30][1]20
{"a": 1}["a"]1
{"a": 1}.a1
{"a": {"b": {"c": 42}}}.a.b.c42
2 in [1, 2, 3]true
"a" in {"a": 1}true (tests keys, not values)
size([1, 2, 3])3
size({"a": 1, "b": 2})2
[1, 2] + [3, 4][1,2,3,4]

Indexing past the end of a list or reading a missing key is an evaluation error (no such key), not null — see Presence and defaults. There is no slice syntax ([1,2,3][1:2] does not parse) and maps cannot be concatenated with +.

Presence and defaults

Accessing a field that does not exist is an error, so guard optional fields with has() or the in operator before reading them. These examples bind body with --data:

Expression--dataResult
has(body.name){"name": "Ada"}true
has(body.name){}false
has(body.user.email){"user": {"id": 1}}false
"name" in body{"name": "Ada"}true
body.missing{}error: no such key: missing

The idiomatic default is a ternary:

bin/octo eval --expr 'has(body.name) ? body.name : "anonymous"' --data '{}'
# {"ok":true,"result":"anonymous"}

has() takes a field selection (has(body.name)), not a bare variable or an index expression. For map keys held in variables, use key in map instead.

Type conversions

ExpressionResult
string(42)"42"
string(3.14)"3.14"
string(true)"true"
int("42")42
int(3.9)3 (truncates toward zero)
int(-3.9)-3
double("3.14")3.14
double(2)2
uint(42)42
bool("true")true
bytes("hi")"aGk="
string(b'hi')"hi"
timestamp("2026-01-01T00:00:00Z")"2026-01-01T00:00:00Z"
timestamp(1767225600)"2026-01-01T00:00:00Z" (from Unix seconds)
int(timestamp("2026-01-01T00:00:00Z"))1767225600
duration("1h30m")"5400s"

type(x) returns a value's type and is useful in comparisons — type("a") == string evaluates to true — but a type itself is not a JSON-serializable result, so an expression must not return one, and string(type(x)) does not compile.

Timestamps and durations

timestamp parses RFC 3339 strings, duration parses Go-style duration strings ("300ms", "90m", "1h30m", "24h"). The variable now is bound to the evaluation time. Timestamps render back as RFC 3339 strings and durations as seconds.

ExpressionResult
timestamp("2026-01-01T00:00:00Z") + duration("24h")"2026-01-02T00:00:00Z"
timestamp("2026-01-02T00:00:00Z") - timestamp("2026-01-01T00:00:00Z")"86400s"
duration("1h") + duration("30m")"5400s"
duration("1h") < duration("90m")true
timestamp("2020-01-01T00:00:00Z") < timestamp("2026-01-01T00:00:00Z")true
now > timestamp("2020-01-01T00:00:00Z")true
string(now)"2026-07-09T20:26:32.867835-07:00"

Accessor methods extract components. Note the conventions: getMonth() is zero-based (January is 0), getDayOfWeek() is zero-based from Sunday, getDayOfMonth() is zero-based while getDate() is one-based.

ExpressionResult
timestamp("2026-07-09T12:30:45Z").getFullYear()2026
timestamp("2026-07-09T12:30:45Z").getMonth()6 (July, zero-based)
timestamp("2026-07-09T12:30:45Z").getDate()9 (day of month, one-based)
timestamp("2026-07-09T12:30:45Z").getDayOfMonth()8 (zero-based)
timestamp("2026-07-09T12:30:45Z").getDayOfYear()189
timestamp("2026-07-09T12:30:45Z").getDayOfWeek()4 (Thursday; Sunday is 0)
timestamp("2026-07-09T12:30:45Z").getHours()12
timestamp("2026-07-09T12:30:45Z").getMinutes()30
timestamp("2026-07-09T12:30:45Z").getSeconds()45
timestamp("2026-07-09T12:30:45.123Z").getMilliseconds()123
timestamp("2026-07-09T12:30:45Z").getHours("America/New_York")8 (accessors take an optional IANA time zone)

On durations, the accessors convert the whole value: duration("90m").getHours() is 1, duration("90m").getMinutes() is 90, and duration("2h").getSeconds() is 7200.

Equality and numeric types

Equality is deep for lists and maps, and CEL refuses to compile comparisons between statically known distinct types:

ExpressionResult
[1, 2] == [1, 2]true
{"a": 1} == {"a": 1}true
null == nulltrue
b"raw" == b"raw"true
1 == 1.0compile error: no matching overload for '_==_' applied to '(int, double)'
1 == 1ucompile error
"1" == 1compile error
dyn(1) == dyn(1.0)true (dynamic values compare numerically)

That last row matters in practice: message data is dynamically typed, and JSON numbers decode as doubles, so comparisons against int literals workbody.n == 1 and body.n > 0 are both true for --data '{"n": 1}'. Arithmetic is stricter: body.n + 1 fails at runtime with no such overload because a double cannot be added to an int. Write body.n + 1.0, or convert: int(body.n) + 1.

On this page