Autonomous loop
A skill on a heartbeat fires the cycle daily. You intervene through Sprout's in-app agent when you want a different call.
What you'll build
One pattern: a cycle that reads state, decides, and acts , on a schedule, without you in the loop. Two common shapes:
- Ship more work to the kid: a planner authors tomorrow's tasks based on yesterday's analysis.
- Publish a report or brief on cadence: pair with Reports and briefs to fire the analyzer every morning, every Sunday, every end-of-session.
The pattern runs on one of two engines. Same read-decide-act shape; different thing turning the crank. Pick the engine first, then build.
Pick your engine
| Sprout-run heartbeat | Self-run runner + loop | |
|---|---|---|
| Infra you own | None , Sprout fires it on schedule. | A machine you keep running, plus one cron line that wakes your agent. |
| Hands available | Sprout tools only. The heartbeat executor is sandboxed: no canvas.update, no network, no files. | Anything your machine can do , rewrite a canvas, call an external API, read and write local files. |
| Cadence | Cron, capped at 4 fires / 24h. | Your own clock , wake as tight as every 10 min; loop.listDue gates what actually runs. |
| Parent visibility | The heartbeat shows in the app; each fire can post to the family inbox. | The parent sees your runner's presence dot and the loop's run ledger in the app's Loops list. |
Engine A , Sprout-run heartbeat
Sprout owns the clock. You author a run skill and hand Sprout a cron; it fires the skill on schedule inside its own sandbox. Two movements, then you intervene through Sprout's in-app agent when you want a different call.
1. Author the run skill that does one cycle (read, decide, act).
skill.write({
name: "<Cycle name>",
description: "What one fire of the loop does.",
category: "home_agent",
prompt: `For {{input.child_name}} ({{input.child_id}}):
1. Read state (task.list, the latest analysis card, etc).
2. Decide what to do next.
3. Act: task.create, skill.invoke, skill.post_result.`,
handsReferenced: ["task_list", "skill_invoke"],
inputVariables: [{ name: "child_name" }, { name: "child_id" }]
})
# Returns { skillId: "<runSkillId>" }2. Schedule it with heartbeat.create.
heartbeat.create({
name: "<Loop name>",
cron: "0 9 * * *", // daily at 9am
tz: "America/New_York",
runSkillId: "<runSkillId>",
skillInput: { child_name: "<name>", child_id: "<kidId>" }
})
# Returns { heartbeatId, chatId, nextFireAt }
# Cadence cap: 4 fires per 24h enforced server-side.3. Intervene through Sprout's agent when you want a different call.
Sprout's in-app agent calls task.update or task.delete on your behalf based on your natural-language request.
Engine B , self-run runner + loop
You own the clock. Your agent runs on your machine, wakes on your cron, and does the work Sprout's sandbox can't , rewrite a canvas, call an outside API, touch a file. Sprout supplies the identity, the mutual exclusion, and the audit trail, so two machines never double-run and the parent can watch it work. Full model: Loop protocol.
1. Author the loop skill. Same as a run skill, but mode: 'loop' with a durable goal , the one thing this loop keeps true on every wake.
skill.write({
name: "<Cycle name>",
category: "home_agent",
mode: "loop",
goal: "The one thing this loop keeps true on every wake.",
prompt: `For {{input.kid}}:
1. Read state.
2. Decide what to do next.
3. Act through the normal gated verbs.`,
inputVariables: [{ name: "kid" }]
})
# Returns { skillId }
# mode:'loop' requires category:'home_agent'.
# Name an input 'kid' (or 'kids') to get kid-lifecycle protections.2. Adopt it with skill.invoke to mint the loop.
skill.invoke({ skillId: "<loopSkillId>", input: { kid: "Ada" } })
# Mints the loop; the response carries a loop{} block:
# { loopId, runners: [ ... ], instructions }
# ONE live loop per (skill, resolved kid). Re-adopting the same kid + inputs
# returns the same loop; different inputs is refused KID_ALREADY_LOOPED
# (the block carries existingLoopId). No runner is bound yet , your first
# claim self-heals the binding.3. Schedule your wake. One cron line , as tight as every 10 minutes. Each wake registers the runner, pulls what's due, claims it, does the work, and closes the ledger.
# wake every 10 minutes
*/10 * * * * /usr/bin/env my-agent run-sprout-loops >> ~/sprout-runner.log 2>&1launchd agent with StartInterval 600 instead , it catches up on wake. Same 10-minute cadence, survives sleep.runner.register({ handle: "mac-mini-cron", agentKind: "claude-code" })
# โ { runnerId, staleAfterSeconds, configNotes }
# Idempotent: the same handle returns the same runnerId every wake, so a
# disk-less cron job recovers its identity.
loop.listDue({ runnerId })
# โ { due: [ { loopId, ... } ], ... }
# The cheap due-gate: returns ONLY loops past nextDueAt. Empty? End the wake.
# For each due loop:
loop.claim({ loopId, runnerId })
# โ { leaseId, skillId, inputs, instructions }
# Single-winner lease , the loser of a race gets ALREADY_CLAIMED.
skill.invoke({ loopId })
# Renders the recipe with the loop's bound inputs. Do the work through the
# normal gated verbs (task.create, canvas.update, gems.adjust, ...).
loop.submitResult({
loopId, leaseId,
result: { status: "success", stableHash: "<hash>", changeLog: { summary: "..." } },
nextDueAt: "<iso>" // omit to let the server floor it to the cadence minimum
})
# ALWAYS close , submit is a control-plane ledger write, never a kid-facing
# action. As an unattended runner, never skip it and never pause to ask.configNotes on runner.register once to record how this runner is wired (scheduler, wake cadence, script and log paths). Every future register echoes it back, so a disk-less wake recovers its own setup context. Omit it on later registers to keep the stored note; pass it to replace.The adopt door always renders , even a refusal (loop cap hit, this kid already loops on this skill) comes back in-band in the loop block, never as a thrown error, carrying an instruction that tells your agent what to do. The error contract lists each code.
When to use it
- The procedure needs to run on its own clock.
- The output isn't the same every time (otherwise a recurring task with
scheduleSpec.dayswould suffice). - Reach for a runner loop when the cycle needs hands Sprout's sandbox doesn't have (a canvas rewrite, an outside API, a local file) or a cadence tighter than 4/24h. Otherwise a heartbeat is less to own.
- Anti-pattern: fixed-content recurrence (same prompt every weekday at 5pm). Use a recurring task instead.
Tools touched
- Heartbeat engine:
heartbeat.create,heartbeat.update,heartbeat.describe. - Runner engine:
skill.write {mode:'loop'},skill.invoke(adopt + render),runner.register,loop.listDue,loop.claim,loop.submitResult. See the Loop protocol. skill.invoke(inside the run skill).task.update,task.delete(via Sprout's agent on parent intervention).
Recommended skills
Drop these into your library to compose with this pattern.
Seen in walkthroughs
Solar system learning loop Weekly family brief Chore + photo proof
Related patterns
- Loop protocol: the actor model, verb lifecycle, and error contract behind Engine B.
- Reports and briefs: the artifact pattern most commonly cadenced by this loop.
- Reactive loop: the push twin. Sprout events trigger the cycle instead of a cron.