#Smart values — the authoring reference
The model in one sentence. Smart values are JSON whose string leaves can carry
{{ … }}JSONata expressions; a whole-string{{ … }}renders to a STRING, mixed strings interpolate, and three$-directive objects cover the rest —$jsonata(the value tool: a string is one expression yielding the RAW value, a structure deep-evaluates its string leaves),$literal(a verbatim escape hatch), and$if(the render conditional). A boolean GATE (acondition, anerrorWhen, a stepif) is a bare expression, never{{ }}.
This is the authoring reference for the expression language used everywhere you configure behavior in Helm: workflow and job definitions, action configs, and app-spec widget fields. The same rules apply in every surface — only the set of bindings in scope changes (see Binding contexts).
The Workflows and Apps guides link here from their own short "smart values" sections; this is the source of truth for the rules, the authoring shapes, the binding set, and the failure modes.
#Table of contents
- How a field is evaluated
- Authoring shapes by field type
- Binding contexts
- Failure-mode catalog
- The error path
#1. How a field is evaluated
#Literal by default
The foundational rule is literal by default: a string with no {{ is
returned verbatim, never evaluated. A bare "source": "sedna" stays the literal
string "sedna". Evaluation is opt-in — you ask for it with {{ … }} or a
$-directive object.
#The three ways to evaluate
| Form | Reads as | Produces |
|---|---|---|
{{ … }} |
inline | A whole-string {{ … }} yields a STRING; a mixed string ("#{{ id }}") interpolates the result into the string. |
{ "$jsonata": … } |
value tool | A STRING value is one expression yielding the RAW value (object / array / scalar — not stringified). A STRUCTURE value deep-evaluates each of its string leaves as an expression. |
{ "$literal": … } |
escape hatch | Emits the value verbatim — nothing inside is walked or evaluated, even {{ … }} spans. |
{ "$if": … } |
conditional | Resolves the taken branch; see $if below. |
The {{ }} form is the string tool — it always produces a string. When you
need the RAW value (an object, an array, a number, a boolean — anything that
must not be stringified), use { "$jsonata": … }.
// {{ }} stringifies — "output" becomes the STRING "[object Object]"-style text
"output": "{{ outputs.fetch.body }}" // ✗ stringified
// $jsonata passes the raw value through unchanged
"output": { "$jsonata": "outputs.fetch.body" } // ✓ raw object/array/scalar
#Boolean gates are bare expressions, not {{ }}
A boolean-gate field — condition (workflow step / trigger), errorWhen
(action), a step if, a navigate step's modal flag, a $pipe filter — is
an EXPRESSION slot. Author it as a bare JSONata expression (or a
{ "$jsonata": … } directive). A {{ }} smart-value is rejected for these
fields.
"condition": "status = 'active'" // ✓ bare expression
"condition": "{{ status = 'active' }}" // ✗ rejected at save
Why bare? A gate must evaluate to a boolean. The bare string IS the expression
(exactly like $if's condition or a GitHub Actions if:). {{ }} is the
string tool — it stringifies, and the string "false" is truthy, so a {{ }}
gate would never block. The schema catches this at save time. The engine always
evaluates a gate's bare string AS an expression, never as a literal string —
there is no "bare string is always truthy" footgun here. For a raw boolean in a
NON-gate slot, use { "$jsonata": … }.
#A few fields require {{ }} (no bare literal)
A handful of scalar / array fields would be a silent bug if you wrote a bare
string, so the schema requires {{ }} (or a $-directive):
idempotencyKey(workflow step) — a bare"context.entityId"would become the literal text, not the resolved id. Write"{{ context.entityId }}", or to force a literal,"{{ 'fixed-key' }}".loopOver(action) when authored as a STRING — a bare"outputs.list.items"is an unambiguous bug. Write"{{ outputs.list.items }}", or author a literal array directly (see below).
#2. Authoring shapes by field type
Fields accept different shapes depending on what they're for. Pick the shape your field's documentation lists; the examples below cover the common ones.
#Structured-output fields (output, contextData, input)
These accept a string, an object, or an array — all three are legitimate. A
bare string here is a valid declarative value (no {{-requirement).
#output — reshape a step's result
// 1. $jsonata string form — pass the RAW value through (object / array / scalar)
"output": { "$jsonata": "outputs.fetch.body" }
// 2. Object literal — each smart-value leaf resolves in place
"output": {
"id": "{{ outputs.upsert.id }}",
"createdAt": "{{ $now() }}",
"tier": "gold" // literal — no {{ → not evaluated
}
// 3. Array literal — each leaf resolves
"output": [
"{{ outputs.a.value }}",
"{{ outputs.b.value }}"
]
#contextData — build a trigger's initial context
contextData shapes the run's starting context from an incoming event. Every
step then reads it as context — the trigger builds contextData, the run
exposes context.
"contextData": {
"entity_id": "{{ payload.id }}",
"actor": "{{ $.metadata.user }}",
"source": "sedna" // literal — no {{ → not evaluated
}
#input — thread data INTO a step
// 1. Pass an entire object through ($jsonata keeps the raw object;
// a whole-string "{{ … }}" would stringify it)
"input": { "$jsonata": "context.input" }
// 2. Build the input explicitly
"input": {
"orderId": "{{ context.input.id }}",
"operator": "system"
}
// 3. Or just a literal payload
"input": { "kind": "demo" }
#Loop-source fields (loopOver)
loopOver runs the action once per item; each iteration exposes item and
index. Author it as a smart-value STRING that yields an array, or as a literal
array directly.
// 1. $jsonata string form yielding an array
"loopOver": { "$jsonata": "outputs.list.items" }
// 2. Literal array — equivalent to writing one action per item
"loopOver": [
{ "name": "a" },
{ "name": "b" }
]
// 3. Mixed array of smart leaves
"loopOver": [
"{{ outputs.x.first }}",
"{{ outputs.x.second }}"
]
A bare-string "loopOver": "outputs.list.items" (no {{) is rejected — see
the failure catalog.
#Action-config fields (http.url, upsertEntity.attributes, …)
Every action's typed config field accepts its concrete value, a {{ }} smart
string, OR a $-directive object. You author these through the action's config
schema — the engine evaluates your value first, then validates the resolved
result against the field's type. Examples: http.url, http.body,
upsertEntity.attributes, an LLM message's content[].text.
{
"name": "http",
"config": {
"url": "{{ 'https://api.example.com/orders/' & input.orderId }}",
"headers": { "Authorization": "{{ secrets.company.PROVIDER_API_TOKEN }}" },
"body": { "$jsonata": "input.payload" }
}
}
#The three $-directives in detail
A $-directive object names a non-default behavior. The directive runs only
when its $-key is the sole key on the object ($if additionally takes its
$then / $else companions). The registered set is exactly $jsonata,
$literal, and $if.
// $jsonata (string form) — ONE expression, yields the RAW value
"output": { "$jsonata": "outputs.fetch.body" }
// $jsonata (structure form) — every STRING LEAF below is itself an expression;
// author a literal string as a JSONata string literal ('created')
"output": { "$jsonata": {
"id": "outputs.upsert.data.id",
"label": "'created'"
} }
// $literal — escape hatch; emits the value verbatim, nothing is walked
"output": { "$literal": { "text": "real data, not a {{ template }}" } }
#$if — the render conditional
$if resolves to its $then branch when the condition is truthy, its $else
branch otherwise. The taken branch is fully resolved. With no branch for the
taken side, the node ELIDES — the array element is dropped, or the object key is
removed.
// keep the "tools" entry only for admins; otherwise the element is dropped
{ "$if": "permissions.policy = 'admin'", "$then": { "id": "tools" } }
Data fetches are not smart values. Producers that fetch data (
$query,$http,$rows,$typeSchema) are NOT directives and are NOT evaluated by the expression walker. In an app spec, declare each feed once by name in a page'sdataSourcesmap and reference it by name from a widget or option picker — see GraphQL & the app data path and the data-source sections of the Apps guide. In a workflow or job, read data with aqueryEntityaction — that is the data path for the orchestrator.
#Shaping a row set ($pipe)
Anywhere a value is a row set — an app widget's dataSource, or a
{ $rows, $pipe } node in a workflow/job action's config / input / output
— you can attach a $pipe: an ordered list of typed steps that reshape the
rows after the data resolves. It replaces hand-written JSONata "relational
programs" with declarative steps, and it works identically in app data sources
and workflow definitions.
A $pipe node is { "$rows": <array>, "$pipe": [ …steps ] }. $rows must
yield an array — use a $jsonata value directive
({ "$jsonata": "outputs.fetch.data" }), since a whole-string {{ }} would
stringify the array. The vocabulary is exactly seven steps, applied left to
right:
| Step | Shape | What it does |
|---|---|---|
map |
{ "map": { "<key>": <smartValue>, … } } |
Project each row through a template — every leaf is a smart value evaluated with the row. |
filter |
{ "filter": "<bare JSONata>" } |
Keep rows whose predicate is truthy (evaluated per row). |
orderBy |
{ "orderBy": [ { "path": "<dotted>", "direction": "asc" | "desc" } ] } |
Multi-key sort; nulls sort last; stable. |
orderByList |
{ "orderByList": { "by": "<dotted>", "list": <array> } } |
Rank rows by the position of by in an external list; unlisted rows trail in input order. |
leftJoin |
{ "leftJoin": { "rows": <array>, "left": "<dotted>", "right": "<dotted>", "as": "<key>" } } |
Bind the first matching external row onto each row under as (absent on no match). |
distinct |
{ "distinct": { "by": [ "<dotted>", … ] } } |
Keep the first row per key (omit by to dedupe whole rows); stable. |
guardedProject |
{ "guardedProject": { "states": [ … ], "key": …, "at": …, "targetState": …, "existing": <array>, "machineSources": [ … ], "collapse": …, "emit": … } } |
State-advancement guard: a candidate row survives only if no existing row shares its key, or its targetState outranks the existing state on the states ladder (machine-sourced or undone existing rows always advance). collapse: "strongest" keeps one winner per key; emit: "annotated" keeps every row and adds a _guard verdict instead of filtering. |
map / filter leaves evaluate against the row (plus any leftJoin
bindings and the ambient config.*); leftJoin.rows, orderByList.list, and
guardedProject.existing resolve once against the render/run context.
// In a workflow action: shape a prior step's rows before upserting.
"payload": {
"$rows": { "$jsonata": "outputs.fetch.data" },
"$pipe": [
{ "filter": "attributes.status = 'active'" },
{ "leftJoin": { "rows": { "$jsonata": "outputs.owners.data" }, "left": "attributes.owner_id", "right": "id", "as": "owner" } },
{ "map": { "title": "{{ attributes.title }}", "owner": "{{ owner.attributes.name }}" } },
{ "orderBy": [ { "path": "title", "direction": "asc" } ] },
{ "distinct": { "by": ["title"] } }
]
}
In an app, the same $pipe attaches to a widget's data source — see
Data sources.
#3. Binding contexts
The expression rules are the same everywhere. What changes is the root — the
object your expression reads paths from. There is no ambient scope: a binding
that is not in the root for that site resolves to undefined, and a {{ }}
template that references it renders empty rather than failing.
Every site, in one table:
| Where the expression is authored | Root of the expression |
|---|---|
Trigger condition |
trigger, source, meta, config.env |
Trigger contextData, idempotencyKey |
the run context being built (below) |
Workflow step input, condition, idempotencyKey |
outputs, context, config.env |
Action config, if, loopOver, streamTransformer |
input, outputs — plus item, index, temp in a loop |
Action errorWhen |
input, result — plus item, index in a loop |
Action output |
input, result, outputs, temp — plus item, index in a loop |
Job output |
input, outputs, temp |
App widget config, $if, source filter, step fields |
the render context (below) |
App $pipe map / filter leaves |
THE ROW, plus an ambient config |
App $pipe leftJoin.rows, orderByList.list |
the render context |
Provider identity at login |
the login surface — the request body, or verified claims |
Provider identity at render |
body, headers, query |
Provider upload.<type>.props |
upload, provider, config |
Provider upload.<type>.errorProps |
error, provider, config |
secrets.* is not in that table because it is never evaluated: a
{{ secrets.… }} reference in a config slot passes through verbatim and is
resolved by the engine at execution time — see
Secret references.
#Trigger scope
A trigger condition is an entity-event predicate, evaluated once by the
event service at match time. It gates event triggers only — a webhook, cron tick
or direct dispatch is already an explicit request to run.
| Binding | Resolves to |
|---|---|
source |
The write-time entity snapshot: source.id, source.type.name, source.attributes.*, and for a relationship event the anchor with the counterpart under source.relationships[]. |
meta.diffs |
What changed, as [{ path, from, to }] — the basis for "only when status became X". |
config.env |
The activating tenant's env. |
trigger |
The triggering definition's id. |
"condition": "meta.diffs[path = ['attributes','status']].to = 'ready'"
contextData and idempotencyKey are evaluated by the engine against the run
context it is assembling — the same source / meta / config / trigger
above, plus input (what a dispatch posted) and workflow_id / user_id.
Whatever contextData returns becomes the run's stored context, which every
step then reads as context.
#Workflow step scope
A step's input, condition and idempotencyKey read:
| Binding | Resolves to |
|---|---|
outputs |
Outputs of earlier steps, by step id (outputs.<stepId>). |
context |
The run's context. What a dispatch posted is context.input.*; an event dispatch also carries context.source.* and context.meta.diffs. |
config.env |
The activating tenant's env values. |
The binding is
context, notcontext_data.context_datais the database column the run stores it in; the expression root exposes it ascontext. Writingcontext_data.…in a step resolves to nothing.
{
"stepId": "notify",
"condition": "context.source.attributes.status = 'ready'",
"input": { "order_id": "{{ context.input.order_id }}" }
}
#Action scope
An action's config — in a workflow job or inline under an app's actions step
— sees only two bindings:
| Binding | Resolves to |
|---|---|
input |
The job's input — what the step's input produced. |
outputs |
Outputs of earlier ACTIONS in this job. |
Neither context nor config reaches an action. That is deliberate: an action
is a reusable unit, so what it needs is threaded in explicitly and named on the
step.
{
"stepId": "notify",
"input": { "order_id": "{{ context.input.order_id }}" },
"actions": [
{
"stepId": "send",
"name": "http",
"config": { "url": "https://api.example.com/orders/{{ input.order_id }}" }
}
]
}
Reading context.* inside the action's config there yields nothing — the
common mistake, and the reason it is worth writing the step's input even when
it looks like a pass-through. Three action fields see more than input and
outputs:
errorWhenreadsresult(the handler's own output) andinput. Forhttpit defaults toresult.status >= 400.outputreadsresult,input,outputsandtemp.- Inside a
loopOveriteration,itemandindexare added to all of them.
#App render scope
When an app spec resolves a widget-level field (if, a source's filter,
notify.message, setPageData.data, navigate.params, an actions step's
input, a scan card's if / props, …) the full render context is in scope:
| Binding | Resolves to |
|---|---|
page |
The current page: page.id, page.params.* (the params a navigate passed), page.data.* (every resolved source and setPageData override, by name), and page.widgets.* (below). |
page.widgets.<id>.state.<field> |
A SIBLING widget's published state — a filter_bar field, a chips selection, a form input. The basis for cross-widget filtering. |
page.widgets.<id>.selectedRows |
A SIBLING data_grid's checked row ids. |
thisWidget |
The evaluating widget's own state slot (thisWidget.state.*; on a selectable data_grid, thisWidget.selectedRows — always an array). |
itemId |
The id of the row a row-level command was invoked on. |
identity |
The caller's resolved identity — every key of the provider's identity block, evaluated for this request (identity.user_email, identity.first_name, and any app-defined key). provider.identity is the same object; provider.provider names the provider. |
context |
The host's request context, exactly as posted with the render request (for a message-bound placement, typically the message being read). |
config |
The activation's config by level: config.app, config.integration, config.company, config.team, config.user — see Config and its scopes. |
permissions |
The per-render permissions snapshot: permissions.policy (the caller's policy name, or null) and permissions.grants[]. |
secretsSet |
The NAMES of secrets already set, grouped by scope (secretsSet.user, secretsSet.connection, …) — never values. |
outputs |
Accumulated outputs of prior steps in the running command (outputs.<stepId>), plus outputs.form inside a form's custom submit command. |
#selectedRows — a grid's checked rows
A selectable data_grid carries its checked row ids on the widget node as
selectedRows, so a command can read them without knowing the grid's inner
element id:
// A grid bulk-action reads its OWN selection:
{ "kind": "actions", "stepId": "send",
"input": { "ids": { "$jsonata": "thisWidget.selectedRows" } },
"actions": [ /* … acts on input.ids … */ ] }
// A footer actions widget reads ANOTHER grid's selection by id:
{ "kind": "actions",
"input": { "ids": { "$jsonata": "page.widgets.certs_grid.selectedRows" } },
"actions": [ /* … */ ] }
Arrays travel through $jsonata (a {{ }} would stringify them). On a
selectable grid selectedRows is ALWAYS an array ([] before any selection),
so a "something selected" gate is just $count(thisWidget.selectedRows) > 0. It
is page-scoped — it reflects the rows checked on the grid's currently fetched
page.
#page.widgets.<id>.state.<field> — a sibling widget's value
A widget's published values are grouped under its widget id, so any render-time
field can read another widget's state as page.widgets.<id>.state.<field>. The
canonical use is filtering a grid by a filter_bar or chips widget: the
grid's source reads the value in a filter particle marked
optional: true, so an unset value drops the predicate (the grid shows
everything) rather than matching zero rows. Nothing is declared between the two
widgets — every interaction re-renders in full, so the read is always current.
"dataSources": {
"tasks": {
"$query": {
"typeName": "Task",
"filter": {
"operator": "and",
"filters": [
{ "operator": "eq", "path": ["attributes", "flag"],
"value": "{{ page.widgets.filters.state.flag }}", "optional": true },
{ "operator": "eq", "path": ["attributes", "status"],
"value": { "$jsonata": "($s := page.widgets.status_chips.state.status; $s = 'all' ? undefined : $s)" },
"optional": true }
]
}
}
}
}
An optional particle elides on undefined, on an empty in list, and on the
empty string — a cleared input is "no filter". See
Apps → Data sources.
#setWidgetState — writing element values from a command
Each entry of a setWidgetState step's state is itself a smart value,
resolved against the running command's outputs. That matters because the
values widget state holds are not all strings — a chips selection is an array,
a page number is a number, a toggle is a boolean — and {{ }} is the string
tool. Use $jsonata to deliver anything that is not a string:
{
"kind": "setWidgetState",
"widgetId": "status_chips",
"state": { "status": { "$jsonata": "outputs.pick.selected" } }
}
The resolved record still has to be a valid state record — the step validates what it resolved to before writing it, so an expression that produces the wrong shape fails the step rather than corrupting the widget.
#$pipe — two roots in one node
A $pipe's steps do not all evaluate against the same thing, and the split is
the point:
mapleaves andfilterpredicates resolve against THE ROW. Inside them a bare path is a row field —attributes.name, notpage.data.x.attributes.name. The activation'sconfigis merged in as an ambient binding (row keys win on a clash), so a row projection can still readconfig.app.*.leftJoin.rowsandorderByList.listresolve against the render context, because they name a second data set to join or rank against, not something the row carries.
"$pipe": [
{ "filter": "attributes.status != 'archived'" },
{ "map": { "id": "{{ id }}", "label": "{{ attributes.name }}" } },
{ "leftJoin": { "rows": { "$jsonata": "page.data.vessels" },
"left": "vessel_id", "right": "id", "as": "vessel" } }
]
The filter and map above read row fields; the leftJoin's rows reads
page.data. Mixing them up is the usual $pipe bug — a map leaf that reaches
for page.… finds nothing, and a leftJoin.rows written as a bare row path
joins against nothing.
#Upload response scope
A provider's upload.<type>.props and .errorProps are the one place a smart
value resolves against neither the page nor a run. props sees
{ upload: { id, filename, contentType, size, expiresAt }, provider, config },
and errorProps sees { error: { code, message, status }, provider, config }.
Nothing else is in scope — the ingest that resolves them is answering a browser,
not rendering a page. See
Apps → File uploads.
#Secret references pass through verbatim
Inside config-surface fields (e.g. config.env.API_KEY, an integration's
config) a smart-value reference like
"{{ secrets.connection.PROVIDER_API_TOKEN }}" is preserved verbatim during
templating — the value is never inlined into the spec. The real value is
resolved at the action boundary, against the config allowlist, only when the
action runs. You author the reference, never the secret plaintext. See
Secrets.
#4. Failure-mode catalog
Every expression error returns the standard envelope —
{ error: { status: 400, code: "BAD_REQUEST", message, details } } — with
details.path pointing at the failing leaf and, where applicable, the JSONata
expression, token, and position.
#{{ }} in a boolean-gate slot
You see (at save):
a predicate must be a bare JSONata expression (e.g. "status = 'active'") or a
{ $jsonata: … } directive; a {{ }} smart-value is the string tool and is not
allowed in a predicate slot
Why. A gate field (condition / errorWhen / step if / navigate.modal
/ $pipe filter) was authored as {{ }}. {{ }} stringifies, and the string
"false" is truthy, so the gate would never block. Fix: drop the {{ }} —
write the bare expression: "if": "status = 'active'".
#Bare string on a {{ }}-required field
You see (at save):
must be a {{ }} smart-value expression; wrap literals as {{ 'value' }}
Why. A field that requires evaluation (idempotencyKey, a string-form
loopOver) got a bare string, which would be taken literally. Fix: write
the real expression ("{{ context.entityId }}") or force a literal
("{{ 'fixed' }}").
#JSONata syntax error inside {{ }}
You see (at run time):
Smart-value resolution failed at "/foo/bar": Unable to compile JSONata expression.
details carries the path, the failing expression, the token, and the
position (index into the expression) when JSONata supplies them. Fix: open
the expression at path; token / position pinpoint the character. The
scanner is quote-aware — a }} inside a JSONata string literal ({{ 'a}}b' }})
is data, not a terminator.
#A render-only binding used in an action config
You see:
Smart-value resolution failed at "/actions/0/config/relationships":
"thisWidget" is not available in this scope. To use it inside a step,
thread it through the step's `input`, e.g.
`input: { x: "{{ thisWidget.x }}" }` at the step site, then read
`{{ input.x }}` inside the step's config.
Why. An action config read a render-only binding (thisWidget / state /
page / provider / permissions). The action runtime sees only
{ input, outputs }, so the binding would yield undefined, collapse to [] /
null, and the action would no-op. The check fires loudly to prevent that
silent collapse. Fix: read the binding at the STEP site and thread it
through the step's input (see Action scope).
#A data source with no recognised producer
You see (at publish):
Unknown "$"-key "$graph". The "$" namespace is a closed set ($jsonata, $literal,
$if, $then, $else, $query, $http, $rows, $typeSchema, $pipe). …
or, when the key is recognised but the node is not a valid source, a shape error
naming $query / $http / $rows / $typeSchema and the unrecognised key
alongside it.
Why. A dataSources entry carries exactly one producer key. Zero
producers, two producers, or a key outside the four all fail — the node is
parsed strictly so a typo cannot slip through as an empty source that silently
returns nothing.
Fix: author one of the four producers. For nested reads — a grid whose rows
each carry related records — use $query with an include tree (as /
fields / single projection) rather than reaching for a second producer. In a
workflow or job action, the equivalent is the queryEntity action.
#An expression fails the publish lint
You see (at publish):
the "{{ }}" expression `page.data.x &` does not compile (position 14): …
the $jsonata expression `…` calls $frobnicate, which is not a jsonata
built-in, a registered stdlib helper, or a spec `functions:` entry.
Why. Every {{ }} span, $jsonata directive, $if condition, and
functions: body is syntax-compiled at publish, and function calls are checked
against the known set (JSONata built-ins + the registered stdlib + your spec's
functions: block + expression-local bindings). A whole-string {{ }} that
BUILDS an object/array additionally WARNS (it stringifies). Fix: open the
expression at the reported position; author object/array structure as a plain
object with smart-value leaves, or { "$jsonata": … } where an expression must
produce the value.
#A directive with a sibling key
You see:
Directive "$literal" must be the only key on its object.
Why. A directive object must be a singleton —
{ "$literal": 1, "other": 2 } and { "$jsonata": "x", "$literal": 1 } are
both rejected. Fix: wrap the directive separately, or move the sibling out:
{ "inner": { "$literal": 1 }, "other": 2 }.
#An unknown $-key
You see (at publish):
Unknown "$"-key "$tabs". The "$" namespace is a closed set ($jsonata, $literal,
$if, $then, $else, $query, $http, $rows, $typeSchema, $pipe). …
Why. The $ namespace is CLOSED. Any object key starting with $ outside
that set is rejected — a domain or structural concept is a plain property or a
widget, never a new $-key; a per-context value reaches a command through
smart-value scope (outputs / page / thisWidget), never a bespoke token.
(JSONata's own $fns like $now / $map live INSIDE expression strings, not
as object keys, and are unaffected.) Fix: use a registered
producer/directive, a plain property, or a widget.
#Compact nested braces (now works)
An expression like {{ {a: {b: 1}} }} — whose outer placeholder closes with a
}} adjacent to a nested object literal — resolves correctly. The scanner is
quote- and brace-depth-aware, so an adjacent }} from a nested literal is not
misread as the placeholder terminator.
JSONata gotcha:
{{ {a: 1} }}returns{}(unquoted keys are field selectors, not literal map keys). Author object literals with quoted keys:{{ {'a': 1} }}.
#5. The error path
Every expression error carries details.path — a JSON pointer
(RFC 6901) to the failing leaf
in YOUR authored document. It is the path you see in your own spec / definition,
not a path into the resolved output.
#Format
| Path | Means |
|---|---|
"/" |
The root (a top-level string / scalar). |
"/a/b/c" |
Object key c inside b inside a. |
"/items/2" |
Index 2 of the items array. |
"/actions/0/config/url" |
An http.url smart-value at action index 0. |
"/wrapper/$jsonata" |
A failure inside a $jsonata directive's sub-tree. |
#Using the path
- Display it verbatim alongside the message — don't normalise or strip the
leading
/. - It's stable: the same node maps to the same path across retries, so a reporter can dedupe / cluster by path.
- It points into the AUTHORED tree, not the resolved one.
"/actions/0/loopOver"is theloopOverauthoring field, not one iteration of the loop.
Where the underlying engine surfaces extra fields (JSONata expression,
token, position), they are preserved alongside path — the wrap is strictly
additive.
// A JSONata syntax error inside a deep input
{
"error": {
"status": 400,
"code": "BAD_REQUEST",
"message": "Smart-value resolution failed at \"/actions/0/config/url\": Unable to compile JSONata expression.",
"details": {
"path": "/actions/0/config/url",
"expression": "?bad",
"message": "Unable to compile JSONata expression.",
"token": "?",
"position": 1
}
}
}