Skip to content

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) }} → 1

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

Terminal window
$ nexus validate order.flow.md
error: `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, a route, a validate rule, 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)) }} → true

is_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 whole

Three 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 in string(), 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 former Body content 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’s descendants_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 an Id in the Header, 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-request
save_body: incoming_request
## Step: route-by-request-type
```route
when name(ctx.incoming_request) == "ListViewDocumentsRequest":
call build-list-view-result
otherwise:
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 }}