Reference grammar
How Loop definitions reference data — the {{ }} value grammar, CEL conditions, the single namespace, and the snake_case ID rule.
- Audience
- Operators running durable agent work
- Focus
- Loops guidance shaped for scanability, day-two clarity, and operator context.
A Loop definition wires nodes together by referencing values — a prior node's output, a declared input, the fanned item. There are two reference surfaces over one namespace, and the surface is chosen by the field, never by the author.
Two surfaces, one namespace
| Surface | Syntax | Where it is used |
|---|---|---|
| Values | Go text/template {{ }} | Every string-valued field: params.*, file-import.pattern, gate rubrics, run-loop.inputs, transform.map.*.template, start input_mapping values |
| Conditions | CEL, returning bool | branch.condition, fan-out.filter, contract.stop_when, watch-events events[].filter |
Values interpolate; conditions evaluate. A branch never uses {{ }}, and a prompt never uses raw
CEL — the field's type decides.
# value field — {{ }}
prompt: "Fix the failures in {{ .inputs.slug }}; the reviewer said {{ .nodes.review.output.summary }}."
# condition field — CEL
condition: "nodes.review.output.decision == 'ship'"The namespace
Both surfaces resolve the same roots. Every dotted path is validated at lint and publish time against the declared input and output schemas — there is no silent empty-string resolution.
| Root | Resolves to | Available where |
|---|---|---|
inputs.<name> | A declared typed input | everywhere |
nodes.<id>.output.<path> | A prior node's harvested, typed output | everywhere (after the node) |
nodes.<id>.status | A prior node's terminal status | everywhere |
item | The fanned unit (element, or array slice when batch_size > 1) | inside a fan-out branch only |
index | The fanned index | inside a fan-out branch only |
trigger.<path> | The activation payload | trigger and webhook starts only |
event.<path> | The matched internal event (correlation fields + payload) | watch-events events[].filter only |
generation | The current generation number | everywhere |
nodes.<id>.output.* only validates against a schema the referenced node declares — set
produces (or a run-agent output_schema) on the source node so its output paths are known.
In {{ }}, roots take a leading dot ({{ .inputs.slug }}, {{ .item.title }}); in CEL they do
not (inputs.slug, item.title). Node IDs are identical in both.
Resolution errors
The linter reports precise codes, not runtime surprises:
| Code | Cause |
|---|---|
unknown_reference | An unknown root, or a nodes.<id> that does not exist |
unresolvable_path | A known symbol with a missing child path |
item_outside_fanout | item or index used outside a fan-out branch |
condition_not_bool | A CEL condition that does not return bool |
node_id_invalid | A node ID that is not snake_case |
Value templates run with missingkey=error and a curated function set (json, join, default,
plus the len builtin) — no arbitrary helpers. CEL conditions are compiled and cost-limited at
publish time.
snake_case node IDs
Node IDs match ^[a-z][a-z0-9_]*$ — lowercase, snake_case, starting with a letter. This is
enforced (node_id_invalid) and is deliberate: the same ID is valid verbatim in a {{ }} template
and a CEL condition, so there is never a preprocessing or escaping step between the two surfaces.
graph:
nodes:
- {
id: load_tasks,
class: action,
kind: ext__dev_cycle__import_tasks,
params: { pattern: "tasks/*.md" },
}
- {
id: run_task,
class: action,
kind: run-agent,
params: { agent: "{{ .inputs.implementer }}", prompt: "Do {{ .item.title }}" },
}
edges:
- { from: load_tasks, to: run_task }Watch-events events:
A watch-events source declares a typed
events list — the subscriptions the Loop parks on. Each entry is { kind, filter }: kind is a
supported hook-event name, and filter is a CEL condition (returning bool) over event, inputs,
and nodes. An omitted filter matches every event of that kind in the Loop's workspace.
- id: on_task_done
class: source
kind: watch-events
events:
- kind: task.status_changed
filter: "event.payload.to_status == 'completed' && event.task_id == inputs.task_id"
- kind: loop.terminal # no filter → every loop terminal in this workspaceThe event root exposes the promoted correlation fields — event.kind, event.seq, event.at,
event.workspace_id, event.task_id, event.run_id, event.loop_run_id, event.loop_name,
event.session_id — plus event.payload, a per-kind map (e.g. event.payload.to_status for
task.status_changed). Filters over event and inputs are exact; a filter that references
nodes.* is intentionally over-inclusive at the doorbell and re-checked exactly at wake, so prefer
inputs-only filters when you want a precise wake.
Start binding mappings
Start bindings map an activation payload into inputs with the same grammar, restricted in v1 to a
flat {{ .trigger.payload.<field> }} per input. Scheduled starts are static-inputs-only — a
mapping is rejected — because a clock fire has no payload. See
automation for how triggers carry the payload.