Pure data shaping
Pure nodes handle the data work inside a plan: formatting, field access, JSON extraction, arithmetic, type coercion, and value assembly. They perform no IO, dispatch no operation, and never pause for approval. Use them anywhere you would otherwise shell out just to reshape data.
In the text form, fmt(…), parse(…), peek x, field access including array indexes
(value.items[0].name) and quoted object keys (response.headers["content-type"]), value templates, and invertible native expressions have first-class
spellings. The @json escape remains only for pathological shapes: non-invertible expr formulas
and jq over a non-symbol input. All of them are ordinary nodes in the JSON wire form.
fmt — string interpolation
label = fmt("BTC: {price} | 24h change: {change}%")
fmt substitutes {name} placeholders from already-bound symbols — the same syntax as
interpolation inside string literals, but explicit about being a formatting step. Unbound
placeholders are left verbatim (no silent data loss). The result is always a string.
fmt(…) and parse(…) are the only pure nodes with special call-style text spellings. Writing
expr(…), jq(…), or peek(…) call-style parses as an ordinary op call with that name — peek's
native form is the keyword spelling peek x, and native formulas are written directly where they
are invertible, for example ok = $score >= 0.8.
For a template longer than a line, give fmt a
multi-line string — the content is verbatim, so an
embedded JSON body needs no escaping:
flow render-payload -> String
env = "staging"
body = fmt("""{
"environment": "{env}",
"checks": ["build", "test"]
}""")
return body
The JSON braces do not need doubling. A {…} run whose contents are not a bound symbol is emitted
verbatim, and the scanner resumes just after the brace it rejected — so the real {env} placeholder
later in the template still resolves.
For structured payloads, prefer building the value with an obj template
and letting the op serialize it. Formatting JSON as a string works, but it gives up the type checking
that a template keeps.
Field access — var.path
When the input is a bound symbol and the path is a simple dotted field path, extract fields with the access sugar:
raw = web.fetch("https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd")
price = raw.bitcoin.usd
kind = plan.kind
content_type = response.headers ["content-type"]
This lowers to a jq node. Notes:
- It is a bind-value form: valid on the right-hand side of
x = …and as a pure value-template leaf. It is not valid inline as a call argument, condition, ormatchsubject. Bind first, then pass or test the symbol. In particular, branch onstatus, notresponse.status, after bindingstatus = response.status. obj.fieldreads an identifier-named object field;obj["content-type"]reads a key that needs punctuation or other JSON-string escaping.obj["a.b"]is one key literally nameda.b, not two segments.list[0]indexes into a list (list[0]is the first element), whileobj["0"]is an object field literally named0.- Flux chose JSON-string brackets instead of widening dot segments or adding a separate quoted-dot syntax. Dot access stays identifier-only; brackets reuse JSON escaping and remain visibly distinct from numeric list indexes.
- Access is strict: a missing field, an out-of-range index, or a field access on a non-object
is a loud error — a typo fails fast instead of silently reading empty. A field that is present
but
nullis never an error. - Mark an access optional with a trailing
?—x.field?,list[0]?— to readnullinstead of erroring when a field may legitimately be absent. - Strictness is a reference-position rule. A bare
x.fieldin a bind value or value template is strict (with the?opt-out). Field access inside anexprcomputation —$x.field == $y,$x.field + 1,filter/mappredicates — stays lenient (missing reads empty), since expr is for computed values where absent-as-empty is the established behaviour; a?there is accepted but redundant.
jq — JSON path extraction
The full jq node supports dot paths with numeric array indexes and JSON-string object keys,
applied to the JSON content of its input. Native source uses numeric brackets —
first = response.results[0].value — while the lowered AST stores that index as the dot segment
.results.0.value. Quoted object keys remain brackets in both forms:
response.headers["content-type"] lowers to .headers["content-type"]. JSON escaping makes dots,
brackets, quotes, backslashes, empty keys, Unicode, and numeric-looking object keys unambiguous. A
non-symbol input, or a hand-built AST using the legacy numeric-bracket path spelling, needs @json
to preserve that exact AST shape:
first = @json { "kind": "jq", "path": ".results[0].value", "input": { "kind": "var", "name": "response" }, "optional": true }
The path grammar is a strict subset of jq — dot-separated field names, numeric index segments, and
JSON-string keys (.field, .field.nested, .field.0.nested,
.headers["content-type"]). No filters, pipes, or conditionals. A numeric .0 indexes a list;
quoted ["0"] names an object field and never indexes a list.
The extracted value keeps its natural JSON type. A model- or host-emitted jq node traverses
missing data leniently (an absent key or out-of-range index yields null); the native
x.field sugar above is strict on missing data unless you add the ? opt-out.
expr — safe inline computation
expr evaluates a whitelisted formula over named variables — a tiny recursive-descent
evaluator, not eval, not a shell. Invertible formulas in bind RHS and condition positions can be
written directly:
ok = $score >= 0.8
when $count > 3 && $state == "ready"
return true
Arithmetic over bound symbols is invertible too:
total = $price * $qty
A formula that uses expr's function library is not spellable natively — round(price, 2) in
text would parse as an op call named round — so function-bearing formulas are written via @json.
Here the formula-local name price maps to the bound symbol unit_price:
unit_price = 12.345
rounded = @json { "kind": "expr", "formula": "round(price, 2)", "vars": { "price": { "kind": "var", "name": "unit_price" } } }
Supported, by precedence (lowest to highest):
| category | forms |
|---|---|
| boolean | ||, &&, unary !, true / false |
| comparison | == != < <= > >= (numeric when both sides are numbers, else lexicographic) |
| arithmetic | + - * / — + concatenates when either side is non-numeric text |
| functions | round(x, n), abs(x), min, max, sum(xs), any(xs), all(xs), has(xs, v), len(x), first(xs), last(xs), lower(s), upper(s), trim(s), replace(s, from, to), repeat(s, n), reverse(s), contains(s, sub), concat(a, b, …), join(xs, sep), split(s, sep) |
| atoms | numbers, '…'/"…" string literals, lists, objects, variables, dotted variable access, parentheses |
Every variable used in the formula must be declared in vars (mapping names to symbol or
literal nodes) — undeclared identifiers error. Comparisons yield a bool, so an expr is also a
valid when/unless/until/assert condition.
Deterministic list transforms
Pure cognition ops cover the common data-shaping jobs that used to require shell snippets or
model-backed "return JSON" prompts. Predicate-bearing ops use a where string evaluated with
it bound to the current element; extra comparands go in vars.
Tier 1 handles projection and list mechanics:
authors = map(items: issues, path: "author.username")
hot = filter(items: issues, vars: { "min": 3 }, where: "it.state == 'opened' && it.score > min")
label_lists = map(items: issues, path: "labels")
labels = flatten(items: label_lists)
rest = skip(items: authors, n: 1)
csv = join(items: authors, sep: ",")
parts = split(s: csv, sep: ",", trim: true)
Tier 2 handles reductions and boolean emitters that compose directly in conditions:
total = sum(items: issues, path: "score")
by_state = count_by(items: issues, path: "state")
by_author = group_by(items: issues, path: "author.username")
when any(items: issues, where: "it.score > 8")
notify()
Tier 3 trims objects and chooses fallbacks:
slim = pick(items: issues, keys: ["id", "title", "state"])
public = omit(items: issue, keys: ["raw_payload"])
merged = merge_obj(objects: [defaults, override])
owner = coalesce(default: "unassigned", values: [issue.assignee.username?, issue.author.username?])
fields = keys(item: merged)
vals = values(item: merged)
Tier 4 uses Rust regex for bounded, linear-time string classification and extraction:
has_error = regex_match(pattern: "ERROR", s: line)
version = regex_extract(group: 1, pattern: "v(\\d+\\.\\d+\\.\\d+)", s: body)
Use map/filter when the input is already structured JSON and the transformation is a field
projection or predicate. Use each when every item must dispatch real work such as read,
web.fetch, or task.
parse — type coercion
Converts a literal, bound symbol, or object/list template into a typed value. Bind a computed jq
or fmt result first; parse(…) itself does not dispatch another computation. It is special-cased
in the expression grammar, so it lowers to the pure node rather than an op call:
price_text = raw.price
price_num = parse(price_text, as: "f64")
form_body = parse({ grant_type: "password", password: secret, username: user }, as: "form")
as is one of f64, i64, bool, json, string, or form. Coercion failures error rather
than silently defaulting ("abc" to f64 is an error); bool is true only for "true" or "1".
json can parse JSON text or serialize a structured value as canonical JSON. form serializes a
flat record as application/x-www-form-urlencoded text; it rejects nested values and omits fields
whose value is null.
Value templates — obj and list
A record or list whose leaves are variables or expressions is a template, assembled at
evaluation time. { … } / [ … ] in text is a template exactly when it is not plain JSON —
an unquoted key or a symbol/expression leaf makes it one; pure JSON stays a literal:
summary = { count: n, intent: extract.intent, items: [a, b], ok: true }
return { refs, status: "done" }
- Each field value may be a symbol, literal,
fmt,jq/field access,expr,parse, or a nested object/list template. - Templates are pure: the analyzer rejects effectful leaves (op calls, control flow) so templates stay side-effect free. Bind a call result first, then reference the symbol.
- Keys are barewords when identifier-safe, otherwise JSON-quoted:
{ "a-b": x }. - An all-literal template is just JSON, and parses as a literal.
This is what makes return { … } work: a flow assembles its structured result from computed
symbols without a single shell-out.
Putting it together
The classic fetch-extract-compute-format chain, with no bash, no approval pauses, and no escapes — every step has a native spelling:
flow btc-double
raw = web.fetch("https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd")
price = raw.bitcoin.usd
doubled = $price * 2
label = fmt("BTC: {price} | Double: {doubled}")
return { doubled, label, price }
For selecting and budgeting context — the other big pure-node use case — see Context packs.
Related docs
- Context packs — pure nodes for model-visible evidence.
- Execution model — symbol values, interpolation, and truthiness.
- Flows & syntax — call, bind, and interpolation syntax.