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/sdk

Today

One day, six jobs, every run it recorded.

00020406081012141618backup-to-s3backup-to-s3: running since 20:15 UTCdaily-digestdaily-digest: ok at 04:00 UTC, 44s, over budgetembeddings-refreshembeddings-refresh: ok at 17:27 UTC, 4m 12sembeddings-refresh: ok at 17:42 UTC, 4m 11sembeddings-refresh: ok at 17:57 UTC, 4m 10sembeddings-refresh: ok at 18:12 UTC, 4m 9sembeddings-refresh: ok at 18:27 UTC, 4m 8sembeddings-refresh: ok at 18:42 UTC, 4m 7sembeddings-refresh: ok at 18:57 UTC, 4m 6sembeddings-refresh: ok at 19:12 UTC, 4m 5sembeddings-refresh: ok at 19:27 UTC, 4m 4sembeddings-refresh: ok at 19:42 UTC, 4m 3sembeddings-refresh: ok at 19:57 UTC, 4m 2sembeddings-refresh: ok at 20:12 UTC, 4m 1sinvoice-runinvoice-run: failed at 03:00 UTC, 412msnightly-reportnightly-report: ok at 02:00 UTC, 1m 12ssync-crmsync-crm: ok at 17:17 UTC, 8ssync-crm: ok at 17:47 UTC, 8ssync-crm: ok at 18:17 UTC, 8ssync-crm: ok at 18:47 UTC, 8ssync-crm: ok at 19:17 UTC, 8ssync-crm: due 19:47 UTC, no run startednow 20:17 UTCokfailedover budgetmissedrunning

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

no output

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, or every 15m, in any timezone.
  • failedThe function threw, the handler answered 4xx or 5xx, or the output did not satisfy expect.
  • stuckA run started and never reported finishing. A killed process, a serverless timeout, a deploy mid-run.
  • slowA successful run took longer than maxDuration, or more than twice the job’s own recent p95.
  • over_budgetA number you reported with job.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.0s

For 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

JobScheduleHealthLast runNext due
backup-to-s30 5 * * *healthyrunning05:00
daily-digest0 4 * * *healthyover budgetok, 44s04:00
embeddings-refreshevery 15mhealthyok, 4m 1s20:27
invoice-run0 3 * * *failingfailed, 412ms03:00
nightly-report0 2 * * *healthyok, 1m 12s02:00
sync-crmevery 30mlateok, 8s19: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.

Read the docs Source on GitHub