Skip to content

Expressions

The expression language. It is used inside {{ }} in a template, in every condition and validate rule, in route branches, and in a dynamic endpoint. Same syntax everywhere.

$.customer.name
$.total > 0 && $.currency == "EUR"
length($.items) == 0 ? "empty" : "has items"
ctx.correlation_id

Reading the message

$ is the current message. Everything else is navigation from there.

Form Meaning
$ The whole message
$.field A field of an object
$.a.b.c Nested fields
$.items[0] An element of an array, zero-based
$.items[0].name Mixed navigation
$["field-name"] A field whose name is not a plain identifier
$.map[$.key] A computed key

A missing field is null; a wrong base is an error

These two cases behave differently, and the difference matters.

A key that is absent from an object yields null. No error, no warning. So $.customer.name on {"customer": {}} is null, and a template writes an empty string.

Navigating into something that is not an object is a step error. $.name.first where name holds a string fails the step rather than yielding null.

Guard with is_null or is_empty when the field may be absent:

!is_null($.customer) && !is_empty($.customer.name)

Boolean operators are evaluated left to right, so the first test protects the second.

Header names need bracket form

Header names are stored exactly as they travelled, lowercased: x-request-id, not x_request_id. Dotted access parses an identifier, and an identifier cannot contain -, so bracket form is the only way to name one.

ctx.headers["x-request-id"] correct
ctx.headers.x_request_id refused at compile time
ctx.headers.x-request-id refused at compile time — parsed as subtraction
ctx.headers.host fine — no hyphen, no ambiguity

Both wrong forms are refused, for different reasons and by different parts of the compiler. The hyphenated one parses as ctx.headers.x minus request minus id and fails on the unbound names. The underscored one parses perfectly and would read a key that cannot exist, so it is refused by name: writing it used to yield null with no error anywhere, and a flow carried an empty field instead of stopping.

The refusal covers the three maps that hold names off a wire — ctx.headers, ctx.HTTP_RESPONSE_HEADERS and ctx.grpc_trailers — and only fields containing _. If a header really is spelled with underscores, which is rare but legal, bracket form says so exactly: ctx.headers["x_request_id"].

Reading variables

ctx.<name> reads a variable. Variables live for the whole execution and are written by steps (save_body, secret_read, oauth2_token, a gRPC call’s trailers) and by the platform.

A name nothing declares is a compile error, so nexus validate catches it before publication. Three families of name pass, and nothing else:

Family Where it comes from
Written by the platform ctx.tenant, ctx.correlation_id, ctx.headers, ctx.HTTP_SC, ctx.HTTP_RESPONSE_BODY, ctx.HTTP_RESPONSE_HEADERS, ctx.oauth2_token, ctx.grpc_trailers, ctx.correlated, …
Written by a step in this flow a save_body: key, a secret_read key — key: or the step name
Declared by the flow a name in config:

This is newer than the language. ctx. used to evaluate an unknown name to null and carry on, which is how five flows in this repository sent empty credentials to an authentication service with every test passing. The check runs on the lowered artifact, so it also sees names inside an endpoint:, inside a validate rule’s message, and inside a library expanded by call_flow or foreach.

There is no second way to read a context name. The step keys that once had one — bearer_token:, the five oauth2_token keys (token_url:, client_id:, client_secret:, scope:, grant_type:) and every value of a headers: map on an egress step — are templates, written {{ ctx.<name> }} and checked exactly like an endpoint:. The bare ctx.<name> spelling on those keys is a compile error that names the key and the fix, so a flow written before this change is refused at republication until the reference is rewritten.

On a synchronous HTTP request the platform provides:

Variable Contents
ctx.correlation_id The correlation ID for this execution
ctx.flow The flow name
ctx.tenant The tenant the flow belongs to — set on every path, not only this one
ctx.headers Inbound request headers, as an object with lowercase keys
ctx.soap_action Present only for a SOAP request
ctx.soap_version Present only for a SOAP request
ctx.correlation_id
ctx.headers["x-request-id"]
ctx.headers["content-type"]

