[TERMINAL · SKILLS]
> mounting /skills...
> indexing 295 manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Terminal.skills
Use Cases/Build a Customer Health Scoring System

Build a Customer Health Scoring System

Build a customer health scoring system with behavioral signals, engagement metrics, product usage tracking, risk alerts, and CS playbook triggers for proactive retention.

#redis#caching#database#pub-sub#queues
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 5 skills

Avg quality 93/100·All SAFE
>

typescript

v

Not yet scored
View skill
>

redis

v1.0.0

Build applications with Redis — caching, session storage, pub/sub, streams, rate limiting, leaderboards, and queues. Use when tasks involve in-memory data storage, real-time messaging, distributed locking, or performance optimization with caching layers.

93/100 quality
1.81× impact
SAFE
View skill
>

postgresql

v1.0.0

Assists with designing schemas, writing performant queries, managing indexes, and operating PostgreSQL databases. Use when working with JSONB, full-text search, window functions, CTEs, row-level security, replication, or performance tuning. Trigger words: postgresql, postgres, sql, database, jsonb, rls, window functions, cte.

87/100 quality
1.53× impact
SAFE
View skill
>

hono

v1.0.0

You are an expert in Hono, the ultrafast web framework for the edge. You help developers build APIs and web applications that run on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda, and Vercel Edge — with a tiny footprint (~14KB), middleware ecosystem, JSX support, RPC client, and Web Standards API compatibility that makes code truly portable across runtimes.

93/100 quality
3.00× impact
SAFE
View skill
>

zod

v1.0.0

You are an expert in Zod, the TypeScript-first schema declaration and validation library. You help developers define schemas that validate data at runtime AND infer TypeScript types at compile time — eliminating the need to write types and validators separately. Used for API input validation, form validation, environment variables, config files, and any data boundary.

100/100 quality
1.21× impact
SAFE
View skill
$

The Problem

Kate leads CS at a 25-person SaaS with 2,000 customers. They find out about churn when the customer cancels — too late. Usage data exists but nobody monitors it systematically. An enterprise customer stopped logging in 3 weeks ago; nobody noticed until they asked for a refund. CS has no way to prioritize — they spend equal time on healthy accounts and at-risk ones. They need health scoring: aggregate usage signals, score accounts 0-100, alert on declining health, trigger CS playbooks for at-risk accounts, and prioritize the team's time.

Step 1: Build the Health Scoring Engine

typescript
import { pool } from "../db";
import { Redis } from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);

interface HealthScore {
  customerId: string;
  score: number;
  trend: "improving" | "stable" | "declining";
  signals: Array<{ name: string; value: number; weight: number; contribution: number }>;
  riskLevel: "healthy" | "monitor" | "at_risk" | "critical";
  lastCalculated: string;
}

interface HealthSignal {
  name: string;
  weight: number;
  calculator: (customerId: string) => Promise<number>;
}

const SIGNALS: HealthSignal[] = [
  { name: "login_frequency", weight: 0.2, calculator: async (id) => {
    const { rows: [r] } = await pool.query("SELECT COUNT(*) as c FROM sessions WHERE customer_id = $1 AND created_at > NOW() - INTERVAL '30 days'", [id]);
    return Math.min(1, parseInt(r.c) / 20);
  }},
  { name: "feature_adoption", weight: 0.2, calculator: async (id) => {
    const { rows: [r] } = await pool.query("SELECT COUNT(DISTINCT feature) as c FROM feature_usage WHERE customer_id = $1 AND used_at > NOW() - INTERVAL '30 days'", [id]);
    return Math.min(1, parseInt(r.c) / 10);
  }},
  { name: "support_sentiment", weight: 0.15, calculator: async (id) => {
    const { rows: [r] } = await pool.query("SELECT AVG(rating) as avg FROM support_tickets WHERE customer_id = $1 AND created_at > NOW() - INTERVAL '90 days'", [id]);
    return r.avg ? parseFloat(r.avg) / 5 : 0.5;
  }},
  { name: "api_usage_trend", weight: 0.15, calculator: async (id) => {
    const key = `usage:${id}:api_calls:${new Date().toISOString().slice(0, 7)}`;
    const current = parseFloat(await redis.get(key) || "0");
    const prevKey = `usage:${id}:api_calls:${new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 7)}`;
    const previous = parseFloat(await redis.get(prevKey) || "1");
    return previous > 0 ? Math.min(1, current / previous) : 0;
  }},
  { name: "payment_health", weight: 0.15, calculator: async (id) => {
    const { rows: [r] } = await pool.query("SELECT COUNT(*) FILTER (WHERE status = 'failed') as failed, COUNT(*) as total FROM payments WHERE customer_id = $1 AND created_at > NOW() - INTERVAL '90 days'", [id]);
    return parseInt(r.total) > 0 ? 1 - (parseInt(r.failed) / parseInt(r.total)) : 1;
  }},
  { name: "team_growth", weight: 0.15, calculator: async (id) => {
    const { rows: [r] } = await pool.query("SELECT COUNT(*) as c FROM users WHERE customer_id = $1 AND created_at > NOW() - INTERVAL '30 days'", [id]);
    return Math.min(1, parseInt(r.c) / 3);
  }},
];

