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 refused at publication, in a call or after a |, in every
expression of the flow: nexus validate, nexus compile and nexus deploy name the step and
the function, and suggest the closest known name when there is one.
A wrong argument type is a step error at run time, not a null, 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; an object or an array becomes its JSON text, with no whitespace and member names in the order of the object ({"z":1,"a":2} gives {"z":1,"a":2}), and is an error when it holds an XML element |
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_object(v) |
boolean | True only for a JSON object, {} included. False for null, an absent field, a boolean, a number, a string, an array and an XML element. Never an error |
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{{ is_object($.customer) }} → true for {"customer": {}}, false for {"customer": "abc"}{{ 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.
is_object answers the value’s type, not whether .field can navigate into it: an XML element
is not an object, so is_object is false for it. Test an XML element with name() or $tag.
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. - A call that would copy too much is refused.
descendants()copies each match whole, so an element nested inside several matches of the same name is copied once per match. When the extra bytes one call would copy pass the installation’sdescendants_max_extra_bytes(32 MiB unless the operator changed it), the step fails with an error that names the setting and the limit, and the flow does not run on. The limit is an estimate of memory, not an exact size, and it applies only to same-name nesting: a call whose matches do not contain one another is never refused. A message that is queued and refused this way is retried like any other failed delivery and then dead-lettered; once the operator has raised the setting it can be replayed. - On a door that does not unwrap, the Header is searched too.
descendants($, "Id")on a whole envelope also matches anIdin theHeader, and names are matched without their namespace. To take only a match in the body, navigate from$.Body…first.
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 }} |