Skip to main content
Updated Sep 17, 2026

CEL Functions Reference

Reference for the built-in functions and operators available in AutoTalk CEL expressions, organized by category.

Two sets of names are in scope, and both are on this page. The CEL standard library — operators, list macros, string functions, type conversions and the timestamp getters — comes from the shared CEL registry in AutoTalk Commons (src/functions/cel/index.ts), which is the same registry the CEL editor's Explorer panel is projected from, so anything the Explorer offers you is documented here. On top of that AutoTalk adds its own helpers — get, coalesce, pluck, the math_* family, the date helpers, the step_* workflow helpers — which exist only here.


Core

get(obj, path, default?)

Safely access a nested property via a dotted path.

get(contact, "address.city") // "São Paulo"
get(contact, "address.zip", "00000-000") // returns default if missing
get(null, "name") // null
get(step(0), "data.results.0.title") // deep path with array index
ParamTypeDescription
objanyObject to access (null-safe)
pathstringDotted path (e.g. "a.b.c")
defaultanyValue returned if path missing (default: null)

has(obj, path?)

Check if a value or nested path exists and is not null/undefined.

has(contact, "platformId") // true if contact.platformId is set
has(myVar) // true if myVar is not null/undefined
has(obj, "a.b.c") // true if full path resolves to non-null

coalesce(...vals)

Return the first non-null, non-undefined value. Variadic — accepts up to 5 arguments. If you need more, nest calls: coalesce(a, b, c, d, coalesce(e, f)).

coalesce(get(contact, "customAttributes.nickname"), contact.name, "Guest") // first non-null wins
coalesce(0, 42) // 0 (not null!)
coalesce(false, true) // false (not null!)
coalesce("", "fallback") // "" (not null!)
Null-check semantics

coalesce() only skips null and undefined. Values like 0, false, and "" are valid and returned.

now(tz?)

Returns the current date/time as an ISO 8601 string.

now() // "2024-01-15T14:30:00.000Z"
format_datetime(now(), "YYYY-MM-DD") // "2024-01-15"

The result is always expressed in UTC (Z suffix), even when the optional timezone argument is passed. To display the current time in a specific timezone, format it — e.g. format_datetime(now(), "HH:mm", "America/Sao_Paulo").

present(val)

Check if a value is meaningfully present. Returns false for null, undefined, empty/whitespace strings, and empty arrays. Numbers and booleans are always present.

present(contact.platformId) // true if non-empty string
present("") // false
present(" ") // false (whitespace only)
present(0) // true
present([]) // false
present([1, 2]) // true
Replaces verbose patterns

present(x) replaces the common pattern size(trim(coalesce(x, ""))) > 0.

blank(val)

Inverse of present(). Returns true for null, undefined, empty/whitespace strings, and empty arrays.

blank(contact.platformId) // true if null or empty
blank("hello") // false
blank(0) // false

Utility

pluck(arr, path)

Extract a property from each object in an array.

pluck(contacts, "name") // ["Alice", "Bob", "Carol"]
pluck(tools, "tool.function.name") // deep path supported

slice(arr, start, end?)

Safely slice an array. Returns [] for non-array inputs. Supports negative indices.

slice(results, 0, 5) // first 5 items
slice(results, -3) // last 3 items
slice(results, 1, -1) // all except first and last
slice(null, 0, 2) // [] (safe for non-arrays)

defaults(obj, fallbacks)

Merge fallback values into an object for keys that are null/undefined. Shallow merge.

defaults(response, {"status": "unknown", "retryable": false})
// Fills in status and retryable only if they are null/undefined in response
Null-check semantics

Like coalesce(), only null/undefined values are replaced. 0, false, and "" are kept.

url_params(base, params)

Build a URL with query parameters. Skips null and empty values. Auto-encodes.

url_params("https://api.example.com/search", {"q": query, "page": 1, "lang": null})
// "https://api.example.com/search?q=hello&page=1" (lang skipped)

truncate(str, maxLen, suffix?)

Truncate a string to a maximum length with an optional suffix.

