Skip to content

compozy.loop/v1 DSL

The Loop definition schema — declared inputs, the contract, node classes and kinds, the gate contract, and start bindings.

For people running agent work18 pages in this section

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
FieldRequiredNotes
apiVersionyesMust be compozy.loop/v1.
kindyesMust be Loop.
metayesname, description, catalog; version is daemon-owned and monotonic.
concurrencynoforbid (default) rejects a second same-Loop start; allow runs in parallel; queue defers to a FIFO queued run.
inputsnoMap of declared inputs.
contractyesThe goal → verify → stop contract.
graphyesnodes + edges.
startnoStart 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 } }
TypeAcceptsNotes
stringtext
numberint or float
booleanbool
filetextThe value is not browsed or checked for existence.
agentan exact agent nameThe daemon checks the workspace agent catalog.
refan exact resource nameref.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

ClassOpennessKinds
actionopenrun-agent, run-loop, transform, goal, or any tool ID
controlclosedfan-out, collect, branch, route, gate, wait, ask, sub-loop
sourceclosedinput, 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.

KindParams
run-agentagent (required), prompt (required), output_schema?, cwd?, runtime?, allowed_tools?, max_turns?
run-looploop (name), inputs?, mode: await (default, parks in awaiting_child) or detach (returns {loop_run_id})
transformmap — each entry is {from: <ns path>}, {value: <literal>}, or {template: "{{ }}"}
goalagent, objective, judge, max_turns, on_exhausted?, output_schema, runtime?; see the Goal node reference
any tool IDparams = 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

KindPurposeKey fields
fan-outRun a batch of branches over a collectioncollection, filter?, batch_size (default 1), max_parallel, positive max_fan_out
collectJoin barrier — waits for fanned branches
branchRoute on a CEL conditioncondition (true/false edges), optional on_eval_error: fail | exit
routeSelect exactly one forward pathordered routes: [{when, to}], mandatory default
gateVerify against criteria and routecriteria, verdict_policy, on_result, max_revisions
sub-loopAn inline nested Loopbody (graph) + contract
waitPark on time or one durable eventparams: 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: acceptable

Thresholds 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: reject

Node reliability fields

FieldContract
timeoutOptional limit for one attempt.
deadlineOptional total limit across attempts and backoff; must be at least timeout.
retry.max_attemptsTotal attempts including the first; 0 disables automatic retry.
retry.on_failureNode-family retry behavior where supported.
retry.backoff.{base,max}Bounded retry delay.
retry.non_retryableFailure classes this node must not retry.
result_contract.{failure_field,message_field?}Reads application-level failure from an otherwise successful payload.
on_errorExactly one of route or allow_fail; optional effects observe the decision.
on_retry, on_success, on_pauseEffects for those committed node transitions.
on_timeout, on_cancel, on_quarantineEffects for those committed node transitions.
on_parent_closeAwaited 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_out

Choose 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

KindPurposeKey fields
inputExpose a declared input to the graphinput_ref
file-importMaterialize a finite collection from filespattern, parse: json | text, produces
watch-sourceWait for an external signal, then tickwatch (spec)
watch-eventsWake on an internal CompozyOS event, then tickevents (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-sourcenever 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).

FamilyKindsReplay ledger
tasktask.status_changed, task.blocked, task.unblocked, task.needs_attention, task.recoveredtask_events
task.runtask.run.completed, task.run.failedtask_events
looploop.terminal, loop.node.terminalloop_run_events
automationautomation.run.completed, automation.run.failedautomation_runs
networknetwork.message.persisted, network.thread.opened, network.direct_room.opened, network.work.opened, network.work.transitioned, network.work.closednetwork_timeline_log
coordinatorcoordinator.spawned, coordinator.decision, coordinator.stopped, coordinator.failedevent_summaries
eventevent.post_recordsession_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 typeFieldsVerdict source?
commandcheck (command), expect (e.g. exit_zero), optional metricno
agent-judgeagent, rubric ({{ }} template), optional runtime and metricyes
humanprompt; metric is not allowedyes
extensiontool (tool ID), inputs, optional metricno

verdict_policy selects how the gate decides:

  • revise_until_clean — iterate until the criteria pass. Requires at least one agent-judge or human criterion (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: 8

The grammar is deliberately narrow:

  • direction is maximize or minimize.
  • min_delta must 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, and extension criteria may declare a metric; human may 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:

CriterionScore contract
commandStandard 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-judgeThe required verdict object gains numeric score; it still includes verdict, blocking_issues, and non-empty evidence for a pass.
extensionStructured 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 automation

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

Keydeliverywatch
iteration_cap500
no_progress.window32
gates.max_revisions10
budget.tokens00
budget.wall_clock_sec00
budget.on_exceededhalthalt
fan_out_width42

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.

On this page