Built-in functions
Every function callable from an expression. Call them as name(args...) anywhere an expression
is accepted — inside {{ }}, in a condition or validate rule, in a route branch, in a
dynamic endpoint.
An unknown function name is a step error, not a null. A wrong argument type is an error too, except where noted: several string functions coerce their input rather than refuse it.
Strings
| Function | Returns | Notes |
|---|---|---|
to_string(v), string(v) |
string | Any value. null becomes an empty string; an XML element becomes its text, see XML |
to_upper(s), upper(s) |
string | Requires a string |
to_lower(s), lower(s) |
string | Requires a string |
trim(s) |
string | Leading and trailing whitespace |
length(v), len(v) |
number | Characters of a string, elements of an array, keys of an object. Error on anything else |
string_length(s), string-length(s) |
number | Length of a string |
concat(a, b, ...) |
string | Any number of arguments, each stringified |
contains(haystack, needle) |
boolean | Substring test. Both arguments are stringified first |
starts_with(s, prefix), starts-with(...) |
boolean | Arguments stringified |
ends_with(s, suffix), ends-with(...) |
boolean | Arguments stringified |
replace(s, from, to) |
string | Replaces every occurrence |
normalize_space(s), normalize-space(s) |
string | Collapses runs of whitespace to single spaces and trims |
split(s, delimiter) |
array of strings | |
join(array, delimiter) |
string | First argument must be an array |
pad_left(s, width, [pad]) |
string | pad defaults to a space; only its first character is used |
pad_right(s, width, [pad]) |
string | Same |
substring(s, start, [length]) |
string | Zero-based, counted in characters, not bytes. start past the end yields an empty string; omitting length runs to the end |
{{ upper($.code) }} → "AB12"{{ length($.items) }} → 3{{ pad_left(to_string($.seq), 6, "0") }} → "000042"{{ substring($.iban, 4, 4) }} → "1234"{{ join(split($.tags, ","), " | ") }} → "a | b | c"Note that length() counts characters for strings and elements for arrays, so the same call
answers two different questions depending on what you pass. Be deliberate.
Numbers
| Function | Returns | Notes |
|---|---|---|
floor(n) |
number | |
ceil(n) |
number | |
abs(n) |
number | |
min(a, b) |
number | Exactly two arguments |
max(a, b) |
number | Exactly two arguments |
{{ floor($.total) }} → 149{{ max($.qty, 1) }} → 1min and max take two arguments, not a list. Nest them for more:
max(max($.a, $.b), $.c).
Dates
Timestamps are milliseconds since the Unix epoch, as numbers. That is the currency all three date functions trade in.
| Function | Returns | Notes |
|---|---|---|
now() |
number | Milliseconds since the epoch, UTC |
date_format(ts_ms, [format]) |
string | format defaults to %Y-%m-%dT%H:%M:%SZ. Accepts a numeric or decimal-string timestamp |
date_parse(s, [format]) |
number | Timestamp in milliseconds. Falls back to RFC 3339 if the format does not match |
date_add(ts_ms, amount, [unit]) |
number | unit defaults to millis |
date_add units: days/day, hours/hour, minutes/minute/mins/min,
seconds/second/secs/sec, millis/ms. Any other unit is an error. Overflow is an error
rather than a wrapped timestamp.
Format strings are strftime directives.
{{ date_format(now()) }} → "2026-08-10T09:14:22Z"{{ date_format(now(), "%d.%m.%Y") }} → "10.08.2026"{{ date_format(date_add(now(), 30, "days")) }} → "2026-09-09T09:14:22Z"{{ date_format(date_add(now(), -1, "hours")) }} → "2026-08-10T08:14:22Z"{{ date_parse("2026-08-10", "%Y-%m-%d") }} → 1786...A timestamp is not rendered directly
now(), date_add() and date_parse() at the top of a rendered expression are refused at
publication — by nexus validate as well as by compile and deploy:
$ nexus validate order.flow.mderror: `now()` renders as an epoch number, not a date --> step "build-receipt" help: wrap it: `date_format(now())`The reason is what the refused form actually produced: "receivedAt": "{{ now() }}" wrote
"1786793555348" under a field name that promises a date. No error anywhere, a plausible answer
instead of a refusal — so the refusal was added.
Only the top of a rendered expression is refused. These are all fine:
- a nested call —
{{ date_format(now()) }},{{ date_add(now(), 1, "days") }}inside one; - a pipe —
{{ now() | date_format }}, whose top is the pipe, not the call; - any expression that is not rendered — a
condition, aroute, avalidaterule, the test of an{{ if }}, the iterable of a{{ for }}; - arithmetic —
{{ now() - $.since }}renders a duration in milliseconds, which may well be what you meant.
A flow that was valid before this rule can be refused on republication. The message names the fix for exactly that reason.
date_format does not quietly return 1970 either: a string it cannot parse, and a number that does
not fit an i64, are both errors naming the value they received.
Regular expressions
| Function | Returns | Notes |
|---|---|---|
regex_match(s, pattern) |
boolean | |
regex_replace(s, pattern, replacement) |
string | Replaces every match |
{{ regex_match($.iban, "^[A-Z]{2}[0-9]{2}") }} → true{{ regex_replace($.phone, "[^0-9]", "") }} → "40721234567"An invalid pattern is a step error. Patterns are compiled at evaluation time, so keep them out of loops over large arrays where you can.
Arrays
| Function | Returns | Notes |
|---|---|---|
array_push(array, item) |
array | Returns a new array; does not mutate |
array_merge(a, b) |
array | Concatenation. Both arguments must be arrays |
{{ array_push($.tags, "urgent") }}{{ array_merge($.local, $.remote) }}Objects
| Function | Returns | Notes |
|---|---|---|
keys(obj) |
array of strings | Key order is preserved |
values(obj) |
array | Key order is preserved |
object_merge(a, b) |
object | Keys from b win on conflict |
{{ join(keys($.attributes), ",") }}{{ object_merge($.defaults, $.overrides) }}Types and tests
| Function | Returns | Notes |
|---|---|---|
to_int(v) |
number | From a number (truncated), a numeric string, or a boolean |
to_float(v) |
number | From a number, a numeric string, or a boolean |
to_number(v), number(v) |
number | Integer if it parses as one, otherwise a float |
is_null(v) |
boolean | True only for null |
is_empty(v) |
boolean | True for null, "", [] and {}. False for 0 and false |
not(b) |
boolean | Requires a boolean; will not coerce |
{{ to_int("0042") }} → 42{{ is_empty($.middleName) }} → true{{ not(is_empty($.name)) }} → trueis_null and is_empty answer different questions. A field that is present and holds "" is
empty but not null; an absent field is both. Use is_null when presence is what matters and
is_empty when the value being usable is what matters.
not() refuses a non-boolean rather than coercing, so not($.count) is an error — write
$.count == 0.
Encoding
| Function | Returns | Notes |
|---|---|---|
base64_encode(s) |
string | |
base64_decode(s) |
string | Error on input that is not valid base64 |
url_encode(s) |
string | Percent-encodes everything that is not alphanumeric |
uuid() |
string | A fresh version 4 UUID on every call |
{{ base64_encode($.payload) }}{{ url_encode($.query) }}{{ uuid() }}url_encode is deliberately aggressive: it encodes every non-alphanumeric character, including
-, . and _. It is meant for building a query-string value, not for encoding a whole URL.
uuid() returns a different value each time it is called, so calling it twice in one template
gives you two identifiers. Assign it once with save_body or build it into a single step if you
need the same value twice.
XML
| Function | Returns | Notes |
|---|---|---|
descendants(node, "name") |
array of elements | Every descendant element with that local name, at any depth, in document order. Empty array when there are none |
name(node) |
string | The element’s local name. Anything that is not an element is an error, never an empty string |
string(node), to_string(node) |
string | The element’s text: all descendant text concatenated. No tag names, no attribute values |
descendants() is the answer to “somewhere in this message there is an element called X”. Dotted
access reaches the first child, one level at a time
(Reading from XML); descendants() searches the whole subtree
and returns all matches, including one nested inside another of the same name.
{{ length(descendants($, "Item")) }} → 3 how many{{ string(descendants($, "OrderId")[0]) }} → "A-1042" the text of the first{{ for x in descendants($, "Item") }}{{ $ }}{{ end }} copy each one wholeThree things worth knowing before you rely on it:
- Both
$and the declared name work. In{{ for x in … }},{{ $ }}and{{ x }}are the same element. A misspelled name is a publish-time error naming what is in scope, not a silent string — it used to yield the literal text"x". - A node is not text.
{{ descendants($, "Id")[0] }}in text position emits the element itself,<Id>A-1042</Id>— which is what you want when copying a subtree and not what you want when you meant the value. For the value, wrap it instring(), or read it through its parent with dotted access. - The root is not a descendant.
descendants($, "Envelope")on an envelope is empty; the function searches children downward. After a SOAP step has unwrapped the envelope, the formerBodycontent is the root.
name() answers the opposite question — not “is X in here” but “what is this”. Its use is
routing on the kind of request: snapshot the message before any transform replaces it, then let a
route guard read the root’s name.
## Step: keep-the-requestsave_body: incoming_request
## Step: route-by-request-type```routewhen name(ctx.incoming_request) == "ListViewDocumentsRequest": call build-list-view-resultotherwise: call build-search-result```A value that is not an XML element — a JSON object, a string, an absent variable — fails the step
loudly rather than returning something empty: a guard fed a plausible "" would silently take
otherwise on every message.
Coming from XSLT, these are the equivalents:
| XPath | Here |
|---|---|
//*[local-name()='X'] (first match, as text) |
string(descendants($, "X")[0]) |
count(//*[local-name()='X']) |
length(descendants($, "X")) |
//Parent/child |
descendants($, "Parent")[0].child |
copy-of select="//*[local-name()='X']" |
{{ for x in descendants($, "X") }}{{ $ }}{{ end }} |