Skip to content

Heartbeat

Step 7 · Event-Driven Loops

The heartbeat stops being a clock and becomes the world: a PR opens, a release tags, a webhook fires — and a beat answers it. Fastest cadence, sharpest…

The heartbeat stops being a clock and becomes the world: a PR opens, a release tags, a webhook fires — and a beat answers it. Fastest cadence, sharpest edges.

The hook

A contributor opens a PR at 2:47 pm. By 2:48 the loop has read the diff, run the lint, and left one comment on the exact line that breaks the style guide. The contributor pushes a fix. The synchronize event fires. The loop re-checks and resolves its own comment. No clock did any of that — the PR did.

Event-driven (plain English)

An event-driven loop beats when something happens, not when time passes. The three trigger families you'll actually use:

  • SCM events — PR opened, PR updated (the synchronize event — every new push to an open PR), release published, issue labeled. This is the workhorse family.
  • Channel messages — a chat message or mention wakes a running session. Powerful, and the one to be careful with. Anyone who can post in the channel is now holding your loop's trigger, so treat inbound text as untrusted input, never as commands.
  • Direct API fire — an HTTP endpoint (a "/fire"-style call) that starts a beat. This is how your own systems become heartbeats.

The rule that keeps this whole family safe is the same one from Step 4, just with higher stakes: events are dropped, not queued. Burst past the cap — twenty PRs land in a minute — and some events simply never become beats. So an event-driven loop always ships with a reconciliation sweep: a slow scheduled loop (Step 6) that periodically asks "what did the events miss?" and quietly closes the gaps.

Diagram

The mechanics in each tool

Each tool binds a beat to SCM events, channels, or an API fire. Note where the "untrusted input" and reconciliation reminders land:

# Claude Code — live docs: https://docs.claude.com/en/docs/claude-code
# SCM events: the GitHub integration answers PR/release events (incl.
#   synchronize on every push to an open PR).
# Channels: a chat channel can wake a running session — treat every
#   inbound message as untrusted; permissions stay tight.
# API: fire a beat from your own systems via the platform's HTTP
#   trigger endpoint. Caps apply: dropped, not queued → pair every
#   event loop with a scheduled reconciliation sweep (Step 6).
# OpenCode — live docs: https://opencode.ai/docs
# GitHub events via the official integration:
#   opencode github install    # sets up the Actions-based event hook
# …or hand-rolled with Actions:
#   on: { pull_request: { types: [opened, synchronize] } }
#   jobs: { review: { steps: [ run: opencode run "Review the diff…" ] } }

[!NOTE] Going deeper: the four heartbeats you now know — interval (4), conditional (5), schedule (6), event (7) — are a menu, not a ladder. Real fleets mix them freely: this repo pairs run-until-done makers with scheduled checkers (see LOOP.md). Choosing among them is the pattern-picker's job in methods.

Check yourself

Q: Your PR-review loop worked perfectly for a month, then silently skipped three PRs during Friday's release rush. Nothing errored. What happened, and what was missing from the design?

Answer

The burst blew past the event cap and the overflow events were dropped, not queued — no error, because nothing actually failed. The beats simply never existed. What was missing is the reconciliation sweep: a slow scheduled loop asking "any open PRs without a review from me?" It exists for exactly this — turning silent gaps into caught gaps.

Try With AI

Wire the simplest event loop you can in a throwaway repo: a GitHub Action on pull_request that runs your agent in report-only mode ("comment a one-line summary of the diff — take no other action"). Open a PR, push twice, and watch synchronize fire the beat again. Then write, on paper, the reconciliation sweep this loop would need in production.

When it goes wrong

SymptomCauseFix
Events silently skipped during burstsDropped-not-queued past the capReconciliation sweep on a schedule; design for gaps as normal
Loop argued with itself on its own PR updateIts push fired synchronize on its own comment/commitGuard clause: ignore events caused by the loop's own identity
Stranger in the channel steered the loopChannel = public trigger, input trustedTreat messages as untrusted; allowlist who can trigger; keep L1
Webhook retries produced a runaway heartbeatRetrying trigger + non-idempotent beatIdempotent beats keyed on event id; dedupe against the spine

Glossary terms used on this page: event-driven, synchronize, reconciliation sweep, idempotent — see the glossary.

Sources: event triggers, channels, and the dropped-not-queued rule come from Panaversity's Loop Engineering: A Crash Course (S1) and Scheduled Tasks: The Loop Skill & Cron Tools (S4). Full attribution: resources/sources.md.