#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 (a condition, an errorWhen, a step if) 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

  1. How a field is evaluated
  2. Authoring shapes by field type
  3. Binding contexts
  4. Failure-mode catalog
  5. 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's dataSources map 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 a queryEntity action — 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, not context_data. context_data is the database column the run stores it in; the expression root exposes it as context. Writing context_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:

  • errorWhen reads result (the handler's own output) and input. For http it defaults to result.status >= 400.
  • output reads result, input, outputs and temp.
  • Inside a loopOver iteration, item and index are 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:

  • map leaves and filter predicates resolve against THE ROW. Inside them a bare path is a row field — attributes.name, not page.data.x.attributes.name. The activation's config is merged in as an ambient binding (row keys win on a clash), so a row projection can still read config.app.*.
  • leftJoin.rows and orderByList.list resolve 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 the loopOver authoring 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
    }
  }
}