Monitor your AI agent's heartbeat

Get alerted the second an autonomous agent, bot, or worker loop silently stops.

An AI agent is one of the easiest things to lose without noticing. It runs in a loop on a schedule or a long-lived process — polling a queue, running a heartbeat, working a task list. There's often no web server to health-check and nothing to page you when the process crashes, the token expires, the container gets OOM-killed, or the loop quietly wedges. It just... stops. You find out hours later when the work didn't get done.

Cronpulse is a dead-man's-switch for exactly this. Your agent pings a unique URL at the end of every cycle. Cronpulse expects those pings on a schedule you set. If one doesn't arrive in time, we assume the agent is down and alert you on Discord, Slack, or a webhook — and tell you again when it recovers. You don't run any monitoring infrastructure; the agent just makes one HTTP request it already knows how to make.

The 30-second setup

  1. Create a check (below, or on the home page) — no signup needed to start.
  2. Copy your unique ping URL.
  3. Have your agent GET or POST that URL once per cycle, after a successful iteration.
  4. Set the period + grace so a missed heartbeat means "something's wrong," then attach Discord/Slack.

One call: create + wire the alert (no signup)

Pass a webhook_url at creation and the whole thing is set up in a single request — no login, no follow-up call. Discord and Slack webhook URLs are auto-detected; any other https URL receives a generic JSON payload.

curl -X POST https://cronpulse.cronpulse.workers.dev/api/checks \
  -H 'content-type: application/json' \
  -d '{"name":"my-agent","period_seconds":300,
       "webhook_url":"https://discord.com/api/webhooks/.../..."}'
# -> {"id":"...","ping_url":".../ping/<KEY>","alert":{"kind":"discord"}}

Now if the heartbeat goes missing, the DOWN alert lands in your channel — for an account you never had to create.

Python agent loop

import time, urllib.request

PING = "https://cronpulse.cronpulse.workers.dev/ping/YOUR-KEY"

while True:
    do_one_agent_cycle()          # your work
    try:
        urllib.request.urlopen(PING, timeout=10)   # "I'm alive"
    except Exception:
        pass                      # never let the ping crash the agent
    time.sleep(60)

Signal start + failure (optional)

Ping /start when a run begins and /fail if it throws — Cronpulse then tracks run duration and pages you on an explicit failure instead of waiting for the timeout:

import urllib.request
BASE = "https://cronpulse.cronpulse.workers.dev/ping/YOUR-KEY"

def ping(path=""):
    try: urllib.request.urlopen(BASE + path, timeout=10)
    except Exception: pass

ping("/start")
try:
    run_agent_task()
    ping()            # success
except Exception:
    ping("/fail")     # alert immediately
    raise

Catch a "zombie" agent — pinging, but not making progress

A liveness ping only proves the loop is turning, not that it's doing useful work. A drifted or wedged agent can happily run while True: ping() on a timer while its actual task has stalled — report and truth diverge, and a silence-only monitor never sees it.

Cronpulse closes that gap with an optional progress token. Attach a token to each success ping that your agent can't produce without actually advancing — a digest of the run's output, a monotonic cursor, the last processed ID. Turn on Stuck detection for the check (a threshold of N). If the same token repeats for N consecutive pings, the job is pinging on schedule but not progressing, and Cronpulse alerts you — separately from a plain missed-ping "down."

import hashlib, urllib.request, urllib.parse
BASE = "https://cronpulse.cronpulse.workers.dev/ping/YOUR-KEY"

def ping(token=None):
    url = BASE + (("?token=" + urllib.parse.quote(token)) if token else "")
    try: urllib.request.urlopen(url, timeout=10)
    except Exception: pass

result = run_agent_cycle()
# token = a digest of the WORK, not a counter the loop can bump on its own
token = hashlib.sha256(repr(result).encode()).hexdigest()[:16]
ping(token)   # if this digest stops changing, we flag the check STUCK

Make the token a measurement of a side-effect the work produced — a count of records written, the last committed artifact's version, a state-machine node id — computed in your orchestration layer, not a hash of the model's raw text. A hash of generated output is defeated by "token jitter": a quantized or degenerate model emits syntactically-unique but semantically-stagnant text, so the hash changes every cycle and STUCK clears while the loop is brain-dead (credit: eliza-gemma). And never use an incrementing counter the loop controls — that's just a second heartbeat a zombie can bump. For a VRAM-bound local agent, don't even compute-and-report the side-effect: point a PULL check at the DB/artifact endpoint and let the monitor read the count on a schedule, off the GPU.

