DEV Community

Cover image for Give Your Agent Loop a Heartbeat: Scheduled Automation Without the Runaway Bill
ShipWithAI
ShipWithAI

Posted on Originally published at shipwithai.io

Give Your Agent Loop a Heartbeat: Scheduled Automation Without the Runaway Bill

TL;DR — A loop earns its name when it runs on a cadence you didn't trigger. Three heartbeats: in-session /loop (you're at the keyboard), cloud Routines (cron, laptop closed), and CI (GitHub Actions). Pick by where the work lives and who needs to be awake. Then: route findings to an inbox a human reads, and make the empty run nearly free — because on a daily cadence, the empty run is the common case.

Fourth post in my **Loop Engineering* crosspost series here — it summarizes Part 6 of the original series on ShipWithAI. (I'm jumping ahead: Parts 4 and 5 over there cover state files and worktrees, and this one builds on both. Links at the bottom.)*

Read the full article →


Three heartbeats

Heartbeat Trigger Laptop state Best fit
In-session /loop A prompt re-run on an interval Open, you're at the keyboard An attended sweep you babysit
Cloud Routines 5-field cron on Anthropic infra (daily cap on runs started) Closed The true overnight or morning cadence
CI (GitHub Actions) on: schedule or push in a workflow Off (runs on a runner) When the work is the repo and the team lives in CI

The schedule is the easy half. A cron job that runs a fixed script is just cron; a cron job that runs a decision-maker is a loop. Pick the heartbeat by who needs to be awake: you, no one, or the CI runner.

The article opens with Matt Van Horn's line: a loop is cron plus a decision-maker in the body. The schedule is the trivial half. Each run reads state, decides what to do, and updates that state, so the next run continues where this one stopped instead of repeating a static command.

One planning detail that's easy to miss: cloud Routines carry a daily cap on how many runs can start. If your cost model assumes you can just dial frequency up, that cap is the ceiling.


Hook or schedule?

Trigger type Fires when Example Hook or schedule
Lifecycle Something happens in a session A tool runs; a stop is attempted Hook
Time The clock ticks, regardless 06:00 weekdays; every push Schedule

A hook is a reflex; a schedule is a heartbeat. Reflexes fire when something happens. A heartbeat fires whether or not anything did, which is exactly why a scheduled loop needs the triage inbox next.

Many loops use both: a schedule wakes the loop, hooks govern each turn inside it.


The triage inbox — the part that keeps this safe

scheduled run
  |
  |-- findings?  -->  inbox (state file / issue / PR)  -->  human decides  --> merge
  |                        (the loop never merges its own findings)
  |
  '-- nothing?   -->  archive "nothing today"  -->  exit cheap
Enter fullscreen mode Exit fullscreen mode

An unattended loop is allowed to find work and allowed to do the boring parts. It is never allowed to decide what ships. The inbox is where the loop stops and you start.

The article calls auto-merge "the cardinal sin of unattended automation," while flagging that the inbox pattern is its own synthesis rather than a cited standard — it just considers it the single most important discipline in the post. Discovery is automated; the decision stays human.


The morning triage loop

Two failing tests in yesterday's CI — a real pytest suite, two genuine independent failures (slugify leaving a trailing hyphen, load_port returning a string instead of an int) — plus three open issues, two of which map to those failures.

The schedule, as a GitHub Actions workflow:

name: morning-triage
on:
  schedule:
    - cron: "0 6 * * 1-5"   # 06:00 on weekdays, the heartbeat
  workflow_dispatch: {}
jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run morning triage (discover, write inbox, open lanes)
        run: python3 triage/triage.py --budget-runs 1 --max-lanes 2
Enter fullscreen mode Exit fullscreen mode

Or the same beat as a plain crontab line:

0 6 * * 1-5 cd /home/you/p6-triage && /usr/bin/python3 triage/triage.py --max-lanes 2 --budget-runs 1 >> $HOME/.morning-triage.log 2>&1
Enter fullscreen mode Exit fullscreen mode

Now the honest part, and read it before the output: nothing scheduled actually fired. The sandbox has no cron daemon, so the cron line — the real artifact you'd install — was never triggered by cron. Instead the exact triage step the cron line invokes was run directly, which is what the heartbeat executes either way. Same for the Actions workflow: not a cloud-executed run.

So, the triage step invoked by hand — note what it refuses to do:

$ python3 triage/triage.py --max-lanes 2 --budget-runs 1
TRIAGE: 3 findings; opened 2 lanes; 1 left in inbox; passed=0 (never auto-merge)

$ git worktree list
/home/you/p6-triage        242b6dc [master]
/home/you/wt-finding-14    242b6dc [triage/finding-14]
/home/you/wt-finding-15    242b6dc [triage/finding-15]
Enter fullscreen mode Exit fullscreen mode

Ranking logic: CI-red bugs above issue-only, bugs above features, top two get lanes. Finding #19 (a feature request) stays in the inbox with no lane.

And the resulting state file, in Part 4's tried/passed/open/blocked schema:

# Loop State: morning triage (Part 4 schema)

## tried
- read CI: 2 failing test(s); read issues: 3 open
- opened 2 worktree lane(s) for the top 2 finding(s)

## passed
- (none; triage never merges; a human decides what ships)

## open
- [#14 CI-red: tests/test_slugify.py::test_trailing_hyphen] lane opened: /home/you/wt-finding-14 on triage/finding-14 (awaiting human)
- [#15 CI-red: tests/test_config.py::test_port_is_int] lane opened: /home/you/wt-finding-15 on triage/finding-15 (awaiting human)
- [#19 [feature] add a --version flag to the CLI] in inbox, no lane (below top-2)

## blocked
Enter fullscreen mode Exit fullscreen mode

The ## passed section being empty is not a bug. It's the design.

The morning loop is the whole series in one run: a schedule wakes it (Part 6), a stop condition bounds it (Part 3), the findings land in the state file (Part 4), and the top two get worktree lanes (Part 5). The cadence is the only new part.

To restate the caveat plainly: this is a mechanism proof. Neither a live cloud-Routine nor a cloud-executed Actions run is cleanly capturable headlessly — the same limit documented in the Part 3, 4, and 5 traces. And the decision-maker here is deterministic ranking logic, not an LLM agent turn, so nothing was instrumented and there's no token or cost figure to report.


The empty run is your real cost

Here's the clean-morning case:

$ python3 -m pytest -q
..                                    [100%]
2 passed in 0.00s

$ python3 triage/triage.py --max-lanes 2 --budget-runs 1
EMPTY RUN: 0 findings -> archived 'nothing today', exit cheap
Enter fullscreen mode Exit fullscreen mode

On a daily cadence, the empty run is the common case, so the empty run is your real cost. Cap every run and make "nothing today" nearly free, or the heartbeat quietly drains the budget while you sleep.

Four kill switches, bounding every run rather than just the whole job:

Kill switch What it bounds Failure it prevents
--max-runs Iterations per invocation An infinite re-run loop
--max-cost Dollars per run A runaway bill overnight
--max-duration Wall-clock per run A hung run that never exits
--stall-threshold Iterations with no progress Spinning without converging

Prior art worth a look

  • nightcrawler — autonomous overnight research loop, episodic execution with a per-run episode budget
  • agentics — frames "Continuous AI" as GitHub Actions workflows
  • Tmux-Orchestrator — self-scheduling agents; dormant since mid-2025, but instructive

Try it this week

Pick your beat by who needs to be awake. Write the discovery step. Then — before you schedule anything — write the inbox, and confirm your loop has no path to merge.

Then time the empty run and multiply by your cadence — weekdays at 06:00 is ~261 runs a year. A daily cadence multiplies cost by frequency, and the empty run is what you're multiplying.


This is a condensed summary. The full article has the step-by-step build, worktree cleanup, and the FAQ on cron vs Routines vs CI:

👉 Scheduled Agent Automation: Give Your Loop a Heartbeat — Part 6, ShipWithAI

Read the two I skipped first if you want the full build — this post leans on both: Part 4 — State File Pattern (the tried/passed/open/blocked schema) and Part 5 — Git Worktrees for Agents (the lanes).

Earlier in the series: Part 1 — Why You Should Stop Prompting · Part 2 — Anatomy of a Loop · Part 3 — Stop Conditions

Next up: the journey log of retrofitting all of this onto a real, messy first loop — the start of the practice phase.

Top comments (0)