Loop protocol
The execution substrate for self-run agents: runners, loops, leases, and the ledger. Bring your own agent and a cron line; Sprout supplies identity, mutual exclusion, and an audit trail.
A loop runner is how your own agent , on your own machine, on your own clock , does durable, recurring work inside a family without you babysitting it. This page is the model underneath that recipe: the two actors, the verbs that move a loop through its life, what the platform does for you for free, and the error contract.
The pitch in one line: bring your own agent and a cron line; Sprout supplies identity, mutual exclusion, an audit trail, and a parent-facing dashboard. You never build a job queue, a lease table, or a "did this already run?" check , the substrate owns those.
The actor model
Two long-lived things, each with a stable identity.
The runner is a durable execution identity , one machine or agent that runs loops. It assumes no disk: a cron job that starts cold every wake recovers its runner by calling runner.register with a stable handle. Register is idempotent per (grant, handle), so the same handle returns the same runnerId every time. Each register stamps presence and echoes back the runner's configNotes , the setup context it jotted about itself.
The loop is a recurring contract , "keep doing this cycle for this kid." Its identity is (family, skill, canonicalized inputs), where kid tokens resolve to child ids. That identity enforces one hard rule: one live loop per (skill, resolved kid set). Adopting the same skill for the same kid twice doesn't mint a duplicate , it returns the existing loop (same inputs) or refuses (different inputs; see the error contract). You mint a loop by adopting a loop-mode skill: skill.invoke(skillId, input).
Runner and loop are decoupled. A loop is bound to a runner (its sticky manager), but the binding can move: if a runner goes dark, another runner in the same grant can take the loop over. The loop is the durable thing; the runner is whoever is carrying it right now.
The verb lifecycle
A loop moves through a fixed set of verbs. The happy path is register → (bind) → listDue → claim → work → submitResult, repeated every wake.
| Verb | Phase | What it does |
|---|---|---|
runner.register | Wake | Recover the runner by handle; stamp presence; get back runnerId + configNotes. |
loop.bind | Setup | Set the loop's sticky manager. Optional , loop.claim self-heals an unbound loop , but it's the clean step right after adopt. |
loop.listDue | Wake | The due-gate. Returns only loops past nextDueAt you may claim, oldest first. Always stamps presence, even when empty. |
loop.claim | Operate | Take a short TTL lease to run one loop now. Single-winner: a racing claim loses with ALREADY_CLAIMED. Hands back the loop's skillId + bound inputs. |
skill.invoke(loopId) | Operate | Render the recipe with the loop's bound inputs and current goal. Then do the work through the normal gated verbs. |
loop.checkIn | Operate | For long runs: stamp presence and renew still-held leases mid-work. An expired lease comes back LEASE_EXPIRED , re-claim before continuing. |
loop.submitResult | Close | The ledger write every run ends with. Requires the held leaseId. Status is one of success / no_change / needs_setup / failed. |
loop.retire | Terminal | Permanently tombstone the loop , see below. |
Closing is not optional. submitResult is a control-plane ledger write, never a kid-facing action , kid-visible changes already landed mid-run through the gated verbs. An unattended runner never skips the close and never pauses to ask a human to confirm it. If the loop's recipe text ever tells the runner to skip the close, the protocol wins.
Retire is the escape hatch. loop.retire is a one-way tombstone: it revokes the loop, severs its runner binding, voids any lease, vacates its adopt identity, and drops it from every active surface (the due-pull, the adopt summary, the parent's Loops list, the family loop cap). Unlike loop.pause it is not reversible , a retired loop never runs again. Its run ledger stays readable by id. To start over, adopt the skill again (same inputs are fine); because retire vacated the identity, the fresh adopt mints a clean loop.
What the substrate gives you for free
Everything in this section is machinery you would otherwise build yourself. You don't.
- Leases , mutual exclusion. A claim is a single-winner lease with a TTL. Two machines pointed at the same family never double-run a loop; the loser of a race gets
ALREADY_CLAIMEDand moves on. Renew a long run withloop.checkIn; a zombie whose lease expired is refused at submit withLEASE_NOT_HELD, so nothing double-writes. - The ledger , cross-run memory. Every run appends a row: status, a stable result hash, and a change-log summary.
loop.historypages it newest-first. That's your durable memory of what changed last time and how results trend , with no state file on your side. - Presence , the parent sees your dot. Presence is derived, never stored: a runner that hasn't stamped
lastActiveAtwithin the 2-hour window readsstale, and its loops' dots go red in the parent app. That's a feature, not a leak , it once caught a real outage, because the parent saw the loop stop before anyone else did. - configNotes , self-describing runners. A runner jots how it's wired (scheduler, cadence, script and log paths) once; every register echoes it back. A disk-less wake recovers its own setup context, and
runner.statusshows it to the parent. - Watchlists. A loop can watch specific kid tasks (
loop.track/loop.untrack), so its surface shows what it's responsible for and its trends count the right work. - Parent controls. The parent can
pause,resume, orretireany loop from the app , no agent round-trip. A paused loop drops out of the due-pull; the runner simply stops being handed it.
The error contract
Loop refusals are in-band and render-always. The adopt door (skill.invoke) always renders the recipe; when the mint can't proceed, the refusal rides the response's loop{} block as an error code plus an instructions string , never a thrown exception. Your agent steers off the structured field (e.g. existingLoopId), not by parsing prose.
| Adopt-door code | Meaning | What to do |
|---|---|---|
LOOP_CAP_REACHED | The family is at its active-loop cap. | Retire or pause another loop, or ask the parent. |
KID_ALREADY_LOOPED | This kid already has a live loop on this skill, with different inputs. Carries existingLoopId. | Invoke with existingLoopId to run the existing loop, or loop.retire it before adopting with new inputs. |
KID_INPUT_REQUIRED | The skill declares a kid/kids input, other inputs arrived, but no kid did. | Re-invoke with kid set to one of the family's children. |
KID_UNRESOLVED | A provided kid token matches no child in the family by id or name. | Retry with a valid child id or name. |
The run phase has its own in-band refusals: ALREADY_CLAIMED (someone else holds the lease , skip it), LEASE_NOT_HELD (your lease expired before submit , nothing recorded; re-claim if the loop is still live), and LOOP_RETIRED (the loop was tombstoned while you ran , discard the work, drop it from your schedule, don't re-claim). Same rule throughout: the code names the fix.
Operating a runner
- Wake at least every 10 minutes. Loops run on their own cadences; a frequent wake lets each loop decide when it is due rather than pinning everything to one schedule.
loop.listDueis the cheap due-gate. It returns only what is actually past due, so waking often is nearly free , an empty wake does almost no work but still stamps your presence.- The presence window is 2 hours. Stamp presence at least that often (any
listDue,register, orcheckIndoes it) or your dot goes red. If your cron dies, that red dot is exactly what tells the parent.
Ready to build one? Autonomous loop , Engine B is the copy-paste recipe.
Further reading
Autonomous loop Reactive loop Home Agent Protocol Heartbeat