Synthetic monitoring for Cloudflare Workers. Checks your endpoints on a cron, decides when something is genuinely broken, and hands that off to Slack, a signed webhook, or an AI agent.
- Detection is deterministic. Thresholds, a state machine, maintenance windows. No model decides whether your site is down.
- Public code, private config. Checks live in D1 and are editable through
the UI, but secret values never enter the database — only
${REFERENCES}, resolved from Worker secrets at request time. - One storage system. D1. No R2 state blob, no analytics dataset you have to reason about.
- A dashboard you'll actually read. One mark per check run, so a five-minute cron looks like five-minute samples rather than a smoothed line.
npm create cloudflare@latest my-monitor -- \
--template clawdwatch/clawdwatch/examples/worker
cd my-monitor
wrangler d1 create clawdwatch # paste the id into wrangler.jsonc
npm run migrate
wrangler secret put SLACK_WEBHOOK_URL # optional, but this is all Slack needs
npm run deployAdd your first check:
curl -X POST "https://your-worker.workers.dev/api/checks" \
-H 'Content-Type: application/json' \
-d '{
"id": "homepage",
"name": "Homepage",
"url": "https://example.com",
"assertions": [
{ "type": "statusCode", "operator": "is", "value": 200 },
{ "type": "responseTime", "operator": "lessThan", "value": 3000 }
],
"tags": ["production"]
}'Open the Worker's URL for the dashboard.
import { createMonitor, slack } from 'clawdwatch';
const monitor = createMonitor<Env>({
d1: (env) => env.MONITORING_DB,
secrets: (env) => ({ SLACK_WEBHOOK_URL: env.SLACK_WEBHOOK_URL }),
notifiers: [slack({ webhook: '${SLACK_WEBHOOK_URL}' })],
});
export default { fetch: monitor.fetch, scheduled: monitor.scheduled };| Type | Operators |
|---|---|
statusCode |
is, isNot |
header |
is, isNot, contains, notContains, matches |
body |
contains, notContains, matches |
responseTime |
lessThan |
jsonPath |
is, isNot, contains, notContains, matches, lessThan, greaterThan |
Response bodies are read to evaluate assertions and then discarded. Only the failure message is stored, truncated to 256 characters — unless a check opts in to capturing why it failed.
When the receiver is a Worker on the same Cloudflare account, a service binding beats a webhook: the platform authenticates the call, so there is no shared HMAC secret to distribute or rotate and no public inbox to defend.
// receiving Worker
import { WorkerEntrypoint } from 'cloudflare:workers';
export class AlertInbox extends WorkerEntrypoint<Env> {
async alert(event: AlertEvent) { /* … */ }
}import { rpc } from 'clawdwatch';
notifiers: [rpc({ binding: (env) => env.AGENT })]Service bindings are same-account only, so webhook() remains the option for
any other receiver. An RPC call carries no signature — authenticity comes
from the binding — so keep signature verification in your HTTP handler and do
not let shared downstream code assume it ran.
Every alert carries schemaVersion (exported as ALERT_SCHEMA_VERSION). The
contract a receiver can rely on:
- adding an optional field does not bump it —
bodySnippetwas added this way; - removing or renaming a field, or changing its meaning or type, does.
Receivers should ignore unknown fields and must not hard-fail on a version higher than they know — degrade to what they can read. A receiver that rejects unknown versions turns every clawdwatch release into a monitoring outage.
A status-code assertion tells you a check returned 500. It does not tell you
what the 500 said — and that is usually where the cause is. Set
captureBodyOnFailure to keep a short excerpt of the body of a failing
response:
{ "id": "api-health", "url": "https://api.example.com/health",
"assertions": [{ "type": "statusCode", "operator": "is", "value": 200 }],
"captureBodyOnFailure": true }Or turn it on for every check at once, and opt individual checks back out:
createMonitor({ /* … */ defaults: { captureBodyOnFailure: true } });The excerpt is capped at 512 characters, taken only from textual content types, scrubbed of secret values, and never captured for a passing check. It is delivered to webhook notifiers and shown on the dashboard, but deliberately not posted to Slack. See SECURITY.md before enabling it on an endpoint whose error bodies may contain personal data.
A check that needs an API key references it by name:
{ "headers": { "X-Api-Key": "${MY_API_KEY}" } }The value comes from secrets in your config and is substituted when the
request is built. Writing a check that contains a literal secret value is
rejected with a 400 — that guard is what keeps a UI-editable, database-backed
system safe to open-source.
For a token that should apply to a whole domain rather than one check — a WAF
bypass, say — use headerRules:
headerRules: [
{ host: /(^|\.)example\.com$/, headers: { 'x-waf-bypass': '${WAF_TOKEN}' } },
],Four event kinds, batched per run so a ten-endpoint outage is one notification rather than ten:
| Kind | When |
|---|---|
opened |
a check crossed its failure threshold |
recovered |
it passed again, with the downtime measured |
reminder |
still down, on the configured cadence |
summary |
what changed this run, including all-clear |
slack() needs a webhook URL and nothing else. webhook() POSTs the raw
event, signed:
notifiers: [
slack({ webhook: '${SLACK_WEBHOOK_URL}' }),
webhook({ url: '${INBOX_URL}', auth: hmac('${SIGNING_SECRET}') }),
]Every delivery is recorded, so the dashboard can tell you whether the last alert actually arrived.
An agent inbox is just a URL, so webhook() is the whole integration. Nothing
is installed into the agent:
- Each alert carries a
linksobject — incident, ack, annotate, run-now — as short-lived signed URLs. An agent can act on the alert it received without holding any standing credential. GET /api/agent.mddescribes the full API, generated from the route table so it cannot drift. Point your agent at it and that is the entire setup.
An agent triaging an incident can write its findings back with
POST /api/incidents/:id/annotate; the note appears on the incident in the
dashboard.
A worked example: thinkbot is an ops agent built on all of this — it receives alerts over a webhook or an RPC binding, correlates them against GitHub, Datadog, Sentry and Rollbar, and reports what changed. Read it as a reference receiver, or deploy it as-is.
Mount behind Cloudflare Access. Reads are open by default; writes always require a principal — a signed-in person, a service token, or a capability link. There is no query-parameter API key, deliberately: URLs leak into logs, and a shared static secret has no identity, expiry, or revocation.
auth: (env) => ({
teamDomain: env.CF_ACCESS_TEAM_DOMAIN,
aud: env.CF_ACCESS_AUD,
capabilitySecret: env.ALERT_SIGNING_SECRET,
}),Both Access claim shapes are handled: people (email) and service tokens
(common_name).
triptechtravel.github.io/clawdwatch
- Getting started
- Configuration
- Notifiers — Slack, webhook, RPC
- AI agents — body capture, payload versioning
- API reference
- Deploying
npm install
npm test # unit suite, then integration against a real D1
npm run buildThe integration suite applies the shipped migration and runs in workerd, so the SQL is genuinely exercised rather than mocked.
Apache-2.0