All loops
OpsHard 25 min· claude-opus-4

Customer churn predictor

Nightly: score accounts, write a CS queue, post top 10 to Slack.

NOT DEPLOYEDNOT DEPLOYED
0191ms
Trigger
cron(0 7 * * *) fired · every day · 07:00
021271ms
Agent
claude-opus-4 · in 1171 tok · out 588 tok
03301ms
Tools
postgres-mcp/postgres:invoke → 200 OK · 231ms
0481ms
Verify
schema check · pydantic v2 passed
05171ms
Output
pagerduty ack · incident summary posted
0651ms
Notify
audit log written · runbook link attached
SUCCESS
0%
0 runs
P50
0ms
median
P95
0ms
tail
AVG COST
per run
LAST OK
never
LAST FAIL
never
none
Latency · last 30 runs0 samples
no runs yet
Latest output · what your users see
PagerDutywarning

p95 latency on /api/checkout crossed 1200ms for 6 min. Correlated deploy: web@d41f2a. Suggested rollback command attached.

deduped ×2·runbook: runbooks/checkout-latency.md#rollback
// press Test to run once · Watch live to keep streaming · Deploy to make it real
The problem

By the time you notice an account has stopped logging in, they've already started evaluating competitors.

The outcome

Every morning your CS team works a ranked queue. The top 10 are in Slack with the three signals that flagged them.

Ingredients & skills

Secrets
  • ANTHROPIC_API_KEY
  • DATABASE_URL
  • SLACK_WEBHOOK_URL
Providers
  • Anthropic
  • Postgres
  • Slack
MCP servers
  • postgres-mcp
#retention#cron#postgres

How it works

SQL pulls product-usage features for every paying account, Claude scores churn risk 0–100, results go into a `cs_queue` table and the top 10 to a Slack channel.

Step 1

1 — Feature query

Six features, all derived. Don't pass raw event tables to the model.

sql
select
  account_id,
  date_part('day', now() - max(ts)) as days_since_last_event,
  count(*) filter (where ts > now() - interval '7 days') as events_7d,
  count(*) filter (where ts > now() - interval '30 days') as events_30d,
  count(distinct user_id) filter (where ts > now() - interval '7 days') as wau,
  bool_or(plan = 'enterprise') as is_enterprise,
  sum(case when event = 'export' then 1 else 0 end) as exports
from events group by 1;
Step 2

2 — Scoring agent

Batch 100 accounts per call. One scored row out per row in.

typescript
const scored = await claude.tools.call("score_batch", { features });
await db.query("insert into cs_queue (account_id, risk, reasons, scored_at) select * from unnest($1::text[], $2::int[], $3::jsonb[], $4::timestamptz[])",
  [scored.map((s) => s.account_id), scored.map((s) => s.risk), scored.map((s) => s.reasons), scored.map(() => new Date())]);
Step 3

3 — Top 10 to Slack

Quick, low-noise. CS pages own the deeper queue.

typescript
const top = await db.query("select account_id, risk, reasons from cs_queue where scored_at::date = current_date order by risk desc limit 10");
await fetch(process.env.SLACK_WEBHOOK_URL!, { method: "POST", body: JSON.stringify({ text: format(top.rows) }) });
One-line deploy

The button above runs the same command with your saved config. This is the raw CLI form.

bash
locker schedule churn-predictor agent.ts --cron '0 6 * * *'

Related loops