Your agent can be running, listening on its port, passing a health check, and doing nothing useful. A summarizer stuck on one malformed feed. A queue worker blocked on a dead API call. A cron job that exited cleanly after processing zero items because an upstream key expired. A port check calls all of these healthy. To monitor AI agents properly you need the agent to prove it did work, and that is what a heartbeat does.
This post builds that in three pieces: a Python helper that pushes a heartbeat only when real work happened, a cron fallback for scripts you can't edit, and the Uptime Kuma settings that turn silence into an alert. I ran the helper's four cases against a mock server on 2026-10-29. I did not run it against a live Uptime Kuma instance, and I say where that matters.
Why does a normal uptime check miss agent failures?
Because a normal check asks "is it reachable?" and agents fail in ways that stay reachable. Here are the failure modes worth designing for, and what catches each one.
| Failure | Port or HTTP check | Heartbeat on real work |
|---|---|---|
| Process crashed | Catches it | Catches it (silence, or explicit down) |
| Cron job never ran (timer disabled, box rebooted) | Misses it | Catches it (silence) |
| Loop hung on a blocked call | Misses it | Catches it (no progress, no push) |
| Run finished but processed 0 items | Misses it | Catches it if you push down on zero |
| Expired API key, every call fails fast | Misses it | Catches it if failures count as no progress |
| Runaway loop burning tokens | Misses it | Does not catch it; use a token budget |
The last row matters. Heartbeats tell you the agent stopped. They don't tell you it is spending too much while it runs. Pair them with a budget guard and with tracing such as self-hosted Langfuse or the tools in this observability comparison.
How does an Uptime Kuma push monitor work?
You create a monitor of type Push, and Kuma gives you a URL containing a token. Your agent calls that URL. If no call arrives inside the monitor's heartbeat interval, the monitor goes down and your notification channels fire.
I read the handler in Uptime Kuma's source (server/routers/api-router.js on the master branch, 2026-10-29) instead of trusting memory. What it does:
- The route is
/api/push/<token>, and it accepts every HTTP method, socurlor a browser both work. - Query parameters are
status,msgandping.statusdefaults toup; any value other thanupis recorded as down, anddownspecifically is treated as an explicit failure that skips the retry logic. msgdefaults toOK.pingis a number of milliseconds; a value of0is stored as empty.- A token that doesn't match an active monitor returns a 404 with
Monitor not found or not active.In fact every error in that handler comes back as a 404, so a 404 can also mean a server-side problem.
Two practical consequences. First, a paused monitor looks identical to a wrong token from the agent's side, so don't treat a 404 as proof you mistyped. Second, the helper below never raises on a failed push, because a monitoring hiccup should not kill the job it watches. Behaviour can change between versions, so check the source or the monitor's own settings page for your release.
A Python helper that only reports real work
The helper uses the standard library, so it drops into any script. run_with_heartbeat wraps a job that returns how many items it processed. It pushes up when the count is at least min_items, down on zero, and down with the exception name if the job crashes, then re-raises so your logs still show the traceback.
import time
import urllib.parse
import urllib.request
def push(base_url, status="up", msg="OK", ping_ms=None, timeout=10):
"""Send one push to an Uptime Kuma push URL. Never raises."""
params = {"status": status, "msg": msg}
if ping_ms is not None:
params["ping"] = f"{ping_ms:.0f}"
url = f"{base_url}?{urllib.parse.urlencode(params)}"
try:
with urllib.request.urlopen(url, timeout=timeout) as resp:
return resp.status
except Exception as exc:
print(f"heartbeat failed: {exc}")
return None
def run_with_heartbeat(job, base_url, min_items=1):
"""Run job() -> int (items processed). Report up only if it did real work."""
start = time.monotonic()
try:
items = job()
except Exception as exc:
push(base_url, "down", f"crashed: {type(exc).__name__}")
raise
ms = (time.monotonic() - start) * 1000
if items < min_items:
push(base_url, "down", f"ran but processed {items} items", ms)
return items
push(base_url, "up", f"processed {items} items", ms)
return items
Use it by returning a count from your agent's entry point. For the daily digest agent, that count is the number of feed items summarized:
BASE = "https://status.example.com/api/push/YOUR_TOKEN"
def main():
items = fetch_and_summarize() # your agent
return len(items)
if __name__ == "__main__":
run_with_heartbeat(main, BASE)
What happened when I tested it
I ran the helper against a small mock server written from the handler behaviour above (so the test proves my code, not Kuma). Output:
1 healthy job -> ('up', 'processed 12 items', '0')
2 zero items -> ('down', 'ran but processed 0 items', '0')
3 crash -> ('down', 'crashed: ValueError', None)
4 progress loop
beat after progress -> True ('up', '2 units done', None)
beat with no progress -> False | new pushes: 0
heartbeat failed: HTTP Error 404: Not Found
5 wrong token -> None
heartbeat failed: <urlopen error [Errno 61] Connection refused>
6 Kuma unreachable -> None
The 0 in cases 1 and 2 is the ping in milliseconds; the toy jobs finished in under half a millisecond. Cases 5 and 6 are the ones people forget: with a wrong token or an unreachable Kuma, the helper prints a line and moves on instead of crashing the agent.
How do I monitor a long-running loop that never exits?
A daemon agent has no "end of run" to hook. The trap is a background thread that pushes every 60 seconds no matter what. That thread keeps pushing while the main loop is hung, so the monitor stays green and tells you nothing.
Push on progress instead. The loop calls tick() each time it finishes a unit of work; a timer calls beat(), which pushes only if the counter moved since the last beat:
class ProgressBeat:
def __init__(self, base_url):
self.base_url = base_url
self.count = 0
self._seen = 0
def tick(self):
self.count += 1
def beat(self):
if self.count == self._seen:
return False # no progress: stay silent so Kuma alerts
self._seen = self.count
push(self.base_url, "up", f"{self.count} units done")
return True
In the test above, a beat after two ticks pushed once, and a second beat with no new ticks sent nothing. Silence is the signal. Call beat() from a thread or from the loop itself every minute or so, and set the monitor's interval to a few multiples of your slowest normal unit of work. An agent that legitimately waits ten minutes on a slow model call needs an interval above ten minutes, or you'll get false alarms.
If your agent runs long multi-step workflows that must survive restarts, a durable execution engine is the better fix; see durable agents with Temporal. Heartbeats just tell you when something needs a human.
What if I can't edit the script?
Wrap it in cron. This version checks the exit code and pushes either result. It is a standard shell pattern but I did not run this exact line against a live monitor, so treat it as untested and try it with a throwaway monitor first.
0 7 * * * /opt/digest/run.sh && curl -fsS -m 10 "https://status.example.com/api/push/YOUR_TOKEN?status=up&msg=OK" || curl -fsS -m 10 "https://status.example.com/api/push/YOUR_TOKEN?status=down&msg=job-failed"
Know the limits. This reports "the script exited 0", not "the script did useful work". A script that swallows its own errors and exits 0 still reports up. If you can add one line that returns an item count, the Python helper is better. Also note the || branch runs if either the job or the success push fails, so a Kuma outage can produce a spurious down push. That is acceptable noise for a first version.
Which settings matter in the monitor?
Create the monitor, choose Push, and set these:
| Setting | What to choose | Why |
|---|---|---|
| Heartbeat interval | Slightly longer than your schedule or slowest unit of work | Prevents alerts from normal jitter |
| Retries | 0 or 1 for daily jobs, 1 to 3 for frequent ones | A missed daily run shouldn't wait three more days to alert |
| Notification | At least two channels, one not on the same server | An alert sent through a dead box never arrives |
| Message in the push | Count and short reason | The alert text becomes your first debugging clue |
Check the maximum heartbeat interval your Uptime Kuma version allows before planning around a weekly job. And remember where Kuma lives. If it shares a VPS with the agent, a full outage silences both; the Vaultwarden and Uptime Kuma stack post covers running it behind HTTPS and why a second instance elsewhere is worth it. If you need a small server for the agent and the monitor, a modest VPS plan is enough for both (affiliate link; the setup works on any provider).
A short checklist before you trust it
- Does the agent push
downon zero items, not only on exceptions? - Does the monitor interval exceed your slowest normal run?
- Did you break the agent on purpose (wrong API key, disabled timer) and confirm an alert arrived?
- Is at least one notification channel off the monitored server?
- Is spend capped separately, since heartbeats can't see cost?
The break-it-on-purpose step is the one that earns trust. A monitor you've never seen fire is a guess.



