The Scheduled Loop
Read19 min · Remember → Retrieve → Act → Report · Updated 2026-09-13
You end this lesson with: A heartbeat that reports when something matters and stays silent otherwise.
Chapters
- The heartbeat, not a cron of reports01
- What to check, and how to bound it02
- State between beats: last-seen markers and dedupe keys03
- Notification fatigue is the failure mode that kills these systems04
- The escalation ladder05
- Wiring the beat to a chat surface06
Lesson 06. The Scheduled Loop
You wire the agent to run every 15 minutes: check the calendar, check the inbox, check GitHub notifications, report anything worth knowing. By the end of day one it has pushed 40 notifications to your phone. "Checked calendar, nothing new." "Checked inbox, nothing new." "Checked GitHub, one comment on a closed issue, probably not important." By day two you've muted the channel. By day three you've forgotten the agent is running at all, which means the one time it actually finds something (a meeting moved an hour earlier) you don't see it either, because the signal is buried under three days of noise you already trained yourself to ignore.
The bug here isn't in any individual check. Each one, read on its own, was arguably correct: the calendar really was unchanged, the inbox really was empty, the closed-issue comment really was low-priority. The bug is architectural: nothing in the loop asked "is this actually worth telling a human," so every check reported, every time, regardless of whether anything changed. A scheduled loop that reports on every beat trains you to stop reading it. The only fix is a loop designed so that silence is the default output, and something has to earn its way past that default.
The heartbeat, not a cron of reports
A heartbeat is a run that fires on a schedule and does a miniature version of the full Remember → Retrieve → Act → Report loop: it reads relevant memory, checks whatever sources it's responsible for, decides whether anything crossed a threshold worth a human's attention, and (this is the part the opening scene skipped) usually decides no and reports nothing at all.
"Usually reports nothing" is not a degraded mode of the heartbeat. It's the intended, default output. A heartbeat that only ever has two outcomes ("here's an update" or "here's an update saying nothing changed") has already failed, because both outcomes cost you the same attention, and only one of them was worth it. The decision function's job is not "summarize what I found." It's "decide whether this beat has anything that clears the bar for interrupting a human," and act accordingly.
What to check, and how to bound it
A heartbeat is only as safe as the checks inside it are bounded. An unbounded check can hang the whole beat, or blow through a token/cost budget on a single run, taking down every other check scheduled after it.
Every check needs, at minimum:
- A scope: what it looks at (unread items since the last beat, not the whole inbox; open issues updated in the last hour, not every issue ever filed).
- A timeout: a hard wall-clock limit per check, so one slow source doesn't stall the whole beat.
- A cost ceiling: a cap on tokens or API calls spent per check, so a check that goes wrong (an inbox with 500 new items after a weekend away) degrades gracefully instead of consuming the run's entire budget.
- A failure mode that doesn't crash the beat: one check erroring should not prevent the other checks from running or the beat from completing. Log the failure, skip that check, move on.
interface Check {
name: string;
scope: string; // human-readable description of what this check looks at
timeoutMs: number;
maxItems: number;
run: (state: BeatState) => Promise<CheckResult>;
}
Treat every check like an untrusted subroutine, even the ones you wrote yourself last week. Bounding is what makes it safe to add a tenth check without worrying it'll take the other nine down with it.
State between beats: last-seen markers and dedupe keys
A heartbeat that starts from zero every run will re-report the same item forever: the calendar event you already saw at 9:00 shows up again at 9:15 because nothing recorded that you'd already seen it. State between beats is what prevents this, and it needs exactly two things.
A last-seen marker per source: a timestamp or cursor recording how far the last successful beat got. The next beat's scope for that source starts from the marker, not from the beginning of time. If the calendar check's marker is 2026-08-19T09:00:00Z, the next beat only asks "what changed since then."
A dedupe key per reportable item: a stable identifier (an event ID, a hash of the item's content) stored once it's been reported, checked before reporting anything else. The marker narrows the window; the dedupe key catches the case where the same item shows up again inside that window (a calendar event that gets edited twice before the meeting, an email thread that gets a second reply).
Notification fatigue is the failure mode that kills these systems
There is a real, well-understood mechanism behind why unfiltered heartbeats stop working, and it's worth naming directly rather than treating it as a vague inconvenience: every notification that turns out not to matter reduces the probability you'll attend to the next one. This compounds: a channel with a bad signal-to-noise ratio doesn't just get ignored occasionally, it gets ignored by default, including on the one beat where something genuinely needed you. The system that "reports everything to be safe" is, in practice, less safe than one that reports rarely and correctly, because the first one trains its own audience to stop listening.
The design consequence: treat the notification budget as a scarce resource you spend deliberately, not a free side effect of running a check. Every notification should be able to answer "why did this clear the bar that the last 50 silent beats didn't."
The escalation ladder
Not every finding deserves the same channel. A three-level ladder keeps the notification budget spent where it matters:
- Log: every beat, every check, writes a line to a local log file regardless of outcome. This is always-on, cheap, and never interrupts anyone. It's what you read when you go looking, not what finds you.
- Digest: findings that are worth knowing but not worth an interruption get batched into a periodic summary (once a day, once a week) instead of pushed immediately. Low-priority GitHub notifications, minor calendar shuffles.
- Ping: an immediate push notification, reserved for findings that cross an explicit, named threshold: a meeting moved with less than an hour's notice, a production alert, an inbound message flagged urgent by its own sender. If you can't name the threshold a finding crossed, it doesn't belong at this level.
Every finding takes exactly one of these three paths, decided once, at the moment it's found:
Log is the default every finding gets for free; digest and ping are earned, one rung at a time, by something the finding itself crossed.
Wiring the beat to a chat surface
A heartbeat that only writes to a local log is invisible unless you go looking. Wiring the ping tier to a chat surface (a webhook that posts to wherever you actually read messages) closes the loop: you get pinged where you already are, and you can reply from anywhere without opening a laptop. The mechanics are simple and deliberately dumb: the heartbeat POSTs a short message to a webhook URL when a finding hits the ping tier; a lightweight poll loop (or a second, much shorter-interval heartbeat) checks that same surface for replies and feeds them back into the agent's next action. Keep this channel one-directional in a hurry and two-directional only once you trust the guardrails in Lesson 07: a chat surface that can trigger writes back into your systems is itself part of the blast radius.
Key Takeaways
- A heartbeat runs a miniature Remember → Retrieve → Act → Report loop on a schedule. Its default output is silence: reporting nothing is success, not a degraded mode.
- Every check needs a scope, a timeout, a cost ceiling, and a failure mode that doesn't take down the rest of the beat.
- State between beats needs both a last-seen marker (narrows scope) and a dedupe key (catches re-reported items inside that scope).
- Notification fatigue compounds: every notification that didn't matter reduces attention to the next one, including the one that does. Treat the notification budget as scarce.
- A three-rung escalation ladder (log, digest, ping) spends that budget deliberately instead of by default.
- Wire only the ping tier to a chat surface at first; keep it one-directional until Lesson 07's guardrails are in place.
What You Have Now
A heartbeat that reports when something matters and stays silent otherwise: a runnable script with a decision function, a persisted state file tracking last-seen markers and dedupe keys per source, and a three-rung escalation ladder wired to a log file, a daily digest, and an immediate push.
Example Files
heartbeat.ts
The complete heartbeat: bounded checks, decision logic, state persistence, and the escalation ladder. Run with bun run heartbeat.ts, schedule it with cron or Task Scheduler (Lesson 08 covers unattended deployment in full).
#!/usr/bin/env bun
// heartbeat.ts
//
// One beat of the standing agent's scheduled loop. Runs bounded checks,
// decides what (if anything) crossed a reporting threshold, dedupes
// against state from prior beats, and routes findings through the
// escalation ladder: log (always) -> digest (batched) -> ping (immediate).
import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs";
const STATE_PATH = "./heartbeat-state.json";
const LOG_PATH = "./heartbeat.log";
const DIGEST_PATH = "./digest-queue.jsonl";
const PING_WEBHOOK = process.env.PING_WEBHOOK_URL; // never hardcode this
interface BeatState {
lastSeen: Record<string, string>; // source name -> ISO timestamp cursor
seenKeys: Record<string, string[]>; // source name -> dedupe keys already reported
}
interface Finding {
source: string;
dedupeKey: string;
summary: string;
tier: "log" | "digest" | "ping";
}
interface CheckResult {
findings: Finding[];
newLastSeen: string;
}
interface Check {
name: string;
timeoutMs: number;
maxItems: number;
run: (state: BeatState) => Promise<CheckResult>;
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
function loadState(): BeatState {
if (!existsSync(STATE_PATH)) return { lastSeen: {}, seenKeys: {} };
return JSON.parse(readFileSync(STATE_PATH, "utf8"));
}
function saveState(state: BeatState): void {
writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
}
function alreadySeen(state: BeatState, source: string, key: string): boolean {
return (state.seenKeys[source] ?? []).includes(key);
}
function markSeen(state: BeatState, source: string, key: string): void {
const keys = state.seenKeys[source] ?? [];
keys.push(key);
// keep the last 500 keys per source -- bounded, not unbounded growth
state.seenKeys[source] = keys.slice(-500);
}
// ---------------------------------------------------------------------------
// Checks -- replace these bodies with your real sources (calendar, inbox,
// the Lesson 04 tool wrapper for GitHub, etc). Each one is bounded.
// ---------------------------------------------------------------------------
const checks: Check[] = [
{
name: "calendar",
timeoutMs: 5_000,
maxItems: 20,
async run(state) {
const since = state.lastSeen.calendar ?? new Date(0).toISOString();
// Stand-in for a real call: replace with your calendar tool wrapper.
const events = await fetchCalendarEvents({ since, limit: 20 });
const findings: Finding[] = [];
for (const ev of events) {
if (alreadySeen(state, "calendar", ev.id)) continue;
const minutesUntil = (Date.parse(ev.start) - Date.now()) / 60_000;
findings.push({
source: "calendar",
dedupeKey: ev.id,
summary: `${ev.title} at ${ev.start}`,
// a meeting moved to under an hour's notice is a named threshold
tier: minutesUntil < 60 && minutesUntil > 0 ? "ping" : "log",
});
}
return { findings, newLastSeen: new Date().toISOString() };
},
},
{
name: "github",
timeoutMs: 8_000,
maxItems: 30,
async run(state) {
const since = state.lastSeen.github ?? new Date(0).toISOString();
const notifications = await fetchGithubNotifications({ since, limit: 30 });
const findings: Finding[] = [];
for (const n of notifications) {
if (alreadySeen(state, "github", n.id)) continue;
findings.push({
source: "github",
dedupeKey: n.id,
summary: `${n.repo}: ${n.title}`,
// named threshold: only direct mentions or requested reviews ping
tier: n.reason === "mention" || n.reason === "review_requested" ? "ping" : "digest",
});
}
return { findings, newLastSeen: new Date().toISOString() };
},
},
];
// ---------------------------------------------------------------------------
// Escalation ladder
// ---------------------------------------------------------------------------
function logFinding(f: Finding): void {
const line = JSON.stringify({ ts: new Date().toISOString(), ...f });
appendFileSync(LOG_PATH, line + "\n");
}
function queueForDigest(f: Finding): void {
appendFileSync(DIGEST_PATH, JSON.stringify(f) + "\n");
}
async function pingNow(f: Finding): Promise<void> {
if (!PING_WEBHOOK) {
console.error(`PING_WEBHOOK_URL not set -- would have pinged: ${f.summary}`);
return;
}
await fetch(PING_WEBHOOK, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: `[${f.source}] ${f.summary}` }),
});
}
async function route(finding: Finding): Promise<void> {
logFinding(finding); // every finding is always logged
if (finding.tier === "digest") queueForDigest(finding);
if (finding.tier === "ping") await pingNow(finding);
}
// ---------------------------------------------------------------------------
// Run one beat
// ---------------------------------------------------------------------------
async function runCheck(check: Check, state: BeatState): Promise<CheckResult | null> {
try {
const result = await Promise.race([
check.run(state),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("timeout")), check.timeoutMs),
),
]);
return result;
} catch (err) {
appendFileSync(LOG_PATH, `${new Date().toISOString()} ERROR ${check.name}: ${err}\n`);
return null; // one check failing never stops the beat
}
}
async function runBeat(): Promise<void> {
const state = loadState();
let reported = 0;
for (const check of checks) {
const result = await runCheck(check, state);
if (!result) continue;
for (const finding of result.findings.slice(0, check.maxItems)) {
if (alreadySeen(state, finding.source, finding.dedupeKey)) continue;
markSeen(state, finding.source, finding.dedupeKey);
await route(finding);
reported += 1;
}
state.lastSeen[check.name] = result.newLastSeen;
}
saveState(state);
appendFileSync(
LOG_PATH,
`${new Date().toISOString()} beat complete, ${reported} finding(s) routed\n`,
);
}
// Stand-ins -- replace with real calls (Lesson 04 tool wrapper, calendar API).
async function fetchCalendarEvents(_opts: { since: string; limit: number }): Promise<
{ id: string; title: string; start: string }[]
> {
return [];
}
async function fetchGithubNotifications(_opts: { since: string; limit: number }): Promise<
{ id: string; repo: string; title: string; reason: string }[]
> {
return [];
}
runBeat();
heartbeat-state.example.json
The shape of the persisted state file after a few beats: last-seen markers per source, and a bounded window of dedupe keys per source.
{
"lastSeen": {
"calendar": "2026-08-19T09:15:00.000Z",
"github": "2026-08-19T09:15:00.000Z"
},
"seenKeys": {
"calendar": ["evt_10221", "evt_10238"],
"github": ["notif_88213", "notif_88214", "notif_88240"]
}
}
Exercises
- Add a third check to
heartbeat.tsfor a source you actually monitor (an RSS feed, a status page, a shared document). Give it a timeout, amaxItemsbound, and at least one named threshold that decidespingvsdigestvslog. - Run the heartbeat with an empty state file, then run it again immediately without changing anything upstream. Confirm the second run produces zero new findings. If it doesn't, your dedupe keys aren't stable across runs.
- Write down, for each check you have, the one sentence that names why a finding from it deserves
pingrather thandigest. If you can't write that sentence, downgrade the check's ceiling tier until you can.
Check your understanding
Reading is free — your progress isn't being saved
Nothing you did on this page is recorded. Paid students get saved progress and verified completions, recorded quiz answers, the spaced-repetition review queue, labs, and a certificate for each course they finish.
30-day money-back guarantee. One-time payment — lifetime access and updates.