LoopSpec v0.1 — reference
A LoopSpec is one typed YAML document that declares a single bounded agent loop. Every input (a blueprint, a hand-written file, an NL draft) normalizes to this shape before any code is emitted. Canonical types live in packages/core/src/types.ts; the compact LLM-facing guide is LOOPSPEC_GUIDE.
The load-bearing rule: the compiler refuses to emit an unbounded loop.
terminateis required andcapsare mandatory (auto-injected if omitted).
Top-level fields
| Field | Required | Description |
|---|---|---|
loopspec | ✓ | Format version — "0.1". |
id | ✓ | Identifier; matches [A-Za-z0-9_.:-]+ (lowered into code/comments). |
pattern | ✓ | react · plan-execute-reflect · evaluator-optimizer · loop-until-dry · map-reduce · poll-until · cron · gauntlet. |
body | ✓ | The iteration: a non-empty list of steps. |
terminate | ✓ | Exit predicate + signal tier (see below). |
caps | auto | Limits. Auto-injected per-pattern if omitted; set them explicitly. |
meta | { name, version, description }. | |
inputs | { <name>: { type, required?, default?, description? } }. | |
state | { store: journal, vars: { <name>: { type, init } } }. Only journal is supported in 0.1 (memory is reserved). | |
schedule | { mode: manual|cron|watch|forever, cron? }. | |
retry | { max, backoff_ms } — transient http/shell/agent failures retry with exponential backoff (default no retry). | |
gates | Durable human-approval gates { after?, when?, ask, strategy?, auto_approve_in? } — lowered to an inline, fail-closed ctx.breakpoint() after the named step (standalone + babysitter). | |
observe | Durable tracing plus executable lifecycle hooks; see observe. | |
target | Default compile target + emitted surfaces: { runtime: standalone|babysitter|claude-code|claude-native|n8n, emit: [cli, skill, doctor] }. | |
provenance | { factory_version, source, run_id } (baked into the artifact). |
Compile target notes
target.runtime chooses the default compile output when loopc compile is run without --target. It does not weaken validation: every target still starts from the same bounded, validated LoopSpec.
standaloneis the hard-guarantee runtime target. It emitsloop.mjs,loop.lock, a local journal, and optional vendored runtime bundle.claude-nativeemits a Claude Code project skill under.claude/skills/<loop>/SKILL.md. The skill command name comes from the sanitized loop id, and the original LoopSpec is embedded at.claude/skills/<loop>/reference/loopspec.json.- Use
--target allwhen you want the Claude-native skill to sit next to a standalone artifact. In that layout, the generated skill can delegate to standalone for runtime-enforced journals, replay, caps, durable sleep, breakpoints, and budget metering. Without standalone, the skill is still usable from Claude Code, but those guarantees are soft and agent-honored.
Types
string · int · number · boolean · json · list · enum[a,b,c]
Step kinds (closed set — no raw code)
- { id, kind: agent, harness, prompt, allowed-tools?, save?, on_done? } # harness: llm | claude-code | codex | opencode | antigravity | cursor-agent | pi | cli | internal
- { id, kind: shell, cmd, save?, on_done? } # runs a shell command
- { id, kind: http, request: { method, url, headers?, body? }, envelope?, save?, on_done? }
- { id, kind: breakpoint, ask, strategy?, auto_approve_in? } # durable human gate
- { id, kind: sleep, for: "5m" | until: "${...}" } # exactly one of for/until; durable
- { id, kind: reduce, over: "${...}", as?, body: [...] } # fan out over a collectionEach step may carry a when: "${...}" guard. agent/shell/http steps may save json-path extractions into state; agent save reads the harness's structured result envelope.
save:{ <stateVar>: "$.path.into.result" }envelope(http only, opt-in): whentrue, the step result is{ status, ok, headers, body }instead of the bare parsed body — sosave: { code: "$.status", payload: "$.body.field" }can read the HTTP status of a JSON response. Default (omitted) keeps the body-direct shape.on_done:{ incr: <var> }|{ set: { <var>: value-or-${expr} } }|{ append: { <listVar>: value-or-${expr} } }(appendinto alistvar is howreduceaccumulates per-item results.) Mutation values may preserve native types and recursively evaluate a safe expression with{ $expr: "state.review" }; the wrapper must contain exactly that one string field. This is intentionally limited toon_done.setandon_done.append—HTTP bodies stay data.
Expression language (${...})
A small, safe subset — no function calls, no arbitrary identifiers:
- Roots:
state.x,inputs.y,env.Z,meta.m,iteration,item(insidereduce). - Operators:
== != < <= > >=,&& || !(andand/or/not),+ - * / %,in. - Literals: numbers,
'strings'/"strings",true/false/null.
&&/|| return operands (JS semantics), so ${a || b} works as a fallback. The same AST is used by the validator (reference + safety checks), the runtime (evaluation), and the emitter (lowered to JS) — so all three agree.
terminate
terminate:
signal: state-predicate # oracle > state-predicate > llm-judge > self-assess
until: "${state.status == 'green'}"
on_exit: { kind: shell, cmd: "./notify.sh ${state.status}" } # optional action on exitRank your signal by trustworthiness: an oracle (tests/compiler/schema) is strongest; a model's self-assessment is weakest (and requires explicit caps).
Termination grounding — the label is checked, not trusted
A declared signal is only as strong as the steps that feed the exit predicate. The factory classifies the evidence chain behind every until (terminationGrounding in @loopyc/core):
| Grounding | Meaning |
|---|---|
external | The exit var(s) are saved by http/shell steps — real-world evidence decides. |
structural | Only on_done mutations (e.g. an unconditional done flag) — deterministic sequencing. |
mixed | Some evidence, some agent self-report. |
agent | Only agent saves feed the exit — the model grades its own work. |
Taints propagate: a done flag set only when an agent-reported score clears a bar is still agent-fed. Declaring oracle or state-predicate over an agent-fed predicate trips the ungrounded-exit warning and the scorecard caps the termination dimension at the self-assessment ceiling — an honest llm-judge/self-assess label scores higher than an inflated one. To upgrade a loop's grade, ground the exit: let a shell exit code, an http status, or a scan count decide, not the agent's own report.
observe
observe:
trace: journal
hooks:
completed:
kind: shell
cmd: "./record-completion.sh"trace controls the scorecard's durable-trace declaration. hooks.completed is a strict action: either { kind: shell, cmd: <non-empty string> } or { kind: http, request: <HttpRequest> }. Unknown hook names, empty actions, and shell/http field mismatches are rejected during parsing.
The standalone runtime attempts this hook once after it has journaled natural termination. It appends an observer event with started, then done or failed. The hook is post-result: failure is visible and durable but cannot rewrite a successful loop result. A process crash after started may leave the delivery outcome uncertain; the runtime does not blindly retry it and does not claim exactly-once external delivery. Other compile targets currently report completion-observer as unsupported in their capability warnings rather than silently promising standalone semantics.
The scorecard awards observer credit only for an executable completion hook or an active top-level notify contract with at least one channel. Arbitrary legacy observe.notify metadata is inert and does not score.
caps (mandatory)
caps:
max_iterations: 288
no_progress: { fingerprint: "${state.status}", max_repeats: 12 } # anti-thrash
budget: { tokens: 200000, usd: 5.0, wallclock: "24h" }
on_cap_exceeded: breakpoint # fail | breakpoint | exit-cleanPer-pattern defaults (when omitted) are in normalize.ts.
artifacts and notify (optional, deny-by-default)
artifacts:
include: ["reports/**/*.md", "metrics/*.json"]
exclude: ["reports/private/**", "**/.env*"]
max_files: 1000
max_bytes: 50000000
notify:
policy: on-change
channels: [ops]Artifact paths are relative allowlist globs with explicit file/count ceilings. Active content, secret/dependency allowlists, traversal, and absolute paths are compile-blocking. Notification channels are logical names; webhook URLs/tokens never belong in LoopSpec. No contract means no indexed files, and an empty channel list means no external calls. See Artifacts and notifications.
Validation — hard gates
loopc validate blocks compilation on any of these:
terminatepresent, with asignaland a parseableuntil.- Exit reachable —
untilreads a state var some step writes, oriteration. self-assesstermination requires explicit caps.- Every
state/inputsreference is declared; everysave/on_donetarget is declared. - Exactly one of
sleep.for/sleep.until. - Names (ids, vars, inputs, reduce aliases) are safe identifiers; expressions are in the safe subset;
schedule: cronhas acron; gateafterreferences a real step. - Artifact globs stay relative and cannot allowlist secrets/active content; notification channels are logical names rather than URLs or credentials.
Soft warnings (non-blocking, downgrade the score): weak signal, ungrounded exit (a strong signal label over an agent-fed predicate), auto-injected caps, missing no_progress on poll/loop-until-dry, missing budget, trace: none.
Worked example
See examples/deploy-watch.yaml — a poll-until loop that checks a deploy, lets an agent fix it when red, sleeps between checks, and exits when green. Scaffold any pattern with loopc new <id> --blueprint <name>.
