Pipeline DSL reference¶
Normative grammar for a pipeline definition — the step kinds, the
compositional primitives, the expression language they evaluate against, the
schema/verify: schema mechanism, and the four tools that launch a pipeline.
See Pipelines for the why/architecture,
and Pipeline registration
for how a definition reaches a session.
Document shape¶
A pipeline definition is one or more ----separated YAML documents:
- Exactly one
pipeline:document — the pipeline itself. - Zero or more
schema:documents — named schemas the pipeline's steps can reference viaverify: schema(see Schemas).
schema: Review
fields:
passed: {type: bool}
notes: {type: string}
---
pipeline: review_and_report
description: Review a document and summarize the verdict.
steps:
- agent: {prompt: "Review {ctx.doc}. Reply with passed/notes.", schema: Review, output: review}
- transform: {value: "review.passed and 'OK' or 'NEEDS WORK'", output: verdict}
pipeline: document keys¶
| Key | Required | Meaning |
|---|---|---|
pipeline |
yes | The declared name. Authoritative for registration and for a call/match step's target — see Pipeline registration. |
description |
no | Human-readable summary; surfaced to the LLM alongside the name when a registered pipeline is listed as a pipeline__<name> catalog action. Defaults to empty. |
steps |
yes | Non-empty list of steps, executed in order (see Step kinds and Primitives). |
input, defaults, and refine are part of the pipeline design's fuller
grammar but have no runtime yet — a document using them fails to parse with
an explicit "not yet supported" error rather than being silently ignored.
Formal grammar¶
The EBNF below is the canonical, current grammar — derived directly from
parse_pipeline_dsl (src/reyn/core/pipeline/parser.py), not from an
earlier design proposal. It covers exactly what the parser accepts today: a
definition conforming to it parses cleanly; a violation is rejected. Mapping
keys are unordered in YAML — the linear order below is for readability, not
a positional requirement. NAME is a bare identifier-like string; EXPR is
an R1 expression source string; TPL is an
agent.prompt template string ({ctx.dotted.path} / {pipe} interpolation,
not R1).
Document ::= YamlDoc ("---" YamlDoc)* (* exactly one PipelineDoc across the whole text *)
YamlDoc ::= SchemaDoc | PipelineDoc
SchemaDoc ::= "schema:" NAME "fields:" FieldMap
PipelineDoc ::= "pipeline:" NAME
("description:" STRING)?
"steps:" Step+
Step ::= "transform:" TransformBody
| "tool:" ToolBody
| "shell:" ShellBody
| "agent:" AgentBody
| "call:" CallBody
| "match:" MatchBody
| "fold:" FoldBody
| "for_each:" ForEachBody
| "parallel:" ParallelBody
TransformBody ::= "{" "value:" EXPR ["output:" NAME] "}"
ToolBody ::= "{" "name:" STRING
["args:" ArgMap]
["schema:" NAME]
["output:" NAME] "}"
ArgMap ::= "{" (KEY ":" ArgValue ("," KEY ":" ArgValue)*)? "}"
ArgValue ::= LITERAL | "!expr" EXPR (* !expr only as the WHOLE value, never nested *)
ShellBody ::= "{" "command:" ArgValue
["schema:" NAME]
["output:" NAME] "}"
AgentBody ::= "{" "prompt:" TPL
["identity:" NAME]
["capabilities:" "{" "tools:" "[" NAME* "]" "}"]
["schema:" NAME]
["output:" NAME] "}"
CallBody ::= "{" "pipeline:" NAME (* static literal, never EXPR *)
["pass:" "[" NAME* "]"]
["output:" NAME] "}"
MatchBody ::= "{" "on:" EXPR
"cases:" "{" (LABEL ":" MatchTarget)+ "}"
["default:" MatchTarget]
["output:" NAME] "}"
MatchTarget ::= "{" "pipeline:" NAME ["pass:" "[" NAME* "]"] "}"
FoldBody ::= "{" [ListSource]
"init:" EXPR
"do:" Step
"output:" NAME (* required, unlike call's *)
["max_items:" INT] "}"
ForEachBody ::= "{" [ListSource]
["max_parallel:" INT]
"on_error:" OnError (* required — no default *)
"do:" Step
"collect:" Step
["output:" NAME] "}"
ParallelBody ::= "{" ["on_error:" OnError] (* optional — defaults to "abort" *)
"branches:" "{" (NAME ":" Step)+ "}"
"collect:" Step
["output:" NAME] "}"
ListSource ::= "over:" EXPR | "items:" "[" LITERAL* "]" (* mutually exclusive *)
OnError ::= "continue" | "abort" | "retry(" INT ")"
FieldMap ::= "{" (NAME ":" FieldType)+ "}"
FieldType ::= "{" "type:" ("bool" | "string" | "number") "}"
| "{" "type:" "enum" "values:" "[" LITERAL+ "]" "}"
| "{" "type:" "list" "of:" FieldType "}" (* 'of' non-list; no lists-of-lists *)
| "{" "type:" "object" "fields:" FieldMap "}"
| "{" "type:" "ref" "schema:" NAME "}"
Structural invariants the grammar alone doesn't show (enforced by the parser and executor, not just documented convention):
- A
call/match/fold'sdo/for_each'sdo/collect/parallel's branch/collectis a full nestedStep— any step kind, including another compositional primitive. call,match's case/default, andparallel'sbranchesall name a static literal pipeline/step target — never a runtime expression. Onlymatch.onandfor_each/fold'soverare runtime-evaluated.pass:is the only channel acall/matchcallee's context is built from — a caller's named store not listed there is invisible to the callee.for_each/parallelbranches each get an isolated copy of the outer named stores — no sibling communication between concurrent items/branches.!expris the only way atool/shellargument becomes a resolved expression instead of a literal — nesting it inside a list/mapping value is a parse error, not a silent no-op.
Step kinds¶
Every step is a single-key mapping naming its kind. Three are linear leaf steps — they read the context, do one piece of work, and produce a result:
transform¶
A pure step: value is evaluated as an R1 expression
against the current context; the result becomes this step's pipe data (and,
if output is set, is also written to that named store).
| Key | Required | Meaning |
|---|---|---|
value |
yes | An R1 expression source. |
output |
no | Named store to write the result to. |
tool (+ shell sugar)¶
A side-effecting step: dispatches name with args through the same
qualified-action-routing-then-bare-lookup a live invoke_action call uses —
so a tool step can name either a qualified action (file__read) or a bare
registered tool name (web_search).
| Key | Required | Meaning |
|---|---|---|
name |
yes | The tool/action name (literal string). |
args |
no | Mapping of argument name → value. Each value is a literal unless tagged !expr (see Literals vs !expr below). |
schema |
no | A registered schema name the result must conform to (verify: schema — see Schemas). Non-conformance fails the step. |
output |
no | Named store to write the result to. |
shell is sugar for a tool step named "shell":
shell is not yet functional at runtime
A shell step (or a tool step naming "shell" directly) parses
correctly, but no "shell" tool is currently registered — every shell
step fails at run time with a "does not resolve to a registered tool"
error. The other step kinds are unaffected. Until this closes, use a
tool step naming a real registered tool/action instead of shell.
| Key | Required | Meaning |
|---|---|---|
command |
yes | Literal or !expr, same rule as a tool step's args values. |
schema |
no | Same as tool. |
output |
no | Same as tool. |
Literals vs !expr¶
A tool/shell argument value is a literal — passed through to the tool
exactly as written — unless it is tagged with the YAML tag !expr:
query and limit are R1 expression sources, resolved against the step's
context at run time; label is the literal string "a plain string".
!expr is only honored as the whole value of an argument — one hiding
inside a nested list or mapping is a parse error, so there is no ambiguity
between "a literal that happens to look like an expression" and "an
expression."
transform.value is always an R1 expression (no !expr tag needed — there is
no literal form for a transform step). An agent step's prompt is never
an R1 expression — see below.
agent¶
An LLM-driven leaf step: prompt (a template string) is interpolated against
the current context and run as one turn in an ephemeral session,
capability-narrowed to capabilities (or the invoker's own profile if
omitted) under identity (or the invoker's own identity if omitted).
- agent: {prompt: "Summarize: {ctx.doc}", capabilities: {tools: [file__read]}, schema: Summary, output: summary}
| Key | Required | Meaning |
|---|---|---|
prompt |
yes | A template string — {ctx.dotted.path} / {pipe} references are interpolated (values only, no operators — this is string interpolation, not an R1 expression). |
identity |
no | The agent identity to run under. Defaults to the run's invoker. A registered pipeline may name any identity; an inline, agent-generated pipeline may only name the invoker's own identity — naming another agent's identity is rejected by the static-analysis gate as a capability escalation (see Ad-hoc inline launch). |
capabilities |
no | {tools: [NAME*]} — narrows the ephemeral session's tool surface. Restrict-only: a pipeline step can never exceed the invoker's own envelope. |
schema |
no | Same verify: schema semantics as tool, applied to the parsed JSON reply. |
output |
no | Named store to write the result to. |
Every agent step, wherever it is reached (top-level or fanned out inside a
for_each), charges the run's shared spawn budget — see
Safety caps.
Compositional primitives¶
Five primitives compose steps into non-linear control flow — the full Appendix-B set, all supported today.
call — sub-pipeline¶
Synchronously runs a registered sub-pipeline by static name and threads its final output out as this step's result.
| Key | Required | Meaning |
|---|---|---|
pipeline |
yes | A static literal pipeline name — never a runtime expression. An unregistered target fails the step. |
pass |
no | List of this pipeline's named-store names to expose to the callee. The callee's context is built fresh from only these names — a store not listed here is structurally invisible to the callee. A name absent from the caller's stores fails the step. |
output |
no | Named store to write the callee's final result to. |
The callee's first step receives the caller's pipe data at the call site; the
callee's own final step output becomes this call step's result. A callee
failure fails the call step.
match — runtime-selected sub-pipeline¶
Evaluates on to a value, selects the case whose label string-equals it, and
runs that case's target exactly like a call step.
- match:
on: "review.passed"
cases:
"True": {pipeline: report_pass, pass: [review]}
"False": {pipeline: report_fail, pass: [review]}
default: {pipeline: report_unknown}
output: report
| Key | Required | Meaning |
|---|---|---|
on |
yes | An R1 expression evaluated against the current context; its stringified result selects a case label. |
cases |
yes | Non-empty mapping of LABEL: {pipeline, pass?} — each target a static literal name, exactly like call. |
default |
no | {pipeline, pass?} run when no case label matches. A step with no matching case and no default fails. |
output |
no | Named store to write the selected callee's result to. |
Every case/default target is a static literal — the runtime value only ever
selects a label, never a target directly.
fold — sequential accumulator¶
Walks a list in order, threading an accumulator through a repeated do step.
- fold:
over: ctx.items
init: "0"
do: {transform: {value: "acc + item"}}
output: total
max_items: 1000
| Key | Required | Meaning |
|---|---|---|
init |
yes | An R1 expression evaluated once, before the first iteration, seeding acc. |
do |
yes | A single step re-invoked once per list item, in a context of {ctx, pipe, item, acc} — item is the current element, acc the running accumulator; do's return value becomes the next acc. |
output |
yes | Named store for the final acc (a fold's whole point is producing a named result — required, unlike call's optional output). |
over |
no* | An R1 expression resolving to the list to walk. |
items |
no* | A static literal list. |
max_items |
no | Caps the walk to the first N elements (a longer source is silently truncated, never an error). |
* over and items are mutually exclusive; if neither is given, the list
falls back to the step's incoming pipe data. Item failure fails the whole
fold. There is no collect (unlike for_each) — each item's result depends
on the accumulated state of the ones before it, so there is nothing to
collect independently.
for_each — concurrent fan-out¶
Runs do over each list item as an isolated concurrent sub-scope, then runs
collect once over the ordered results.
- for_each:
over: ctx.reviewers
max_parallel: 4
on_error: "retry(2)"
do: {agent: {prompt: "Review as {item}: {ctx.doc}", schema: Review}}
collect: {transform: {value: "pipe"}}
output: reviews
| Key | Required | Meaning |
|---|---|---|
do |
yes | A step run once per item, in a context of {ctx, pipe, item} — ctx is an isolated copy of the outer named stores (no sibling visibility between items), pipe is this step's own incoming pipe data held constant across every item. |
collect |
yes | A step run once, after the fan-out, over the ordered list of surviving item results (its pipe context). Its result is this step's overall result. |
on_error |
yes | One of continue (a failed item is dropped from the results, never re-run on resume), abort (a failed item cancels the still-pending items and fails the whole step), or retry(N) (re-run the failed item up to N more times, then fall back to abort). |
over |
no* | Same as fold. |
items |
no* | Same as fold. |
max_parallel |
no | Caps live concurrency (a Semaphore). Omitted, defaults to a conservative finite value — never unbounded by omission. |
output |
no | Named store to write collect's result to. |
* over/items are mutually exclusive, falling back to incoming pipe data
like fold. There is no item-level acc (that is fold-only) — an item
cannot see any other item's result.
parallel — heterogeneous named-branch fan-out¶
for_each's heterogeneous sibling: instead of fanning one do step out over
a runtime-sized list, parallel fans a static, finite set of distinct
named branches out concurrently, then runs collect once over the named map
of their results.
- parallel:
on_error: "abort"
branches:
security: {agent: {prompt: "Security-review {ctx.doc}", schema: Review}}
style: {agent: {prompt: "Style-review {ctx.doc}", schema: Review}}
collect: {transform: {value: "{'security': security, 'style': style}"}}
output: reviews
| Key | Required | Meaning |
|---|---|---|
branches |
yes | A non-empty {NAME: Step} mapping — each branch is its own, independently-shaped step (a different kind/config per name), unlike for_each's one do re-invoked per item. Every branch runs concurrently; the branch count itself is the concurrency bound (no max_parallel — the set is statically finite). |
collect |
yes | A step run once, after every branch lands, over the named map {branch_name: result} (not an ordered list, unlike for_each). Its result is this step's overall result. |
on_error |
no | One of continue, abort (the default when omitted — unlike for_each, where on_error is required), or retry(N) — same semantics as for_each's on_error. A continue-dropped branch's key is absent from collect's named map. |
output |
no | Named store to write collect's result to. |
Each branch's context is {ctx, pipe} — ctx an isolated copy of the outer
named stores, pipe this step's own incoming pipe data held constant across
every branch. There is no item/acc (those are for_each/fold-only) and
no sibling visibility between branches.
The R1 expression language¶
transform.value, a tool/shell argument tagged !expr, and match.on
all resolve against the same small, total expression language (R1) — a
purpose-built tree-walking interpreter, not a general scripting language and
not a code-execution sandbox. It has no recursion, no user-defined functions,
no unbounded loops (every combinator iterates one already-materialized list
exactly once), no IO, and no eval/exec.
expr ::= or_expr
or_expr ::= and_expr ("or" and_expr)*
and_expr ::= not_expr ("and" not_expr)*
not_expr ::= "not" not_expr | comparison
comparison ::= additive (cmp_op additive)?
additive ::= multiplicative (("+" | "-") multiplicative)*
multiplicative ::= unary (("*" | "/") unary)*
unary ::= "-" unary | primary
primary ::= NUMBER | STRING | "true" | "false" | "null"
| "(" expr ")"
| "[" (expr ("," expr)*)? "]"
| "{" (IDENT ":" expr ("," IDENT ":" expr)*)? "}"
| combinator
| path
combinator ::= "map" "(" expr "," lambda ")"
| "filter" "(" expr "," lambda ")"
| "all" "(" expr "," lambda ")"
| "any" "(" expr "," lambda ")"
| "find" "(" expr "," lambda ")"
| "count" "(" expr ")"
| "sum" "(" expr ")"
| "join" "(" expr "," expr ")"
| "get" "(" expr "," STRING ("," expr)? ")"
lambda ::= IDENT "->" expr (* only valid as a combinator's own argument *)
path ::= IDENT ("." IDENT)*
cmp_op ::= "==" | "!=" | "<" | ">" | "<=" | ">="
Literals: true / false / null, integers, floats, single- or
double-quoted strings.
Field refs: a dotted path against the context, e.g. ctx.review.passed
or bare pipe. A missing path or a non-mapping intermediate segment raises —
bare paths are not safe navigation; use get(...) for that (below).
Operators: and / or / not; comparisons == != < > <= >=
(</>/<=/>= require two numbers or two strings; ==/!= work on
anything); arithmetic + - * / (numeric; + also concatenates strings
and lists). Division by zero raises.
Combinators — the only call-like syntax the grammar has, a fixed closed set:
| Combinator | Signature | Meaning |
|---|---|---|
map |
map(list, item -> expr) |
Transform each element. |
filter |
filter(list, item -> expr) |
Keep elements where the lambda is true. |
all |
all(list, item -> expr) |
True iff every element satisfies the lambda. |
any |
any(list, item -> expr) |
True iff some element satisfies the lambda. |
find |
find(list, item -> expr) |
First matching element, or null. |
count |
count(list) |
Element count. |
sum |
sum(list) |
Numeric sum. |
join |
join(list, sep) |
String-join. |
get |
get(base, "dotted.path", default?) |
Safe navigation — unlike a bare Path, never raises on a missing path; returns default (or null) instead. |
A lambda (item -> expr) is only ever valid as the direct argument of
map/filter/all/any/find — it is not a value that can be assigned or
passed around, and naming anything outside this fixed combinator set as a
function call is a parse error.
Example expressions: "'Hello, ' + ctx.name + '!'", "ctx.n + 1",
"all(ctx.reviews, r -> r.passed)".
An agent step's prompt is a different mechanism: a template string
where {ctx.dotted.path} / {pipe} references are interpolated as plain
values — not R1 expressions, no operators inside the braces.
Schemas — verify: schema¶
A schema names a nested, monomorphic type: a set of fields, each a scalar
(bool/string/number), an enum, a typed list (its element type,
of, is mandatory — no untyped lists, and lists-of-lists are not allowed), a
nested inline object, or a ref to another registered schema (a
recursive-reference cycle across the registered set is rejected at
registration time).
schema: Review
fields:
passed: {type: bool}
notes: {type: string}
tags: {type: list, of: {type: string}}
A tool/shell/agent step's schema: NAME key names a registered schema
its result (or, for agent, its parsed JSON reply) must conform to —
non-conformance fails the step. Schemas declared in the same DSL document set
(standalone schema: documents) are what makes this possible for an ad-hoc
inline pipeline too, since its schemas travel with
the same definition string.
Invocation¶
Four tools launch a pipeline. All four converge on the same execution: a
launch spawns a dedicated PipelineExecutorDriver session and the pipeline
runs inside it (see Driver-as-session) —
none of them run a pipeline inline on the caller's own turn.
| Tool | Registered / inline | Sync / async |
|---|---|---|
run_pipeline |
Registered, by name |
Sync — attached, blocks until terminal |
run_pipeline_async |
Registered, by name |
Async — detached, returns immediately |
run_pipeline_inline |
Inline, ad-hoc definition string |
Sync — attached, blocks until terminal |
run_pipeline_inline_async |
Inline, ad-hoc definition string |
Async — detached, returns immediately |
Registered launch¶
run_pipeline(name, input?) and run_pipeline_async(name, input?) look a
pipeline up by its registered name (see
Pipeline registration).
input seeds the pipeline's initial named context (ctx.*) for its first
step; omit it for a pipeline that needs no seed input. A name that isn't
registered fails clearly.
Sync vs async¶
- Sync (
run_pipeline,run_pipeline_inline): the caller attaches to the driver-session's run and blocks until it reaches a terminal state, reading the result back in-band ({status: "ok", data: {run_id, output, named_stores}}, orerror/cancelled). Livepipeline_step_started/pipeline_step_completedevents stream to the caller for the run's duration (what a TUI live view renders), and a cooperative Ctrl-C stops the run cleanly at the next step boundary. If the attach itself is interrupted by a crash, the run is not lost — it is handed to the same recovery path async uses, and the result arrives later as an inbox message instead ({status: "started", data: {run_id}}). - Async (
run_pipeline_async,run_pipeline_inline_async): returns{status: "started", data: {run_id}}immediately; the final result arrives later as a[pipeline]inbox message.
Ad-hoc inline launch¶
run_pipeline_inline(definition, input?) and
run_pipeline_inline_async(definition, input?) take a pipeline DSL string
the calling agent generates at run time — the same Appendix-B grammar as a
registered pipeline file, including any schema: documents the definition's
own steps reference. There is no pre-registration: the string is parsed and
run through a static-analysis gate before anything is spawned, so a bad
definition fails clearly and spawns nothing:
- The definition parses.
- Every step
schema:reference resolves within the definition's own schemas. - Every
toolstep's name resolves to a registered tool or qualified action. - (Structural, not runtime-checked) the driver-session spawns under the invoker's own identity and narrows restrict-only, so a generated pipeline can never exceed the invoker's own envelope by construction.
- No
toolstep launches a pipeline or delegates — nesting iscall-only. - Inline-only: an
agentstep'sidentity, if set, must equal the invoker's own identity. A registered pipeline is exempt from this check (a trusted registrant deliberately chose the identity); an inline, agent-generated one naming a different identity is a capability escalation and is rejected.
An inline run is crash-recoverable identically to a registered one — its full parsed definition (including its schemas) is persisted into the work-order, so recovery never needs to re-parse or look anything up.
Safety caps¶
Two operator-set caps in reyn.yaml's safety.spawn block bound a pipeline
run's fan-out, threaded into every run/resume call:
# reyn.yaml
safety:
spawn:
max_pipeline_fan_out_depth: 5 # default
max_pipeline_spawns: 100 # default
| Key | Default | Meaning |
|---|---|---|
max_pipeline_fan_out_depth |
5 |
Maximum nesting depth of for_each fan-out scopes (a top-level for_each is depth 1; a for_each inside another's do/collect is depth 2; …). A for_each that would exceed this fails the step rather than spawning. 0 = unlimited. |
max_pipeline_spawns |
100 |
Maximum number of ephemeral sessions one pipeline run may spawn across all its agent steps — top-level or fanned out via for_each. A per-run monotonic counter; a spawn past the cap fails the step. 0 = unlimited. |
Both default to conservative finite values — a run is never unbounded by omission. Neither cap is reachable by an LLM at run time; both are operator-set and restart-only.
Security¶
See Pipeline registration § Security:
launching a pipeline (any of the four tools above) sits on the same
HIGH-severity, spawn-adjacent capability floor as delegating to another
agent. A context narrowed by the untrusted-content floor or an unbound
delegate's floor cannot launch a pipeline, registered or inline.
Grammar (for generation)¶
A compact, self-contained block for an agent authoring a pipeline definition
at run time (e.g. for run_pipeline_inline) — the grammar plus the rules
that don't fall out of the grammar alone, plus one canonical example. This
section stands on its own; it does not assume the prose above has been read.
Grammar — same EBNF as Formal grammar above, repeated here for convenience:
Document ::= YamlDoc ("---" YamlDoc)* (* exactly one PipelineDoc total *)
YamlDoc ::= SchemaDoc | PipelineDoc
SchemaDoc ::= "schema:" NAME "fields:" FieldMap
PipelineDoc ::= "pipeline:" NAME ("description:" STRING)? "steps:" Step+
Step ::= "transform:" "{" "value:" EXPR ["output:" NAME] "}"
| "tool:" "{" "name:" STRING ["args:" ArgMap] ["schema:" NAME] ["output:" NAME] "}"
| "shell:" "{" "command:" ArgValue ["schema:" NAME] ["output:" NAME] "}"
| "agent:" "{" "prompt:" TPL ["identity:" NAME]
["capabilities:" "{" "tools:" "[" NAME* "]" "}"]
["schema:" NAME] ["output:" NAME] "}"
| "call:" "{" "pipeline:" NAME ["pass:" "[" NAME* "]"] ["output:" NAME] "}"
| "match:" "{" "on:" EXPR "cases:" "{" (LABEL ":" MatchTarget)+ "}"
["default:" MatchTarget] ["output:" NAME] "}"
| "fold:" "{" [ListSource] "init:" EXPR "do:" Step "output:" NAME
["max_items:" INT] "}"
| "for_each:" "{" [ListSource] ["max_parallel:" INT] "on_error:" OnError
"do:" Step "collect:" Step ["output:" NAME] "}"
| "parallel:" "{" ["on_error:" OnError] "branches:" "{" (NAME ":" Step)+ "}"
"collect:" Step ["output:" NAME] "}"
MatchTarget ::= "{" "pipeline:" NAME ["pass:" "[" NAME* "]"] "}"
ArgMap ::= "{" (KEY ":" ArgValue ("," KEY ":" ArgValue)*)? "}"
ArgValue ::= LITERAL | "!expr" EXPR
ListSource ::= "over:" EXPR | "items:" "[" LITERAL* "]"
OnError ::= "continue" | "abort" | "retry(" INT ")"
FieldMap ::= "{" (NAME ":" FieldType)+ "}"
FieldType ::= "{type: bool}" | "{type: string}" | "{type: number}"
| "{type: enum, values: [" LITERAL+ "]}"
| "{type: list, of:" FieldType "}"
| "{type: object, fields:" FieldMap "}"
| "{type: ref, schema:" NAME "}"
EXPR ::= (* see The R1 expression language above *)
TPL ::= (* a string with {ctx.dotted.path} / {pipe} interpolation, values only *)
Hard rules (violating any of these is either a parse error or a run-time step failure — never a silent wrong result):
call'spipeline:,match's case/defaultpipeline:, and everyparallelbranch's step: onlypipeline:targets incall/matchare ever a static literal name — never an expression. The runtime-evaluatedmatch.ononly ever selects a case label, never a target directly.!exprmarks atool/shellargument as an R1 expression; everything else is a literal, passed through untouched. Do not write{ctx.x}inside an unmarked argument expecting interpolation — that only works foragent.prompt(TPL), and only there.pass:is the only way acall/matchcallee sees any of the caller's named stores — list every name the callee needs. An omitted name is invisible to the callee, not silently inherited.for_each.on_erroris required — statecontinue/abort/retry(n)explicitly.parallel.on_erroris optional and defaults toabort.- A
for_each/parallelitem or branch cannot see any other item's or branch's result — onlycollectsees the merged set (an ordered list forfor_each, a{branch_name: result}map forparallel). fold.outputis required (a fold's entire point is a named accumulated result); every other step kind'soutputis optional.- Every
agentstep is capability-narrowed to at most the invoking session's own envelope — naming a widercapabilitiesset than the invoker has does not grant it. In an inline (agent-generated, not file-registered) definition, anagentstep'sidentitymust be omitted or equal to the invoker's own — naming any other identity is rejected. !exprmay only be the entire value of anargs/commandentry — never nested inside a list or mapping value.
One canonical example (all three step kinds, one primitive, one schema):
schema: Review
fields:
passed: {type: bool}
notes: {type: string}
---
pipeline: review_and_report
description: Review a document and summarize the verdict.
steps:
- agent:
prompt: "Review {ctx.doc}. Reply with passed (bool) and notes (string)."
schema: Review
output: review
- transform:
value: "review.passed and 'OK' or 'NEEDS WORK'"
output: verdict
- tool:
name: file__write
args: {path: "verdict.txt", content: !expr verdict}
output: written
Note: this uses file__write, not shell — shell currently fails at run
time (see the warning above), so avoid it in a generated definition until
that closes.