Monitoring scheduled AI agents
Wire a heartbeat ping into Claude Routines, n8n, GitHub Actions, LangGraph, and plain cron — so a scheduled agent that skips a run, finishes empty, or hangs on a model call raises an alert instead of nothing.
A scheduled agent is a cron job with a worse failure mode. It can run, spend tokens, exit 0, and produce nothing — and every check that watches processes or exit codes will call that a success.
This page wires an AI Agent monitor into the platforms agents actually run on. It shares everything a heartbeat monitor does — schedule, grace period, stuck-run detection — and adds the part that makes it a distinct job kind: your run reports what it produced, and SteadyCron judges that report against rules you set.
Prerequisite. Create a job, pick AI Agent, set the schedule your agent is meant to keep, and say what it produces (tickets, rows, documents — this labels the count). Copy the ping URL from the job page. Everything below assumes
https://ping.steadycron.com/<your-ping-token>.
The pattern: report the count
Almost every agent-monitoring mistake is the same mistake — pinging success because the process ended:
# Wrong twice over: it reports success for an empty run, and it never told us
# the run started — an agent monitor rejects a success ping with no run open.
python run_agent.py && curl -fsS https://ping.steadycron.com/<your-ping-token>
Agent frameworks are built to keep going. A tool call fails, the model apologises and moves on, the loop finishes cleanly with nothing written. The exit code describes the interpreter, not the work.
So report what the run produced, and let SteadyCron judge it:
PING="https://ping.steadycron.com/<your-ping-token>"
curl -fsS "$PING/start" # required: opens the run
python run_agent.py --task nightly-digest
rows=$(wc -l < out/digest.md 2>/dev/null || echo 0)
body=$(printf '{"itemsProduced": %d}' "$rows")
curl -fsS -H 'Content-Type: application/json' -d "$body" "$PING/success"
Note what is not in that snippet: an if. You report the number; the monitor’s
empty-result rule turns a zero into a failed run and an alert. That matters
because the branch is the part people get wrong — and a check you forgot to
write is indistinguishable from a check that passed.
itemsProduced is whatever proves the run did its job: rows written, tickets
triaged, documents embedded, messages sent. Pick the cheapest count that cannot
be non-zero when the agent did nothing. You name the unit when you create the
monitor, and it labels the number everywhere in the dashboard.
What else to report
Everything below is optional, and each field unlocks something:
| Field | What it enables |
|---|---|
costUsd, or tokensIn/tokensOut + model | Per-run cost, spend ceilings, and the month-to-date rollup |
steps, toolCalls | Loop detection — alert when a run’s counts say it is spinning |
durationMs | Duration ceiling |
traceUrl | The alert deep-links straight into your tracing tool |
summary | One line of context in the alert itself |
POST https://ping.steadycron.com/<your-ping-token>/success
{
"itemsProduced": 412,
"steps": 12,
"toolCalls": 31,
"durationMs": 71000,
"model": "claude-opus-5",
"tokensIn": 41200,
"tokensOut": 3100,
"traceUrl": "https://cloud.langfuse.com/…/run/abc",
"summary": "412 rows written, 3 skipped"
}
Send cost either way: costUsd if you already know it, or tokens plus the model
id and SteadyCron works it out. A model it cannot price is recorded as unpriced
rather than free — it never silently reads as $0.
Don’t wait for tonight’s run to find out it works. The monitor’s Runs tab has a send a test report control that submits a synthetic run through the real pipeline — including a deliberately empty one. Use it to confirm your rules and your alert channel before you trust the thing.
The three pings
| Ping | Send it when | What it buys you |
|---|---|---|
/start | the run begins — required | Opens the run: gives it a measured duration and arms stuck-run detection |
/success | the run finished | Clears the missed/failed state, and submits the report we evaluate |
/fail | the run errored, or produced nothing | An immediate alert with your own message attached |
/start is not optional on an agent monitor. A /success with no run open is
rejected with 422 and a body naming the start URL, and nothing is recorded —
without a start time we cannot tell a slow run from a hung one, so a success we
cannot time is a success we cannot judge. /fail is the exception: it is always
accepted, because refusing a failure report would turn a known failure into
silence.
Both GET and POST work, but a report needs POST with
Content-Type: application/json. Bodies are capped at 2 KB for agent
monitors — a structured report has no reason to be large. Unknown fields are
ignored and field names are case-insensitive, so a partially instrumented agent
still works: the rules that depend on a missing field simply do not evaluate.
Picking a grace period and an agent run time
Agent runtimes vary far more than a backup’s does — the same prompt can take 40 seconds or six minutes depending on how many tool calls the model decides it needs. Because an agent reports twice, “did it start?” and “did it finish?” are timed separately, on two clocks with two different anchors:
| Setting | Counted from | Missing it means |
|---|---|---|
| Grace period | the scheduled time | no start ping arrived, so the run never began → missed |
| Agent run time | the start ping | no completion arrived, so the run hung → abandoned |
- Grace period measures how late your trigger may fire, not how long a run takes. Size it for whatever launches the agent to wake up — a cold CI runner is the usual reason a start ping is slow — and keep it short: seconds to a few minutes. It does not need to cover the run’s duration; that is what the second clock is for.
- Agent run time is how long after the start ping the run may go without finishing. Size it off the slowest run you have seen, plus headroom. It is required on an agent monitor, and it does double duty: a run that did finish beyond it is reported as slow.
The split is what makes the two incidents distinguishable, and they need different fixes. A run that never began means the thing that launches your agent is broken — a disabled workflow, a sleeping machine, a dropped schedule event. A run that began and went quiet means the agent itself hung. Before this split, one grace period had to absorb both, so it had to be at least as long as your slowest run, and a late trigger stayed invisible until an entire run’s worth of time had passed.
Claude Routines and Managed Agents
Anthropic runs the schedule, so the usual failure isn’t a dead trigger — it’s that a routine gets skipped, or finishes having accomplished nothing, and nothing tells you. Desktop scheduled tasks only run while the app is running and the machine is awake; cloud Routines fire regardless, but neither pages you when the result is missing.
Use the MCP server here. There is no wrapper process to put reporting code
in, so the alternative is a curl instruction in the prompt — and an
instruction is something a model can skip. A tool call is part of the agent’s own
action surface.
Add SteadyCron as an MCP server (https://api.steadycron.com/mcp, authenticated
with a SteadyCron API key), then end the prompt with:
Call report_run_start with jobKey "nightly-digest" before you begin.
Do the work. Write the result to out/digest.md.
When you are done, count what you actually produced and call report_run with
that count. If you produced nothing, report 0 — do not omit the field, and do
not round up.
The available tools are list_agent_monitors, report_run_start, report_run,
and report_run_failure.
Two things worth knowing:
- Ask for the count, not for a verdict. “Report success when you’re done” invites the model to report success because it finished. Asking for a number moves the judgement to our side, where a model cannot talk itself past it.
- A run that never starts sends nothing at all — which is the point. That’s the case the Routines dashboard shows as an absence and SteadyCron shows as a missed schedule, with an alert.
n8n
n8n’s Schedule Trigger fires the workflow; the AI Agent node does the work. The trap is that an agent node returning an empty string is a perfectly successful n8n execution — green checkmarks all the way down.
Wire it as Schedule Trigger → HTTP Request → AI Agent → HTTP Request. No IF node: report the count and let the empty-result rule decide.
HTTP Request (before the agent)
Method: GET
URL: https://ping.steadycron.com/<your-ping-token>/start
HTTP Request (after the agent)
Method: POST
URL: https://ping.steadycron.com/<your-ping-token>/success
Body (JSON):
{ "itemsProduced": {{ $json.rows.length }} }
Then handle thrown errors, which skip the reporting node entirely: on the AI
Agent node set On Error to Continue (using error output), and wire that
second output to a /fail node. Without it, a tool that raises ends the
execution quietly and no completion ping is sent. The start ping already fired,
so you’d still catch it: the run stays open and is declared abandoned once the
agent run time elapses. But you lose the error message and the faster alert.
GitHub Actions
Scheduled workflows are the least reliable trigger in common use, for reasons that have nothing to do with your code:
- GitHub disables scheduled workflows after 60 days of repository inactivity. Nothing emails you.
scheduleevents are dropped under load, and can be delayed by many minutes.- The
scheduleevent only runs on the default branch — moving the workflow to a feature branch silently stops it.
None of those produce a failed run you could get notified about; they produce no run at all. An external schedule expectation is the only thing that sees it.
name: nightly-digest
on:
schedule:
- cron: "0 2 * * *"
workflow_dispatch:
jobs:
digest:
runs-on: ubuntu-latest
env:
PING: https://ping.steadycron.com/${{ secrets.SC_PING_TOKEN }}
steps:
- uses: actions/checkout@v4
- run: curl -fsS "$PING/start" # required
- run: python run_agent.py --task nightly-digest
- name: Report what the run produced
run: |
rows=$(wc -l < out/digest.md)
body=$(printf '{"itemsProduced": %d}' "$rows")
curl -fsS -H 'Content-Type: application/json' -d "$body" "$PING/success"
- if: failure()
run: |
curl -fsS -H 'Content-Type: application/json' \
-d '{"status":"failure","summary":"workflow failed — see run log"}' "$PING/fail"
Notes on that workflow:
|| trueon the start ping. A monitoring hiccup must never be the reason your agent didn’t run.- No verification step. Reporting
itemsProduced: 0is enough — the empty-result rule fails the run and alerts. There is no branch to get wrong. if: failure()still matters: it catches a crash, where the reporting step never runs at all, and turns it into an immediate alert rather than waiting out the agent run time before the open run is declared abandoned.- Store the token as a repository secret. Ping URLs are credentials — see token rotation.
LangGraph and Python agents
Wrap the invocation so that every exit path reports something. You report the counts; the rules decide what they mean:
import requests
from my_agent import graph # your compiled LangGraph
PING = "https://ping.steadycron.com/<your-ping-token>"
def report(path="", **fields):
# Monitoring must never be able to break the run.
try:
requests.post(PING + path, json=fields, timeout=5)
except requests.RequestException:
pass
report("/start") # required
try:
state = graph.invoke({"task": "nightly-digest"})
# Report the counts unconditionally — including a zero. A graph that
# returns cleanly has not necessarily done anything, and reporting the
# zero is what lets SteadyCron say so.
report(
"/success",
itemsProduced=len(state.get("rows", [])),
steps=state.get("steps"),
toolCalls=state.get("tool_calls"),
model="claude-opus-5",
tokensIn=state.get("tokens_in"),
tokensOut=state.get("tokens_out"),
traceUrl=state.get("trace_url"),
)
except Exception as exc:
report("/fail", status="failure", summary=str(exc)[:500])
raise
The same shape works for CrewAI, the OpenAI Agents SDK, or a hand-rolled loop — the only agent-specific part is deciding which count proves the run did its job.
Set an explicit timeout on the model client too. Many SDKs default to no request timeout, and one wedged connection turns a nightly job into a process that is still running on Thursday. SteadyCron’s stuck-run detection will alert you, but bounding the call is cheaper than being paged about it.
Plain cron and any shell runner
Three things belong on the crontab line itself, before any monitoring: a lock so slow runs can’t overlap, a hard deadline so a hung call can’t run until morning, and a wrapper script so the logic is testable.
0 2 * * * flock -n /tmp/agent.lock timeout 30m /opt/agent/run.sh
#!/usr/bin/env bash
# /opt/agent/run.sh
set -uo pipefail
PING="https://ping.steadycron.com/<your-ping-token>"
curl -fsS "$PING/start" # required
if python run_agent.py --task nightly-digest; then
rows=$(wc -l < out/digest.md 2>/dev/null || echo 0)
body=$(printf '{"itemsProduced": %d}' "$rows")
curl -fsS -H 'Content-Type: application/json' -d "$body" "$PING/success"
else
curl -fsS -H 'Content-Type: application/json' \
-d '{"status":"failure","summary":"agent exited non-zero"}' "$PING/fail"
fi
timeout 30m and the monitor’s maximum run duration do different jobs: the
first kills the run, the second tells you it happened. Set the monitor’s
deadline slightly longer than timeout, so a killed run reports /fail on its
own rather than being reported as stuck.
Other runners — systemd timers, Docker, Kubernetes CronJobs, Windows Task Scheduler — are covered in Ping recipes for every scheduler.
Keeping the report safe to read
The report is attached to the alert across every channel, so treat it as something that will land in a chat room.
Send the count, not the contents. Keep prompts, retrieved documents, and
customer data out of it — itemsProduced: 412 tells the on-call everything they
need; the 412 rows themselves do not. Bodies are capped at 2 KB, which is a
deliberate nudge in the same direction.
Log the raw provider error on a failure, not the agent’s paraphrase of it. An agent loop that catches the exception and rewrites it as “I was unable to complete the task” destroys the one piece of information you need at 03:00.
Include traceUrl if you use a tracing tool. The alert then takes the reader
one click from “something broke” to the actual run — which is the division of
labour worth aiming for: SteadyCron pages you, your tracing tool shows you why.
What this does and does not cover
SteadyCron watches the run, not the reasoning. It tells you:
- a scheduled run never happened,
- a run finished having produced nothing,
- a run cost more than your ceiling — per run, or across the month,
- a run’s step or tool-call count says it is looping,
- a run started and never finished,
- and keeps per-run history with a month-to-date spend total per agent.
It does not do span-level tracing of model and tool calls, prompt management, evaluation, or answer-quality scoring. For those use a tracing tool such as Langfuse or LangSmith, and put the trace URL in the ping payload — the two are complementary. Tracing explains a run that happened; a heartbeat is how you find out about the run that didn’t.
Related
- Heartbeat monitoring — grace periods, stuck-run detection, token rotation
- Alerting — channels, consecutive-failure thresholds, quiet hours
- Cron jobs for AI agents — the use case, including moving the trigger itself to an HTTP job
- Scheduled AI agent not running? — step-by-step diagnosis when a run has already gone quiet
- Plans & limits — monitor counts and frequency floors