The complete nflow DSL reference: pipeline operators (->, ||, &), projection blocks, @arguments, :modifiers, and the directive surface (@require, @include, @assert, @on_error, @config:strict).
A .nflow file contains Flow directives and a pipeline expression. An atom names an action available to the runner; operators compose them; projections can reshape values between them.
The compiler pre-processes directives, parses the pipeline, and resolves referenced atoms and modifiers against the active registry. Directive and modifier availability can depend on the bundles loaded by a runner; use nflow list to inspect the active catalog rather than relying on a fixed feature list.
Six forms. Everything else composes from these.
A -> B — pass the left result to the next action.A | B — alias for ->.A || B — try the right expression if the left expression fails.(A & B) — run the grouped branches in parallel.{ key: expression } — project fields and expressions into a new object.action @{ key: value } — pass named values as the action's arguments.action:mod=val — apply a modifier to that action.Parallel results use each branch action's name as its gather key. A name used by only one branch keeps its existing key; repeated names get name#1, name#2, and so on in source order. If a generated label is already in use, trailing # characters are added until it is unique.
# Built-in parallel composition feature test
( const @{ value: "left" } & const @{ value: "right" } )The { ... } block lets you reshape complex JSON on the fly — extracting fields, injecting new values, and merging state without writing any code.
@assert: result.summary == "Julia (ID: 101) is active"
{
user: { id: 101, name: "Julia", metadata: { status: "active" } },
system_flag: true
}
-> {
id: .user.id,
name: .user.name,
status: .user.metadata.status,
summary: .user.name + " (ID: " + string(.user.id) + ") is " + .user.metadata.status
}Projection expressions use expr-lang. String concatenation, arithmetic, comparison, and field access all work. .user.id accesses a nested path; string(.user.id) coerces an integer for concatenation.
Use action @{ ... } to pass named values as the action's typed arguments.
sleep @{ duration_ms: 5000 }
const @{ value: "hello" }
fail @{ kind: "Validation", message: "User email is invalid" }Arguments are injected before the validation Before hook, so they join the input map in validation. They are the typed channel for supplying per-call parameters that are not part of the data pipe.
A modifier is one :name or :name=value annotation on an atom. Modifiers mutate the atom's *action.Builder in source order before the action is built. They are the DSL's only mechanism for per-atom configuration that is not an argument and not part of the pipeline topology.
fetch:timeout=5s:retry=3:auth:role=admin:tag="fast,hot":timeout=2s — per-action wall-clock deadline.:retry=3 — retry up to N times with exponential jitter backoff.:cache=30s — read-through cache with singleflight stampede protection.:auth — require authenticated context (flag, no value).:role=admin — enforce an RBAC role claim.:name=mykey — override the gather key for parallel branches.:tag="hot,fast" — metadata tags for catalog and observability.:read_only — mark the atom as side-effect free for the runtime.:strict / :lenient — opt in/out of runtime request validation per atom.Directives start with @ and run at compile time, before the pipeline is compiled.
@description "..." — attach a human-readable description.@assert: expression — check the final result after the flow runs.@pipeline name ... @end — declare a named pipeline.@include "path.nflow" — inline another local Flow file at this position.@require ... — request an extension or library bundle for the flow.@on_error { when error.kind == "X" -> action, ... else -> action } — route failures by error kind.@config:strict=true — enable runtime request validation via validation.Struct.@sandbox name { image, memory_mb, cpus, ... } — declare a named sandbox profile.@description "Per-action Timeout and Local Fallback"
@assert: result.recovered == true
{ query: "SELECT * FROM huge_table" }
-> sleep:timeout=1s @{ duration_ms: 5000 }
|| { recovered: true, error: "Database took too long, serving fallback data." }@on_error routes failures to an action by matching error.kind. Combine with retry on the same atom to keep transient failures out of the catch block.
@on_error {
when error.kind == "Timeout" -> error.info
when error.kind == "Validation" -> error.info
else -> error.info
}
@assert: result.kind == "Validation"
fail @{ kind: "Validation", message: "User email is invalid" }The error.info action returns the recovered error as a structured map — useful for logging, metrics, and structured responses. The handler receives the categorized xerr.Kind, not the raw Go error.
`@config:strict` + `:strict` / `:lenient` enables runtime request validation via validation.Struct. This closes the gap that projections and @{} injections open: the compile-time edge check in pipeline_types.go only catches direct A -> B, not when a projection is in between.
@config:strict=true enables validation for every typed atom in the file.consumer:strict opts in even when the file is lenient.helper:lenient disables validation when global strict is on.:lenient always wins over :strict when both appear.Strict runs only on typed actions (those with TypedPayload.ReqPayload()). An any action has no schema to check, so validation never runs regardless of flags. The error reaches the transport as xerr.KindValidation (HTTP 400).