---
name: pingwell-setup
description: Set up Pingwell monitoring for the current project. Finds its public endpoints and scheduled jobs, proposes uptime checks, heartbeats, a status page and alert channels, creates them through the Pingwell MCP server once the person agrees, and wires heartbeat pings into the code. Use when the person says "set up Pingwell", "monitor this project", "add uptime monitoring" or "watch my cron jobs".
---

# 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 `/<code>`.

Keep the URL out of the code: read it from configuration (`PINGWELL_PING_URL_<JOB>` 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.
