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:

FieldWhat it enables
costUsd, or tokensIn/tokensOut + modelPer-run cost, spend ceilings, and the month-to-date rollup
steps, toolCallsLoop detection — alert when a run’s counts say it is spinning
durationMsDuration ceiling
traceUrlThe alert deep-links straight into your tracing tool
summaryOne 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

PingSend it whenWhat it buys you
/startthe run begins — requiredOpens the run: gives it a measured duration and arms stuck-run detection
/successthe run finishedClears the missed/failed state, and submits the report we evaluate
/failthe run errored, or produced nothingAn 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:

SettingCounted fromMissing it means
Grace periodthe scheduled timeno start ping arrived, so the run never began → missed
Agent run timethe start pingno 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.
  • schedule events are dropped under load, and can be delayed by many minutes.
  • The schedule event 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:

  • || true on the start ping. A monitoring hiccup must never be the reason your agent didn’t run.
  • No verification step. Reporting itemsProduced: 0 is 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.