Get started
Set up in one command
One command reads your repo and sets up monitors for the real endpoints, heartbeats for the real cron jobs, a status page and alerts. No account needed to start.
npx upbutler initRun it in the project root (Node 18+ or Bun). It scans the repo, shows the plan, asks where alerts should go, and then:
- creates a workspace (no sign-up; the email you give gets a link to claim it),
- writes
upbutler.yamland applies it: HTTP monitors, heartbeats, a status page, - inserts the heartbeat ping into each cron job where the place is unambiguous, after showing the diff,
- adds a short block to
AGENTS.md(andCLAUDE.mdif you have one) so the next coding-agent session knows the project is monitored.
In Claude Code with the UpButler plugin, type /upbutler:setup instead.
See the plan first
$ npx upbutler audit
acme-shop: Next.js, Vercel, Stripe, Supabase, OpenAI
Scanned 15 files: 10 routes, 1 scheduled job, 8 env var names (names only, never values).
Production URL: https://acme-shop.com (.env.example (NEXT_PUBLIC_SITE_URL))
What would break without you noticing (6):
• The site goes down or its certificate expires [✓ monitor homepage]
• /api/health stops answering [✓ monitor api-health]
• Cron: daily-digest silently stops running [✓ heartbeat cron-daily-digest]
• Stripe webhooks stop arriving [✓ heartbeat stripe-webhooks]
• Supabase fails [○ optional supabase-alive]
• OpenAI fails [○ optional openai-llm]
"upbutler init" would set up: 4 HTTP monitors, 2 heartbeats, 1 status page, 2 optional monitors (need keys).init also writes repo: owner/name into the file, read from the git remote (override with --repo). It links incidents to commits and diffs. With --fix-with-claude it additionally turns on Fix with Claude: it asks for a fine-grained GitHub token, creates the responder and writes .github/workflows/upbutler-fix.yml (--fix-delivery local or routine for the other two ways).
upbutler audit (the same as init --dry-run) needs no account and makes no network request. It prints what would break without anyone noticing and what init would set up.
What it recognises
| In the repo | Becomes |
|---|---|
| Routes: Next.js (app and pages router), Remix / React Router, SvelteKit, Nuxt, Astro, Express, Hono, Fastify, NestJS, FastAPI, Flask, Django, Rails, Go (net/http, chi, gin, echo) | HTTP monitors for the homepage, health endpoints and a few key public pages |
Schedules: vercel.json crons, GitHub Actions schedule, node-cron, Celery beat, sidekiq-cron, Cloudflare cron triggers, Render cron jobs | Heartbeats, with the period taken from the cron expression |
| A Stripe webhook route | A heartbeat that alerts when no webhook arrived for 3 days |
Env var names such as SUPABASE_*, OPENAI_*, ANTHROPIC_*, OPENROUTER_* | Optional monitors, commented out in the file because they need a key |
DATABASE_URL, REDIS_URL, RESEND_*, POSTMARK_* | Advice in the report: these cannot be checked from outside, so the health route should cover them |
Deploy config (vercel.json, netlify.toml, fly.toml, wrangler.toml, render.yaml, railway.json, Dockerfile) | The production URL, and a deploy hook to paste into the host |
Never monitored: routes that only accept POST, PUT, PATCH or DELETE, routes behind a login (by path, middleware matcher or the handler's own auth check), and dynamic routes. The plan lists them under "Left alone" with the reason. Proposals stop at 10 monitors, the Free plan; raise it with --max-monitors.
Heartbeat pings in your code
app/api/cron/daily-digest/route.ts:9
+ // upbutler:heartbeat cron-daily-digest — tells UpButler this job ran (see upbutler.yaml)
+ await fetch("https://upbutler.com/hb/hb_…").catch(() => {});A ping is inserted only at the end of a handler's success path: before the final return of a route handler, at the end of a node-cron callback or Celery task, or as a last step of a single-job workflow. When a handler returns from inside try blocks or branches, or the schedule calls a function defined elsewhere, init leaves the code alone and prints the snippet to paste. Re-running never inserts twice. Skip this step with --no-code-changes.
The ping URL contains the heartbeat token. It can only report that the job ran; if your repository is public, move it to an environment variable.
upbutler.yaml
# yaml-language-server: $schema=https://upbutler.com/schemas/upbutler.yaml.json
version: 1
project: acme-shop
baseUrl: https://acme-shop.com
monitors:
- id: homepage
name: Homepage
url: https://acme-shop.com/
sslExpiryDays: 14
- id: api-health
name: API health (/api/health)
url: https://acme-shop.com/api/health
heartbeats:
- id: cron-daily-digest
name: "Cron: daily-digest"
schedule: 0 5 * * *
periodSec: 86400
graceSec: 3600
source: app/api/cron/daily-digest/route.ts
statusPages:
- id: status
name: Acme Shop Status
groups:
- name: Website
components:
- id: homepage
name: Website
monitors: [homepage]
alerts:
channels: [Email] # by name; channels are created in the dashboard or with channels.create
deploy:
guard: true
monitors: [homepage, api-health]
responders: [fix-with-claude] # agent responders paged firstCommit it. Every entry has a stable id: renaming a monitor in the file renames it in UpButler and keeps its history. Only the fields you write are managed; anything you leave out keeps whatever was set in the dashboard. Monitor entries accept the same fields as the API. Editors that support the YAML language server get completion from /schemas/upbutler.yaml.json.
Error-rate monitors
monitors:
- id: shop-errors
kind: error_rate
errorRate:
provider: vercel # vercel | netlify | cloudflare | generic
include: ["/api/"] # only these path prefixes (default: all paths)
downAtPct: 5 # 5xx rate that means down
degradedAtPct: 1
minRequests: 20 # fewer requests in the window can't take it down
secret: ${env:VERCEL_DRAIN_SECRET} # optional: the drain's signature secretAn error-rate monitor (kind: error_rate) is fed by your host's log drain. The drain URL holds a secret token, so it is never written to the file: upbutler apply prints it once, when it creates the monitor (resources.monitors.<id>.drainUrl in the JSON output). Paste it at your hosting provider. Lost it? POST /monitors/:id/error-rate/rotate-token mints a new one.
A spend monitor is an entry with a spend object (dailyCapUsd, monthlyBudgetUsd, anomaly, sources, autoStopRuns). The source key runs works in any workspace; balance:… and provider:… keys are ids of one workspace.
Secrets
monitors:
- id: openai-llm
kind: llm
url: https://api.openai.com/v1
llm:
model: gpt-5-mini
apiKey: ${env:OPENAI_API_KEY}${env:NAME} is filled in by upbutler apply from the environment of the shell or CI job, never from files. The value is stored encrypted and never returned. Changing a secret in the file is detected on the next apply.
Keep it in sync
upbutler plan # what would change (nothing is written)
upbutler plan --exit-code # CI: exit 2 when the file and the workspace differ
upbutler apply # create and update; never deletes
upbutler apply --prune # also delete what left the file
upbutler export -o upbutler.yaml # existing workspace → file$ upbutler plan
~ heartbeat cron-daily-digest changed: periodSec
+ monitor page-docs
? monitor page-login no longer in the file (kept; --prune deletes it)
Plan: 1 to create, 1 to update, 0 to delete, 9 unchanged, 1 not in the file.- Idempotent. Applying the same file twice changes nothing.
- Nothing is deleted unless you pass
--prune, and prune only touches resources this project's file created or adopted. Monitors made by hand or by another repo are never candidates. - Several repos, one workspace.
projectscopes everything a file manages. - Already set up by hand?
upbutler exportwrites the file from your workspace; applying it changes nothing and adopts the resources. - After adding an endpoint or job, run
upbutler initagain: it adds the new entries to the existing file and leaves the rest of it alone.
For agents
npx upbutler init --yes --email [email protected] --json--yes asks nothing; --json prints one object with the changes, heartbeat ping URLs, status page URL, claim status, files changed and next steps. Without --email the claim link is in the output: show it to your human. Over MCP or REST the same reconcile is the setup_apply tool (POST /api/v1/setup/apply), which takes the file as JSON (config) or text (yaml), with dryRun and prune; setup_export is the reverse.
curl -X POST https://upbutler.com/api/v1/setup/apply \
-H "Authorization: Bearer $UPBUTLER_API_KEY" -H "Content-Type: application/json" \
-d '{"dryRun": true, "config": {"version": 1, "project": "acme", "monitors": [{"id": "homepage", "url": "https://acme.com/"}]}}'Options
| Flag | Meaning |
|---|---|
--url | Production URL, when it cannot be inferred from the repo |
--email | Where alerts and the claim link go |
--alerts | A Slack, Discord or webhook URL to alert right away |
--yes, --json | Non-interactive, machine-readable |
--dry-run | Report only; no account, no request |
--no-code-changes | Do not edit job code; print the pings instead |
--max-monitors | Most monitors to propose (default 10) |
--dir, -f | Project root and file name |