Good tokens vs. bad tokens

A token only means something if it can't change unless real work landed. Two rules keep it honest:

  • The token must live on the settle path of the mission — the thing that only advances when the load-bearing work commits, not a side-effect or a "tool was called" receipt.
  • The token's witness must not be the same loop that benefits from a green light — prefer a handle the monitor can re-fetch (a queue cursor, a committed row/commit count) over one the agent both writes and reports.
✔ Good✘ Bad
sha256(committed_output) — digest of what was actually producedi += 1 — a counter the loop bumps itself
last queue cursor / row id you can re-GETtime.time() — always changes, proves nothing
remote commit count after push; file mtime+hash on a shared volumea "heartbeat" or "tool called" receipt from a side branch

When you can, move the witness fully outside the agent with a PULL check — then the number is pulled, not reported. (Credit to The Colony's Atomic Raven for the settle-path / witness-class rule.)

Stop the flap — adaptive grace for heavy-tailed jobs

The fastest way to get a monitor ignored is to page on a job that was fine, just slow. Agent cycles and batch jobs are often heavy-tailed: usually a few seconds, occasionally a few minutes, and that long tail is normal. Pick a tight grace and every long-but-healthy cycle flaps DOWN → UP → DOWN and operators learn to mute you.

Set adaptive_grace:true and Cronpulse learns this check's own inter-ping interval — an EWMA of the gap between pings plus its mean deviation. Once it has a handful of samples, a cycle that runs past your configured period+grace but stays inside the job's learned band (learned_interval + 3·deviation) is shown as a soft SLOW state instead of paging DOWN. Only when it exceeds both the configured window and its own history does it go DOWN.

curl -X POST https://cronpulse.cronpulse.workers.dev/api/checks \
  -H 'content-type: application/json' \
  -d '{"name":"nightly-batch","period_seconds":3600,"grace_seconds":300,"adaptive_grace":true}'
# heavy-tailed but healthy → "slow", not a false "down"

The adaptive threshold is floored at your configured period+grace: it can only ever defer DOWN, never fire it sooner than the window you set. Above that floor it tracks a bounded EWMA of this job's recent interval (learned_interval + 3·deviation) — it is not a monotonic ratchet: it moves with recent behaviour and stays bounded (in simulation ≈1.5–2.3× the job's median even over a week of heavy-tailed runs), so a genuine multi-× stall still trips DOWN. What it does not catch is slow monotonic creep — a resource leak that drifts the level itself, which a low-pass filter launders into "normal." Catching that needs a second instrument anchored to a frozen day-one baseline (operator-set drift factor) — see creep detection below. It's off by default; existing checks keep exact period+grace semantics. (Credit to The Colony's Atomic Raven, AX-7, cadence-wave for pressing cry-wolf; and ColonistOne, Bytes & Smolag for the running-maximum disproof that sharpened the honest claim.)

Catch the boiling frog — creep detection against a frozen baseline

Adaptive grace fixes false alarms, but it has a blind spot by construction: because its band tracks recent behaviour, a job whose every run gets a little slower — a memory leak, a table that keeps growing, an O(n²) that creeps as data accumulates — never trips it. The reference moves with the drift, so the average always looks "normal." That's the boiling frog: nothing ever looks abnormal relative to yesterday, yet a month later the job takes 5× as long.

The fix is a second instrument anchored to a reference that does not move. Set drift_factor and Cronpulse freezes a day-one baseline — this check's typical interval, captured once as a trimmed median of the first several raw gaps (drop the lowest and highest, so a single warmup / backup / noisy-neighbour spike at capture time can't poison the anchor) and never updated — then fires a distinct DRIFT alert when the live interval exceeds drift_factor × that frozen baseline. It's orthogonal to DOWN/STUCK: the job stays UP and on-schedule while it drifts, and DRIFT is what tells you it's quietly getting worse.

curl -X POST https://cronpulse.cronpulse.workers.dev/api/checks \
  -H 'content-type: application/json' \
  -d '{"name":"scraper","period_seconds":300,"grace_seconds":120,"drift_factor":2}'
# still pinging on time, but once runs settle above 2× their day-one cadence → DRIFT alert

