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 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) }} → 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_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)) }} → 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.

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.

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 }}