CronWatch records every run of your scheduled jobs in your own database, and speaks up when one is missed, fails, gets stuck, runs slow or costs too much.
It is a TypeScript library, not a service. No server to run, no account to make, and no copy of your job output on anybody else’s machine. MIT licensed and small enough to read in an afternoon.
npm install @cronwatch/sdkToday
One day, six jobs, every run it recorded.
2026-09-22 · 21 runs by 20:17 UTC. Drawn from the library’s own records for six sample jobs, not a mockup. Hover any mark for the run.
At 03:00
The invoice run failed. Here is what each of them said.
cron03:00 UTC
CronWatch03:00 UTC
[cronwatch] invoice-run failed
Started 2026-09-22 03:00:00 UTC (now), ran 412ms.
Error: Error: connect ECONNREFUSED 10.0.0.12:5432
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1615:16)
Output (tail):
Loading 1,204 open invoices
Connecting to billing database
A handler that answers 4xx or 5xx counts as failed too, and so does a run whose output does not contain what you told it to expect: the job that exits cleanly and did nothing. With a Claude key, a two-sentence diagnosis rides along.
Declare
Say it where the job lives.
One client per app, each job declared once, and a wrapper around the work. The schedule, the grace period and the timeout sit next to the code they describe, and every run lands in the SQLite file or Postgres database you already have.
app/api/cron/nightly/route.tsTypeScript
import { cronwatch } from "@cronwatch/sdk";
import { sqlite } from "@cronwatch/sdk/sqlite";
import { slack } from "@cronwatch/sdk/slack";
const cw = cronwatch({
store: sqlite({ path: "./data/cronwatch.db" }),
alerts: [slack({ webhookUrl: process.env.SLACK_WEBHOOK_URL! })],
});
const nightlyReport = cw.job("nightly-report", {
schedule: "0 2 * * *",
grace: "15m",
});
export const GET = nightlyReport.handler(async (job) => {
const report = await buildReport();
job.log("Report written:", report.path);
});
Vercel cron, Next.js, node-cron, BullMQ, GitHub Actions, Hono, Bun, or a script from crontab. Anywhere a fetch handler or an async function runs.
The silence
It notices what never ran.
Nothing threw and nothing ran, so nothing was logged. That is the failure cron never reports, and the one a check finds. The same check catches the run that started and never finished, and the one that cost three times what it should.
CronWatch19:52 UTC
[cronwatch] sync-crm missed its scheduled run
Due 2026-09-22 19:47:31 UTC (30m 10s ago), and no run had started by 2026-09-22 19:52:31 UTC (25m 10s ago) (grace 5m).
Schedule: every 30m.
Last run: ok 2026-09-22 19:17:31 UTC (1h 10s ago).CronWatch04:00 UTC
[cronwatch] daily-digest went over budget
tokens: 131,000, limit 90,000 (three times the usual 30,000).
cost: 3.4, limit 2 (budget).
Started 2026-09-22 04:00:00 UTC (44s ago).CronWatch09:41 UTC
[cronwatch] invoice-run recovered
A run 2026-09-22 09:41:00 UTC (20s ago) succeeded after: failed.
Ran 20s.Every condition sends one message when it opens and one when the next run succeeds. A job that fails all night pages you once, and the fix announces itself. The check runs from cw.start() in a long-running server, or from a cron hitting /cronwatch/api/check on Vercel.
Catches
Six conditions. That is the whole product.
missedA run was due and none started within the grace period. Cron expressions,@hourly, orevery 15m, in any timezone.failedThe function threw, the handler answered 4xx or 5xx, or the output did not satisfyexpect.stuckA run started and never reported finishing. A killed process, a serverless timeout, a deploy mid-run.slowA successful run took longer thanmaxDuration, or more than twice the job’s own recent p95.over_budgetA number you reported withjob.metric()went above its ceiling, or three times its usual median.recoveredA run succeeded after any of the above. One message, naming what it recovered from.
Agents
Your agent reads the same records.
CronWatch ships an MCP server. Claude Code, Cursor or any MCP client can list what is failing, read the error and the output, and silence a job while it fixes it. A Claude Code skill in the repo teaches the agent to add monitoring to a job as it writes it.
claude mcp add cronwatch -e CRONWATCH_URL=… -e CRONWATCH_TOKEN=… -- npx -y @cronwatch/mcp@cronwatch/mcpstdio
list_jobs()
6 jobs, 2 needing attention.
backup-to-s3: healthy
schedule: 0 5 * * *
last run: running since 2026-09-22T20:15:41.496Z
next due: 2026-09-23T05:00:00.000Z
last 7 runs: 100% ok, p50 9m, p95 9m
daily-digest: healthy (open: over_budget)
schedule: 0 4 * * *
last run: ok at 2026-09-22T04:00:00.000Z, 44.0s
next due: 2026-09-23T04:00:00.000Z
last 8 runs: 100% ok, p50 41.0s, p95 44.0s
embeddings-refresh: healthy
schedule: every 15m
last run: ok at 2026-09-22T20:12:41.496Z, 4m
next due: 2026-09-22T20:27:41.496Z
last 12 runs: 100% ok, p50 4m, p95 4m
invoice-run: failing (open: failed)
schedule: 0 3 * * *
last run: failed at 2026-09-22T03:00:00.000Z, 412ms
next due: 2026-09-23T03:00:00.000Z
last 8 runs: 88% ok, p50 20.6s, p95 21.8s
consecutive failures: 1
nightly-report: healthy
schedule: 0 2 * * *
last run: ok at 2026-09-22T02:00:00.000Z, 1m
next due: 2026-09-23T02:00:00.000Z
last 8 runs: 100% ok, p50 1m, p95 1m
Builds the PDF and emails it to finance
sync-crm: late (open: missed)
schedule: every 30m
last run: ok at 2026-09-22T19:17:31.496Z, 8.0s
next due: 2026-09-22T19:47:31.496Z
last 5 runs: 100% ok, p50 8.0s, p95 8.0sFor people
And a dashboard, mounted inside your app.
At /cronwatch, behind whatever auth you already have. The same records the agent reads, with the run history, output and metrics of every job.
/cronwatch6 jobs, 2 needing attention · 20:17 UTC
| Job | Schedule | Health | Last run | Next due |
|---|---|---|---|---|
| backup-to-s3 | 0 5 * * * | healthy | running | 05:00 |
| daily-digest | 0 4 * * * | healthyover budget | ok, 44s | 04:00 |
| embeddings-refresh | every 15m | healthy | ok, 4m 1s | 20:27 |
| invoice-run | 0 3 * * * | failing | failed, 412ms | 03:00 |
| nightly-report | 0 2 * * * | healthy | ok, 1m 12s | 02:00 |
| sync-crm | every 30m | late | ok, 8s | 19:47 |
Why
Why a library, and not a service.
The hosted monitors are good, and you pay for them with a monthly bill, an account, and a copy of your job output on someone else’s servers. A library needs none of that. It lives in the app that runs the jobs, writes to the database you already have, and sends the alert straight from your app to your channel.
The one thing it cannot see is an app that is entirely down, because nothing inside a dead process can alert. Pair it with any uptime monitor for that. Everything else, from a job that never fires to one that costs three times what it should, it catches from the inside. Stores: SQLite or Postgres. Alerts: Slack, Discord, a signed webhook, the console, or your own function.
Give it one job tonight, and find out in the morning whether it ran.