SaaS Metrics skill for Codex
MRR bridges that reconcile to the penny. Codex turns raw subscription data into an MRR bridge (new, expansion, contraction, churn, reactivation) that must reconcile month by month, then derives churn, GRR, NRR, cohorts, LTV, CAC payback and burn multiple with every definition written down. A bundled Python script and a Postgres query produce the bridge, and it flags the classic traps like annualising monthly churn by twelve.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- saas-metrics Installs to ~/.agents/skills/saas-metrics/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install saas-metrics The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project saas-metrics Installs to .agents/skills/saas-metrics/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/saas-metrics.zipunzip -o saas-metrics.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) saas-metrics PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $saas-metrics, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Calculates and explains SaaS metrics from billing data - MRR and ARR movement bridges (new, expansion, contraction, churn, reactivation), logo and revenue churn, gross and net revenue retention, cohort tables, ARPA, LTV, CAC, payback, quick ratio, burn multiple and Rule of 40 - with stated definitions that reconcile. Use when asked to compute MRR, churn or NRR from Stripe or a subscriptions export, build a SaaS metrics dashboard, check investor-deck numbers, or explain why two churn figures disagree.
Use it when
- You need MRR, ARR, churn or retention figures from billing data
- Two teams report different churn or NRR numbers
- Metrics are going into a board pack or fundraising deck
- You want cohort retention tables by signup month or plan
Use something else when
- You need forward-looking forecasts and runway: use financial-modeling
- You are defining product engagement metrics like activation or DAU: use product-metrics
- You need the SQL patterns for funnels and retention in general: use sql-for-analytics
How to check the result
Every month satisfies start + new + reactivation + expansion - contraction - churn = end, and the latest MRR matches the billing system after documented normalisation differences.
The mistake it prevents
Multiplying monthly churn by twelve, or counting a plan upgrade processed as cancel-and-resubscribe as churn plus a new customer.
The standard: every number has a written definition, the MRR bridge reconciles to the penny (start + new + reactivation + expansion - contraction - churn = end), and nobody compares a monthly rate to an annual one. Most disputes about churn are definition disputes; settle the definition first.
Workflow
- Get raw data, not someone’s dashboard: subscription or invoice line items with customer id, plan, amount, billing interval, discount, start/end dates, status. From Stripe that is subscriptions and subscription items (or invoice lines); export via the Dashboard, Sigma, or the API (network access required).
- Read any existing metric definitions (metrics doc, dbt models,
AGENTS.md). Match them unless they are wrong, and say so explicitly if you change one. - Normalise to one row per customer per month with MRR (see rules below).
- Build the MRR movement bridge and check that it reconciles.
- Derive retention, unit economics and efficiency metrics from the bridge.
- Report each metric with its formula, period and data cut-off date.
MRR normalisation rules
| Item | Treatment |
|---|---|
| Annual or multi-year plan | Contract value / months in term, every month of the term |
| Discounts and coupons | Subtract; a 100% coupon means 0 MRR (not a paying customer) |
| Free trials, free plans | 0 MRR; track separately as pipeline |
| One-off fees (setup, services) | Exclude from MRR; report as non-recurring revenue |
| Usage / metered billing | Report separately or use trailing 3-month average; state which |
| Taxes, VAT | Exclude |
| Failed payments / past due | Keep in MRR during the dunning window, then churn; state the window |
| Currency | Convert at a fixed rate per period so FX moves do not appear as expansion |
| Paused subscriptions | 0 MRR while paused; returning counts as reactivation |
MRR movement bridge
For each customer, compare this month’s MRR to last month’s:
| Last month | This month | Movement |
|---|---|---|
| 0, never paid before | > 0 | New |
| 0, paid before | > 0 | Reactivation |
| > 0 | higher | Expansion |
| > 0 | lower but > 0 | Contraction |
| > 0 | 0 | Churn |
Run the bundled script on a CSV of customer_id,month,mrr to produce the bridge, monthly
logo churn, GRR, NRR and optional cohort retention; it exits non-zero if the bridge does
not reconcile:
python3 scripts/mrr_movements.py subscriptions.csv --cohorts
The same logic in SQL (Postgres), given a customer_month_mrr table with a row for every
customer-month (zero-filled):
select month,
sum(case when prev_mrr = 0 and mrr > 0 and not paid_before then mrr else 0 end) as new_mrr,
sum(case when prev_mrr = 0 and mrr > 0 and paid_before then mrr else 0 end) as reactivation_mrr,
sum(case when prev_mrr > 0 and mrr > prev_mrr then mrr - prev_mrr else 0 end) as expansion_mrr,
sum(case when prev_mrr > 0 and mrr > 0 and mrr < prev_mrr then prev_mrr - mrr else 0 end) as contraction_mrr,
sum(case when prev_mrr > 0 and mrr = 0 then prev_mrr else 0 end) as churned_mrr
from (
select customer_id, month, mrr,
coalesce(lag(mrr) over w, 0) as prev_mrr,
coalesce(max(case when mrr > 0 then 1 else 0 end) over (w rows between unbounded preceding and 1 preceding), 0) = 1 as paid_before
from customer_month_mrr
window w as (partition by customer_id order by month)
) t
group by month order by month;
Definitions to use (state them in the report)
| Metric | Formula | Notes |
|---|---|---|
| ARR | MRR x 12 | Only recurring revenue; never annualise one-off or usage spikes |
| ARPA | MRR / paying customers | Per account, not per seat |
| Logo churn (monthly) | Customers lost in month / customers at start of month | Exclude customers acquired during the month from the denominator |
| Gross MRR churn (monthly) | (Contraction + churn) / starting MRR | |
| Net MRR churn (monthly) | (Contraction + churn - expansion - reactivation) / starting MRR | Can be negative |
| GRR (annual, cohort) | MRR now from customers active 12 months ago, each capped at their MRR then / their MRR 12 months ago | Max 100% |
| NRR (annual, cohort) | MRR now from customers active 12 months ago / their MRR 12 months ago | Includes expansion; excludes new customers |
| LTV | ARPA x gross margin % / monthly revenue churn rate | Cap the lifetime (for example 5 years) when churn is tiny |
| CAC | Fully loaded sales + marketing spend in period / new customers in period | Include salaries, tools, agencies; lag spend by the sales cycle if long |
| CAC payback (months) | CAC / (ARPA x gross margin %) | |
| Quick ratio | (New + expansion + reactivation) / (contraction + churn) | Monthly MRR |
| Magic number | (Quarter revenue - prior quarter revenue) x 4 / prior quarter S&M spend | |
| Burn multiple | Net burn / net new ARR, same period | |
| Rule of 40 | Revenue growth % + profit margin % (state EBITDA or FCF) | Annual figures |
Monthly to annual: annual retention = (1 - monthly churn)^12. A 3% monthly churn is about 69% annual retention, not 64%. Never multiply monthly churn by 12.
Rough benchmarks
Use only as context, label them as such, and cite the source if the user wants them in a deck. They vary widely by segment, contract size and stage.
| Metric | SMB / self-serve | Mid-market / enterprise |
|---|---|---|
| Monthly logo churn | 3-7% common | under 1-2% |
| NRR | 90-105% | 110-130%+ for strong performers |
| GRR | 80-90% | 90%+ |
| CAC payback | under 12 months | 12-24 months |
| LTV:CAC | 3:1 or better is the usual target | same |
| Burn multiple | under 1.5 is efficient; above 2 is a warning | same |
Cohort analysis
- Cohort by first paid month (not signup month), then track logo retention and revenue retention separately; revenue cohorts above 100% show expansion.
- Read diagonals to spot a product or pricing change that affected every cohort at once, and rows to compare acquisition channels or plan tiers.
- Small cohorts (under ~30 customers) swing wildly; show n beside every row.
- Right-censor: a cohort that is 3 months old has no month-12 value. Leave it blank, never 0.
Gotchas
- Upgrades processed as cancel-and-resubscribe show up as churn plus new. Merge by customer, not by subscription id.
- Customers with several subscriptions must be summed before the bridge, or a plan switch looks like churn and new on the same day.
- Annual plans churn only at renewal; monthly logo churn on a mostly-annual base looks artificially low. Report renewal rate for annual contracts instead.
- Blended CAC including organic signups flatters paid channels. Show paid CAC separately when marketing spend decisions depend on it.
- NRR computed on a trailing-month basis and annualised by x12 is not NRR. Use the 12-month cohort definition.
- Investor-facing numbers: present definitions next to every figure. Inflated metrics in a fundraise create real liability; do not tune definitions to make a number look better.
Verification
- Bridge reconciles every month (the script enforces this).
- Ending MRR for the latest month matches the billing system’s own MRR figure within the difference explained by normalisation rules; list those differences.
- Spot-check five customers by hand across a plan change, a discount, an annual renewal, a churn and a reactivation.
Output template
## SaaS metrics - <period>, data through <date>
| Metric | Value | Prior period | Definition |
|---|---|---|---|
| MRR | | | normalised per rules above |
| Net new MRR | | | new + reactivation + expansion - contraction - churn |
| Logo churn (monthly avg) | | | |
| GRR / NRR (12-month cohort) | | | |
| CAC / payback | | | |
| Burn multiple | | | |
**Drivers:** <2-4 bullets: what moved and why, with customer or segment evidence>
**Data caveats:** <normalisation choices, missing data, FX handling> What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "SaaS Metrics"
short_description: "MRR bridges that reconcile to the penny"
default_prompt: "$saas-metrics Compute our MRR, churn and NRR for the last 12 months from this Stripe subscriptions export" scripts/mrr_movements.py
#!/usr/bin/env python3
"""MRR movement bridge and retention from a per-customer monthly MRR CSV.
Input CSV columns (header required): customer_id, month, mrr
month is YYYY-MM; mrr is the normalised monthly recurring revenue for that
customer in that month (annual plans divided by 12, discounts applied, no
one-off fees). A customer missing from a month counts as 0 MRR that month.
Usage:
python3 mrr_movements.py subscriptions.csv # movement table
python3 mrr_movements.py subscriptions.csv --cohorts # plus logo retention cohorts
Exits 1 on malformed input or if the bridge fails to reconcile.
"""
import argparse
import csv
import sys
from collections import defaultdict
from decimal import Decimal, InvalidOperation
def month_range(first, last):
y, m = map(int, first.split("-"))
ly, lm = map(int, last.split("-"))
out = []
while (y, m) <= (ly, lm):
out.append(f"{y:04d}-{m:02d}")
y, m = (y + 1, 1) if m == 12 else (y, m + 1)
return out
def load(path):
data = defaultdict(lambda: defaultdict(Decimal))
with open(path, newline="", encoding="utf-8-sig") as fh:
reader = csv.DictReader(fh)
need = {"customer_id", "month", "mrr"}
if not reader.fieldnames or not need <= {f.strip() for f in reader.fieldnames}:
sys.exit(f"error: CSV needs columns {sorted(need)}, got {reader.fieldnames}")
for n, row in enumerate(reader, start=2):
month = row["month"].strip()[:7]
if len(month) != 7 or month[4] != "-":
sys.exit(f"error: line {n}: month must be YYYY-MM, got {row['month']!r}")
try:
data[row["customer_id"].strip()][month] += Decimal(row["mrr"].strip() or "0")
except InvalidOperation:
sys.exit(f"error: line {n}: mrr is not a number: {row['mrr']!r}")
if not data:
sys.exit("error: no rows")
return data
def pct(num, den):
return f"{(num / den * 100):9.1f}%" if den else " n/a"
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("csv")
ap.add_argument("--cohorts", action="store_true", help="print logo retention by first paid month")
args = ap.parse_args()
data = load(args.csv)
months = month_range(min(m for c in data.values() for m in c), max(m for c in data.values() for m in c))
ever_paid = set()
cols = ["month", "start", "new", "reactiv", "expand", "contract", "churn", "end",
"logos", "logo_churn", "GRR_m", "NRR_m"]
print(" ".join(f"{c:>10}" for c in cols))
ok = True
prev = {c: Decimal(0) for c in data}
for i, month in enumerate(months):
cur = {c: data[c].get(month, Decimal(0)) for c in data}
mv = defaultdict(Decimal)
lost_logos = 0
for c in data:
p, n = prev[c], cur[c]
if p == 0 and n > 0:
mv["reactiv" if c in ever_paid else "new"] += n
elif p > 0 and n == 0:
mv["churn"] += p
lost_logos += 1
elif n > p:
mv["expand"] += n - p
elif n < p:
mv["contract"] += p - n
if n > 0:
ever_paid.add(c)
start, end = sum(prev.values()), sum(cur.values())
bridge = start + mv["new"] + mv["reactiv"] + mv["expand"] - mv["contract"] - mv["churn"]
if bridge != end:
ok = False
start_logos = sum(1 for v in prev.values() if v > 0)
grr = start - mv["contract"] - mv["churn"]
nrr = grr + mv["expand"]
row = [month, start, mv["new"], mv["reactiv"], mv["expand"], mv["contract"], mv["churn"], end]
out = [f"{row[0]:>10}"] + [f"{v:>10.2f}" for v in row[1:]]
out += [f"{sum(1 for v in cur.values() if v > 0):>10}",
pct(Decimal(lost_logos), Decimal(start_logos)) if i else " n/a",
pct(grr, start) if i else " n/a", pct(nrr, start) if i else " n/a"]
print(" ".join(out))
prev = cur
if args.cohorts:
first = {c: min(m for m, v in data[c].items() if v > 0) for c in data if any(v > 0 for v in data[c].values())}
print("\nLogo retention by first paid month (share of cohort still paying N months later)")
for cohort in months:
members = [c for c, f in first.items() if f == cohort]
if not members:
continue
later = months[months.index(cohort):]
cells = [pct(Decimal(sum(1 for c in members if data[c].get(m, 0) > 0)), Decimal(len(members))) for m in later[:13]]
print(f"{cohort} n={len(members):<4} " + " ".join(cells))
if not ok:
sys.exit("error: MRR bridge did not reconcile for at least one month")
print("\nbridge reconciles: start + new + reactivation + expansion - contraction - churn = end")
if __name__ == "__main__":
main()
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.