010 agents
For agents
The machine-readable surface: what looply agent returns, what it guarantees, and the rules a program driving looply has to follow.
The coding agent is one half of the arrangement; this page is the other half — driving looply itself from a program rather than from a terminal. One command carries the whole state: what a run is doing, what is in the queue, what is waiting on a person. The rest of the page is what its fields mean, what they promise, the two commands beside it a program reaches for — the queue in full, and the way to end a run — and what a program must not do while a round is in flight.
looply agent
looply agent --json # the whole snapshot as one JSON document
looply agent --text # the same state, fifteen key=value lines
$ looply agent --text
schema=1
project=tabsy
configured=true
paired=true
run.live=true
run.stale=false
round=14
phase=proving
queue.ready=22
queue.draft=3
queue.blocked=5
questions=0
totals.committed=13
totals.discarded=1
suggests=waitJSON is the default: the command prints JSON unless --text is given, and --json wins when both appear. Unknown flags are ignored. There is nothing else to pass — no flag for the item cap, none for the staleness window. looply agent is absent from looply help, whose list stops at the commands meant to be typed by hand, though the bare looply welcome screen does list it. The help text ends with "not built yet: item", and that one command is the whole of what is missing: typing looply item prints an unknown-command line and exits 64. Everything else the help lists is built, looply queue and looply stop included, and both are as drivable from a program as from a terminal. Stopping has its own section below.
What it reads
The snapshot is assembled from four files and nothing else. No network call, no agent call, no cost. Every read is optional and every failure is swallowed, so a missing or corrupt file becomes a fact about the state rather than an error — which is why the command still answers in a directory looply has never seen.
| file | what it supplies |
|---|---|
.looply/config.yaml | project.name, project.id, project.configured |
.looply/state/status.json | run, round, totals, questions |
.looply/state/queue.json | queue counts and queue.items |
~/.looply/machine.json | machine.paired, machine.id, machine.serverUrl |
The three guarantees
schemaVersionchanges when a field's meaning changes, not when a field is added. Check it and refuse to guess, rather than silently misreading a field that was redefined under you.- Every field is present or explicitly null. Nothing is omitted, so
undefinednever has to be told apart from absent-because-false.roundis the one whole object that goes null, and it is null between rounds. - Exit 0 whenever the question could be answered, including when the answer is that nothing is running. A non-zero exit means the question could not be answered, never that the answer was dull.
Every field
| field | meaning |
|---|---|
schemaVersion | Integer, 1 today. |
cliVersion | The CLI's own version string, 0.1.0. |
project.name | From the config. The literal string unknown when there is no config to read. |
project.configured | Whether .looply/config.yaml was found and parsed. |
project.id | Project id from the config, or null. |
machine.paired | Whether ~/.looply/machine.json holds an identity. False when the file is missing or unreadable. |
machine.id, machine.serverUrl | The paired machine and the server it pairs with. Both null when unpaired. |
run.live | True only while status.json says a run is going. |
run.id, run.branch | The run id, and the branch it created: looply/run- plus a UTC stamp to the second. |
run.startedAt, run.endedAt | ISO timestamps. endedAt is null while the run is live. |
run.stopReason | One of min-hours, max-rounds, signal, command, queue-empty. Null until the run ends. |
run.stale | Null when there is no status.json to read, so no run has ever happened here. True only when the run is live and the last event is older than 3 minutes. False otherwise. The window is fixed at 180000 ms and no flag moves it. |
round | Null between rounds and before the first one. |
round.n, round.attempt | Round number within the run, and which attempt at this item. Attempt 1 is the first: the runner sets it to the item's attempts count plus one, and a freshly scanned item starts at 0 attempts. |
round.itemId, round.title | The item being worked. Title is null when the ledger has not seen that item's title. |
round.phase | picked, implementing, gating or proving. Terminal states never appear: round.ended clears the round. |
round.checks[] | One entry per gate step — name, ok, skipped, ms. A step that timed out reads ok false; the timedOut flag stays in the ledger and is not carried here. |
totals | rounds, committed, discarded, abandoned for the current run. Reset at run.started; all zeros when nothing has run. |
| field | meaning |
|---|---|
queue.ready | Items in state ready — the only pickable state. |
queue.draft | Items in state draft or verifying, counted together under the one name. |
queue.blocked | Items in state blocked. |
queue.total | Every item in queue.json whatever its state, including stale, duplicate, split, in_progress, done and closed, which the three counts above do not cover. |
queue.items[] | Up to 50 items. The counts stay complete regardless. |
item id, title, tag, state | Identity and lifecycle state of one item. |
item attempts | How many rounds have been spent on it. |
item lastFailure | Why the last attempt was thrown away, or null. The next attempt's prompt carries this text back to the coding agent. |
item evidence[] | path and line for each piece of evidence; line is null when none was recorded. The quote stored in the ledger is not carried here. |
questions[] | id, text, default and deadline for each open question. |
suggests | One token, or null. |
queue.items is capped at 50 and comes out in the order the queue file stores them, which the code does not promise is priority order. The runner picks by sorting ready items on orderKey and taking the first, and orderKey is not one of the fields the snapshot carries. So the array tells you what is in the queue, not what will be picked next — and past 50 items it may not even contain the item the next round takes. The four counts beside it are always complete.
looply queue
looply queue [--all] reads the same .looply/state/queue.json and renders it for a person. Items are grouped under one heading per state, in triage order — ready, in_progress, verifying, draft, blocked, split, then the settled ones — and each item prints three things beneath its id and title: where a round would start, which is the first piece of evidence as path:line and falls back to the path alone when no line was recorded; how many attempts it has had, when that is above zero; and why it is where it is, which is the blocked reason for a blocked item, the item a duplicate merged into, and otherwise the last failure. Settled items — done, closed, stale, duplicate — are left out unless --all is given, with a count of how many were hidden and the flag that shows them. An empty queue prints that and points at looply scan. A queue with drafts and nothing ready says nothing is pickable yet and points at looply verify. It exits 0; a corrupt queue.json is the one thing that makes it exit 1, because the store refuses to start empty over a file something else damaged.
For a program there is nothing on that screen looply agent does not already carry, and the grouping is no more pick order than queue.items is: the headings are a fixed triage order in the source, while the runner sorts ready items on orderKey. Two differences are worth knowing anyway. queue prints every item rather than the first 50, so it is the cheapest way to see a queue past the snapshot's cap. And it takes no --config: it goes straight to the state directory under the working directory.
suggests
| value | meaning |
|---|---|
init | No config parsed here. looply has not been set up in this directory. |
answer-question | At least one question is open. Something needs a human decision. |
wait | A run is live. Let it work. |
scan | Nothing ready and nothing in draft. There is nothing to work on. |
run | Everything else: nothing running, and at least one item ready or in draft. Drafts count, because a run with nothing pickable verifies them itself before it picks. |
null | Typed nullable, but the current rules never return it. |
The rules are a chain and the first match wins, in exactly the order of the table. That is worth knowing because the token can hide a fact you also care about: a live run with an open question reports answer-question, not wait, and run comes back with queue.ready at 0 as long as queue.draft is not. Read the counts and run.live when you need more than the token.
The draft-only case is not a mistake in the rule. A run that finds nothing pickable checks its drafts against the code first and picks from whatever survives, so drafts are work the run can reach. looply verify [--max N] is that same pass asked for on its own, and it is the cheaper way to learn how many drafts are real before committing to a run; --max caps how many are checked and the rest stay draft. One edge is worth knowing: queue.draft counts draft and verifying together, but a run only starts its own pass when at least one item is actually in draft. A queue holding nothing but verifying items therefore suggests run, and the run refuses with nothing to do and exits 1. looply verify checks both states, so it is what clears that case.
One caveat on the token: nothing in the CLI emits a question.asked event today. When a proof comes back partial in a terminal run, the runner logs that there is nobody to ask and discards. So a locally driven project's questions array stays empty and answer-question does not appear. Handle it anyway; it is in the schema and the server has a question API behind it.
Rules for a program driving looply
- Never start a run inside a round. If
run.liveis true, the answer is wait. Nothing enforces this for you: the launcher's guard is a map in one daemon process, and a run's branch is stamped only to the second, so two runs started a second apart get two branches over one working tree. - Never run git inside a round. The runner owns the branch, the staging, the commit and the discard, and it discards with
git reset --hardfollowed bygit clean -fd -e .looply. Uncommitted work in the tree is gone at that point, yours included — and a commit you made yourself survives the reset, which lands the change the round had decided to throw away. The implementer prompt says this to the coding agent in as many words. - Do not edit
.looply/stateby hand. events.jsonl is append-only with (runId, seq) unique; status.json is a projection of it, rewritten atomically after every append. A hand edit desynchronises the two and the app starts showing a run that is not happening. - Treat blocked items as read-only. An item is blocked because it needs a schema change, a credential or a decision, not because nobody got to it. ready is the only state a round can pick.
- Poll rather than wait.
looply agentis a file read with no watch mode and no push; call it again when you want a newer answer. It costs nothing to call. - Ask cheaply first.
looply doctor,looply queueandlooply run --dry-runcall no agent and spend nothing, so use them to find out that a config is wrong or a queue is empty instead of starting a run to discover it.looply verifydoes call an agent — one short call per unchecked item, capped by--max— but it is the cheapest agent work looply does, and it is what turns a draft into something a round can pick. - Ask a run to end, never kill it.
looply stopand a signal are both read between rounds, so the round in flight finishes and the run closes withrun.endedand its tallies. A killed process leaves the ledger's last line mid-round and the projection claiming a live run that nothing will ever end. Read Stopping a run before wiringlooply stopin: its liveness check is broken today, and a signal is what actually lands. - A question is a stop. When
questionsis non-empty, answer it through POST /v1/questions/:id/answer or the app; that route wants an operator grant or better. Do not work around it. Raising a question is not a route a program can reach from outside: POST /v1/questions is machine-token-only and only into that machine's own projects.
Stopping a run
looply stop [--now] writes a stop request to .looply/state/control.json and exits 0. The runner reads that file between rounds, next to the signal check and for the same reason: the round in flight finishes, and the run ends with stopReason command rather than naming whichever budget limit happened to expire at the same moment. --now kills nothing, and that is deliberate — a round cut off halfway leaves a half-written tree, and the next run refuses to start on one — so the flag adds a line saying the round in flight still finishes and changes nothing else.
When no run is live, stop writes nothing at all and says so. That is the honest answer rather than a shortcut: a run clears any pending request as its first act after run.started, so last night's stop cannot wedge tonight's run, and a request written while nothing is going would sit on disk looking like a pending order nothing will ever obey. The runner clears a stop the moment it acts on one, by writing resume over it rather than deleting the file — an unlink racing a reader in another process is how a request goes silently missing. pause is the third kind the same file carries and the CLI has no command for it: it arrives from a paired device, and it holds the run on its branch with its queue intact, so resuming continues the same run instead of paying for a fresh one.
The liveness check stop uses is wrong today, so do not build on it yet. It reads run.live out of .looply/state/status.json, and the projection has no run object — live is a top-level field, which is where looply agent, looply status and the welcome screen all read it. The check therefore answers false whatever is happening, and stop prints that no run is going and writes nothing, --now included. Until that path is fixed, what actually ends a run is a signal to the run's own process — HUP, INT or TERM, where the first finishes the round in flight and stops with stopReason signal, a second forces once, and a third falls through to the default disposition and kills — or a stop from a paired device, which the daemon writes to the same control file.
Exit codes
| code | meaning |
|---|---|
0 | The command answered. looply agent returns this from any directory it can read, including one with no looply in it. |
1 | The command failed and the reason is on stderr — a failed gate step or a tripped interlock in doctor, an existing config in init, an uncaught error anywhere. |
64 | No such command. Usage goes to stderr. |
91 and 92 exist, but not as exit statuses. gate.ts defines 91 for the production-database interlock and 92 for a touched railed path; they travel as abortCode on the gate.result event and appear in doctor's ABORT line, while the process still exits 1. docs/AGENTS.md lists them in its exit-code table — the CLI does not return them. Branch on the event, not on the exit status.
How looply reads your agent
The other direction is worth knowing if you are swapping the agent. The prompt never touches the command line: it is written to the agent's stdin and stdin is closed, so agent.command in the config carries flags only and no round data at all. It defaults to claude -p --dangerously-skip-permissions, with agent.output_format_flag defaulting to --output-format. The format is chosen per caller: looply scan asks for json and reads the structured envelope; the implementer, the prover and looply verify ask for text and read the transcript. An agent put behind this must read its prompt from stdin. scripts/round-e2e.sh in the looply repo is the smallest working example of that contract: agent.command points at a shell stub that reads stdin and answers, and 28 checks drive a whole round through it — the committed outcome and the prover-rejected one — with no coding agent involved.
| envelope field | meaning |
|---|---|
ok | Taken from is_error, never from the subtype. The CLI emits subtype success with is_error true when an API error's text is what landed in result. |
text | result when there is one, otherwise the errors joined by newlines. |
subtype | success, error_during_execution, error_max_turns and the rest. A label for the reason, not the verdict. |
errors | The string errors from a failure envelope; an empty array otherwise. |
parseAgentEnvelope reads the merged stdout and stderr, collects every line whose first non-space character is an opening brace, and walks those candidates from the end. Each is taken to its matching brace, string-aware, so a closing brace inside the agent's own prose does not end the document early; the first candidate that parses to an object with type result is the envelope. It returns null when there is none: an older CLI, text format, or a pass killed mid-write all produce output with no envelope, and each falls through to the raw stream rather than being reported as an agent failure. unwrapEnvelope composes with the exit code instead of replacing it, so a non-zero exit stays a failure whatever the envelope claims. error_max_turns is why any of this exists: a run truncated at its turn limit still exits 0, and read as a completed answer it makes a lens report a clean codebase after a pass that never finished looking.