truncate("Hello World", 5) // "Hello"
truncate("Hello World", 8, "...") // "Hello..."
truncate(null, 10) // "" (null-safe)
truncate("Hi", 100) // "Hi" (no truncation needed)
ParamTypeDescription
stranyValue to truncate (coerced to string, null returns "")
maxLennumberMaximum length of result (including suffix)
suffixstringAppended when truncated (default: "")
tip

The suffix is included within maxLen: truncate("Hello World", 8, "...") returns "Hello..." (8 chars).

tpl(template, vars)

Simple string interpolation. Replaces {key} placeholders with values from an object.

tpl("Hello {name}!", {"name": "Alice"}) // "Hello Alice!"
tpl("*{title}*\n{domain}\n{url}", article) // formatted article text
tpl("{address.city}, {address.country}", contact) // dotted path support
tpl("Hi {name}", {"name": null}) // "Hi " (null → empty)
ParamTypeDescription
templatestringTemplate string with {key} placeholders
varsobjectObject with values to interpolate

join_present(separator, ...values)

Join values with a separator, skipping blank values. Uses the same rules as present(): null, empty/whitespace strings, and empty arrays are skipped. 0 and false are kept. Accepts at most 5 arguments in total — the separator plus up to 4 values.

join_present(", ", "Alice", "Bob", "Carol") // "Alice, Bob, Carol"
join_present(" - ", title, null, author) // "Title - Author" (null skipped)
join_present(" ", "Hello", "", "World") // "Hello World" (empty skipped)
join_present(" | ", 0, false, "text") // "0 | false | text" (0/false kept)
Replaces conditional concatenation

join_present(" - ", prefix, text) replaces the pattern (present(prefix) ? prefix + " - " : "") + text.

encode_uri(str)

