compozy.loop/v1 DSL
The Loop definition schema — declared inputs, the contract, node classes and kinds, the gate contract, and start bindings.
A Loop definition is a data-first, serializable document — compozy.loop/v1 YAML on disk, never a
program of inline functions. The daemon compiles it to a resolved form that the coordinator and
executors consume; authors and agents read and write the YAML. This page is the schema reference.
For the {{ }} and CEL grammar used inside fields, see the
reference grammar.
Top-level shape
apiVersion: compozy.loop/v1 # required — the only accepted value
kind: Loop # required
meta: { name, description, version, catalog }
concurrency: forbid # forbid (default) | allow | queue
inputs: {} # declared typed inputs
contract: {} # goal, verification, stop model, terminal outcomes
graph: { nodes: [], edges: [] } # the body DAG
start: [] # how a run may be started| Field | Required | Notes |
|---|---|---|
apiVersion | yes | Must be compozy.loop/v1. |
kind | yes | Must be Loop. |
meta | yes | name, description, catalog; version is daemon-owned and monotonic. |
concurrency | no | forbid (default) rejects a second same-Loop start; allow runs in parallel; queue defers to a FIFO queued run. |
inputs | no | Map of declared inputs. |
contract | yes | The goal → verify → stop contract. |
graph | yes | nodes + edges. |
start | no | Start bindings; defaults to manual only. |
meta.catalog carries the browsable metadata: use_when, keywords, and category.
Declared inputs
Each input names a type; a caller supplies values at run time.
inputs:
slug: { type: string, required: true }
implementer: { type: agent, default: code_implementer }
reviewer: { type: agent, default: code_reviewer }
worker_runtime:
{ type: runtime, default: { provider: codex, model: gpt-5.5-codex, reasoning: high } }
verify_command: { type: string, default: "" }
auto_commit: { type: boolean, default: false }
spec: { type: ref, ref: { kind: skill } }
release_token: { type: ref, ref: { kind: secret } }| Type | Accepts | Notes |
|---|---|---|
string | text | |
number | int or float | |
boolean | bool | |
file | text | The value is not browsed or checked for existence. |
agent | an exact agent name | The daemon checks the workspace agent catalog. |
ref | an exact resource name | ref.kind selects the catalog used for validation. |
runtime | { provider?, model?, reasoning? } | A partial object; exact custom model IDs are valid when accepted. |
Per-input fields: type (required), required (default false), description, default, and
ref (for type: ref). String-like inputs may also declare enum; those choices take precedence
over a catalog picker. ref.kind is closed to skill, loop, worktree, session, workspace,
or secret.
Effective values resolve one field at a time in this order: run input, workspace config, global
config, then the definition default. The daemon validates the winning value immediately before a
dry run or run starts, including entity existence and runtime support. A failure starts no run and
returns input_validation with { loop, field, kind?, value?, origin, reason }. Secret inputs expose
Vault reference names and metadata only; secret values never enter the catalog or error payload.
The contract
contract:
goal: "Ship the tasks under {{ .inputs.slug }} and verify them."
definition_of_done: "All tasks pass their gates and the project's checks are green."
constraints: [] # optional guardrail statements
boundaries: [] # optional scope statements
stop_when: # optional CEL terminal condition; a scalar expression is also valid
expr: "generation >= 4"
on_eval_error: exit # fail | exit
verification: # gate criteria that decide "done"
- {
id: checks,
type: command,
check: "test -d artifacts/{{ .inputs.slug | shellQuote }}",
expect: exit_zero,
}
terminal_states: [done, no-op, blocked, failed, exhausted, stalled, canceled]
iteration_cap: 50 # 0 = unbounded (watch Loops)
no_progress: { window: 3 }
budget: { tokens: 0, wall_clock_sec: 0, on_exceeded: halt } # 0 = off
runtime_defaults:
worker: { provider: codex, model: gpt-5.4, reasoning: high }
judge: { provider: claude, model: opus }
runtime_rules:
- match: { type: frontend }
runtime: { model: gpt-5.5-codex }verification uses the same gate criteria shape as a gate node. stop_when
accepts either a CEL string or the strict { expr, on_eval_error } object. A broken continuation
predicate exits by default so it cannot keep a run alive; set on_eval_error: fail to end the run
failed instead. The stop model — iteration_cap, no_progress, and budget — is documented in
full on the guardrails page. terminal_states lists the fixed seven outcomes.
runtime_defaults.worker seeds worker runtime fields; runtime_defaults.judge seeds judge fields.
Each runtime_rules entry matches exactly one task id, type, or complexity and replaces only
the runtime fields it sets.
A command check is authored shell code. Any runtime value inserted into check must end with
| shellQuote, as in the example above. Publish and dry-run reject unquoted command substitutions so
an input, trigger payload, or node output cannot introduce shell syntax. Leave the template action
outside authored single quotes, double quotes, and backslash escapes because shellQuote supplies
the complete quoting context. Dynamic command templates also reject actions inside shell comments
and << constructs such as heredocs and here-strings.
The contract may react to terminal truth with on_done, on_noop, on_blocked, on_failed,
on_exhausted, on_stalled, and on_canceled. Each list contains
effects; terminal effects observe the
outcome and never rewrite it.
The body graph
graph.nodes is a list of typed nodes; graph.edges are { from, to } dependency edges. Edges
are acyclic — the linter rejects a cycle. Node IDs match ^[a-z][a-z0-9_]*$ — lowercase and
snake_case — which makes the same ID valid in both {{ }} and CEL without escaping.
Every node shares an envelope — id, class, kind — plus optional session, timeout,
deadline, retry, result_contract, on_error, lifecycle effects, on_parent_close, harvest,
and produces (a JSON schema declaring the node's output so downstream nodes.<id>.output.*
references validate).
Node classes
| Class | Openness | Kinds |
|---|---|---|
action | open | run-agent, run-loop, transform, goal, or any tool ID |
control | closed | fan-out, collect, branch, route, gate, wait, ask, sub-loop |
source | closed | input, file-import, watch-source, watch-events |
Action nodes
Four action kinds are reserved; every other action kind is a literal tool ID
(compozy__*, ext__*, or mcp__*) resolved through the runtime tool registry — so every native,
extension, and MCP tool is a Loop action for free.
| Kind | Params |
|---|---|
run-agent | agent (required), prompt (required), output_schema?, cwd?, runtime?, allowed_tools?, max_turns? |
run-loop | loop (name), inputs?, mode: await (default, parks in awaiting_child) or detach (returns {loop_run_id}) |
transform | map — each entry is {from: <ns path>}, {value: <literal>}, or {template: "{{ }}"} |
goal | agent, objective, judge, max_turns, on_exhausted?, output_schema, runtime?; see the Goal node reference |
| any tool ID | params = the tool's own input schema, template-interpolated; optional harvest |
A run-agent node with an output_schema publishes typed output, so the next node can reference
nodes.<id>.output.<field> — the type-safe chaining pattern.
params.runtime is a per-node { provider, model, reasoning } layer. For an imported task item,
fields resolve independently in this order: per-run rule, task frontmatter, configured rule,
params.runtime, runtime_defaults.worker, then the agent definition. Rule specificity is
id > type > complexity; a later rule wins at equal specificity. A child run-loop resolves its
own rules and never inherits the parent's per-run rules.
run-agent sessions use the target agent definition plus workspace defaults for sandbox and
permission policy. allowed_tools is a narrowing override only: every listed value must be a
canonical tool ID already allowed by the resolved agent profile. A widening or unknown tool
rejects the node run deterministically before an ungated session can start.
Control nodes
| Kind | Purpose | Key fields |
|---|---|---|
fan-out | Run a batch of branches over a collection | collection, filter?, batch_size (default 1), max_parallel, positive max_fan_out |
collect | Join barrier — waits for fanned branches | — |
branch | Route on a CEL condition | condition (true/false edges), optional on_eval_error: fail | exit |
route | Select exactly one forward path | ordered routes: [{when, to}], mandatory default |
gate | Verify against criteria and route | criteria, verdict_policy, on_result, max_revisions |
sub-loop | An inline nested Loop | body (graph) + contract |
wait | Park on time or one durable event | params: exactly one of for, until, event; optional expect, ahead_arrival, expires |
fan-out exposes item and index to its branch scope — a single element at batch_size: 1, an
array slice above it. Use bind_as and index_as to give nested fan-outs distinct names.
The default strategy is wait_all. fail_fast ends on the first definitive failure, and race
accepts the lowest-index successful lane. best_effort requires both a threshold and an explicit
declaration that missing results are acceptable:
- id: inspect_files
class: control
kind: fan-out
collection: "{{ .nodes.changed.output.files }}"
bind_as: file
index_as: file_index
strategy:
kind: best_effort
threshold: 66%
missing: acceptableThresholds use a percentage such as 66% or a count such as { count: 2 }. A collect result is
succeeded, partial, or failed. Its output contains total, succeeded, failed, canceled,
coverage_rate, and partial. Read live counts through nodes.<fan-out-id>.progress.*; inside that
fan-out body, progress.* is the short form. Available fields are total, succeeded, failed,
canceled, running, pending, settled, success_rate, and failure_rate. Rates are 0 for an
empty collection.
Routing predicates fail the node by default when CEL evaluation fails. A branch or filtered
watch-events node may set on_eval_error: exit to end the Loop successfully instead. Predicate
costs at or above 80% of the configured CEL limit emit a durable warning; exceeding the limit is an
authoring failure handled by the same policy.
A route checks its CEL conditions in declaration order and takes the first match. If none match,
it takes the mandatory default. Every destination must be a unique direct forward edge. A broken
condition fails closed with predicate_evaluation_failed; it never falls through to the default.
- id: classify
class: control
kind: route
routes:
- when: nodes.score.output.value >= 0.8
to: publish
- when: nodes.score.output.value >= 0.5
to: revise
default: rejectNode reliability fields
| Field | Contract |
|---|---|
timeout | Optional limit for one attempt. |
deadline | Optional total limit across attempts and backoff; must be at least timeout. |
retry.max_attempts | Total attempts including the first; 0 disables automatic retry. |
retry.on_failure | Node-family retry behavior where supported. |
retry.backoff.{base,max} | Bounded retry delay. |
retry.non_retryable | Failure classes this node must not retry. |
result_contract.{failure_field,message_field?} | Reads application-level failure from an otherwise successful payload. |
on_error | Exactly one of route or allow_fail; optional effects observe the decision. |
on_retry, on_success, on_pause | Effects for those committed node transitions. |
on_timeout, on_cancel, on_quarantine | Effects for those committed node transitions. |
on_parent_close | Awaited run-loop policy: terminate, cancel, or abandon. |
Automatic retry is class-gated: only transport and attempt_timeout qualify. Mechanical actions
inherit family defaults; run-agent and run-loop require explicit node-level retry, and goal
does not use generic node retry. See Failure handling for precedence,
classification, and delivery guarantees.
Durable wait control
- id: wait_for_approval
class: control
kind: wait
params:
event: { kind: approval.received }
expect: { type: object, required: [approved] }
ahead_arrival: consume_on_entry
expires:
after: 72h
escalate:
- emit: { kind: approval.overdue }
route: timed_outChoose exactly one wait source: for (duration), until (timestamp), or event (subscription).
expect is the JSON schema for a manual or event resume payload. ahead_arrival is
consume_on_entry or reject. expires.after is optional; expiry may enqueue escalate effects
and/or route to one authored node. Without expires, the wait remains parked indefinitely.
Source nodes
| Kind | Purpose | Key fields |
|---|---|---|
input | Expose a declared input to the graph | input_ref |
file-import | Materialize a finite collection from files | pattern, parse: json | text, produces |
watch-source | Wait for an external signal, then tick | watch (spec) |
watch-events | Wake on an internal CompozyOS event, then tick | events (subscription list) |
A definition that contains a watch-source node is a watch Loop — it defaults to
iteration_cap: 0 and holds the watching state between ticks.
See extending Loops.
Watch-events source
A watch-events node parks the Loop at zero cost and wakes it when an internal CompozyOS event
commits — a task status transition, a task-run outcome, a loop terminal. It is the event-driven
sibling of watch-source: watch-source polls an external signal through an extension, while
watch-events observes CompozyOS's own durable ledgers. Hook dispatch is only the doorbell; the matched
batch is always re-derived from the append-only ledger at wake, so a subscription survives daemon
downtime and dropped hooks.
The node carries a typed events list. Each subscription names a supported hook kind and an
optional filter — a CEL expression over event, inputs,
and nodes. Multiple subscriptions OR together; an empty filter matches every event of that kind
in the Loop's workspace.
graph:
nodes:
- id: on_task_done
class: source
kind: watch-events
events:
- kind: task.status_changed
filter: "event.payload.to_status == 'completed'"
- kind: loop.terminal
- id: react
class: action
kind: run-agent
params:
{ agent: "{{ .inputs.responder }}", prompt: "Handle {{ .nodes.on_task_done.output }}" }
edges:
- { from: on_task_done, to: react }The matched batch lands at nodes.<id>.output for downstream nodes (fan out over it to process each
event). A Loop with a watch-events node holds watching between wakes and — unlike
watch-source — never stalls on silence. Its default configuration is still the delivery
profile, including iteration_cap: 50; set contract.iteration_cap: 0 explicitly when it must run
without an iteration limit. A quiet subscription is healthy dormancy, not a failure; the Loop parks
until an event matches.
Supported kinds. events[].kind validates against the full hook catalog at publish; a
kind outside the supported set fails lint (watch_events_kind_unsupported). Only post-state
observation hooks are subscribable — sync-eligible pre_* hooks are rejected (you watch committed
state, you do not intercept it).
| Family | Kinds | Replay ledger |
|---|---|---|
task | task.status_changed, task.blocked, task.unblocked, task.needs_attention, task.recovered | task_events |
task.run | task.run.completed, task.run.failed | task_events |
loop | loop.terminal, loop.node.terminal | loop_run_events |
automation | automation.run.completed, automation.run.failed | automation_runs |
network | network.message.persisted, network.thread.opened, network.direct_room.opened, network.work.opened, network.work.transitioned, network.work.closed | network_timeline_log |
coordinator | coordinator.spawned, coordinator.decision, coordinator.stopped, coordinator.failed | event_summaries |
event | event.post_record | session_events:<session_id> |
event.post_record subscriptions must constrain event.session_id with equality; otherwise lint
returns watch_events_filter_too_broad. Its output includes metadata such as record_type,
sequence, turn_id, agent_name, and session_id; record content is never copied into the
watch-events batch. The kind select and lint error text always name the registry-derived supported
set, so the grammar never changes as families are added.
The parked read-model — the active subscriptions, per-stream cursors, and last wake — is visible on the
run detail (compozy loop status --run-id <run-id> -o json, HTTP/UDS, and the web run page) only
while the Loop is dormant on events.
The gate contract
A gate node (and the contract's verification) is a list of typed criteria. Each criterion
is a typed object, never a bare string — push vague checks toward a command where you can.
Criterion type | Fields | Verdict source? |
|---|---|---|
command | check (command), expect (e.g. exit_zero), optional metric | no |
agent-judge | agent, rubric ({{ }} template), optional runtime and metric | yes |
human | prompt; metric is not allowed | yes |
extension | tool (tool ID), inputs, optional metric | no |
verdict_policy selects how the gate decides:
revise_until_clean— iterate until the criteria pass. Requires at least oneagent-judgeorhumancriterion (a verdict source); the linter rejects it otherwise (verdict_policy_requires_judge).fixed_passes— a fixed number of passing runs, for command-only gates.
An agent-judge verdict emits blocking issues — { id, note } objects. The id set is
load-bearing: the stall signature
compares the repeated set across the no-progress window. A malformed judge response degrades to a
revision, never a silent pass. on_result maps a verdict (pass, fail, blocked, approval,
error, timeout, invalid_output) to continue, revise, next_generation, escalate,
halt, or an in-body direct forward target written as { route: node_id }. The removed branch
action is rejected. approval accepts only escalate or halt and cannot use an object route to
bypass a pending approval. max_revisions caps the loop before the gate fails.
An agent-judge.runtime value overrides runtime_defaults.judge field by field for that criterion
only. Task runtime rules never apply to judges.
Metric criteria
Add metric to one machine criterion when a Loop must improve one scalar score without accepting a
regression. The block requires direction; min_delta is optional and defaults to 0, which still
requires a strict improvement.
- id: quality
class: control
kind: gate
criteria:
- id: acceptance_score
type: agent-judge
agent: reviewer
rubric: >
Score the candidate from 0 to 1 against the definition of done. Approve only when the
cited evidence supports every required outcome.
metric:
direction: maximize
min_delta: 0.05
verdict_policy: revise_until_clean
on_result:
fail: revise
max_revisions: 8The grammar is deliberately narrow:
directionismaximizeorminimize.min_deltamust be finite and non-negative.- A definition may contain at most one metric criterion. Multi-objective ratchets are not part of
compozy.loop/v1. command,agent-judge, andextensioncriteria may declare a metric;humanmay not.- A missing or non-finite required score produces
invalid_output. It never passes and never advances the best generation.
Each scorer has a typed response contract:
| Criterion | Score contract |
|---|---|
command | Standard output must be exactly one JSON object with no extra fields, for example {"score":0.72}. The configured command expectation still decides pass/fail. |
agent-judge | The required verdict object gains numeric score; it still includes verdict, blocking_issues, and non-empty evidence for a pass. |
extension | Structured tool output gains numeric score alongside verdict; blocking_issues and evidence remain available. |
The score participates in the ratchet. A candidate becomes best only when
the aggregate gate verdict is approved and the finite score improves the current baseline in the
declared direction by at least min_delta.
Runtime validation and provenance
compozy loop validate --workspace <ref> --file <path> validates definition-contained runtime
values. compozy loop run --dry-run and normal submission validate the effective runtime after
workspace, stored, and per-run layers resolve; the daemon repeats the check immediately before
binding. A failure returns structured runtime_validation items and starts no ACP process.
Use repeatable --runtime flags for per-run defaults and rules. The expression is
provider/model@reasoning; - leaves one field unset, and model IDs may contain additional
slashes. Run detail exposes the final binder-applied resolved_runtime and each field's source
through CLI, HTTP/UDS, native tools, SSE, and the read-only web run inspector.
Start bindings
start lists the kinds that may launch a run. Declared kinds are a read-only allowlist; the
catalog and detail screens render them as chips.
start:
- { kind: manual }
- { kind: cli }
- { kind: http }
- { kind: uds }
- { kind: native_tool }
- { kind: schedule } # hands-free starts via automationAvailable kinds: manual, cli, http, uds, native_tool, schedule, trigger, webhook,
network, extension. Hands-free starts (schedule, trigger, webhook) ride CompozyOS's existing
automation primitives — a Trigger or Job targets the Loop with
typed inputs. A watch-source is a body-node concept, never a start binding.
Config defaults
[loops.defaults.<kind>] in config.toml seeds new Loops of that kind (delivery or watch).
These defaults are a RestartRequired config plane, separate from the per-Loop
configure store.
| Key | delivery | watch |
|---|---|---|
iteration_cap | 50 | 0 |
no_progress.window | 3 | 2 |
gates.max_revisions | 10 | — |
budget.tokens | 0 | 0 |
budget.wall_clock_sec | 0 | 0 |
budget.on_exceeded | halt | halt |
fan_out_width | 4 | 2 |
Write-time validation rejects negative fan_out_width values; positive values have no daemon-wide
ceiling. The effective config a run uses is a four-layer merge — definition defaults ⊕
[loops.defaults.*] ⊕ the per-Loop config store ⊕ per-run overrides. Other bounded fields remain
clamped to their documented ceilings. See
configure.