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.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- saas-metrics

Installs to ~/.agents/skills/saas-metrics/ for every project on this machine.

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

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.

~/.agents/skills/saas-metrics/SKILL.md

--- name: saas-metrics description: Calculates and explains SaaS metrics from billing data - MRR and ARR movement bridges (new, expansion, contraction, churn, reactivation), logo and… license: MIT ---

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

  1. 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).
  2. 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.
  3. Normalise to one row per customer per month with MRR (see rules below).
  4. Build the MRR movement bridge and check that it reconciles.
  5. Derive retention, unit economics and efficiency metrics from the bridge.
  6. Report each metric with its formula, period and data cut-off date.

MRR normalisation rules

ItemTreatment
Annual or multi-year planContract value / months in term, every month of the term
Discounts and couponsSubtract; a 100% coupon means 0 MRR (not a paying customer)
Free trials, free plans0 MRR; track separately as pipeline
One-off fees (setup, services)Exclude from MRR; report as non-recurring revenue
Usage / metered billingReport separately or use trailing 3-month average; state which
Taxes, VATExclude
Failed payments / past dueKeep in MRR during the dunning window, then churn; state the window
CurrencyConvert at a fixed rate per period so FX moves do not appear as expansion
Paused subscriptions0 MRR while paused; returning counts as reactivation

MRR movement bridge

For each customer, compare this month’s MRR to last month’s:

Last monthThis monthMovement
0, never paid before> 0New
0, paid before> 0Reactivation
> 0higherExpansion
> 0lower but > 0Contraction
> 00Churn

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)

MetricFormulaNotes
ARRMRR x 12Only recurring revenue; never annualise one-off or usage spikes
ARPAMRR / paying customersPer account, not per seat
Logo churn (monthly)Customers lost in month / customers at start of monthExclude 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 MRRCan be negative
GRR (annual, cohort)MRR now from customers active 12 months ago, each capped at their MRR then / their MRR 12 months agoMax 100%
NRR (annual, cohort)MRR now from customers active 12 months ago / their MRR 12 months agoIncludes expansion; excludes new customers
LTVARPA x gross margin % / monthly revenue churn rateCap the lifetime (for example 5 years) when churn is tiny
CACFully loaded sales + marketing spend in period / new customers in periodInclude 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 multipleNet burn / net new ARR, same period
Rule of 40Revenue 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.

MetricSMB / self-serveMid-market / enterprise
Monthly logo churn3-7% commonunder 1-2%
NRR90-105%110-130%+ for strong performers
GRR80-90%90%+
CAC paybackunder 12 months12-24 months
LTV:CAC3:1 or better is the usual targetsame
Burn multipleunder 1.5 is efficient; above 2 is a warningsame

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.yamlgenerated from this page

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.py119 lines, 4.9 KB
#!/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.

esc