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_idReading 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"] correctctx.headers.x_request_id refused at compile timectx.headers.x-request-id refused at compile time — parsed as subtractionctx.headers.host fine — no hyphen, no ambiguityBoth 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_idctx.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
nulltrue false42 -73.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) > 0length() 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 | upperEquivalent 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 FaultDotted 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.