#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 workflow's starting context_data from an incoming event.

"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_data.input" }

// 2. Build the input explicitly
"input": {
  "orderId":  "{{ context_data.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 (or as a page-data snapshot) 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 six 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.

map / filter leaves evaluate against the row (plus any leftJoin bindings and the ambient config.*); leftJoin.rows and orderByList.list 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 same expression rules run in two contexts. What changes is the set of bindings in scope — the variables your {{ … }} and $jsonata expressions can read.

#Workflow / action scope

When an action's config resolves — whether in a workflow run or inline under an app's actions step — the scope is JUST:

Binding Resolves to
input The step / action input.
outputs Outputs of earlier actions in the same job (outputs.<stepId>).
context_data The workflow's accumulated context (workflow steps).
payload The trigger event payload (trigger contextData).
secrets Scoped secret references inside config slots (see below).

Render-only bindings (thisWidget, state, page, provider, permissions) are deliberately absent here — the action runtime is generic and must not depend on app-render concepts. If a step needs render data, the calling app threads it through the step's input:

{
  "kind": "actions",
  "input": {                              // app render resolves this
    "userId": "{{ provider.userId }}",
    "rowId":  "{{ thisWidget.state.selectedRow }}"
  },
  "actions": [
    {
      "name": "queryEntity",
      "config": {
        "typeName": "Activity",
        "where": {
          "operator": "eq",
          "path": ["user_id"],
          "value": "{{ input.userId }}"   // action runtime resolves this
        }
      }
    }
  ]
}

Reading thisWidget directly inside an action config is a fail-loud error (see the failure catalog) — it would otherwise yield undefined and the action would silently no-op.

#App render scope

When an app spec resolves a widget-level field (if, baseFilter, notify.message, setPageData.data, navigate.params, an actions step's input, …) the full render context is in scope:

Binding Resolves to
thisWidget This widget's own state slot. On a selectable data_grid, thisWidget.selectedRows is the checked row ids (always an array).
state All widget states under their scoped keys.
page Page metadata + page data (page.data.*) + route params (page.params.*, e.g. page.params.id).
page.widgets.<id>.state.<field> A SIBLING widget's field value (e.g. a picker / form-select) — the basis for cross-widget filtering.
page.widgets.<id>.selectedRows A SIBLING data_grid's checked rows.
provider The resolved external-provider context for this render.
permissions The per-render permissions snapshot.
outputs Accumulated outputs from prior steps in the sequence.
config The consumer-tenant resolved config (config.app, config.user, …).

#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": "{{ thisWidget.selectedRows }}" },
  "actions": [ /* … acts on input.ids … */ ] }

// A footer actions widget reads ANOTHER grid's selection by id:
{ "kind": "actions",
  "input": { "ids": "{{ page.widgets.certs_grid.selectedRows }}" },
  "actions": [ /* … */ ] }

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 input values are grouped under its widget id, so any render-time config can read another widget's field as {{ page.widgets.<id>.state.<field> }}. The canonical use is filtering a data_grid by a picker — the grid's baseFilter reads the picker value, and because baseFilter re-resolves on every render, the grid re-filters when the value changes. Two companions make this ergonomic:

  • mark the baseFilter particle optional: true so an UNSET value drops the predicate (grid shows all) rather than matching zero rows;
  • add dependsOn: [{ widget, field }] to the grid's source so that, when that widget changes, the grid re-resolves against the new value.
{
  "operator": "eq",
  "path": ["attributes", "flag"],
  "value": "{{ page.widgets.flagPicker.state.flag }}",
  "optional": true
}

See Filter a data_grid by a picker value in the Apps guide.

#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.company.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 Workflow / action scope).

#Unknown / removed data-source producer key

You see (at publish):

data source must carry exactly one producer key ($query, $http, $rows, or $typeSchema)

Why. A dataSources entry (or page-data snapshot) carries no recognised producer key — usually a removed generation ($entity, $graphql) or a typo. Fix: author one of the four producers. The old $graphql grid / snapshot forms map onto $query with an include tree (nested reads, as / fields / single projection). For a workflow / job action, use queryEntity instead.

#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, $dependsOn). …

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