URL-encode a string. Reserved URI characters (&, =, ?, /, #) are not escaped, so it is only suitable for encoding a whole URL — not for individual query-parameter values.

encode_uri("hello world") // "hello%20world"
encode_uri("a&b=c") // "a&b=c" (reserved characters preserved)
Building query strings

To add query parameters to a URL safely, use url_params() — it encodes each parameter value for you.

format_currency(currency, locale, amount)

Format a number as currency. All three arguments are required, in this order: currency code, locale, then amount.

format_currency("USD", "en-US", 1234.5) // "$1,234.50"
format_currency("BRL", "pt-BR", 99.9) // "R$ 99,90"

Operators

OperatorWhat it doesExample
!Logical NOT!step_ok(0)
&&Logical AND, short-circuitingstep_ok(0) && present(step_data(0, "id"))
||Logical OR, short-circuitingblank(contact.name) || contact.name == "-"
==, !=Equality and inequalitystep_data(0, "status") == "open"
<, <=, >, >=Comparisonsize(step_data(0, "items")) > 0
+Add. Also joins two strings, concatenates two lists, and adds a duration to a timestamp[1, 2] + [3][1, 2, 3]
-, *, /, %Subtract, multiply, divide, remainder(total - paid) % 2 == 0
inMembership: is this element in the list, or this key in the map?"vip" in contact.tagstrue

in is the one people miss. It saves a whole loop when you only need to know whether a value is present, and it composes with map below:

"open" in step_data(0, "items").map(r, r.status)

Lists & macros

The macros run an expression over every element of a list. They are written receiver-style — the list first — and their first argument names the loop variable, which exists only inside that macro.

MacroWhat it doesExample
list.all(x, pred)True when every element matches. True for an empty listrows.all(x, x.total > 0)
list.exists(x, pred)True when at least one element matches. False for an empty listrows.exists(x, x.status == "open")
list.exists_one(x, pred)True when exactly one element matchesrows.exists_one(x, x.primary)
list.map(x, expr)A new list, with expr applied to each elementrows.map(x, x.email)
list.map(x, pred, expr)The same, but only over the elements matching predrows.map(x, x.status == "open", x.id)
list.filter(x, pred)A new list holding only the matching elementsrows.filter(x, x.status == "open")

This is the shortest route to "how many of the rows a search step returned are still open" — no Execute code step needed:

size(step_data(0, "items").filter(r, r.status == "open"))
step_data(0, "items").map(r, r.status == "open", r.name) // names of the open ones
step_data(0, "items").all(r, present(r.email)) // is every row usable?

Type conversion

FunctionWhat it doesExample
int(v)To a whole number. Truncates a decimal towards zero; a timestamp becomes epoch seconds, a duration becomes whole secondsint("42")42, int(4.9)4
uint(v)To an unsigned whole number. Comes back as a wrapper value rather than a plain number — see belowuint("42")
double(v)To a decimal numberdouble("3.14")3.14
bool(v)To a boolean, from "true", "false", "1" or "0"bool("true")true
bytes(v)A string to raw bytesbytes("hi")
string(v)Anything to its text form. A timestamp becomes an ISO 8601 string, a duration becomes seconds with an sstring(123)"123"
timestamp(v)A full ISO 8601 string, or epoch milliseconds, to a timestamptimestamp("2024-01-15T00:00:00Z")
duration(v)A duration string — "90m", "1h30m", "3600s" — to a durationduration("48h")
type(v)The type of a valuetype(1)
dyn(v)Passes a value straight through, as a dynamic typedyn(v)

Five things to know before putting one of these in a field:

  • A conversion that cannot succeed does not just come back empty — it takes the whole expression with it, and no guard can catch it. int("abc"), bool("yes"), uint("abc"), duration("abc") and timestamp("2024-01-15") (no time part) all yield nothing, and so does everything built around them: int("abc") > 5 is empty rather than false, "n=" + string(int("abc")) is empty rather than "n=", and present, blank, coalesce and ? : are empty too — present(int("abc")) is not false and coalesce(int("abc"), 0) is not 0. This is the one place the failure is unlike a missing step path, where present really does answer false: a condition written !present(int(step_data(0, "qty"))) to mean "the quantity was not a number" never fires. Test the raw value before converting, so the conversion only ever runs on something it can take:

    qty.matches("^-?[0-9]+$") ? int(qty) : 0 // 0 when qty is "abc"
  • double fails differently, and more quietly still. double("abc") is NaN: present calls it present, blank calls it not blank, string() renders it "NaN", and every comparison against it is falsedouble("abc") > 5 and double("abc") <= 5 both. The matches guard above is the fix here too.

  • uint() hands back a wrapper, not a number. uint("42") compares correctly (uint("42") == 42 is true) but does no arithmetic — uint("42") + 1 is empty — and a field stores the wrapper instead of 42. Use int() unless you specifically need the unsigned type, or unwrap it: int(uint(v)), string(uint(v)).

  • timestamp and duration are not values a field can hold. They are useful inside an expression — comparing, adding, feeding a getter. To get something a field can store, wrap them: string(timestamp(x) + duration("48h")) is an ISO string, int(duration("90m")) is 5400.

  • type() does not survive into a field either, and its comparisons do not work here. Use present / blank to ask whether a value is there, and compare against the value itself rather than its type.


String

CEL includes built-in string functions. Most work as both receiver-style and function-style:

"hello".contains("ell") // true (receiver-style)
contains("hello", "ell") // true (function-style)
FunctionDescriptionExample
contains(str, sub)Check if string contains substring"hello".contains("ell")
startsWith(str, prefix)Check prefix"hello".startsWith("he")
endsWith(str, suffix)Check suffix"hello".endsWith("lo")
matches(str, pattern)Regular-expression match"abc123".matches("^abc[0-9]+$")true
size(x)Length of a string, list, or mapsize("hello")5, size([1, 2, 3])3
split(str, sep)Split into arraysplit("a,b,c", ",")["a","b","c"]
lowerAscii(str)LowercaselowerAscii("HELLO")"hello"
upperAscii(str)UppercaseupperAscii("hello")"HELLO"
trim(str)Trim whitespacetrim(" hi ")"hi"
substring(str, start, end?)Extract substringsubstring("hello", 1, 4)"ell"
replace(str, old, new)Replace occurrencesreplace("aab", "a", "x")"xxb"
indexOf(str, sub)First index of substringindexOf("hello", "l")2
lastIndexOf(str, sub)Last index of substringlastIndexOf("hello", "l")3
charAt(str, index)Character at indexcharAt("hello", 0)"h"
join(list, sep?)Join array to stringjoin(["a","b"], ",")"a,b"
format(str, args)Fill %s / %d placeholders from a list"%d open".format([2])"2 open"
strings.quote(str)Wrap in quotes and escape what is insidestrings.quote("a b")"\"a b\""

Math

FunctionDescriptionExample
math_add(a, b)Addmath_add(5, 3)8
math_subtract(a, b)Subtractmath_subtract(10, 3)7
math_multiply(a, b)Multiplymath_multiply(4, 3)12
math_divide(a, b)Dividemath_divide(10, 3)3.333...
math_round(n, decimals?)Roundmath_round(3.456, 2)3.46
math_floor(n)Floormath_floor(3.7)3
math_ceil(n)Ceilingmath_ceil(3.1)4
math_abs(n)Absolute valuemath_abs(-5)5
math_pow(base, exp)Powermath_pow(2, 3)8
math_sqrt(n)Square rootmath_sqrt(16)4
math_sin(n)Sine (radians)math_sin(0)0
math_cos(n)Cosine (radians)math_cos(0)1
math_tan(n)Tangent (radians)math_tan(0)0
math_log(n, base?)Logarithm (natural log by default)math_log(8, 2)3
math_exp(n)e raised to the power nmath_exp(0)1
math_max(arr)Maximummath_max([1,5,3])5
math_min(arr)Minimummath_min([1,5,3])1
math_mean(arr)Averagemath_mean([1,2,3])2
math_median(arr)Medianmath_median([1,2,10])2
math_std(arr)Standard deviation (sample)math_std([2,4,6])2
math_variance(arr)Variance (sample)math_variance([2,4,6])4
math_dot(a, b)Dot product of two vectorsmath_dot([1,2,3], [4,5,6])32
math_norm(arr)Euclidean norm (vector length)math_norm([3,4])5
math_clamp(val, min, max)Constrain to rangemath_clamp(15, 0, 10)10
math_evaluate(expr)Evaluate a math expression stringmath_evaluate("2 + 3 * 4")14

math_clamp(val, min, max)

Constrain a number within a range.

math_clamp(page, 1, 100) // ensure page is between 1 and 100
math_clamp(-5, 0, 10) // 0 (below min)
math_clamp(15, 0, 10) // 10 (above max)

DateTime

FunctionDescription
now(tz?)Current date/time as an ISO 8601 string (always expressed in UTC)
format_datetime(d, fmt, tz?, locale?)Format a datetime. Accepts any dayjs format string — e.g. "DD/MM/YYYY", "YYYY-MM-DD HH:mm:ss", "HH:mm" — plus an optional timezone and locale ("en", "pt-BR", "es")
add_datetime(d, n, unit)Add time. Units: "years", "months", "weeks", "days", "hours", "minutes", "seconds", "milliseconds"
diff_datetime(d1, d2, unit)Difference between two datetimes
zoned_datetime(dateStr, timeStr, tz, format?)Build a timezone-aware datetime from separate date and time strings, e.g. zoned_datetime("2024-01-15", "14:30", "America/Sao_Paulo"). To convert or display an existing datetime, use format_datetime instead
is_before(d1, d2)d1 before d2?
is_after(d1, d2)d1 after d2?
is_between(d, start, end)d between start and end?
is_same_date(d1, d2, unit?, tz?)d1 and d2 equal at the given unit? (default unit: "millisecond") — e.g. is_same_date(a, b, "day") checks same calendar day
is_same_or_before(d1, d2, unit?, tz?)d1 same as or before d2?
is_same_or_after(d1, d2, unit?, tz?)d1 same as or after d2?

Timestamp and duration getters

These read one part out of a timestamp or a duration. They work both receiver-style — timestamp(x).getFullYear() — and function-style — getFullYear(timestamp(x)) — and every timestamp getter takes an optional IANA timezone as its last argument. Without one they read the value in UTC.

GetterOn a timestampOn a duration
getFullYear(t, tz?)Four-digit year
getMonth(t, tz?)Month, 0–11 — January is 0
getDate(t, tz?)Day of the month, 1–31
getDayOfMonth(t, tz?)Day of the month, 0-based — the 15th is 14
getDayOfWeek(t, tz?)Day of the week, 06, Sunday is 0
getDayOfYear(t, tz?)Day of the year, 0-based — 1 January is 0
getHours(v, tz?)Hour, 0–23Whole hours in the duration
getMinutes(v, tz?)Minute, 0–59Total minutes — duration("1h30m") gives 90, not 30
getSeconds(v, tz?)Second, 0–59Total seconds
getMilliseconds(v, tz?)Millisecond, 0–999The part under a second, so 0 for any whole-second duration
timestamp(step_data(0, "createdAt")).getDayOfWeek("America/Sao_Paulo") == 0 // fell on a Sunday, local time
getMonth(timestamp(now())) + 1 // month as 1-12

Two of these are easy to get wrong: getDate counts from 1 while getDayOfMonth counts from 0, and getMonth counts from 0. When you want a date to show someone rather than a number to compare, format_datetime above is the better tool.


JSON

FunctionDescription
json_parse(str)Parse JSON string to object
json_stringify(obj)Serialize object to JSON string

TOON

TOON is a compact, human-readable data serialization format.

FunctionDescription
toon_encode(value)Encode a value to a TOON string
toon_decode(str)Parse a TOON string back into a value
toon_encode({"name": "Alice", "age": 30}) // "name: Alice\nage: 30"
toon_decode("name: Alice\nage: 30") // {"name": "Alice", "age": 30}

BSON / ObjectId

FunctionDescription
object_id(value?)Create or normalize an ObjectId instance (new one if no argument). Wrap with object_id_to_string() for the hex string
object_id_is_valid(id)Check if string is valid ObjectId
object_id_to_string(id)Convert an ObjectId to its hex string
bson_serialize(value, encoding?)Serialize a value to BSON bytes, returned as a base64 (default) or "hex" string
bson_deserialize(str, encoding?)Decode a base64 (default) or "hex" BSON string back into a value
ejson_stringify(value, relaxed?)Serialize a value to a MongoDB Extended JSON string (relaxed mode by default)
ejson_parse(str, relaxed?)Parse a MongoDB Extended JSON string into a value
bson_serialize({"a": 1}) // "DAAAABBhAAEAAAAA" (base64)
bson_serialize({"a": 1}, "hex") // "0c0000001061000100000000"
bson_deserialize("DAAAABBhAAEAAAAA") // {"a": 1}
ejson_stringify({"a": 1}) // "{\"a\":1}"

Workflow-Specific

These functions are available only in workflow and agent expressions (not in form CEL).

step(N)

Access output from workflow step N (0-indexed).

step(0) // full step output object
step(0).data // the step's data payload
step(0).status // HTTP status code (for HTTP steps)

step_ok(N)

Check if step N completed successfully. Returns true when executionContext.status is "completed" AND HTTP status is 200 (or null for non-HTTP steps).

// Before: verbose
get(step(0), "executionContext.status") == "completed" && (get(step(0), "status") == null || get(step(0), "status") == 200)

// After: one function call
step_ok(0)

step_data(N, path?, default?)

Get data from step N at an optional dotted path. Returns default (or null) if missing.

The path is relative to the step's datastep_data(0, "title") reads step(0).data.title. Do not repeat data in the path: step_data(0, "data.title") looks for step(0).data.data.title, silently misses, and returns the default — which is indistinguishable from the field being empty. (step_data(0, "data.x") is only correct when the response body itself has a top-level data key, as JSON:API responses do.)

// Before
get(step(0), "data.title", null)

// After
step_data(0, "title")
step_data(0, "results.0.name", "Unknown")
step_data(1, "choices.0.message.content")

Actions that publish at the step root

Most actions put their output under data, which is why the path is relative to it. Twenty do not — they declare their outputs at the step root and have no data object at all. This is not an obscure tail: actions/ai/llm/chat/generate is on the list, and its choices is the step output workflows walk most often.

ActionRoot outputs
actions/ai/agent/sendmessages
actions/ai/llm/chat/generatechoices
actions/agent/lifecycle/session/set_reply_delayreplyDelayMs
actions/ai/text/speakabletext, model
actions/data/company/resource/countcount
actions/mcp/connecturl, toolCount
actions/mcp/connect/autotalk-mcpurl, toolCount
actions/media/audio/synthesizefile, bucket, fullPath, size, contentType, format, cache, billedMeter, billedMs
actions/media/audio/transcribejobId, jobStatus, enginePath, providerType, diarized
actions/media/document/generatefile, bucket, fullPath, fileName, size, contentType, format
actions/media/readtext, supported, truncated, reason, mediaKind, mimeType, fileName, size
actions/media/storage/deletedeleted, existed, bucket, fullPath
actions/media/storage/probedurationSeconds, width, height, formatName, videoCodec, audioCodec, hasAudio, sizeBytes, contentType, cached
actions/media/storage/signed-urlsignedUrl, contentType, size, mediaKind, bucket, fullPath, name, expiresAt
actions/media/storage/uploadfile, signedUrl, contentType, size
actions/media/transformjobId, jobStatus, preset
actions/monitors/cancelmonitorId, monitorState
actions/monitors/createmonitorId, monitorState, expiresAtIso
actions/network/http/download-to-storagestatus, file, signedUrl, contentType, size
actions/security/auth/jwt/generatejwt

Read those with get(step(N), "field"):

get(step(0), "count") > 0 // resource/count
get(step(1), "choices.0.message.content") // llm/chat/generate
get(step(2), "mediaKind") == "pdf" // media/read

step_data also resolves against the root for these — a step with neither data nor result falls back to the step object — so step_data(0, "count") works too. The fallback is deliberately narrow and does not apply to a step that does have data: on actions/network/http/request/send, step_data(N, "status") still returns your default rather than the HTTP status code, because status is a sibling of data at the root, not a field inside it. (On the root-shaped actions/network/http/download-to-storage, status is a step output, so step_data(N, "status") does return it — the rule is the same, the action's shape is different.) Keys belonging to the execution envelope (executionContext, actionContext, safeError, safeResult, conditionPassed, aclInfo) are never reachable through step_data on any step.

There is a third shape, and exactly one action is in it today. Execute code publishes its return value as a top-level result and has no data, so step_data and step_has_content resolve inside that value: step_data(0, "count") reads step(0).result.count, which is usually what you want. Its siblings at the step root — files, logs, executionTimeMs — are not reachable that way and must be read with get(step(N), "files"). This holds even when the code returned nothing, because result is then null, which is still enough to take that branch. That is why the table above has no row for actions/code/execute: it is neither data-shaped nor root-shaped.

Whichever spelling you use, a path that does not exist returns the default silentlystep_ok(N) stays true. Never trust a remembered list: check the action's declared outputs (get_action_definition(actionType), whose STEP OUTPUTS tip is derived from those outputs, or the action reference) before writing the path.

step_error(N)

Get error info from a failed step. Returns {code, user_message, retryable} or null.

// Before
coalesce(get(step(0), "executionContext.safeError.code"), "UNKNOWN")

// After
step_error(0) // {code: "TIMEOUT", user_message: "...", retryable: true}
get(step_error(0), "code", "UNKNOWN") // "TIMEOUT"

step_has_content(N, path)

Check if step N completed successfully AND has present (non-null, non-empty) data at the given path. Combines step_ok(N) && present(step_data(N, path)) into one call.

// Before: two checks
step_ok(0) && present(step_data(0, "articles"))

// After: one function call
step_has_content(0, "articles")

The path follows exactly the same rule as step_data: relative to data, or — for the root-shaped actions — relative to the step root.

getContext()

Get the workflow execution context object.

getTool()

Get the context object of the currently running tool — the same context the step(N) helpers read from. Takes no arguments; any arguments passed are ignored.

getRoot()

Get the root workflow context.