Skip to content

Combinators & templates

Combinators combine values from other values: chain a transform pipeline, pick a value by case, merge several reactive inputs, or build up an object. Reach for one when a single query or state key isn’t quite the shape a block wants. See the expression reference for the exact inputs.

@expr/pipeline

Runs a source expression through a sequence of transform operators, each receiving the previous result. Reach for it whenever you’d otherwise nest transforms inside transforms — it reads top-to-bottom instead of inside-out.

{
"@expr/pipeline": [
{ "@expr/query": "SELECT StatusCode, Duration FROM spans" },
{ "@expr/filter": { "where": "StatusCode > 0" } },
{ "@expr/derive": { "duration_ms": "Duration / 1000000" } }
]
}

@expr/set_context in a pipeline

A pipeline step that publishes named entries into scope for the rest of the chain, passing the flow through unchanged. An operator-shaped entry (an @expr/filter, say) is applied to the incoming value; a complete expression evaluates on its own; { "@expr/pipeline": [] } captures the flow verbatim. Downstream steps read the names with @expr/get_context. Names may not start with _, which the runtime reserves for its own scope entries (_timerange, _filter), and a @expr/pipeline entry holds operators only (a standalone chain is written as its head expression with the operators nested in it). Pure scoping in every host, workflow steps included: nothing is persisted; durable cross-run state is @expr/workflow_state.

@expr/query as a pipeline step

A from-less structured query in step position runs SQL over the incoming value: the flow binds as the driving relation, so select/where/group_by read its columns, and a join key (full_join, left_join, …) joins another table against it — a view alias or an inline expression, with as aliasing the flow for the on predicate. SQL text and { "sql": … } bodies stay complete queries with their own FROM and never consume the flow.

{
"@expr/pipeline": [
{
"@expr/query": {
"from": "services",
"select": { "as": { "ServiceName": "ServiceName", "value": "ErrorRate" } }
}
},
{
"@expr/query": {
"as": "c",
"full_join": { "from": "baselines", "as": "p", "on": "c.ServiceName = p.ServiceName" },
"select": {
"as": {
"ServiceName": "coalesce(c.ServiceName, p.ServiceName)",
"delta": "c.value - p.value"
}
}
}
}
]
}

@expr/case

Picks a value by matching a context value against a set of cases (with a default) — a lookup table. Use it to map a status to a colour, a mode to a label, an enum to a human string, without a chain of conditionals.

{ "@expr/case": { "status": { "ok": "green", "error": "red", "default": "gray" } } }

@expr/color

Reads a source table, maps one of its values to a semantic colour tone, and appends the tone as a new column. Use it to colour a cell by its outcome, severity or status class (a named palette), or by where a number falls on a scale. Every source column passes through unchanged.

from is a table source (a sibling @block/context binding, an @expr/query, or any other table-producing expression). value is the SQL value to map: a bare column name ("Outcome") or any SQL fragment ("lower(SeverityText)", so a value that arrives mixed-case can be normalised before the lookup runs). color is the mapping, either { "palette": … } or { "scale": … }. as names the appended column (default color).

Chain it in a pipeline, once per column, then bind each appended column on its cell:

{
"@block/table": {
"from": {
"@expr/pipeline": [
{ "@expr/query": { "from": "spans", "select": ["Outcome", "Status"] } },
{
"@expr/color": {
"value": "Outcome",
"color": { "palette": "outcome" },
"as": "outcome_bg"
}
},
{
"@expr/color": {
"value": "Status",
"color": { "palette": "http_status" },
"as": "status_bg"
}
}
]
},
"columns": [
{ "title": "Outcome", "value": "Outcome", "background_color": { "@expr/get": "outcome_bg" } },
{ "title": "Status", "value": "Status", "background_color": { "@expr/get": "status_bg" } }
]
}
}

Supported palettes: outcome (OTel StatusCode — Unset, Ok, Error), severity (OTel log severities), http_status (1xx–5xx), annotation_severity (critical, warning, ok, info, neutral). The signal and cache chart palettes have no tone equivalent and are not supported.

A value the palette does not define falls back to unknown — a BackgroundColor tone (default 'default', i.e. no tint). The long palette form pairs the name with an explicit fallback:

