Build

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:

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 heartbeatSelf-run runner + loop
Infra you ownNone , Sprout fires it on schedule.A machine you keep running, plus one cron line that wakes your agent.
Hands availableSprout 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.
CadenceCron, capped at 4 fires / 24h.Your own clock , wake as tight as every 10 min; loop.listDue gates what actually runs.
Parent visibilityThe 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.
tips_and_updatesRule of thumb. If the cycle only calls Sprout tools, use a heartbeat and own nothing. The moment it needs to rewrite a canvas, hit an external API, or touch a file, it's a runner loop.

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).

Shell
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.

Shell
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.

Parent app ยท Sprout agent
You: Cancel tomorrow morning's planned task. Move the journal to Saturday.
Sprout: Got it. Canceling the task and moving the journal to Saturday at 8am.

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.

Shell
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.

Shell
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.

crontab
# wake every 10 minutes
*/10 * * * * /usr/bin/env my-agent run-sprout-loops >> ~/sprout-runner.log 2>&1
infoOn macOS, a laptop that sleeps skips cron fires. Use a launchd agent with StartInterval 600 instead , it catches up on wake. Same 10-minute cadence, survives sleep.
Each wake
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.
tips_and_updatesconfigNotes , jot once, echoed always. Pass 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

Tools touched

Recommended skills

Drop these into your library to compose with this pattern.

Morning brieftemplateNext-session plannertemplate

Seen in walkthroughs

Solar system learning loop Weekly family brief Chore + photo proof

Was this page helpful?