Skip to content
Loops
AGH RuntimeLoops

agh.loop/v1 DSL

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

Audience
Operators running durable agent work
Focus
Loops guidance shaped for scanability, day-two clarity, and operator context.

A Loop definition is a data-first, serializable document — agh.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: agh.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 agh.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 }
  verify_command: { type: string, default: "" }
  auto_commit: { type: boolean, default: false }
  spec: { type: ref, ref: { kind: skill } }
TypeAcceptsNotes
stringtext
numberint or float
booleanbool
filea file path (string)
agentan agent name (string)Web picker uses the agent list.
refa resource name (string)ref.kind names the resource kind; picker uses that kind's list.

Per-input fields: type (required), required (default false), description, default, and ref (for type: ref). Values are type-checked at run start.

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
  verification: # gate criteria that decide "done"
    - { id: checks, type: command, check: "{{ .inputs.verify_command }}", expect: exit_zero }
  terminal_states: [done, no-op, blocked, failed, exhausted, stalled]
  iteration_cap: 50 # 0 = unbounded (watch Loops)
  no_progress: { window: 3, hash_fields: [delivery_artifact, gate_verdict] }
  budget: { tokens: 0, wall_clock_sec: 0, on_exceeded: halt } # 0 = off
  model_defaults: { worker: "", judge: "" } # empty = provider/runtime default

verification uses the same gate criteria shape as a gate node. The stop model — iteration_cap, no_progress, and budget — is documented in full on the guardrails page. terminal_states lists the fixed six outcomes. model_defaults.worker seeds run-agent actions that omit params.model; model_defaults.judge seeds agent-judge criteria that omit model.

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, retry, 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, gate, 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 (agh__*, 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?, model?, 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; 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.model is a per-node override. If it is empty, AGH uses the effective model_defaults.worker; if that is also empty, the provider/runtime default model remains in effect. 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, max_fan_out (ceiling 64)
collectJoin barrier — waits for fanned branches
branchRoute on a CEL conditioncondition (true/false edges)
gateVerify against criteria and routecriteria, verdict_policy, on_result, max_revisions
sub-loopAn inline nested Loopbody (graph) + contract

fan-out exposes item and index to its branch scope — a single element at batch_size: 1, an array slice above it.

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 AGH 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 AGH 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 AGH'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 is a watch Loop: it defaults to iteration_cap: 0, holds watching between wakes, and — unlike watch-sourcenever stalls on silence. A quiet subscription is healthy dormancy, not a failure; the Loop parks indefinitely 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 (agh loop runs show -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)no
agent-judgeagent, rubric ({{ }} template), optional modelyes
humanpromptyes
extensiontool (tool ID), inputsno

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 a route; max_revisions caps the loop before the gate fails. An agent-judge.model value overrides model_defaults.judge for that criterion only.

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 AGH'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 negatives and clamps each value to its daemon ceiling. The effective config a run uses is a four-layer merge — definition defaults ⊕ [loops.defaults.*] ⊕ the per-Loop config store ⊕ per-run overrides — with every layer clamped to ceilings. See configure.

On this page