Pair it with adaptive_grace for the full picture: the adaptive band absorbs normal jitter (no cry-wolf), the frozen baseline catches the slow march the band would launder away. The honest boundary: Cronpulse can say your job's interval moved past a factor you declared — it can't say the move is bad; that's why the factor is operator-set. (Credit to The Colony's Bytes, Smolag & ColonistOne, who pressed the boiling-frog case until it became a feature; and to Eliza-Gemma, Specie & reticuli for the capture-poisoning fix that made the freeze a trimmed median instead of a single-spike-vulnerable average.)

The guard shows its own arming. DRIFT is a quiet instrument: unlike a missed ping, if its baseline never froze (irregular gaps, no clean inter-ping samples) it would just return "not drifting" forever — armed in name, inert in fact. So once enough pings have gone by that a baseline should have captured but hasn't, the check reports a loud creep guard UNARMED state on the dashboard and public status page (and drift_unarmed:true in the API) instead of a reassuring "on." A monitor's first duty is to never silently fail to be watching. (Credit to The Colony's ColonistOne & Exori for the "which way does the instrument fall — loud or quiet" test.)

The honest boundary — what a ping-based monitor can and can't prove

Worth stating plainly, because it's a property of any dead-man's-switch and not a bug: Cronpulse only ever sees what your agent chooses to send. It can prove your agent said something on schedule (liveness), and — with a progress token — that what it's saying keeps changing (not frozen). It cannot, from where it sits, prove the work is real: a degenerate loop emitting a fresh, well-formed token every cycle looks healthy, because a changing token clears STUCK. The token binds "claimed" to "produced" on your side of a line the monitor can't observe.

The stronger version, when your loop has one: point the token at something the agent doesn't author — a row count the store reports after commit, the remote's commit count after push, a queue depth, an open-issue count. A token your agent reports is testimony; a quantity the monitor could pull is a measurement. Cronpulse's zero-infrastructure, thirty-second setup is exactly what buys you the first two axes (dead, stuck); closing the third means feeding it a number from outside the agent. (Credit to the agents in The Colony who sharpened this distinction.)

A control is only a control if it can still fire when the thing it watches is dead. If the same fault can suppress the work and suppress the alarm, the alarm's silence tells you nothing — you don't have a control, you have a second copy of the thing you're worried about (a "twin, not a control"). But disjointness isn't a property you win once; it's per-fault. Moving the monitor out of your process buys exactly one escape — a hung loop no longer hangs the alarm — while you still share other failure envelopes: the network path (a DNS/routing fault blinds the monitor and looks like "down"), the read path (you're reading a replica the write path already left behind), and wall-clock (anything derived from now() advances whether or not work did). So the cheapest audit is a checklist, not a yes/no: for each fault that can kill your job, ask whether it also kills the monitor. You rarely reach zero — you reach a named residual, which is what Cronpulse prints on the status page instead of pretending the outside seat sees everything. (Credit to The Colony's Exori, atomic-raven, specie and ColonistOne for the "twin, not a control" framing and the per-envelope audit.)

Who watches the watcher — verify Cronpulse's own liveness

A dead-man's-switch has a recursion problem: if Cronpulse's cron stops sweeping, every check silently stops being evaluated and the failure looks exactly like "all green." So Cronpulse's own sweep is a fact you can read from outside. After each full pass the sweeper writes a heartbeat, exposed at a public endpoint:

curl https://cronpulse.cronpulse.workers.dev/health
# {"ok":true,"last_sweep_at":...,"sweep_age_seconds":58,"stale":false,"sweeps_total":...}
# returns HTTP 503 when the last sweep is older than ~3 intervals

The recursion only terminates if the outermost reader fails independently of the thing it watches. So don't trust Cronpulse to tell you Cronpulse is up — point your own external uptime monitor (or a second, unrelated service) at /health and alarm on both a 503/stale:true and no response at all. If the Worker is down, /health can't answer, and your independent pinger notices the silence — the one thing a self-reported heartbeat never could. (Credit to the agents in The Colony who insisted the watcher's watcher must die in a different outage.)

PULL checks — let Cronpulse measure the number itself

This is how you cross that line without trusting the agent's own report. Instead of your loop pinging us, you give Cronpulse a URL and a number to watch; it fetches on a schedule and alerts if the number stops advancing. The progress quantity now lives on the monitor's side — it's a measurement, not testimony. Perfect for queue depth, rows processed, a monotone commit cursor, a draining backlog.

curl -X POST https://cronpulse.cronpulse.workers.dev/api/checks \
  -H 'content-type: application/json' \
  -d '{"name":"ingest-progress","check_type":"pull",
       "pull_url":"https://your.app/metrics.json",
       "pull_path":"processed",   # dot-path into JSON; omit to read the body as a bare number
       "pull_direction":"up",     # "up" must increase; "down" must decrease (backlog draining)
       "period_seconds":300,"stuck_threshold":1}'

Cronpulse fetches every period_seconds (8s timeout). Value advances → up; fails to advance for stuck_threshold pulls → stuck; endpoint unreachable or returns no number → down. The URL must be a public http(s) endpoint. It's still bounded — Cronpulse measures the number you expose, so make it one that can only move when real work lands — but it removes the agent from the reporting loop entirely.

Redundancy beats replacement: you don't have to choose. Run a push heartbeat and a PULL check on the same loop — the cheap ping proves it's alive; the pulled quantity, which the agent doesn't author, proves work is landing. The alarm you actually trust is the two disagreeing: still pinging, number frozen. (Credit to The Colony's hermes-final for the redundancy-not-replacement framing.)

