Why Paperclip uses a scheduled heartbeat (pull) rather than event-driven webhooks (push), the tradeoffs in latency vs. simplicity, and the guarantee that no two heartbeats for one agent overlap.
The mental model behind the heartbeat system, why Paperclip chose a cron-based pull model instead of event-driven push, and what happens inside one heartbeat tick.
The heartbeat is a scheduled job — a clock tick — that wakes up every N minutes and asks: "Is there any open work? If so, dispatch agents to handle it."
Every 5 minutes:
┌─ Is company paused? → skip
├─ Is budget exceeded? → pause company, skip
├─ Find open issues per active agent
│ ├─ Claim one issue per agent (atomic checkout)
│ ├─ Invoke adapter (LLM call or script)
│ └─ Record result + cost
└─ Log heartbeat run stats
This is a pull model: agents don't run continuously; they are awakened by the clock. The work queue is the source of truth, not in-memory state.
| Pull (heartbeat) | Push (event-driven) |
|---|---|
| Simple: one cron job | Complex: message broker, event bus |
| Durable: work survives restarts | At-risk: unprocessed events lost on crash |
| Observable: every run logged | Hard to trace: events flow through brokers |
| Rate-limited by design | Risk of thundering herd |
| Slightly higher latency (max: tick interval) | Low latency |
For autonomous background agents, latency of a few minutes is acceptable. Durability and observability matter more.
T=0:00 Cron fires → POST /internal/heartbeat/tick
T=0:00 Lock company row (SELECT FOR UPDATE)
T=0:00 Check: paused? over budget? → continue
T=0:01 Query open issues for each active agent
T=0:01 For each issue found:
Atomic checkout (UPDATE...RETURNING)
Assemble prompt (system + task)
Invoke adapter
T=0:30 Adapter returns (LLM call took ~29s)
T=0:30 Update issue: status=resolved, result=output
T=0:30 Insert cost_event
T=0:31 Update heartbeat_run: agents=1, issues=1, cost=$0.02
T=0:31 Unlock company
T=5:00 Next tick fires
An agent is eligible for the heartbeat tick if:
- status = 'active'
- monthly_spend_this_month < monthly_budget_usd
- Company is not paused
If two heartbeat ticks overlap (e.g. the cron runs again before the first tick finishes), the atomic checkout prevents double-work. The second tick claims different issues or skips if none are open.