ctx.headers is filled only on the synchronous path. On a queued, scheduled or connector-driven execution the variable is an empty object — there was no HTTP request to take headers from. Reading a header off it gives null, which renders as nothing: a headers: value built on it omits the header with a warning, and a dedup_key built on it is refused on every message rather than silently merging them.

Credential headers are never present: authorization, proxy-authorization, cookie, set-cookie, www-authenticate and x-api-key are excluded deliberately, so a flow cannot copy a caller’s credential into a downstream request or a log. It is one list, applied identically wherever headers reach a flow — an inbound request, a backend response (ctx.HTTP_RESPONSE_HEADERS), gRPC metadata — so a name withheld on one path is withheld on all of them. Hop-by-hop headers are stripped as well, on top of that list rather than instead of it. Duplicate header values are joined with ", ".

ctx.tenant works, on every path — the executor mirrors the tenant into the variables before the first step. It is the same value for the whole of a flow’s traffic, though, so it identifies the flow, never the message: a dedup_key built from it is refused at publication for exactly that reason.

Literals

null
true false
42 -7
3.14
"text"

Strings use double quotes.

Operators

Highest precedence first.

Operators Meaning
! - Logical negation, arithmetic negation
* / % Multiplication, division, remainder
+ - Addition, subtraction
< <= > >= Comparison
== != Equality
&& Logical and — short-circuits
|| Logical or — short-circuits
? : Conditional — evaluates only the branch it takes
$.total * 1.19
$.qty > 0 && $.qty <= 100
$.status == "OK" || $.status == "PARTIAL"
!is_empty($.reference)
$.retryCount >= 3 ? "give-up" : "retry"

&& and || short-circuit, so the left side can guard the right:

$.items != null && length($.items) > 0

length() is not called when $.items is absent. That matters because length(null) is an error, not zero — so without short-circuiting a guard written this way would fail on exactly the input it exists to guard against. Both operators still require boolean operands: 0 is not false and a non-empty string is not true.

+ on two numbers adds; on strings, use concat() rather than relying on +.

Comparison against null works with == and !=. To distinguish “absent” from “present but empty”, use is_null and is_empty — they answer different questions.

Functions

Call a function as name(args...). See Built-in functions for the full list.

length($.items)
upper($.code)
date_format(now(), "%Y-%m-%d")
regex_match($.iban, "^[A-Z]{2}[0-9]{2}")

There is also a pipe form for single-argument functions, which reads better when chaining:

$.name | trim | upper

Equivalent to upper(trim($.name)).

Types and comparison

Values are the JSON types plus native XML. Coercion is deliberately narrow: functions that expect a string mostly stringify their input, while arithmetic and not() require the real type.

Expression Result
"5" == 5 false — no cross-type equality
to_int("5") == 5 true
not($.count) error — not() requires a boolean
$.count == 0 works
upper($.number) works — stringified first
$.text * 2 error — arithmetic needs numbers

When a value crosses a system boundary, convert explicitly. In particular, 64-bit integers from a gRPC response arrive as strings, so compare them as strings or convert with to_int() where the value is small enough to be safe.

Reading from XML

An XML value is not an object, and field access on it means something specific:

Form Reads
$.name The attribute name if the element has one, otherwise the first child element named name
$["$tag"] The element’s tag name
$["$ns"] The element’s namespace, or null

Attributes win over child elements when both carry the same name. That is unusual enough to avoid in a message you design, and it is the reason a document with an id attribute and an <id> child reads the attribute.

A child element whose only content is text reads as that text, so $.Body.Order.Id on

<Body><Order><Id>A-1042</Id></Order></Body>

is the string "A-1042", not an element. A child with element children of its own stays an element, so the chain continues. Names match on the local name — a prefix in the document is not part of it, so $.Body reads <soapenv:Body>.

An absent name reads as null, but field access on null is an error — so guard on the level that can be missing, not on the leaf:

{{ if length(descendants($, "Fault")) > 0 }}…{{ end }} # right
{{ if descendants($, "Fault")[0].faultcode != null }}… # errors when there is no Fault

Dotted access only ever reaches the first matching child, one level at a time. To search at any depth, or to get every match, use descendants(); to read an element’s own name — routing on what kind of request arrived — use name(). See JSON and XML for how to work with an XML message end to end.