{
"@expr/color": {
"from": "spans",
"value": "lower(SeverityText)",
"color": { "palette": { "name": "severity", "unknown": "muted" } },
"as": "severity_bg"
}
}

Numeric scales

A scale buckets a numeric value into tones. It takes the same type, domain, scheme, range, reverse and unknown fields as a @block/plot colour scale, but scheme, range and unknown hold tones instead of CSS colours. The output is still a BackgroundColor tone, so a scale steps across tones rather than shades of one hue.

{
"@expr/color": {
"from": "instances",
"value": "HeapShare",
"color": { "scale": { "type": "threshold", "domain": [0.75, 0.9] } },
"as": "heap_bg"
}
}
  • type: "threshold" reads domain as ascending boundaries. n boundaries give n + 1 steps, and a value equal to a boundary lands in the step above it, so the example tints HeapShare below 0.75 default, 0.75 up to 0.9 warning, and 0.9 or above error.
  • type: "quantize" splits [min, max] into equal-width steps. Without a domain it reads the extent from the rows, and a column whose values are all equal takes the first tone. Values outside the domain take the first or last tone. A quantize scale without a domain always runs on the database.
  • type: "quantile" takes no domain. It splits the rows into equal-count steps by rank, and rows with equal values share a step.
  • quantize and quantile take one step per tone, or a bucket count through { "quantize": { "n": 4 } } / { "quantile": { "n": 4 } }.

The tones come from scheme or range, from the low end of the scale to the high end:

  • scheme names a tone ramp: alert (default, warning, error; the default) or health (success, warning, error). The ramp is sampled evenly when the step count differs from its length.
  • range lists one tone per step instead, e.g. ["default", "info"].
  • reverse: true flips the order, for measures where low values are bad.
  • unknown is the tone for a null value (default default).
  • domain accepts an expression, e.g. { "@expr/get": "heap_limits" }, so the boundaries can follow a setting or page state.

@expr/combine_latest and @expr/concat

Merge several reactive inputs. @expr/combine_latest emits an array of the latest values of its inputs, re-emitting whenever any one changes — use it to feed a block something that depends on several state keys or queries at once. @expr/concat flattens several array-valued expressions into one list — handy for stitching together option lists or rows from more than one source.

{
"@expr/combine_latest": [{ "@expr/get_context": "userName" }, { "@expr/get_context": "userRole" }]
}

@expr/scalar

Coerces a table down to a single value. Most scalar-typed props reject a query outright — a query yields a table, and a title is text — so when you genuinely want a query result in one, wrap it: @expr/scalar takes the sole cell of a one-row result (and errors on more rows than one). A few blocks do this for you where it’s the obvious intent, like @block/stat’s value.

{
"@expr/scalar": {
"@expr/query": {
"from": "traces",
"select": { "as": { "Services": "uniqExact(ServiceName)" } }
}
}
}

@expr/object, @expr/spread and @expr/get

Small value helpers. @expr/get reads a value out of context and walks a dot path into it ("totals.TotalCost") — pull a single field out of a one-row query, or a nested property out of a state object.

{ "@expr/get": "totals.TotalCost" }

@expr/object builds a one-property object from a { key, value } pair. Both positions take an expression, so the key can be computed too.

{ "@expr/object": { "key": "env", "value": { "@expr/get_context": "env" } } }
{ "@expr/object": { "key": { "@expr/get_context": "field" }, "value": 1 } }

A many-property object is a merge of pairs, which is what @expr/spread is for.

@expr/spread merges objects left to right — literal ones, expressions resolving to one, or @expr/object pairs — so a later part’s keys win. Reach for it to assemble a map from pieces computed separately: a base set of values plus a conditional override.

{
"@expr/spread": [
{ "service": "checkout" },
{ "@expr/object": { "key": "env", "value": { "@expr/get_context": "env" } } }
]
}

Both are checked against the position they sit in: where a slot’s type says what its values are, each value is validated against it rather than accepted as anything.

@expr/resolve

Deep-substitutes any embedded @expr/* expressions inside a JSON-like value, leaving the surrounding shape intact. Reach for it to template a structured object — say, a block’s config — where only some fields are dynamic.

{
"@expr/resolve": {
"title": "Dashboard",
"user": { "@expr/get_context": "userName" },
"tags": ["static", { "@expr/get_context": "currentTag" }]
}
}