export async function calculateHealthScore(customerId: string): Promise<HealthScore> {
  const signals: HealthScore["signals"] = [];
  let totalScore = 0;

  for (const signal of SIGNALS) {
    const value = await signal.calculator(customerId);
    const contribution = value * signal.weight;
    totalScore += contribution;
    signals.push({ name: signal.name, value: Math.round(value * 100), weight: signal.weight, contribution: Math.round(contribution * 100) });
  }

  const score = Math.round(totalScore * 100);
  const prevScore = parseInt(await redis.get(`health:prev:${customerId}`) || String(score));
  const trend = score > prevScore + 5 ? "improving" : score < prevScore - 5 ? "declining" : "stable";
  const riskLevel = score >= 70 ? "healthy" : score >= 50 ? "monitor" : score >= 30 ? "at_risk" : "critical";

  await redis.set(`health:prev:${customerId}`, score);
  await redis.setex(`health:score:${customerId}`, 86400, JSON.stringify({ score, trend, riskLevel }));

  await pool.query(
    `INSERT INTO health_scores (customer_id, score, trend, risk_level, signals, calculated_at) VALUES ($1, $2, $3, $4, $5, NOW())
     ON CONFLICT (customer_id) DO UPDATE SET score = $2, trend = $3, risk_level = $4, signals = $5, calculated_at = NOW()`,
    [customerId, score, trend, riskLevel, JSON.stringify(signals)]
  );

  if (riskLevel === "critical" || (riskLevel === "at_risk" && trend === "declining")) {
    await redis.rpush("notification:queue", JSON.stringify({ type: "health_alert", customerId, score, riskLevel, trend }));
  }

  return { customerId, score, trend, signals, riskLevel, lastCalculated: new Date().toISOString() };
}

export async function getHealthDashboard(): Promise<{ distribution: Record<string, number>; atRisk: HealthScore[]; improving: HealthScore[] }> {
  const { rows } = await pool.query("SELECT * FROM health_scores ORDER BY score ASC");
  const distribution: Record<string, number> = { healthy: 0, monitor: 0, at_risk: 0, critical: 0 };
  for (const r of rows) distribution[r.risk_level]++;

  return {
    distribution,
    atRisk: rows.filter((r: any) => r.risk_level === "critical" || r.risk_level === "at_risk").slice(0, 20),
    improving: rows.filter((r: any) => r.trend === "improving").slice(0, 10),
  };
}

Results

  • Churn predicted 30 days early — declining health score from 72→41 over 3 weeks triggers alert; CS reaches out before customer considers leaving; saves $50K ARR per save
  • CS time prioritized — dashboard shows 15 critical accounts (of 2,000); CS focuses on highest-impact accounts; coverage up 3x with same team
  • Enterprise no-login caught — login_frequency signal drops to 0; health score plummets; alert fires after 7 days, not 3 weeks; executive sponsor contacted
  • Multi-signal scoring — payment failure alone doesn't mean unhealthy; combined with low usage + support complaints = real risk; nuanced picture
  • Trend tracking — account improving from 35→55 = recovering, deprioritize; account declining from 80→60 = new risk, escalate; direction matters as much as score