Skip to content
Loops
AGH RuntimeLoops

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

SurfaceSyntaxWhere it is used
ValuesGo text/template {{ }}Every string-valued field: params.*, file-import.pattern, gate rubrics, run-loop.inputs, transform.map.*.template, start input_mapping values
ConditionsCEL, returning boolbranch.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.

RootResolves toAvailable where
inputs.<name>A declared typed inputeverywhere
nodes.<id>.output.<path>A prior node's harvested, typed outputeverywhere (after the node)
nodes.<id>.statusA prior node's terminal statuseverywhere
itemThe fanned unit (element, or array slice when batch_size > 1)inside a fan-out branch only
indexThe fanned indexinside a fan-out branch only
trigger.<path>The activation payloadtrigger and webhook starts only
event.<path>The matched internal event (correlation fields + payload)watch-events events[].filter only
generationThe current generation numbereverywhere

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:

CodeCause
unknown_referenceAn unknown root, or a nodes.<id> that does not exist
unresolvable_pathA known symbol with a missing child path
item_outside_fanoutitem or index used outside a fan-out branch
condition_not_boolA CEL condition that does not return bool
node_id_invalidA 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 workspace

The 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.

On this page