# Repose — uptime monitoring for agents Repose (https://repose.sh) is uptime monitoring with consensus-based alerting: checks run from real probe machines in three regions on three continents (Amsterdam, Virginia, Singapore), and by default all three must agree before an alert fires. Three regions on three continents means the default threshold is unanimity — the strictest rule in the category, and the reason a single-region blip never pages anyone. Every alert decision is recorded in an immutable audit trail. ## Connect via MCP (preferred) Endpoint: https://repose.sh/api/mcp (streamable HTTP) Auth: Authorization: Bearer rsk_… (create a token in Settings → API tokens; read-write scope for setup) Claude Code: claude mcp add repose --transport http https://repose.sh/api/mcp \ --header "Authorization: Bearer rsk_YOUR_TOKEN" Tools: get_workspace_overview (call first), list_monitors, get_monitor, list_incidents, create_monitor, update_monitor, create_notification_channel, create_maintenance_window, update_status_page, acknowledge_incident, record_deploy. ## Typical automated setup 1. get_workspace_overview — see plan, limits, and what exists. 2. create_monitor for each service: - kind http: { name, url, intervalSeconds, expectedStatus } - kind tcp: { name, host, port } · kind dns/ssl: { name, host } - kind heartbeat: returns a ping URL for your cron job to GET. 3. create_notification_channel — slack/discord webhook or email. 4. update_status_page — title, description, contact link. The public page is live immediately at the URL in the overview. 5. Optional: create_maintenance_window before planned deploys. 6. Wire record_deploy into the release pipeline. This is the single highest-value thing you can add: probes only ever show symptoms, so "a deploy landed 90 seconds before the first failure" is the closest thing to a cause Repose can hold. Every field is optional. ## REST alternative Same Bearer token against https://repose.sh/api/v1: GET/POST /monitors · PATCH/DELETE /monitors/:id GET /incidents · POST /incidents/:id/acknowledge GET/POST /deploys POST /deploys accepts a read token as well as a write one, because recording deploys is open on every plan. Body is all-optional: { service, sha, ref, environment, url, monitorId, at } ## Finding what to monitor A repo with 60 routes has about six failure domains. Pick domains. get_workspace_overview first, always. Caps free/solo/starter/pro/scale: monitors 25/50/100/500/2000; heartbeats 5/10/25/100/500, a second and tighter cap that also counts against the total; interval floor 180/60/30/30/15s, rejected not clamped. Spend ~60% of the headroom; "unlimited" is a string; tokenScope "read" means propose. list_monitors and dedupe first. create_monitor accepts name, kind, url, expectedStatus, host, port, recordType, intervalSeconds, consensusRequired, timeoutMs, dependsOn, alertWhenUpstream (+paused on update). Keyword / JSON-path / header assertions, latency budgets and request method are dashboard edits, not API fields — research them, create what you can, hand over an exact click-path, and never claim coverage you didn't create. Probes follow redirects: expectedStatus is the FINAL status. Never 3xx — the monitor would see 200 and go red on check one in all three regions, the exact shape of a real page. 401/403/405 are terminal and fine. Probes run from Amsterdam, Virginia and Singapore on the public internet — no VPN, no VPC peering, no tailnet. ## Where to look — host first, then path Host, ranked by independent agreement — two sources beats one: OpenAPI servers[].url · Ingress spec.tls[].hosts · cert-manager dnsNames · ACM SANs · Terraform DNS records · Kamal proxy.host · nginx server_name on `listen 443 ssl` · render.yaml and .do/app.yaml domains · fly.toml app · CI environment.url · a post-deploy curl. Django ALLOWED_HOSTS only where a cert SAN or CSRF_TRUSTED_ORIGINS confirms TLS. example.com and localhost are placeholders. Never VERCEL_URL / NEXT_PUBLIC_VERCEL_URL, which is per-deployment; vercel.json and netlify.toml hold no production domain. Almost nothing ships a health endpoint: Express, Fastify, Next, Remix, React Router, Nitro, SvelteKit, FastAPI, Flask, Starlette, Sinatra, Symfony, net/http, chi, gin and echo have none. Open the file, never infer from the framework name. Real defaults: Rails 7.1+ /up, boot only and explicitly not a DB check; Laravel 11+ /up via withRouting(health:) but Laravel 10 none, told apart by app/Http/Kernel.php; Spring Boot 2+ /actuator/health (1.5.x /health), moved by base-path and context-path and gone from the public port when management.server.port is set; FastAPI /openapi.json, often off in prod. Path traps: Next app/**/route.ts drops (group) and @slot, must export GET or HEAD, and basePath plus rewrites apply last. Nitro server/api/x.get.ts is /api/x but server/routes/x.ts is /x, and .post.ts answers 405 to us. Express/Fastify/NestJS need the whole mount chain; NestJS setGlobalPrefix `exclude` is the classic /v1/health 404. Skip [param], {id}, catch-alls. Best of all because a human maintains it: k8s httpGet.path, render.yaml healthCheckPath, fly.toml check path, Kamal proxy.healthcheck.path — take the PATH only, that host is pod-local, then confirm the Ingress routes it. ## Scheduled work becomes heartbeat monitors A cron that stops firing produces no failing request anywhere, so no http check can see it. Where they hide: vercel.json crons[] · netlify scheduled functions · wrangler [triggers] · render.yaml type: cron · k8s CronJob · .github/workflows on: schedule · GitLab $CI_PIPELINE_SOURCE=="schedule", whose interval is UI-only, so ask · crontab files, where /etc/cron.d has SIX fields because the sixth is a username · systemd *.timer · aws_cloudwatch_event_rule, six-field cron(), '?' required, UTC · app schedulers: Laravel routes/console.php, Celery beat_schedule where crontab(minute=0) is HOURLY, sidekiq-cron, Solid Queue, whenever, node-cron, supabase cron.schedule(). Skip suspend: true, DISABLED, @reboot, OnBootSec-only. Hard ceiling: grace is intervalSeconds x 2, derived and not settable, and intervalSeconds maxes at 3600 — a heartbeat covers job periods to ~30 minutes comfortably, 2 hours absolutely. A nightly backup CANNOT be a heartbeat today; it would page two hours after every ping, forever. Report those as "found, not coverable as a heartbeat yet". Otherwise set intervalSeconds to the job's period, raised for known lateness (Actions queueing, CircleCI's 10-minute jitter). A heartbeat is also red from creation — the first evaluation returns "never pinged" identically in all three regions, so consensus fires at once. Propose the monitor and the one-line ping edit together, and create it once the human confirms the ping lands, or create it paused. Ping on the success path: ` && curl -fsS "$REPOSE_PING_URL"`. Never in finally, in GitLab after_script, or at the top of the task — all three keep pinging while the job is broken. systemd ExecStartPost is after-success only on Type=oneshot; on Type=simple append to ExecStart with &&. Laravel: ->pingOnSuccess(), not ->thenPing(). ## dependsOn — the differentiator, and you CAN set it Every monitor takes dependsOn: string[], and create_monitor accepts it. When a check fails while a declared upstream is degraded, the incident is tagged upstream instead of blamed on the customer. alertWhenUpstream "hold" suppresses the page entirely; the default "tag" annotates but still pages. The 14 keys, exactly — an unrecognised key is rejected with 422, not stored: stripe, github, cloudflare, vercel, twilio, sendgrid, digitalocean, netlify, datadog, supabase, openai, anthropic, mongodb, zoom. cloudflare, vercel, netlify and digitalocean sit in FRONT of the request, so they belong on every monitor for that hostname — decide from the wire (cf-ray, x-vercel-id, x-nf-request-id, the CNAME), not from a config file that may be stale. The other ten sit BESIDE it: map URL to handler, walk imports from that handler only, check the call is on the happy path, and check it is awaited and its failure changes the response. Fire-and-forget sends, queue pushes and .catch(()=>{}) return 200 straight through an outage, so the check never fails and the dependency can never fire — put those on the worker's heartbeat instead. Not dependencies: anything under .github/ (their CI, not their runtime) · build-only vars (CONTEXT, DEPLOY_URL, CI) · dd-trace, which buffers and drops spans rather than failing a request · browser SDKs like @stripe/stripe-js, since probes don't run page JS · self-hosted Supabase or Mongo, which no status page covers · SDKs used only in scripts/ or tests. Under-attach rather than over-attach. A missing dependency costs one noisy page that is still true; a wrong one on "hold" hides a real outage. Show the human the evidence for each before you set it. ## What not to monitor, and how to spot an unreachable address One monitor at 60s is 4,320 requests a day, forever, from three continents. Never: anything that mutates (/api/cron/*, revalidate, seed, checkout, send/sync/purge/import) — Vercel triggers its own crons with a GET, so a probe runs the job · metered calls (LLM, SMS, email), the customer's bill · webhook receivers, signature-checked POSTs whose delivery metrics you'd skew · single-use token URLs, where a probe on a ping URL keeps a dead cron looking alive and one on an ack link acknowledges every incident · rate-limited routes like login and search · deep health checks fanning out to every dependency, which is what dependsOn is for · third-party APIs · preview and staging hosts · ten static pages behind one CDN. Unreachable fails loudly: all three regions miss in the same second, exactly the consensus condition. localhost and all of 127.0.0.0/8 · 0.0.0.0 · ::1 · RFC1918 · CGNAT 100.64/10 · 169.254/16 · IPv6 fc00::/7, fe80::/10, and ::ffff:a.b.c.d / 64:ff9b:: which a dotted-quad regex misses · .internal .local .test .svc.cluster.local .railway.internal .flycast .ts.net · single-label hosts (api, db, redis) · compose service names · *.compute.internal, internal-*.elb.amazonaws.com, *.rds.amazonaws.com — that last class is the sneaky one: public DNS answers, the security group doesn't. Resolving is necessary, never sufficient. Datastore ports are never a tcp monitor: 5432, 3306, 6379, 27017, 5672. Managed hosts (*.neon.tech, *.supabase.co, *.mongodb.net) resolve publicly and are still IP-allowlisted; one going green from all three regions means you found an exposed database — tell the human, use dependsOn instead. Reserve tcp for a deliberately public port http doesn't cover: an SMTP relay, an MQTT or websocket gateway. ## A sane default set 5-12 monitors on a first pass. At 15, stop and ask. In priority order: 1. http per public hostname that can fail independently — the URL a human would check by hand. 60s, or the plan floor if slower. 2. ssl on each of those hostnames, 300s. Expiry is the only outage that is fully predictable and still takes people down. Warn that on auto-rotating issuers (Let's Encrypt, Cloudflare, Vercel) a routine rotation raises a cert-change event. 3. dns on the apex, recordType A, 300s. A bad zone push looks nothing like a 500, and a warm resolver cache hides it from the http check. 4. http on each service's shallow liveness route, the one the repo already declares. One per service, never one per route. 5. one or two public read-only routes that genuinely hit the database — GET /login (never the POST), a data-rendered page, a public read API. This is where a broken deploy shows as a 500 while / still serves from cache. Django's /admin/login/ is NOT this: it returns 200 with the database down, since sessions are lazy and an anonymous GET queries nothing. 6. heartbeat per scheduled job inside the 2-hour ceiling whose silent death would be an incident, once the ping is wired. 7. heartbeat for deployables with no inbound port — workers, queue consumers. Then stop. Below the line is a ranked proposal with one line of reasoning each, not a create_monitor call. Unpaused monitors publish by name at repose.sh/status/: name them for what a customer would recognise. ## Ask rather than guess - Which host is production, when more than one is plausible. The one appearing most often is usually the dev fallback. - Auth-gated routes: expectedStatus 401 with an honest name, or a different route? Never lift a token out of .env to get past it. - Any handler you couldn't read or prove side-effect free. Quote it. - Every cron, before any heartbeat: which of these, if it silently stopped, is an incident — and who is adding the ping? - Every dependsOn you inferred, with the evidence, before they set it. - If channels is empty: these monitors will detect outages and tell nobody. - The cut line, with the plan and both numbers, whenever your list is longer than the budget. Never truncate silently. ## Notes for agents - Alerting quality is the product: single-region flaps are suppressed by design, so do not create duplicate monitors to "double check". - Set dependsOn whenever you can justify it. It is the one thing here that no other monitoring product does, and it is what turns "your site is down" into "your site is down because Stripe is". - Plans cap monitors/intervals; the API returns plain-English errors with the cap when you hit one. Do not retry past a cap error. - Deploy markers are correlation, never proof of cause. Repose cannot see what was in a deploy, only that one happened. Do not report a deploy as the cause of an incident on timing alone. - Humans: https://repose.sh/docs