How to know if your AI agent stopped running
Scheduled agents rarely crash loudly. They just stop: a key expires, a laptop sleeps through 6 a.m., a run "succeeds" with an empty answer. Here's how to find out what happened right now, and how to make sure you hear about it within minutes next time.
Short answer: don't wait for an error, because there often isn't one. Have every run check in with an outside monitor when it finishes, along with a small summary of what it produced. If the check-in doesn't arrive on schedule, or the summary looks wrong, you get alerted. This is called a heartbeat, or a dead man's switch.
Why AI agents fail without telling you
A scheduled agent has more ways to go quiet than an ordinary cron job, and most of them produce no error message anyone will see. They fall into four groups:
| What happened | Typical causes | Does anything alert you? |
|---|---|---|
| It never started | The machine was asleep, rebooted or out of disk. The scheduler was paused, the workflow was deactivated, or GitHub disabled a scheduled workflow after 60 days without repository activity (public repos). | No. There's no run, so there's no failed run. |
| It started and died | Expired or rotated API key, revoked OAuth token, 429 rate limits, a hit spending cap, a retired model name, an out-of-memory kill. | Only if you're watching logs, and the agent didn't swallow the error. |
| It hung | An API call with no timeout, a tool waiting for input, a loop that never reaches its stop condition. Later runs can pile up behind it. | No. The process is still "running". |
| It "succeeded" with nothing to show | A search returned zero results, the model refused or apologized, a tool error was caught and paraphrased, a file was written empty. | No. Exit code 0 and a green check. |
The last group is the one that bites hardest with agents. An agent loop is designed to keep going when a tool fails, so "the process finished" tells you very little about whether the work got done.
Check whether it ran, right now
If you suspect a run was missed, work from the outside in: first confirm the trigger fired, then look at what the agent did.
- Look for the output, not the logs. Is today's report in the folder, the row in the sheet, the message in Slack? If the deliverable is missing, you already know the answer.
- Check the scheduler. Did cron, GitHub Actions, n8n or your agent platform start a run at the expected time? If no run exists, the problem is the trigger or the machine, not the agent.
- Read the run's result. If a run exists, look at its exit code and, for agents, the structured result. Claude Code's
--output-format jsonincludessubtypeandis_error; anything other than a clean success is worth reading. - Check the provider. Rate limits, spending caps and key problems show up in your model provider's usage dashboard before they show up anywhere else.
# Plain cron: did the scheduler even start the job?
grep CRON /var/log/syslog | tail -20 # Debian/Ubuntu
journalctl -u cron --since "24 hours ago" # systemd hosts (the unit may be "crond")
# GitHub Actions: list recent scheduled runs of one workflow
gh run list --workflow nightly-agent.yml --event schedule --limit 10
# Claude Code: run the task by hand and read the result fields
claude -p "your task" --output-format json | jq '{subtype, is_error, num_turns}'
This works once. The point of the next section is that you shouldn't have to do it again.
Set up a heartbeat so you're alerted next time
A heartbeat flips the question around. Instead of searching for a failure, you wait for a success, and get told when it doesn't come. Because it watches for silence, it catches every failure in the table above, whatever the cause.
1. Write down the schedule
Be specific: "every weekday by 06:30", "every hour", "at least once a day". Then pick a grace period that covers normal variation: the longest normal run plus any scheduler delay. A daily agent that takes up to 15 minutes might get 30 minutes of grace. GitHub Actions schedules can start well after the scheduled time under load, so allow 30 to 60 minutes there.
2. Create a monitor and copy its ping URL
Use a monitor that runs somewhere else, not on the same machine or n8n instance as the agent, because a check that lives with the job dies with it. In Pulsecheck, add a job with your period and grace and you get a unique ping URL like https://pulsecheck.run/p/YOUR-JOB-ID.
3. Ping at the end of every successful run
Make the ping the last step, after you've checked the output. A ping at the start only proves the job began. A ping at the end, behind a check, proves the work got done. Any GET or POST to the URL counts. Use a short timeout and a retry, and never let a failed ping fail an otherwise good run.
4. Report failures straight away
From your error path, call the same URL with /fail on the end. You hear about it immediately rather than when the deadline passes, and anything you POST with it (an error message, an exit code) shows up with the alert.
5. Send a summary and add output rules
This is what turns "it ran" into "it worked". POST a tiny JSON summary of the run, such as {"count": 12, "report_url": "https://..."}, then add rules like count at least 1 or must include report_url. A run that breaks a rule is flagged as Ran but output looks wrong, even though it exited cleanly. (Output checks are on Pulsecheck's Pro and Agency plans. Missed-run and failure alerts work on every plan, including the free one.)
6. Test the alert
Run the job once by hand and watch the monitor go green. Then break it on purpose: point it at a bad API key, or simply skip a run, and confirm the alert actually reaches your inbox or Slack channel. An alert you've never seen fire is an alert you can't trust.
Copy-paste examples
Replace YOUR-JOB-ID with the ID from your own ping URL.
Claude Code (or any headless agent CLI)
Run the task headlessly, read the structured result, then report what was produced. The timeout makes sure a stuck run ends and gets reported instead of hanging silently.
#!/usr/bin/env bash
# run-digest.sh: run a Claude Code task headlessly and report the result to Pulsecheck
set -uo pipefail
PING="https://pulsecheck.run/p/YOUR-JOB-ID"
# Hard time limit so a stuck run can't hang forever
out=$(timeout 30m claude -p "Write today's client digest to out/digest.md" \
--output-format json --allowedTools "Read,Write")
code=$?
# Did the agent itself report an error?
is_error=$(echo "$out" | jq -r '.is_error' 2>/dev/null || echo unknown)
subtype=$(echo "$out" | jq -r '.subtype // "none"' 2>/dev/null || echo none)
if [ "$code" -ne 0 ] || [ "$is_error" != "false" ]; then
curl -fsS -m 10 -X POST "$PING/fail" -H "Content-Type: application/json" \
-d "{\"exit\": $code, \"subtype\": \"$subtype\"}"
exit 1
fi
# Report what the run actually produced, not just that it ended
lines=$( [ -s out/digest.md ] && wc -l < out/digest.md || echo 0 )
curl -fsS -m 10 --retry 3 -X POST "$PING" -H "Content-Type: application/json" \
-d "{\"count\": $lines, \"report\": \"out/digest.md\"}"
Schedule it with cron, launchd or your agent platform, and set an output rule of count at least 1 so an empty digest raises an alert.
Python agent
import requests
PING = "https://pulsecheck.run/p/YOUR-JOB-ID"
def main():
try:
leads = run_agent() # your agent: returns the items it produced
except Exception as e:
# Tell Pulsecheck straight away instead of waiting for the deadline
requests.post(f"{PING}/fail", json={"error": str(e)[:300]}, timeout=10)
raise
# Send a summary. An output rule like "count at least 1" catches empty runs.
try:
requests.post(PING, json={"count": len(leads), "source": "crm"}, timeout=10)
except requests.RequestException:
pass # never let the monitoring call break a good run
if __name__ == "__main__":
main()
Cron on a server
flock -n stops runs piling up behind a stuck one, and timeout bounds each run. The success ping only fires if the job exits 0; anything else calls /fail.
# m h dom mon dow command
# Every day at 06:00: no overlapping runs, a 30-minute limit, success or /fail ping
0 6 * * * if flock -n /tmp/agent.lock timeout 30m /opt/agent/run.sh; then curl -fsS -m 10 --retry 3 https://pulsecheck.run/p/YOUR-JOB-ID; else curl -fsS -m 10 https://pulsecheck.run/p/YOUR-JOB-ID/fail; fi
GitHub Actions schedule
Keep the ping URL in a repository secret. Remember that the cron in a workflow runs in UTC, and give the monitor generous grace for late starts.
# .github/workflows/nightly-agent.yml
name: Nightly agent
on:
schedule:
- cron: "0 14 * * *" # GitHub schedules run in UTC
workflow_dispatch: {}
jobs:
run:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- run: python agent.py > result.json # prints a JSON summary, e.g. {"count": 12}
- name: Ping Pulsecheck
if: success()
run: |
curl -fsS -m 10 --retry 3 -X POST "${{ secrets.PULSECHECK_URL }}" \
-H "Content-Type: application/json" --data @result.json
- name: Report failure
if: failure()
run: curl -fsS -m 10 "${{ secrets.PULSECHECK_URL }}/fail"
n8n
n8n's Error Workflow only fires when an execution starts and then fails. If the trigger never fires, or the workflow is switched off, there's no execution and no error, which is why the heartbeat matters.
Last node of the workflow: HTTP Request
Method: POST
URL: https://pulsecheck.run/p/YOUR-JOB-ID
Send Body: on, Body Content Type: JSON
JSON: {"count": {{ $items().length }} }
Settings: Execute Once = on, On Error = Continue
Error path: a separate workflow that starts with an Error Trigger node,
followed by an HTTP Request node that POSTs to https://pulsecheck.run/p/YOUR-JOB-ID/fail.
Then pick that workflow under Workflow Settings → Error Workflow.
Common mistakes
- Pinging at the start of the run. It catches a dead scheduler but misses crashes, hangs and empty runs.
- Running the monitor on the same box. If the machine is down, the thing that should alert you is down too.
- Trusting exit code 0. Agents swallow tool errors by design. Check the artifact and send a count.
- No timeouts. A single wedged API call can hold a run open for hours. Bound the call and the whole run.
- Grace that's too tight. Alerts that cry wolf get muted. Size grace to real run times.
- One URL for many jobs. Give every agent and workflow its own monitor so a healthy job can't mask a dead one.
- Sending real data in the ping. Send counts and field names, not customer records or secrets.
FAQ
What is a heartbeat monitor?
A heartbeat monitor (also called a dead man's switch) waits for your job to check in. Each run sends a request to a unique URL when it finishes. If the request doesn't arrive within the expected interval plus a grace period, the monitor alerts you. It catches failures that leave no error behind, such as a scheduler that stopped firing or a machine that was asleep.
How long should the grace period be?
Use the longest normal run time plus any scheduler delay. For a daily agent that takes up to 15 minutes, 30 minutes of grace is reasonable. GitHub Actions schedules can start late under load, so allow 30 to 60 minutes there.
Can I monitor an agent that runs on my laptop?
Yes. The monitor lives outside your machine, so if the laptop is asleep, offline or shut down at run time, the ping never arrives and you get alerted. That is exactly the failure an on-machine check can't report.
How do I catch an agent that runs but produces nothing useful?
Send a small summary with the ping, such as {"count": 12}, and set an output rule like "count at least 1" or "must include field report_url". In Pulsecheck, output checks are on the Pro and Agency plans. A run that breaks a rule is flagged as "Ran but output looks wrong" even though it technically succeeded.
Should the ping go at the start or the end of the run?
At the end, after you've checked the output. A ping at the start only proves the job started, so it misses runs that crash, hang or finish empty.
What should I send in the payload?
Counts, flags and field names, such as how many items were processed or whether a file was written. Avoid customer data or secrets; the ping is a status signal, not a log.
Hear about it before your clients do.
Pulsecheck gives every agent, workflow and cron job its own ping URL, and alerts you by email or Slack when one goes quiet, fails, or comes back with nothing. Free for 3 jobs.
Start free