Skip to content

Reference grammar

How Loop definitions reference data — value templates, CEL conditions, effect context, and the snake_case ID rule.

For people running agent work16 pages in this section

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 {{ }}String-valued fields: params.*, effect payloads, gate rubrics, run-loop.inputs, transforms, and start mappings
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 current-generation node's harvested, typed outputeverywhere (after the node)
nodes.<id>.statusA current-generation node's terminal statuseverywhere
previous.generationThe immediately preceding generation numbergenerations 2 and later
previous.nodes.<id>.statusA previous-generation node's terminal statusgenerations 2 and later
previous.nodes.<id>.output.<path>A previous-generation node's typed outputgenerations 2 and later
previous.verdicts.<gate_id>.<field>One prior machine verdict: outcome, score, blocking_issues, or criteriagenerations 2 and later
previous.route_causesOrdered gate IDs whose prior verdicts caused successiongenerations 2 and later
best.generation / best.scoreThe accepted metric baselineafter a best candidate exists
best.nodes.<id>.output.<path>Output from that best generation; status and verdicts are not projectedafter a best candidate exists
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.

One materialization pass

Contract narrative fields — goal, definition_of_done, constraints[], and boundaries[] — accept only declared inputs references. CompozyOS resolves those fields once, after effective inputs are known and before a Goal agent or judge receives the contract. Missing references fail before the Goal performs work.

Goal params values are resolved recursively at the same execution boundary. A direct reference keeps its JSON type instead of becoming text, while output_schema remains the authored schema and is never treated as a template. Nested gate inputs follow the same rule.

There is no second template pass inside the agent or judge. If an input value itself contains {{ ... }}, those braces remain literal data; they cannot introduce a new reference. Run details therefore expose both executed_definition, the raw authored snapshot, and materialized_contract, the input-resolved contract that governed the run.

Effect templates

Effect with and emit.payload values use a deliberately smaller namespace: declared inputs plus the immutable effect context captured with the lifecycle transition. In {{ }} templates, use the leading dot.

RootContents
effect.identityscope, workspace_id, loop_name, loop_run_id, generation, optional node_id, item_index, trigger
effect.failureClassified class, code, cause, hint, target, retry_after, and retry_eligible when present
effect.quarantineThe sanitized quarantine record when present
effect.attemptnumber, optional next_attempt_at, and optional disposition
effect.linksrun, plus node-scoped resume and approval decision links
on_retry:
  - emit:
      kind: release.retry_scheduled
      payload:
        run_id: "{{ .effect.identity.loop_run_id }}"
        node_id: "{{ .effect.identity.node_id }}"
        attempt: "{{ .effect.attempt.number }}"
        retry_at: "{{ .effect.attempt.next_attempt_at }}"

Effect context is recorded before dispatch, secret-redacted, and bounded. It cannot read mutable node outputs or change the transition it observes. See Failure handling for delivery rules.

Cross-generation repair context

previous and best are read-only projections. previous always means generation N-1, even when generation N was restored from an older best candidate. best names that accepted metric baseline. On generation 1, previous is an empty object; before a metric candidate becomes eligible, best is an empty object.

Use with to keep a value template valid before history exists, and read only the fields the next attempt needs:

prompt: |
  Improve the current candidate.
  {{ with .previous }}
  The prior quality gate returned {{ .verdicts.quality.outcome }}.
  Blocking issues: {{ .verdicts.quality.blocking_issues }}
  {{ end }}
  {{ with .best }}
  Preserve the useful parts of generation {{ .generation }}, scored {{ .score }}.
  {{ end }}

For several rejecting gates, previous.verdicts remains lossless because it is keyed by gate ID. previous.route_causes gives the persisted causal order; there is no singular previous.verdict. Prefer field-level references over interpolating a whole history object, which adds prompt payload without sharpening the repair.

Conditions use the same roots without the leading dot. For example, a metric Loop can stop after an accepted best update:

stop_when: "best.score >= 0.9"

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__spec_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). event.seq is the durable, monotonic replay position within its watch stream. For loop events, it is shared across runs and is separate from the per-run SSE sequence on GET /loop-runs/:run_id/events. 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