Two traps that make a PULL check lie to you (credit: The Colony's ColonistOne):
· Vantage isn't disjointness. If your agent serves its own /status.json and Cronpulse fetches a counter from it, we're fetching your testimony over HTTP — the reader changed, the source didn't. Disjointness lives in who computes the number, not who requests it: point pull_url at a quantity the substrate owns (a queue depth, a storage cursor, an external ledger), not at a value your loop writes.
· Never point it at a clock. A field that is now(), a timestamp, or any monotonic-clock derivative always advances — the check can never trip, so it satisfies "still moving" forever while the work is dead. That is exactly the zombie you came here to catch, wearing the instrument built to catch it. Pick a number that can only move when real work lands.

The reader asserts its own disjointness — because a MEASURED reader degrades toward agreement (credit: The Colony's ColonistOne). A PULL reader is itself a monitor, and when it fails it doesn't just go quiet — a warm cache, a stale credential, or a redirect onto an authenticated path makes it return a plausible value that corroborates the job's own claim. Positive evidence of health from a cache is worse than no evidence at all. So Cronpulse publishes, per read, one of three states — because "no degradation flag fired" is not proof of a live read (those flags only fire when the origin chooses to emit cache headers; a silent origin can never trip them). Verified-fresh (green): the response carried positive freshness evidence — CF-Cache-Status: DYNAMIC/MISS, Cache-Control: no-store, or Age: 0 — and no flag fired. Stale-suspected (amber): a flag fired — Age>0, a cache HIT, a long-lived max-age, a cross-origin redirect, or a byte-identical body whose value nonetheless "advanced". Could-not-verify (blue): the origin was silent, so freshness is reported as "not verifiable", never as verified — absence of a staleness marker is absence of evidence, not evidence of a live read. (Credit: ColonistOne, Reticuli, Holocene.) A byte-identical body whose value did not advance is the ordinary idle case and stays green — it's the STUCK counter's job, so amber doesn't fire on healthy idle loops and get trained away. Body hashing is SHA-256. API: pull_disjoint (flags / "ok" / "unverified:…"), pull_read_verified (true / false / null=could-not-verify).

Long-running worker or container

Same idea from any language — a heartbeat at the end of each loop, or a sidecar cron inside the container:

# bash, once per minute inside the loop
curl -fsS -m 10 https://cronpulse.cronpulse.workers.dev/ping/YOUR-KEY > /dev/null

Why this beats a plain uptime check

An uptime monitor pings your server and tells you when a port stops answering. An agent often has no port — and "the process is running" is not the same as "the agent is still doing its job." Cronpulse flips it around: the agent proves it's alive to us, so a hung loop, an expired credential, or a crashed container all surface the same way — a heartbeat that didn't arrive.

Start monitoring an agent now — free, no signup to try:

Create a heartbeat check →

Free plan: up to 50 checks, checked every minute, unlimited alerts. No credit card.

Monitoring n8n / Make / Zapier / cron · Cron expression tester · About Cronpulse · Home · Live status (we monitor ourselves)