# Pingwell documentation Pingwell monitors websites and APIs from eight regions (uptime checks), watches scheduled jobs that ping it (heartbeats), publishes status pages, and alerts by email, Slack, Discord, Telegram, webhooks and PagerDuty. AI agents can set it up for a project through the MCP server at https://mcp.pingwell.dev/mcp. # Getting started Source: https://pingwell.dev/docs Pingwell watches three things for you: sites and APIs we check from the outside (**monitors**), jobs that report in on a schedule (**heartbeats**), and the **status pages** that tell your users what is going on. This page takes you from nothing to all three. Or let your coding agent do it: [Set up with your agent](/docs/agent-setup). ## 1. Sign in Sign in with your Pingwell account. Your first sign-in creates a workspace on the Free plan, with you as its owner. On Pro and Unlimited you can invite teammates under **Settings → Members**. ## 2. Add a monitor Open **Monitors → New monitor** and pick a type: - **HTTP** checks a URL. Set the method, the status codes you expect (`200-299` by default), an optional keyword the response must or must not contain, and a timeout. - **TCP** opens a connection to a host and port, for databases, mail servers and anything else that listens. Choose how often to check and from which regions; the plan sets the shortest interval and how many regions you can pick. The first result arrives within one interval. When a check fails, it is checked again from up to two more regions. The monitor goes **down** when at least two regions, and at least half of those that answered, agree; when fewer fail, it is **degraded**, so one bad network path never pages anyone. ## 3. Add a heartbeat Open **Heartbeats → New heartbeat**, give it a name, the period your job runs on, and a grace time for slow runs. You get a ping URL: ```sh https:///p/ ``` Call it when the job finishes. If no ping arrives within the period, the heartbeat goes **late**; if the grace passes too, it goes **down**. [The ping API](/docs/heartbeats) covers start signals, failures and exit codes. ## 4. Choose where alerts go Under **Alerts → New channel**, add an email channel for the members you pick, or Slack, Discord, Telegram, [PagerDuty](/docs/pagerduty) or a [webhook](/docs/webhooks). Then pick on each monitor or heartbeat which channels hear about it. Send a test from the channel's page to see it arrive. ## 5. Publish a status page Under **Status pages → New page**, add components, give each the monitor or heartbeat it follows, and publish. Read on in [Status pages](/docs/status-pages) for incidents, maintenance, passwords and your own domain. # Set up with your agent Source: https://pingwell.dev/docs/agent-setup Your coding agent already knows your project: its routes, its deploy config, its cron jobs. Connect it to Pingwell and it can read the project, propose the monitoring that fits, create it once you agree, and add the heartbeat pings to your code. Open your project in the agent and say: ```text Set up Pingwell for this project. ``` ## Connect your agent Every client connects to the same server: ```text https://mcp.pingwell.dev/mcp ``` The first time, your browser opens Pingwell. Sign in, or create an account, pick the workspace the agent may manage, or create a new one, and approve. The agent gets access to that one workspace and nothing else. ### Claude Code ```sh claude mcp add --transport http pingwell https://mcp.pingwell.dev/mcp ``` Then run `/mcp` in Claude Code and choose **pingwell** to sign in. ### Cursor Add Pingwell to Cursor, or add it to `.cursor/mcp.json` yourself: ```json { "mcpServers": { "pingwell": { "url": "https://mcp.pingwell.dev/mcp" } } } ``` ### Claude and Claude Desktop Open **Settings → Connectors → Add custom connector**, name it Pingwell and paste the server URL. ### ChatGPT With developer mode on, open **Settings → Connectors → Create**, name it Pingwell and paste the server URL. ChatGPT signs in through the same approval screen. ### CI and headless agents An agent that cannot open a browser uses a workspace API key instead. Create one under **Settings → API keys** and send it as a bearer token: ```sh claude mcp add --transport http pingwell https://mcp.pingwell.dev/mcp \ --header "Authorization: Bearer $PINGWELL_API_KEY" ``` An API key acts as an admin of its workspace. Keep it in your secret store, never in the repository. ## What happens during a setup 1. **It reads the workspace.** The agent learns your plan, what it allows and what already exists, so it never proposes something the plan refuses. 2. **It reads the project.** Health routes and the production URL become monitors. Scheduled jobs (Vercel crons, GitHub Actions schedules, crontab, node-cron, Celery beat, Sidekiq-cron, Kubernetes CronJobs, systemd timers, the Laravel scheduler) become heartbeats. 3. **It proposes.** You see each monitor, heartbeat, status page and alert channel before anything is created. Change what you like. 4. **It creates.** Through the same API the panel uses, with the same validation and plan limits. 5. **It wires the pings.** Each job gets a request to its ping URL at the end of a successful run. The URL comes from an environment variable, and the agent tells you where to set it. 6. **It handles your domain.** On Pro and Unlimited, the agent sets your status page's custom domain and gives you the two DNS records. If it can also reach your DNS provider, through a Cloudflare or other DNS MCP server, it creates them and waits until the domain is live. Nothing is deleted without you. A delete returns a link you open in Pingwell to confirm it. ## The setup skill Agents that support skills set up better with ours. It lists where each framework keeps its schedules, gives ping snippets for each stack, and keeps credentials out of your code. For Claude Code: ```sh mkdir -p .claude/skills/pingwell-setup curl -fsSL https://pingwell.dev/agent/SKILL.md -o .claude/skills/pingwell-setup/SKILL.md ``` Other agents can read the same file at [pingwell.dev/agent/SKILL.md](/agent/SKILL.md). The whole documentation is also available as plain text for agents at [/llms-full.txt](/llms-full.txt). ## Revoke access Every connected agent is listed under **Settings → Connected agents**, with when it last acted. Revoking one ends its access at once. Removing a person from the workspace also ends the access of every agent they connected. # MCP reference Source: https://pingwell.dev/docs/mcp Pingwell's MCP server lets an AI agent read and configure one Pingwell workspace. It speaks the [Model Context Protocol](https://modelcontextprotocol.io) over streamable HTTP. [Set up with your agent](/docs/agent-setup) covers connecting each client. ## Endpoint | | | |---|---| | URL | `https://mcp.pingwell.dev/mcp` | | Transport | Streamable HTTP, `POST` only. Each request stands alone: there are no sessions to resume. | | Auth | OAuth 2.1 with PKCE through the client's own sign-in (scopes `pingwell:read` and `pingwell:write`, one workspace per connection), or a workspace API key as `Authorization: Bearer ` | | Scope | One workspace, the one approved at sign-in or the API key's own | A request without valid credentials gets `401` with a `WWW-Authenticate` header pointing at the server's OAuth metadata, which MCP clients follow to sign in. ## Tools Tools work in the connected workspace, so none takes a workspace id. Every tool says whether it only reads, whether it changes things, and whether it reaches outside Pingwell, so your client can ask before the ones that change things. ### Setting a project up | Tool | What it does | |---|---| | `get_setup_context` | The plan, what it allows and what is used, the regions, the ping base URL, the records an earlier setup made (by key) and the ones made another way. Agents call it first. | | `plan_setup` | Describe the whole setup, with a stable key for each record, and get back what applying it would create, change or remove, and anything the plan does not allow. Nothing changes yet. | | `apply_setup` | Carry out a plan once the person agrees. Returns heartbeat ping URLs and DNS records. Reusing a key later updates that record. | | `get_approval` | Whether the person has approved a removal. Plans that remove records, and the delete tools, wait for their approval. | ### Workspace | Tool | What it does | |---|---| | `get_workspace` | The plan, what it allows, what is used, and the base URL pings go to. | | `list_probe_regions` | The regions checks run from. | | `get_overview` | What is up and down right now. | ### Monitors | Tool | What it does | |---|---| | `list_monitors`, `get_monitor` | Read uptime checks and their state. | | `create_monitor` | Add an HTTP check (a URL) or a TCP check (a host and port). | | `update_monitor` | Change any field. | | `pause_monitor`, `resume_monitor` | Stop and restart checking. Nothing is lost. | ### Heartbeats | Tool | What it does | |---|---| | `list_heartbeats`, `get_heartbeat` | Read heartbeats, with their ping URLs. | | `create_heartbeat` | Watch a scheduled job. Returns the ping URL and examples. | | `update_heartbeat` | Change the name, period, grace or labels. | | `pause_heartbeat`, `resume_heartbeat` | Stop and start expecting pings. | ### Status pages | Tool | What it does | |---|---| | `list_status_pages`, `get_status_page` | Read pages, their components and domain state. | | `create_status_page`, `update_status_page` | Create a page or change its name, slug, visibility, language or password. | | `set_status_page_components` | Choose what the page shows: monitors, heartbeats, fixed statuses or regions. | | `publish_status_page` | Push the page live now. | | `set_custom_domain` | Serve the page on your hostname. Returns the CNAME and TXT records to create. Pro and Unlimited. | | `get_custom_domain_status` | Whether the domain is live yet, and the records it still needs. | ### Alerts | Tool | What it does | |---|---| | `list_channels`, `create_channel`, `update_channel` | Email to members, Slack, Discord, webhooks and PagerDuty. | | `test_channel` | Send a real test message. It reaches people, so agents say so first. | | `set_check_channels` | Choose which channels a monitor or heartbeat alerts. | | `list_channel_groups`, `create_channel_group`, `update_channel_group` | Named sets of channels. | ### Docs | Tool | What it does | |---|---| | `search_docs` | Search this documentation. | ## Errors A refused change comes back as a tool error the agent can act on: - **Invalid input** names each field and what is wrong with it. - **A plan limit** says which limit and its value, and carries the link to upgrade under **Settings → Billing**. Agents relay it rather than retry. - **Not found** means the id is not in this workspace. - **Rate limited** means too many changes in a minute; wait and retry. Every tool goes through the same API the panel uses, with the same checks. Nothing an agent does can get around a plan limit or a role. ## Security - **One workspace per connection.** Approving an agent grants it the workspace you pick, with your role there, and no other. - **Revocable.** **Settings → Connected agents** lists every agent with when it last acted. Revoking one ends its access at once, and removing a person from the workspace ends the access of every agent they connected. - **Short-lived tokens.** Access tokens last an hour and refresh tokens rotate on every use. A stolen refresh token that is used twice revokes the whole connection. - **Deletes need you.** No tool deletes anything directly. A delete returns a link you open in Pingwell to confirm. - **No secrets in code.** Agents put ping URLs in configuration, never in committed files, and never write API keys or tokens anywhere. # Heartbeat pings Source: https://pingwell.dev/docs/heartbeats A heartbeat expects a ping at least once per **period**. If none arrives within the period it goes **late**; once the **grace** has passed as well it goes **down**, and your channels hear about it. The URL is the credential: anyone with it can ping, so keep it out of public logs. ## Endpoints Every endpoint accepts `GET` and `POST`, needs no authentication, and never redirects. | Request | Meaning | |---|---| | `/p/` | The run succeeded. | | `/p//start` | A run started. A later success or failure ends it and records its duration. | | `/p//fail` | The run failed: the heartbeat goes down at once. | | `/p//` | The run ended with this exit code, `0` to `255`. `0` is a success; anything else is a failure. | Add `?rid=` to tie a start to its end when runs overlap. A run id is 1 to 64 of `A-Z a-z 0-9 . _ : -`. A `POST` body is kept with the ping: the first 10 KB of up to 100 KB, shown on the heartbeat's page. Send the tail of your job's log and you will know why it failed without logging in to the server. ## Responses Plain text, never cached: | Status | Body | Meaning | |---|---|---| | `200` | `OK` | Recorded. | | `400` | an explanation | The run id is malformed. | | `404` | `not found` | No heartbeat has this key, or the path is not a ping URL. | | `413` | `too large` | The body is over 100 KB. | | `429` | `rate limited` | Too many pings for this key; `Retry-After` says when to try again. | ## Examples ### curl ```sh # At the end of a script curl -fsS -m 10 --retry 3 https:///p/ > /dev/null ``` ### crontab A crontab entry is one line, run by `/bin/sh`. Put the job and its report in a small script: ```sh #!/bin/sh # /usr/local/bin/backup-and-report.sh: report the exit code, with the end of the log as the body /usr/local/bin/backup.sh > /tmp/backup.log 2>&1 code=$? tail -c 10000 /tmp/backup.log | curl -fsS -m 10 --retry 3 --data-binary @- "https:///p//$code" > /dev/null ``` ```sh 0 3 * * * /usr/local/bin/backup-and-report.sh ``` ### Start and finish ```sh RID=$(date +%s) curl -fsS -m 10 "https:///p//start?rid=$RID" ./long-job.sh code=$? curl -fsS -m 10 "https:///p//$code?rid=$RID" ``` ### GitHub Actions ```yaml jobs: nightly: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: ./scripts/nightly.sh - name: Report to Pingwell if: always() run: | if [ "${{ job.status }}" = "success" ]; then path=""; else path="/fail"; fi curl -fsS -m 10 --retry 3 "https:///p/${{ secrets.PINGWELL_PING_KEY }}$path" ``` Source: https://pingwell.dev/docs/webhooks # Alert webhooks A webhook channel POSTs one JSON document per alert to the channel's https URL. This is the contract receivers build against; a change a receiver could trip on bumps `version`. ## Request ``` POST Content-Type: application/json User-Agent: Pingwell-Alerts/1 X-Pingwell-Delivery: 5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d X-Pingwell-Timestamp: 1792962000 X-Pingwell-Signature: sha256= ``` ```json { "id": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "version": 1, "event": "down", "check": { "type": "monitor", "id": "0b6f1a52-…", "name": "Public API" }, "status": "down", "reason": "failing in us-east-1 (timeout), sa-east-1 (timeout)", "at": "2026-10-25T12:00:00.000Z", "url": "https://app.pingwell.dev/monitors/0b6f1a52-…" } ``` | Field | Meaning | |---|---| | `id` | The delivery. The same on every retry of it, and in `X-Pingwell-Delivery`: drop one you have already handled. | | `version` | `1`. | | `event` | `down`, `degraded` (monitors), `late` (heartbeats), `detected` and `changed` (posture and security findings), `recovered`, `resolved` (security findings), `test`, or `digest` (its own shape, below). Ignore an `event` you don't handle. | | `check` | The monitor or heartbeat, or for posture the host or domain (`type` is `host` or `domain`); `null` on a `test`. | | `finding` | Only on posture events: `{ "check", "code", "key", "severity", "params" }`, below. | | `channel` | Only on a `test`: `{ "id", "name" }` of the channel that sent it. | | `status` | The check's status after the transition: `down`, `degraded`, `late`, `up`, or `test`; for posture `detected`, `changed`, or `ok` once a finding is gone. | | `reason` | Failing regions and probe reasons, or the heartbeat's reason; may be `null`. | | `at` | When the transition happened, ISO 8601 UTC with milliseconds. | | `url` | The check in the control panel. | `degraded` and `late` are sent only if still true two minutes later. `recovered` is sent only to a channel that heard of the problem it ends. Nothing repeats while a check stays down. ## Posture findings Pingwell checks the hosts and domains your HTTP monitors use: TLS certificates, domain registration, security headers, exposed files and DNS records. A finding arrives as `detected` when first seen and `recovered` when it is gone. A DNS record that moved arrives as `changed`, a note rather than a problem, with the records before and after in `reason`. Posture events are sent at once, never held, and a posture `recovered` goes out whether or not the channel heard the finding. PagerDuty channels get `detected` and `recovered`, never `changed`. `finding.check` is one of `tls`, `domain_expiry`, `headers`, `exposed_files` or `dns`. `code` names the finding (`tls.expiring`, `headers.no_hsts`, `exposed.file`, `dns.changed` and so on), and `params` carries its values, such as the `days` left or the exposed `path`. `key` tells apart findings of one code: the days-left threshold an expiry crossed (21, 7 or 1 for a certificate, 30, 7 or 1 for a domain), or a file's path. `severity` is `critical`, `high`, `low` or `info`. New codes may appear: treat one you don't know by its severity. ## Security findings Pingwell reads the Dependabot and code-scanning alerts of the repos you connect, and matches every component list it holds for a repo (an SBOM or lockfile from CI, or a lockfile read from GitHub) against [OSV.dev](https://osv.dev). It does that when the list arrives, again within the hour after an advisory naming one of its packages is published or changed, and weekly. A Dependabot alert and an OSV match of the same advisory, package and manifest are one finding. A finding arrives as `detected` when it opens (or reopens), `changed` when an open finding's severity moves, and `resolved` once nothing reports it open any more: GitHub fixed or dismissed it, or the version is gone from the component list. A dismissal on GitHub closes the finding even while OSV still matches it. `check` is the repo (`type: "repo"`, `name` its `owner/name`), and `status` is the finding's state: `open`, `fixed`, `dismissed` or `auto_dismissed`. A repo's first scan, and the first match of each component list, record what is already there without sending anything, except critical findings. `finding` carries `id` (Pingwell's, stable across events), `kind` (`dependency` or `code`), `canonicalId` (the CVE, else the GHSA, else the OSV id, or the code-scanning rule), `aliases` (the other ids), `severity`, `state`, `summary`, `purl` (the package, without a version) and `manifest` for a dependency, `ruleId` and `path` for code scanning, and `url` (the alert on GitHub, or the advisory on osv.dev when only OSV reports it). Where both report a finding, GitHub's summary, severity and link are the ones sent. PagerDuty channels get one incident per finding, resolved with it. ## Issue SLAs A rule that opens issues can set targets for acknowledging and closing them, per severity. An issue still open past its time to acknowledge, or not closed past its time to close, sends `sla_breached`, once per target. Reopening an issue starts its timers again. Once an issue that breached closes, it sends `resolved`; an issue that never breached closes without sending anything. `check` is the issue (`type: "issue"`, its `id`, and as `name` the check, host, domain or repo it is about), and `url` opens it. `status` is the issue's state: `open`, `acknowledged`, `in_progress` or `closed`. `issue` carries `id`, `title`, `severity`, `state`, `target` (`ack` or `close`, on a breach) and `dueAt`. PagerDuty channels get one incident per issue, opened by the breach and resolved by the close. ## Digests A rule with a digest action batches the signals it matches into one delivery per channel group and slot (hourly, daily or weekly, in the workspace's timezone). A webhook in that group gets a document of its own shape, with `event: "digest"`. It arrives only once someone sets up a digest rule naming the webhook's group, so a receiver that handles only alerts never sees one unasked. Headers and signature are the same as an alert's. ```json { "id": "9f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a", "version": 1, "event": "digest", "group": { "id": "1a2b3c4d-…", "name": "Payments" }, "cadence": "daily", "slotEnd": "2026-10-28T09:00:00.000Z", "total": 5, "sources": [ { "id": "0b6f1a52-…", "type": "monitor", "name": "Public API", "url": "https://app.pingwell.dev/monitors/0b6f1a52-…", "counts": { "down": 2, "recovered": 2 } } ], "moreSources": 0, "url": "https://app.pingwell.dev/alerts/rules" } ``` | Field | Meaning | |---|---| | `id` | The delivery, as for alerts: the same on every retry. | | `group` | The channel group the digest went to; `id` is `null` if the group was deleted after the slot closed. | | `cadence` | `hourly`, `daily` or `weekly`. | | `slotEnd` | The slot's end (its boundary), ISO 8601 UTC. The digest is sent at or after it. | | `total` | Every signal in the slot, those of sources left out of `sources` included. | | `sources` | The busiest sources first, at most 20, each with a count per event. | | `moreSources` | How many sources the list left out. | | `url` | The alert rules in the control panel. | A digest has no `check`, `status`, `reason` or `at`. An empty slot sends nothing. A signal routed after its slot already went out arrives in a second, smaller digest with the same `slotEnd`. ## Verifying the signature The signature is HMAC-SHA256, keyed with the channel's signing secret (`whsec_…`, shown once when the channel is created), over the timestamp header, a dot, and the raw request body: ```js import { createHmac, timingSafeEqual } from "node:crypto"; function verify(secret, headers, rawBody) { const timestamp = headers["x-pingwell-timestamp"]; const expected = "sha256=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"); const given = Buffer.from(headers["x-pingwell-signature"] ?? ""); const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300; return fresh && given.length === expected.length && timingSafeEqual(given, Buffer.from(expected)); } ``` `X-Pingwell-Timestamp` is unix seconds at sending time (each retry is signed afresh); refusing old timestamps stops a captured delivery from being replayed. ## Responses and retries Any 2xx is delivered. A 5xx, a 429, a timeout (10 s) or a connection failure is retried after 30 s, 2 min, 10 min and 30 min; the fifth failure, or any other 4xx at once, is dead and shows on the channel. Redirects are not followed. The URL must be https and resolve only to public addresses. # Status pages Source: https://pingwell.dev/docs/status-pages A status page shows your users the state of what they depend on. It is published to the edge, so it stays up when your own servers are the thing that is down. ## Components A page is a list of components, such as "API" or "Dashboard". Give each component the monitor or heartbeat behind it and it follows that check: operational while it is up, degraded or down with it. Or give a component a status you set by hand instead of a check. Each component carries its daily history, so visitors see how the last weeks went, not only this minute. Check regions are off by default. Turned on in the page's settings, each check that runs in two or more regions gains an expander listing Pingwell's probe regions with their latest result, latency and 24-hour uptime, and a note when one of our probes is degraded. Turn it on when your visitors care where a slowdown is coming from. ## Incidents and maintenance Post an **incident** when something is wrong: a title, an impact (none, minor, major or critical), the components it touches and an update. Add updates as you learn more and resolve it when it is over. Schedule **maintenance** ahead of time with a start and an end. During the window the components show maintenance instead of down, and the page says what is planned. Resolved incidents and finished maintenance stay on the page for a while, then drop off. ## Password-protected pages On Pro and Unlimited, a page can ask for a password, for an internal audience or a single customer. Visitors see nothing of the page until they enter it. ## Your own domain On Pro and Unlimited, a page can live at a hostname you own, such as `status.example.com`: 1. Add the hostname on the page's **Domain** settings. The panel shows two DNS records. 2. Publish both at your DNS provider: a `CNAME` for the hostname, and a `TXT` record at `_pingwell.` that proves the domain is yours. 3. The domain goes live once both records are in place and its certificate is issued, usually within minutes. A domain still pending after seven days is released. The page stays reachable at its Pingwell address as well. ## JSON and a badge Every public page also serves its current state as JSON at `/status.json`, for your own dashboards, and a status badge at `/badge.svg` for a README or a footer. # Alert through PagerDuty Source: https://pingwell.dev/docs/pagerduty A PagerDuty channel turns a check's problems into one PagerDuty incident and resolves it when the check recovers. Pingwell sends through PagerDuty's Events API v2; nothing comes back the other way, so acknowledging in PagerDuty changes nothing in Pingwell. ## Connect a service 1. In PagerDuty, open the service that should page and go to **Integrations → Add integration → Events API v2**. 2. Copy the **integration key** (32 characters). 3. In Pingwell, open **Alerts → Add alert channel → PagerDuty**. Paste the key, pick the region your PagerDuty account lives in (United States or European Union), and add the channel. The region picks the endpoint: `events.pagerduty.com` for a US-hosted account, `events.eu.pagerduty.com` for an EU-hosted one. A key from an EU account is refused on the US endpoint, and the other way round, so the alert dies with PagerDuty's message until the region matches. The key is kept and never shown again. Put the channel in a group or on the checks that should page, like any other channel. ## What pages, and when | In Pingwell | In PagerDuty | |---|---| | A monitor or heartbeat goes **down** | An incident opens, severity `critical` | | A monitor is **degraded**, or a heartbeat is **late**, and still is two minutes later | An incident opens, severity `warning` | | The check **recovers** | The incident resolves | Every event about one check carries the same dedup key, `pingwell:monitor:` or `pingwell:heartbeat:`, so a check has one open incident at a time. A warning followed by a down raises that incident instead of opening a second one; the recovery resolves it. A recovery is sent only to a channel that heard about the problem, so Pingwell never resolves an incident it did not open. The incident's summary is the alert's headline in the channel's language, with the failing regions or the heartbeat's reason. `custom_details` carries the check (type, id and name), the event, the reason and a link to the check in Pingwell; so does the incident's **Open in Pingwell** link. ## Test **Send test** on the channel opens a test incident at severity `info` on its own key and resolves it straight away. Whether an `info` event pages anyone is your service's urgency rule in PagerDuty. ## Replacing the key Open the channel's settings and paste a new key under **Replace integration key**; leave the field empty to keep the current one. The region can be changed there too. ## Failures A key PagerDuty refuses marks the alert dead at once, with PagerDuty's message, on the channel's page. A rate limit or an outage on their side is retried after 30 s, 2 min, 10 min and 30 min, like a webhook. # Set up Pingwell for this project You have the Pingwell MCP server (`https://mcp.pingwell.dev/mcp`). It works in one Pingwell workspace: the one the person approved when they connected. Your job is to read the project, propose a monitoring setup that fits the workspace's plan, create it once the person agrees, and change the code so scheduled jobs report in. ## 1. Learn the workspace Call `get_setup_context` first. In one answer it gives: - the plan, what it allows (monitors, heartbeats, shortest interval, regions, alert channels, status pages, custom domain) and what is already used; - the probe regions; - `managed`: the records an earlier setup made, each with its `key`; - `unmanaged`: the records made some other way, in the panel or by hand. Reuse a `managed` record's key to change it. Don't propose anything already listed under `unmanaged`. ## 2. Read the project Look for **what the outside world reaches**. Each is a candidate monitor: - Health routes: `/health`, `/healthz`, `/status`, `/api/health`, `/up` (Rails), `/ping`. Search the router code (Express/Fastify/Hono `get("/health"`, Next.js `app/api/health/route.ts`, Django `urls.py`, Rails `routes.rb`, Laravel `routes/web.php`, Go `http.HandleFunc`). - The production URL: `homepage` in package.json, the domain in `vercel.json`, `netlify.toml`, `fly.toml`, `wrangler.toml` (`routes`), `render.yaml`, `app.yaml`, Terraform DNS records, the README, `.env.example` keys like `APP_URL` or `PUBLIC_URL`. - Things that listen but don't speak HTTP: a database or mail server the person runs themselves is a TCP monitor (`host` and `port`). Skip managed services the provider already watches. Only public targets work: Pingwell refuses private, loopback and link-local addresses. Never monitor `localhost` or a staging URL behind a VPN. Look for **what runs on a schedule**. Each is a candidate heartbeat: | Where | What to look for | |---|---| | Vercel | `crons` in `vercel.json` (`path`, `schedule`) | | GitHub Actions | `on: schedule: - cron:` in `.github/workflows/*.yml` | | crontab | `crontab` files, `cron.d/`, Dockerfiles that install cron, `supercronic` | | Node | `node-cron`, `cron`, `croner`, `agenda`, `bree`, BullMQ repeatable jobs | | Python | Celery beat (`beat_schedule`, `CELERY_BEAT_SCHEDULE`), APScheduler, `schedule`, Django-Q, RQ scheduler | | Ruby | `sidekiq-cron` (`config/schedule.yml`), `whenever` (`config/schedule.rb`), GoodJob cron, Solid Queue recurring tasks | | PHP | Laravel scheduler (`routes/console.php`, `app/Console/Kernel.php` `schedule()`) | | Kubernetes | `kind: CronJob` manifests and Helm templates | | systemd | `*.timer` units | | Cloud | Cloudflare Workers `triggers.crons` in `wrangler.toml`, AWS EventBridge schedules, Google Cloud Scheduler, Fly machines with `schedule` | For each job, work out how often it runs (the period) and how long a run can take. Grace is how long past the period Pingwell waits before alerting: give slow jobs more, at least the longest normal run. ## 3. Plan the setup Put the whole setup in one `plan_setup` call: - each monitor: name, URL or host and port, interval (at least the plan's minimum, a multiple of 10 seconds), regions; - each heartbeat: name, the job it watches, period and grace; - a status page if the project has users who would read one: its name, slug and components; - where alerts go: email to the person is the default; add Slack, Discord, a webhook or PagerDuty only if the person gives you the destination; - `routing`: which channels each check alerts. Give every record a stable `key` that names what it watches, such as `api-health`, `nightly-backup` or `ops-slack`. Keep the same key when you plan again: Pingwell updates that record instead of making a second one. A new key makes a new record. ```json { "monitors": [{ "key": "api-health", "monitor": { "name": "API health", "type": "http", "url": "https://api.example.com/health", "intervalSeconds": 60 } }], "heartbeats": [{ "key": "nightly-backup", "heartbeat": { "name": "Nightly backup", "periodSeconds": 86400, "graceSeconds": 3600 } }], "statusPage": { "key": "public", "page": { "name": "Example status", "slug": "example" }, "components": [{ "name": "API", "monitor": "api-health" }] } } ``` Nothing changes yet. The plan says what applying would create, change or remove, and anything the workspace's plan doesn't allow. Show it to the person. If it doesn't fit the plan, say what doesn't fit and offer a smaller setup or the upgrade link the plan returns. `prune: true` also removes records an earlier setup made that the manifest no longer lists. A plan that removes anything comes with an approval link: give it to the person, and check `get_approval` until they have approved. ## 4. Apply it Once the person agrees, call `apply_setup` with the plan's `planId`. It runs the plan exactly as planned, in the right order: channels, checks, routing, the status page, then publishing. It stops at the first failure and reports what was done, what failed and what was skipped. Tool errors say what to fix: a validation error names the field, a plan limit carries the upgrade link. Relay those instead of guessing, fix the setup, and call `plan_setup` again: it picks up from wherever the last run stopped. A plan expires after 30 minutes. If it has, or the workspace changed, plan again. For a one-off change later, such as one more heartbeat, the individual tools (`create_heartbeat` and the rest) work too. ## 5. Wire the heartbeat pings in `apply_setup` returns each new heartbeat's ping URL in `next` (`create_heartbeat` returns it too). Add a request to it at the end of each job's **successful** run, so a crash or a hang means no ping. Report failures explicitly when the job can tell: `/fail`, or the exit code as `/`. Keep the URL out of the code: read it from configuration (`PINGWELL_PING_URL_` or the project's own config system), add the variable to `.env.example` with an empty value, and tell the person where to set the real value (their host's environment settings, GitHub Actions secrets, a Kubernetes Secret). Anyone with a ping URL can ping it. **Never** write an API key, an OAuth token or any other credential into the code or a committed file. Shell or crontab: ```sh /usr/local/bin/backup.sh && curl -fsS -m 10 --retry 3 "$PINGWELL_PING_URL_BACKUP" > /dev/null ``` Node: ```js await runJob(); await fetch(process.env.PINGWELL_PING_URL_REPORTS, { method: "POST", signal: AbortSignal.timeout(10_000) }); ``` Python: ```python import os, urllib.request run_job() urllib.request.urlopen(os.environ["PINGWELL_PING_URL_SYNC"], timeout=10) ``` Ruby: ```ruby run_job Net::HTTP.get(URI(ENV.fetch("PINGWELL_PING_URL_INVOICES"))) ``` PHP (Laravel): ```php Schedule::command('reports:send')->daily()->pingOnSuccess(env('PINGWELL_PING_URL_REPORTS')); ``` Go: ```go if err := runJob(); err == nil { client := http.Client{Timeout: 10 * time.Second} client.Get(os.Getenv("PINGWELL_PING_URL_EXPORT")) } ``` GitHub Actions (the URL in a repository secret): ```yaml - name: Report to Pingwell if: always() run: | if [ "${{ job.status }}" = "success" ]; then suffix=""; else suffix="/fail"; fi curl -fsS -m 10 --retry 3 "${{ secrets.PINGWELL_PING_URL }}$suffix" ``` Kubernetes CronJob: chain the ping after the job in the container's command, with the URL from a Secret through `env.valueFrom.secretKeyRef`. Vercel cron: the cron calls a route, so ping at the end of that route's handler, after the work succeeds. ## 6. A custom domain for the status page Custom domains need a paid plan. Put the hostname in the setup's `statusPage.customDomain`, or call `set_custom_domain`. Either way you get two DNS records back: a CNAME and a TXT that proves ownership. - If you have a DNS tool for that domain (a Cloudflare, Route 53 or other DNS MCP server), create both records with it. The CNAME must be DNS-only, not proxied. - Otherwise give the person both records exactly as returned, and say where to add them. Then poll `get_custom_domain_status` every minute or so until it reports `active`. It usually takes a few minutes once both records resolve. ## 7. Finish Summarize what was created, with links: the panel at `https://app.pingwell.dev`, the status page URL, and each heartbeat's job with the variable the person must set. Say which pings will start arriving after their next deploy. Removing anything is the person's call. A delete tool doesn't delete: it returns an approval link the person opens to confirm, so give them the link. Where no delete tool exists, ask them to remove it in the panel. Don't look for another way around it. `search_docs` answers questions about the ping API, status pages, domains and alert channels. The full docs are at https://pingwell.dev/docs.