Work loops
A work loop is a records-native primitive — a managed plan/act/check cycle that emits every step as a record, runs tools that are permission-checked before they execute, and ends with a structured outcome that names exactly what happened.
Overview
A work loop is what you get when you stop treating "an actor doing goal-structured work" as an opaque process and start treating it as a sequence of records on a thread.
The actor doing the work can be an LLM agent, a human driving a UI, a workflow, or any other automation — the loop primitive is the same. Every turn it takes, every tool it calls, every result it gets, every halt reason, are all records on the loop's thread. You can subscribe to them, query against them, fold them, and audit them with the same tools you use for every other Syncropel record. There is no separate "agent log" to scrape.
A loop is more than a one-shot task runner, though. It is a cognitive loop: it reads its own track record before it acts, does real work, and is judged by someone else when it finishes — and that external judgment is the only thing that moves its standing. The next section unpacks each of those three edges.
loop opens on a new thread
│
├─ turn 1: core.work.turn.v1
│ ├─ tool call: core.work.tool_call.v1
│ └─ tool result: core.work.tool_result.v1
│
├─ turn 2: core.work.turn.v1
│ └─ ...
│
└─ outcome: core.work.loop_outcome.v1 ← structured terminal recordThe loop runs in one of two modes:
- In-process — the loop runs in the same process that started it (
spl work-loop "..."does this by default). Workspace tools have direct host access —bash, file reads, file writes — confined to a workspace directory. - Remote — the loop runs on the instance (
spl work-loop ... --remote, or anyPOST /v1/work/loopcall). Tools are built in (no host shell); records land on the instance and the thread is watchable from anywhere.
The cognitive loop: read, do, be judged
A work loop is a citizen of the coordination protocol, not an island. It closes a full cycle around the record log:
reads its own track record ┐
▼
┌────────────────────────────────────────────┐
│ loop runs: plan → act → check → outcome │
└────────────────────────────────────────────┘
▼
an external actor judges the outcome ┘
│
▼
the verdict moves the loop's trust ──▶ next loop reads itIt reads its own track record. When a loop starts, it can see the trust an actor has earned in the relevant domain — its history of work that was accepted versus rejected. A loop with a strong record is given a little more rope; a loop with a thin or poor record is held to a tighter line. The loop's caution is calibrated by evidence, not guesswork.
It does real work. The middle is the plan/act/check cycle described below — turns, tool calls, results, all recorded.
It is judged by someone else. This is the part that makes the loop honest. A loop's own outcome record — its self-reported "done, succeeded" — does not earn it any trust. A loop cannot give itself a passing grade. Trust moves only when a different actor with standing — a person, or a trusted reviewer — looks at what the loop produced and records a verdict: accept or reject, optionally with notes and a work domain. That verdict is itself a record, and it is what folds into the loop actor's trust.
The cycle then closes: the verdict updates trust, and the next loop reads that updated trust when it decides how cautiously to proceed. A loop that consistently produces accepted work earns more latitude over time; one whose outcomes are routinely rejected does not — and it can never shortcut that by grading itself.
You record a verdict with spl work-loop-review <thread> --accept (or --reject), the same way a completed task is approved or rejected at the evaluation gate. The separation is structural: the actor doing the work and the actor judging it must be different identities for the judgment to count toward trust.
Why "records-native"
Other agent frameworks treat the loop as the source of truth and emit telemetry on the side. Syncropel inverts this: the records are the truth. The loop is a managed task whose state is derived from the records on its thread.
The consequence is that everything follows from existing Syncropel primitives:
- Pause + resume is free — pause is just halting the runner; resume is re-reading the thread and continuing.
- Multi-actor visibility is free — anyone on the loop's thread sees its records.
- Federation is free — a peer pulling the thread gets the whole loop.
- Cancel is a record (
core.work.loop_cancel.v1DO) that the runtime propagates — no out-of-band shutdown signal. - Trust flows naturally through the record's
actorfield — the loop runs under a real DID (the user's DID, in the chat-actor delegation case), and trust accumulates on that DID, not on an opaque "agent" process.
How a loop completes
A turn either calls a tool or it doesn't. The loop continues as long as the agent keeps calling tools. When the agent emits a turn with no tool calls, the work is finished and the loop closes with an outcome.
Three other paths end the loop early:
- A resource ceiling is reached — turns, tokens, wall-clock, or a USD cost cap. The outcome names which one.
- The agent can't make progress — it needs a tool it doesn't have, the goal is ambiguous, or it needs a decision from you. The outcome is
blockedwith a structured coercion record alongside it. - A repeated-failure pattern is detected — the same tool call with the same arguments fails multiple times in a row. The loop halts with
guardrail_haltrather than thrashing.
In every case the terminal record carries enough structure that a reader knows exactly what happened. The loop never returns a confident-but-fabricated "done."
Outcomes
Every loop ends with a core.work.loop_outcome.v1 record. The outcome field is one of eight values:
| Outcome | success | What it means |
|---|---|---|
completed | true | The agent ran to a turn with no tool calls — the work is done. |
failed | false | The loop crashed unrecoverably — an upstream or infrastructure error, not an agent decision. |
cancelled | false | Someone explicitly cancelled the loop while it was running. |
blocked | false | The loop halted because it could not proceed. A paired core.work.coercion.v1 record carries the structured reason. |
max_turns | false | The loop hit its turn ceiling before finishing. Increase max_turns or scope the goal smaller. |
budget_exhausted | false | A token, USD, or wall-clock budget ran out. The body carries a budget_kind field naming which one. |
guardrail_halt | false | The same tool call with the same arguments failed enough times in a row that the loop was stopped to prevent thrashing. The body carries the tool name, an arguments hash, and the failure count. |
awaiting_actor | — | The loop is paused waiting for a human decision (a core.work.decision_request.v1 was raised). Not a terminal outcome — the loop resumes when you respond. |
The blocked outcome is the most important one to understand, because it is how a well-behaved loop reports an honest "I can't do this" rather than producing noise.
Coercion reasons
A blocked outcome is always paired with a core.work.coercion.v1 record whose reason field is one of:
| Reason | What happened |
|---|---|
tool_unavailable | The loop identified a tool it would need to make progress, but the tool is not in its grant. If the tool is in the actor's requestable set, the loop can escalate via a decision request. |
ambiguous_goal | The goal has more than one reasonable interpretation. The loop surfaces the alternatives rather than picking one silently. |
precondition_failed | A goal assumed something that turned out not to hold — for example, "summarise the records on this thread" when the thread is empty. |
decision_required | The loop reached a fork that needs a human call. A core.work.decision_request.v1 is emitted; the loop blocks until you respond. |
input_invalid | A goal parameter could not be parsed into something concrete — an unparseable date range, a malformed reference. |
internal_error | The language model or an upstream service returned an unexpected error after the loop's retry budget was exhausted. |
The coercion record carries a narrative field explaining the situation in prose, and, where relevant, a missing_tools array or a decision_request_ref. Workspace surfaces render it as a card alongside the outcome.
The contract that makes this work is that the loop's tool registry is the source of truth: a loop cannot "almost" call a missing tool, and the engine will not let it. When the language model produces a tool call the loop does not have, the registry rejects it and the loop halts with a structured coercion record. There is no path through which a missing tool becomes a confident-but-fabricated answer.
Tier-shaped guardrails
Every actor has a tier (Trial / Paid / Team / Operator), and the tier shapes what the loop can do:
| Tier | Max turns | Wall-clock | Token budget | Daily cost | Daily loops |
|---|---|---|---|---|---|
| Trial | 5 | 2 min | 20k | $0.50 | 10 |
| Paid | 16 | 10 min | 200k | $5 | 200 |
| Team | 50 | 30 min | 1M | $50 | 2000 |
| Operator | ∞ | ∞ | ∞ | ∞ | ∞ |
Requests that exceed the tier's ceiling are denied up front — the loop does not start, no compensation record is emitted, the actor gets a clear denial_reason (WORK_DAILY_CAP_EXCEEDED or WORK_DAILY_COUNT_EXCEEDED). Use POST /v1/work/loop/preview to size goals before sending them.
Task budget
A loop can carry an optional task_budget — a per-loop ceiling on turns, tokens, or USD cost that overlays the tier's daily limits. When set, the budget is visible to the language model in its system prompt, so the model can pace its own work and wrap up gracefully as the budget shrinks. When the budget is hit, the loop ends with budget_exhausted and the budget_kind field names which one.
spl work-loop "Summarise this week's dispatches" --max-turns 8 --usd-budget 0.50Repeated-failure guardrail
If the same tool call with the same arguments fails repeatedly — same name, same arguments, same error shape — the loop will eventually halt rather than continue retrying. This catches the case where the model gets stuck on a single failed call and would otherwise spin until the turn cap runs out.
The behaviour is two-stage:
- After a small number of identical failures, the next attempt with the same signature returns a synthetic failure result telling the agent "this exact call has failed N times; change strategy or stop." The agent reads it on its next turn and decides what to do.
- If the agent keeps trying anyway, the loop halts with
guardrail_haltand acore.work.guardrail.v1record naming the tool, the arguments hash, and the count.
The guardrail is shape-aware — it does not count one-shot transient errors, only repeated identical failures.
Compensation records — when an assumption is violated
Sometimes a loop violates an assumption the system made about it — for example, the loop wraps up prematurely as it nears its context limit. The engine emits a core.work.compensation.v1 record naming the boundary it crossed, the assumption, and the context.
Compensation records are observable to operators. The review endpoints (GET /v1/work/compensation/review, /review/top) aggregate per-actor anomalies against the fleet median — actors whose loops compensate at well above the fleet rate are surfaced. This is how the system notices when a previously-trustworthy actor's loops start misbehaving.
Importantly, compensation records are not punishment — they are observability. A high compensation count on a single goal might mean the loop is exploring an unusual boundary; a sustained high rate across many goals is what the anomaly score catches.
Chat-actor delegation — @agent
A conversation with Scribe or the Syncropic Guide is one-shot: you ask, it answers. For multi-step work, open the message with @agent <goal> and the chat-actor delegates to a real loop.
Two things change when delegation happens:
- The loop runs as the user, not as Scribe/Guide. It uses the user's identity, permissions, and tier — Scribe steps aside for the duration. Every record the loop emits is signed by the user's actor DID.
- The chat-actor's response and the loop's outcome pair up. A
core.work.delegation.v1record opens the delegation; the loop's outcome closes it. This is the same pair-completion pattern decision requests use.
Delegation is auto-classified — Scribe sees a message that opens with @agent and starts a loop. The classifier is heuristic (verbs like "build", "audit", "find every…"); it can be tightened with an explicit @agent opening.
See Agents for the user-facing view of this handoff — what the launch surface shows, what the capability is, what the outcomes mean.
How to start a loop
Three entry points, same primitive:
# CLI — local in-process
spl work-loop "Summarise this week's dispatches"
# CLI — instance-run with a task budget
spl work-loop "..." --remote --max-turns 8
# HTTP API
curl -X POST http://localhost:9100/v1/work/loop \
-H "Authorization: Bearer $SPL_TOKEN" \
-H "Content-Type: application/json" \
-d '{"goal": "...", "max_turns": 8}'
# Chat — @agent delegation
# (type into any Scribe/Guide thread)
@agent audit my last 7 days of dispatches and write a summarySee the CLI reference and API reference for full surface, and the body-kinds reference for the records the loop emits.
What's next
- Agents — the user-facing view of LLM actors driving loops, including
@agentinvocation and outcome interpretation. - Scribe — the default conversational agent that delegates to a loop via
@agent. - Actors — the DIDs records (and loops) are attributed to.
- Threads — the coordination context a loop lives on.
- Trust — how the records a loop emits feed the trust signal.
Helpers
A helper is an automated participant that does one kind of work well — a solver, a classifier, a checker. It is not necessarily a language model, but it is still an actor with a DID, a trust profile, and an audit trail, and Syncropel learns which helper to trust for which work.
Trust
Evidence-based reputation computed from outcomes reviewed by independent evaluators — domain-scoped, statistically honest, and decaying over time. Not a policy, not a vote, not an opinion.