Skip to content

Free Claude skill · Metrics and investor updates · v1.0

Investor update writing skill for Claude

Give Claude this period's numbers, last period's and your notes, and it drafts an investor update in a fixed order: a one-line TL;DR, a KPI table, highlights, lowlights, asks, cash and runway, and team. A script works out every change, net burn, runway and burn multiple, and lists what got worse or missed plan, so the bad news goes in the update and every figure can be traced back to your numbers.

By the StartupReader editorial desk · Updated 2026-11-08 · MIT licence · Works on claude.ai, Claude Code and the Claude API

At a glance2026

Investor update writing

sections in every update
7
net burn measures compared
2
network calls
0
MIT licence
Free
Investor update writing at a glance: sections in every update: 7; net burn measures compared: 2; network calls: 0; MIT licence: Free.

What it does

  • Turns this period's and last period's numbers into a KPI table with the change and % change, percentage points for rates, and the gap to plan.
  • Lists every metric that got worse or missed plan, so none of them is left out of the lowlights.
  • Works out net burn from the change in cash and from expenses minus revenue, says when they disagree, and gives runway and burn multiple.
  • Drafts the update in a fixed order: TL;DR, numbers, highlights, lowlights, asks, cash and runway, team.
  • Keeps the tone numbers-first and free of hype, and checks every figure against the script output and your notes.
  • Takes MRR, churn and retention straight from the SaaS metrics analysis skill when you have a customer-level MRR export.

Ask Claude things like

  • “Write our October investor update from these numbers and notes.”
  • “Here's last month's update and this month's figures. Same format, please.”
  • “Draft a quarterly update for our angels. We missed our revenue plan: how do we say it?”
  • “What's our runway and burn multiple for this month's update?”

What you give it

  • Each KPI's value this period and last period, and your plan if you have one.
  • Your last update, if there is one, so the KPIs, their order and their definitions stay the same.
  • Cash at the end of each period and any money raised, and ideally revenue and operating expenses for the period.
  • Notes on wins, problems, hires and departures, and what you need from investors.

What you get back

  • A ready-to-edit update with a subject line, TL;DR, KPI table, highlights, lowlights, asks, cash and runway, and team.
  • Net burn, runway and burn multiple from the script, with the method stated.
  • A definitions line for every KPI, so next period's update compares like with like.

How changes are shown

Money and counts get an absolute change and a percentage change: MRR down $51, or 1.0%. Rates such as churn, retention and gross margin get a change in percentage points instead. Logo churn going from 2% to 3% is up 1 percentage point; calling it up 50% is arithmetically true and misleading, and investors comparing updates notice when the basis shifts from one month to the next.

The percentage change is left blank where it means nothing: for rates, and where last period's figure was zero or negative, such as a loss that narrowed. When you give a plan, the table adds the plan and the gap to it in the same way.

  • Money and counts: change and % change, e.g. -$51 and -1.0%.
  • Percentages: change in percentage points, e.g. -7.0 pp.
  • Ratios and months: change and % change, e.g. +0.3x or -1.3 mo.

Net burn, runway and burn multiple

Net burn is the cash the company loses in a month, leaving out money raised. The script works it out two ways when it can: from the change in the bank balance, and from operating expenses minus revenue, the formula in StartupReader's burn rate and runway calculator. The two often differ because of when customers pay, annual prepayments or one-off bills, so it shows both and tells Claude to say which one the update uses.

Runway is cash divided by monthly net burn with burn held flat, the calculator's simple case. Burn multiple is net burn over the period divided by net new ARR (MRR times 12 if you report MRR). On one month it is noisy, so the script says so, and when ARR did not grow it reports the ratio as not meaningful rather than printing a negative number.

Why bad news goes near the top

Investors who find a missed plan or a lost customer in the numbers before the founder mentions it tend to read the rest of the update more sceptically. So the skill feeds the script's "Worse than last period" and "Behind plan" lines into the lowlights, and a key metric that fell or missed plan also goes in the TL;DR. Each lowlight says what happened, why if the founder knows, and what is being done.

The other rules serve the same goal. The KPIs, their order and their definitions stay the same from update to update, and a change is stated rather than slipped in. Every figure comes from the script or your notes. Asks name a type of person and an action, because "intros welcome" is easy to ignore and a specific request is easy to act on.

Worked example

Acme Desk, the made-up helpdesk startup from the SaaS metrics example, sends its angels a monthly update. October's MRR, customers, churn and retention come from that skill. The plan was $5,300 of MRR and 27 customers. Cash fell from $96,000 to $90,000, on $5,000 of revenue and $11,000 of expenses. The founder's notes: two new customers; a $299-a-month customer cancelled after moving support in-house; check-in calls start for every account paying $199 or more; a part-time support engineer starts on 3 November; they want intros to heads of support at 50 to 200 person online retailers.

The founder asks

Draft our October investor update. The numbers are in the metrics JSON. Notes: two new customers, lost a $299/month account that moved support in-house, starting check-ins with every account on $199+, part-time support engineer starts 3 Nov. Ask: intros to heads of support at 50-200 person online retailers.

Claude reads the skill, then runs its script with the figures from the documents. These are the script's real outputs:

$ python scripts/update_numbers.py assets/example-metrics.json
Acme Desk: October 2026 vs September 2026 (1 month) | Metric | October 2026 | September 2026 | Change | % change | Plan | vs plan ||---|---|---|---|---|---|---|| MRR | $4,974 | $5,025 | -$51 | -1.0% | $5,300 | -$326 (-6.2%) || Paying customers | 26 | 25 | +1 | +4.0% | 27 | -1 (-3.7%) || Logo churn (monthly) | 4.0% | 4.0% | 0.0 pp |  |  |  || Net revenue retention (monthly) | 94.0% | 101.0% | -7.0 pp |  |  |  || Gross margin | 80.0% | 79.0% | +1.0 pp |  |  |  | Worse than last period: MRR -$51 (-1.0%); Net revenue retention (monthly) -7.0 ppBehind plan: MRR -$326 (-6.2%); Paying customers -1 (-3.7%) Cash and runway  Cash at the end of October 2026: $90,000 (September 2026: $96,000; raised in the period: $0)  Net burn from the change in cash: $6,000 a month  Net burn from expenses minus revenue: $11,000 - $5,000 = $6,000 a month  Runway = $90,000 / $6,000 = 15.0 months (net burn from expenses minus revenue, held flat)  Burn multiple: not meaningful, ARR (MRR x 12) did not grow this period Percentage metrics change in percentage points (pp). % change is blank where it is not meaningful (percentages, or a zero or negative base).

The answer, in the skill's format (abridged):

Subject: Acme Desk October 2026 update: $4,974 MRR, 15.0 months of runway

TL;DR: MRR fell 1.0% to $4,974, $326 behind plan, after a $299-a-month customer moved support in-house. Our ask: intros to heads of support at online retailers.

Numbers

Metric October 2026 September 2026 Change % change Plan vs plan
MRR $4,974 $5,025 -$51 -1.0% $5,300 -$326 (-6.2%)
Paying customers 26 25 +1 +4.0% 27 -1 (-3.7%)
Logo churn (monthly) 4.0% 4.0% 0.0 pp
Net revenue retention (monthly) 94.0% 101.0% -7.0 pp
Gross margin 80.0% 79.0% +1.0 pp

The one cancellation explains the drop in net revenue retention; logo churn held at 4.0%.

Highlights

  • 2 new customers signed, taking paying customers to 26 (+1 net).
  • Gross margin 80.0%, up 1.0 pp.

Lowlights

  • MRR $4,974: down $51 (1.0%) on September and $326 (6.2%) behind plan. A $299-a-month customer moved support in-house, and the two new customers did not replace it.
  • Net revenue retention 94.0% for the month, down 7.0 pp. We are starting check-in calls with every account paying $199 or more.
  • Paying customers 1 behind plan (26 against 27).

Asks

  1. Intros to heads of support at 50 to 200 person online retailers.

Cash and runway

$90,000 in the bank at the end of October, down from $96,000, with nothing raised. Net burn was $6,000 a month on both measures, so runway is 15.0 months if burn stays flat.

Team

  • A part-time support engineer starts on 3 November.

Definitions: MRR is monthly recurring revenue at month end. Logo churn is customers lost / customers at the start of the month. Net revenue retention is (starting MRR + expansion - contraction - churned) / starting MRR, for the month.

A made-up company and numbers. Your answer depends on your documents, and Claude's wording varies from run to run; the figures come from the script.

Install it

The skill is one folder. Download it once and add it wherever you use Claude; skills don't sync between claude.ai, Claude Code and the API, so each needs its own copy.

claude.ai and the Claude apps

  1. Download the ZIP and keep it zipped.
  2. In Claude, open Settings > Capabilities and turn on Code execution and file creation (called Create and edit files in the newer settings layout).
  3. Open Customize > Skills, click +, choose Create skill, then Upload a skill, and pick the ZIP.
  4. Leave the skill switched on. Claude uses it when your request matches its description; you don't have to name it.

On Team and Enterprise plans an owner first turns on code execution and skills under Organization settings > Plugins & skills. Anthropic's help centre has the details for each plan.

Claude Code

Unzip it into your personal skills folder to use it in every project, or into .claude/skills/ in a repository to share it with your team. Claude picks it up automatically; you can also run it by typing /investor-update-writing.

Terminal
mkdir -p ~/.claude/skills
unzip investor-update-writing.zip -d ~/.claude/skills/

Skills with a script need Python 3 on your computer.

Claude API

Upload the unzipped folder once; the response includes the skill's id. Uploaded skills are shared across your API workspace.

Terminal
curl -X POST "https://api.anthropic.com/v1/skills" \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2026-10-21" \
  -F "files[]=@investor-update-writing/SKILL.md;filename=investor-update-writing/SKILL.md" \
  -F "files[]=@investor-update-writing/assets/example-metrics.json;filename=investor-update-writing/assets/example-metrics.json" \
  -F "files[]=@investor-update-writing/assets/template.md;filename=investor-update-writing/assets/template.md" \
  -F "files[]=@investor-update-writing/scripts/update_numbers.py;filename=investor-update-writing/scripts/update_numbers.py"

Then list it in a request's container, with the code execution tool that skills run in (up to 20 skills per request):

Terminal
curl https://api.anthropic.com/v1/messages \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2026-10-21" \
  -H "content-type: application/json" \
  -d '{
    "model": "claude-opus-5-5",
    "max_tokens": 4096,
    "container": {
      "skills": [{ "type": "custom", "skill_id": "YOUR_SKILL_ID", "version": "latest" }]
    },
    "tools": [{ "type": "code_execution_20250825", "name": "code_execution" }],
    "messages": [{ "role": "user", "content": "..." }]
  }'

From Anthropic's skills API guide. Skills on the API run with no network access, which is why these skills' scripts use only Python's standard library.

What's in the skill

Every file, exactly as it is in the download. Claude reads SKILL.md when a request matches, opens the other files only when a step needs them, and runs scripts without loading their code. Read them before you install: a skill can direct Claude to run code, so only add skills you trust.

Folder
investor-update-writing/
  SKILL.md
  assets/example-metrics.json
  assets/template.md
  scripts/update_numbers.py
SKILL.md90 lines
---
name: investor-update-writing
description: Drafts monthly or quarterly investor updates, board updates and shareholder letters from a founder's metrics and notes, in a fixed structure (a one-line TL;DR, a KPI table with changes against last period and plan, highlights, lowlights, specific asks, cash and runway, and team). A bundled script works out every change, net burn, runway and burn multiple so no figure is calculated by hand, and the writing rules keep the update numbers-first and free of hype, with bad news early and metric definitions consistent from one update to the next. Use when a founder asks for an investor update, monthly update, quarterly update, board update, shareholder or stakeholder update, or wants to report MRR, ARR, revenue, churn, burn rate, runway, hiring or fundraising progress to investors, angels or a board.
license: MIT
compatibility: Works on claude.ai, Claude Code and the Claude API. The script needs Python 3.8+ (standard library only) and no network access.
metadata:
  author: StartupReader
  version: "1.0"
  homepage: https://startupreader.com/claude-skills/investor-update-writing
---

# Investor update writing

Investors read updates quickly and compare them with the last one. An update earns trust when it reports the same numbers, measured the same way, every time, and when bad news arrives before anyone has to ask. Write it only from the founder's numbers and notes.

## Workflow

Copy this checklist and tick it off as you go:

```
- [ ] 1. Collect this period's numbers and notes
- [ ] 2. Write the metrics JSON (same KPIs and definitions as last time)
- [ ] 3. Run scripts/update_numbers.py
- [ ] 4. Draft the update from assets/template.md
- [ ] 5. Check tone and traceability
```

### 1. Collect the inputs

Ask for everything missing in one message:
- The period (month or quarter) and the previous update, if there is one. Reuse its KPIs, their order and their definitions.
- Each KPI's value this period and last period, and the plan or target if the founder has one.
- Cash at the end of this period and the last, and any money raised in the period. If they have them, revenue and operating expenses for the period.
- Notes: wins, problems, hires and departures, and what they need from investors.

Never fill a gap with an estimate. If a KPI was not measured, leave it out and say so in the update.

**If there is a customer-level MRR export.** When the saas-metrics-analysis skill is available, run it on the export first and copy its figures exactly as printed: MRR, ARR, customers, logo churn, NRR and GRR for this month and last month from its table. For a quarterly update, take MRR, ARR and customers from the quarter-end months, add up the three months of new, expansion and churned MRR, and label any rate as monthly or as the quarter's average. Put its definitions in the update's definitions line. Without that skill, take MRR from the founder's billing tool and say which tool.

### 2. Write the metrics JSON

Copy [assets/example-metrics.json](assets/example-metrics.json) and fill it in:
- `kpis`: each with `name`, `unit` (`money`, `count`, `percent`, `ratio` or `months`), `this`, `last` and, if there is one, `plan`.
- Percentages are fractions: `0.04` is 4%.
- Names containing churn, burn, CAC, payback, cost or expense are treated as lower-is-better. For any other metric where lower is better, add `"higher_is_better": false`.
- Name the recurring revenue KPI exactly `MRR` or `ARR` so the script can work out the burn multiple.
- `cash`: `this`, `last` and `raised` (equity or debt received this period, which is not burn). `operating`: `revenue` and `expenses` as totals for the whole period.
- `months_in_period`: 1 for monthly, 3 for quarterly.

### 3. Run the numbers

```bash
python scripts/update_numbers.py metrics.json
python scripts/update_numbers.py assets/example-metrics.json
```

The script prints the KPI table in Markdown, a "Worse than last period" line, a "Behind plan" line, and cash, net burn, runway and burn multiple. Runway is cash divided by monthly net burn, with burn held flat. If it reports an error, fix the input it names and run it again. Do not work out any figure yourself.

### 4. Draft the update

Fill in [assets/template.md](assets/template.md), in this order:

1. **TL;DR**: one sentence with the most important number, the biggest problem and the top ask.
2. **Numbers**: the script's table, unchanged, then one or two sentences on what moved most and why.
3. **Highlights**: two to four bullets, each starting with a number or a named outcome.
4. **Lowlights**: everything on the "Worse than last period" and "Behind plan" lines that matters, plus problems from the notes. Say what happened, why (if known) and what the founder is doing about it.
5. **Asks**: one to three, each naming who and what. "Intros welcome" is not an ask; "an intro to a head of support at a 50 to 200 person online retailer, to test our escalation workflow" is.
6. **Cash and runway**: cash, net burn and which figure it is, and runway in months, from the script.
7. **Team**: joiners, leavers with their roles, and open roles.

End with a definitions line for every KPI. Follow the tone rules below throughout.

### 5. Check before sending

- Every number appears in the script output or the founder's notes.
- If "Worse than last period" or "Behind plan" lists a key metric, it is in the TL;DR or the lowlights.
- Each ask names a type of person or company and the action wanted.
- KPI names and definitions match the last update, or the change is stated.
- No hype words or exclamation marks.

## Tone rules

- **Numbers first.** Start bullets with the figure: "$4,974 MRR, down 1.0%", not "MRR dipped slightly".
- **No hype.** Leave out words like excited, thrilled, huge, massive, incredible, game-changing and crushing it, and exclamation marks. Let the numbers carry it.
- **Bad news early and plainly.** If a key metric fell or missed plan, the TL;DR says so. Do not soften with "slight", "temporary" or "one-off" unless the founder's notes show why.
- **Same metrics, same definitions.** Keep last update's KPIs and order. If a definition changed, say so in the update and give last period on the new basis, or say that it cannot be restated.
- **Every number traceable.** Each figure comes from the script output or the founder's notes, quoted as printed. No forecasts unless the founder gave them, labelled as their plan.
- **Short.** Investors skim. Cut anything that is not a number, a decision, a problem or an ask.
- Updates get forwarded. Name customers, candidates or partners only if the founder's notes already do.
assets/example-metrics.json17 lines
{
  "company": "Acme Desk",
  "currency": "$",
  "period": "October 2026",
  "previous_period": "September 2026",
  "months_in_period": 1,
  "kpis": [
    { "name": "MRR", "unit": "money", "this": 4974, "last": 5025, "plan": 5300 },
    { "name": "Paying customers", "unit": "count", "this": 26, "last": 25, "plan": 27 },
    { "name": "Logo churn (monthly)", "unit": "percent", "this": 0.04, "last": 0.04 },
    { "name": "Net revenue retention (monthly)", "unit": "percent", "this": 0.94, "last": 1.01 },
    { "name": "Gross margin", "unit": "percent", "this": 0.80, "last": 0.79 }
  ],
  "cash": { "this": 90000, "last": 96000, "raised": 0 },
  "operating": { "revenue": 5000, "expenses": 11000 }
}
assets/template.md40 lines
Subject: [Company] [period] update: [headline KPI and value], [runway] months of runway

Hi all,

**TL;DR:** [One sentence: the number that matters most, the biggest problem, and the top ask.]

## Numbers

[The KPI table from the script, pasted unchanged.]

[One or two sentences on what moved most and why.]

## Highlights

- [Number first, then what happened and why it matters.]
- [Two to four bullets. Each one has a figure or a named outcome.]

## Lowlights

- [What went wrong or is behind plan, with the number, the cause if known, and what you are doing about it.]
- [Everything on the script's "Worse than last period" and "Behind plan" lines that matters belongs here.]

## Asks

1. [Who exactly, and what you want them to do. Example: an intro to a head of support at a 50 to 200 person online retailer, for a 30-minute call to test our escalation workflow.]
2. [One to three asks: intros, candidates for a named role, or advice on a specific decision.]

## Cash and runway

[Cash at the end of the period, net burn a month and which figure it is (change in cash, or expenses minus revenue), runway in months with burn held flat. Fundraising plans or timing, if any.]

## Team

- [Joiners and leavers with their roles, open roles, and anything investors can help fill.]

[Name]

---
Definitions: [How each KPI is calculated, the same as last update unless stated. Say what changed, if anything.]
scripts/update_numbers.py285 lines
#!/usr/bin/env python3
"""Numbers for an investor update: KPI table, net burn, runway and burn multiple.

Reads a small JSON file with this period's and last period's figures and prints
a Markdown KPI table with absolute and % changes (percentage points for
percentages), the comparison with plan, the metrics that got worse or are
behind plan, and cash, net burn, runway and burn multiple. Claude pastes these
figures into the update instead of doing any arithmetic itself.

Standard library only and no network, so it runs in the Claude API sandbox, on
claude.ai and in Claude Code.

Usage:
  python scripts/update_numbers.py metrics.json

JSON fields (see assets/example-metrics.json):
  company            optional, for the heading
  currency           optional symbol or code for money, e.g. "$", "₹", "EUR"
  period             label of this period, e.g. "October 2026" or "Q3 2026"
  previous_period    label of the period compared with
  months_in_period   1 for a monthly update (default), 3 for a quarterly one
  kpis               list of {name, unit, this, last, plan?, higher_is_better?, decimals?}
                     unit: money | count | percent | ratio | months
                     percent values are fractions: 0.04 means 4%
                     higher_is_better defaults to false for names containing
                     churn, burn, CAC, payback, cost or expense (but not LTV:CAC),
                     and to true otherwise.
                     A KPI named "MRR" or "ARR" is used for the burn multiple.
  cash               optional {this, last?, raised?}: cash at the end of each
                     period and money raised from investors or lenders in this one
  operating          optional {revenue, expenses}: totals for this period

Net burn per month = (expenses - revenue) / months_in_period, or, without
"operating", (cash last - cash this + raised) / months_in_period.
Runway (months) = cash this / net burn per month, with burn held flat.
Burn multiple = net burn over the period / net new ARR over the period.
"""
import json
import re
import sys

UNITS = ("money", "count", "percent", "ratio", "months")
DEFAULT_DECIMALS = {"money": 0, "count": 0, "percent": 1, "ratio": 1, "months": 1}
LOWER_IS_BETTER = re.compile(r"churn|burn|\bcac\b|payback|cost|expense", re.IGNORECASE)
HIGHER_ANYWAY = re.compile(r"\bltv\b", re.IGNORECASE)  # LTV:CAC contains "CAC" but higher is better
# Churn and margins are shares of a whole, so as fractions they cannot pass 1.
SHARE_OF_WHOLE = re.compile(r"churn|margin", re.IGNORECASE)
# A percent fraction above this (1,000%) is almost certainly a whole-number percentage.
MAX_PERCENT_FRACTION = 10
MONTHS_PER_YEAR = 12


def fail(message):
    print(f"Error: {message}", file=sys.stderr)
    sys.exit(2)


def signed(x, decimals):
    """Format with an explicit + or -, and no sign (and no -0) when it rounds to zero."""
    if round(x, decimals) == 0:
        return f"{0:,.{decimals}f}"
    return f"{x:+,.{decimals}f}"


def number(value, where, minimum=None):
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        fail(f"{where} must be a number, not {json.dumps(value)}")
    if minimum is not None and value < minimum:
        fail(f"{where} cannot be below {minimum:g} (is {value:g})")
    return float(value)


class Formatter:
    def __init__(self, currency):
        self.currency = currency or ""

    def money(self, x, decimals, signed=False):
        if round(x, decimals) == 0:
            x = 0.0
        sign = "-" if x < 0 else ("+" if signed and x > 0 else "")
        body = f"{abs(x):,.{decimals}f}"
        cur = self.currency
        if len(cur) > 1 and cur.isalpha():
            cur += " "
        return f"{sign}{cur}{body}"

    def value(self, kpi, x, sign=False):
        unit, d = kpi["unit"], kpi["decimals"]
        if unit == "money":
            return self.money(x, d, sign)
        if unit == "percent":
            return (signed(x * 100, d) if sign else f"{x * 100:,.{d}f}") + "%"
        text = signed(x, d) if sign else f"{x:,.{d}f}"
        if unit == "ratio":
            return text + "x"
        if unit == "months":
            return text + " mo"
        return text

    def change(self, kpi, new, old):
        """Absolute change (pp for percentages) and % change, or '' where % is not meaningful."""
        if kpi["unit"] == "percent":
            return f"{signed((new - old) * 100, kpi['decimals'])} pp", ""
        diff = self.value(kpi, new - old, sign=True)
        rel = f"{signed((new - old) / old * 100, 1)}%" if old > 0 else ""
        return diff, rel


def load(path):
    try:
        with open(path, encoding="utf-8-sig") as f:
            data = json.load(f)
    except OSError as e:
        fail(f"cannot read {path}: {e.strerror}")
    except json.JSONDecodeError as e:
        fail(f"{path} is not valid JSON: {e.msg} at line {e.lineno}, column {e.colno}")
    if not isinstance(data, dict):
        fail("the JSON must be an object with period, previous_period and kpis")
    for key in ("period", "previous_period"):
        if not isinstance(data.get(key), str) or not data[key].strip():
            fail(f'"{key}" is missing: give a label such as "October 2026"')
    months = data.get("months_in_period", 1)
    if isinstance(months, bool) or not isinstance(months, int) or months < 1:
        fail('"months_in_period" must be a whole number of months: 1 for monthly, 3 for quarterly')
    data["months_in_period"] = months
    kpis = data.get("kpis")
    if not isinstance(kpis, list) or not kpis:
        fail('"kpis" must be a non-empty list of {name, unit, this, last}')
    seen = set()
    for i, k in enumerate(kpis, 1):
        if not isinstance(k, dict):
            fail(f"kpis[{i}] must be an object")
        name = k.get("name")
        if not isinstance(name, str) or not name.strip():
            fail(f"kpis[{i}] needs a name")
        where = f'KPI "{name}"'
        if name.lower() in seen:
            fail(f"{where} appears twice")
        seen.add(name.lower())
        if k.get("unit") not in UNITS:
            fail(f"{where}: unit must be one of {', '.join(UNITS)} (is {json.dumps(k.get('unit'))})")
        for field in ("this", "last"):
            if field not in k:
                fail(f'{where} needs "{field}". If last period was not measured, leave the KPI out and say so in the update')
            k[field] = number(k[field], f"{where} {field}")
        if k.get("plan") is not None:
            k["plan"] = number(k["plan"], f"{where} plan")
        if k["unit"] == "percent":
            for field in ("this", "last", "plan"):
                v = k.get(field)
                if v is None:
                    continue
                if abs(v) > MAX_PERCENT_FRACTION or (SHARE_OF_WHOLE.search(name) and v > 1):
                    fail(f"{where} {field} is {v:g}: percentages are fractions, e.g. 0.04 for 4%")
        hib = k.get("higher_is_better")
        if hib is None:
            k["higher_is_better"] = bool(HIGHER_ANYWAY.search(name)) or not LOWER_IS_BETTER.search(name)
        elif not isinstance(hib, bool):
            fail(f"{where}: higher_is_better must be true or false")
        d = k.get("decimals", DEFAULT_DECIMALS[k["unit"]])
        if isinstance(d, bool) or not isinstance(d, int) or not 0 <= d <= 4:
            fail(f"{where}: decimals must be a whole number from 0 to 4")
        k["decimals"] = d
    cash = data.get("cash")
    if cash is not None:
        if not isinstance(cash, dict) or "this" not in cash:
            fail('"cash" must be an object with "this" (cash at the end of this period)')
        for field in ("this", "last", "raised"):
            if cash.get(field) is not None:
                cash[field] = number(cash[field], f"cash.{field}", minimum=0)
    op = data.get("operating")
    if op is not None:
        if not isinstance(op, dict) or "revenue" not in op or "expenses" not in op:
            fail('"operating" must be an object with "revenue" and "expenses" for this period')
        for field in ("revenue", "expenses"):
            op[field] = number(op[field], f"operating.{field}", minimum=0)
    return data


def worse(k, new, old):
    return new < old if k["higher_is_better"] else new > old


def main():
    try:
        sys.stdout.reconfigure(encoding="utf-8")  # currency symbols such as the rupee sign on any console
    except (AttributeError, ValueError):
        pass
    if len(sys.argv) == 2 and sys.argv[1] in ("-h", "--help"):
        print(__doc__)
        sys.exit(0)
    if len(sys.argv) != 2:
        fail("give one metrics JSON file, e.g. python scripts/update_numbers.py assets/example-metrics.json")
    data = load(sys.argv[1])
    fmt = Formatter(data.get("currency"))
    kpis = data["kpis"]
    months = data["months_in_period"]
    this_label, last_label = data["period"].strip(), data["previous_period"].strip()
    has_plan = any(k.get("plan") is not None for k in kpis)

    heading = f"{data['company']}: " if data.get("company") else ""
    print(f"{heading}{this_label} vs {last_label} ({months} month{'s' if months > 1 else ''})")
    print()
    head = ["Metric", this_label, last_label, "Change", "% change"] + (["Plan", "vs plan"] if has_plan else [])
    print("| " + " | ".join(head) + " |")
    print("|" + "---|" * len(head))
    worse_lines, behind_lines = [], []
    for k in kpis:
        diff, rel = fmt.change(k, k["this"], k["last"])
        cells = [k["name"], fmt.value(k, k["this"]), fmt.value(k, k["last"]), diff, rel]
        if worse(k, k["this"], k["last"]):
            worse_lines.append(f"{k['name']} {diff}" + (f" ({rel})" if rel else ""))
        if has_plan:
            plan = k.get("plan")
            if plan is None:
                cells += ["", ""]
            else:
                pdiff, prel = fmt.change(k, k["this"], plan)
                cells += [fmt.value(k, plan), pdiff + (f" ({prel})" if prel else "")]
                if worse(k, k["this"], plan):
                    behind_lines.append(f"{k['name']} {pdiff}" + (f" ({prel})" if prel else ""))
        print("| " + " | ".join(cells) + " |")
    print()
    print("Worse than last period: " + ("; ".join(worse_lines) if worse_lines else "none"))
    if has_plan:
        print("Behind plan: " + ("; ".join(behind_lines) if behind_lines else "none"))

    cash, op = data.get("cash"), data.get("operating")
    money = lambda x: fmt.money(x, 0)
    print()
    print("Cash and runway")
    if cash is None and op is None:
        print('  Not computed: add "cash" (and "operating" or cash.last) to get net burn and runway')
    burn_cash = burn_op = None
    if cash is not None:
        raised = cash.get("raised") or 0.0
        line = f"  Cash at the end of {this_label}: {money(cash['this'])}"
        if cash.get("last") is not None:
            line += f" ({last_label}: {money(cash['last'])}; raised in the period: {money(raised)})"
            burn_cash = (cash["last"] - cash["this"] + raised) / months
        print(line)
        if burn_cash is not None:
            print(f"  Net burn from the change in cash: {money(burn_cash)} a month")
    if op is not None:
        burn_op = (op["expenses"] - op["revenue"]) / months
        sums = f"{money(op['expenses'])} - {money(op['revenue'])}"
        if months > 1:
            sums = f"({sums}) / {months}"
        print(f"  Net burn from expenses minus revenue: {sums} = {money(burn_op)} a month")
    burn = burn_op if burn_op is not None else burn_cash
    basis = "expenses minus revenue" if burn_op is not None else "the change in cash"
    if burn_op is not None and burn_cash is not None and round(burn_op) != round(burn_cash):
        print(f"  The two burn figures differ by {money(abs(burn_op - burn_cash))} a month, usually from the timing of"
              " collections, annual prepayments or one-off payments. Say which one the update uses.")
    if burn is not None and cash is not None:
        if burn > 0:
            print(f"  Runway = {money(cash['this'])} / {money(burn)} = {cash['this'] / burn:.1f} months"
                  f" (net burn from {basis}, held flat)")
        else:
            print(f"  Not burning cash this period (net burn {money(burn)} a month): runway is not limited by burn")
    elif burn is not None:
        print('  Runway needs "cash": {"this": ...}')

    arr_kpi = next((k for k in kpis if re.match(r"^(mrr|arr)\b", k["name"].strip(), re.IGNORECASE)
                    and k["unit"] == "money"), None)
    if arr_kpi is not None and burn is not None and burn > 0:
        factor = MONTHS_PER_YEAR if arr_kpi["name"].strip().lower().startswith("mrr") else 1
        net_new_arr = (arr_kpi["this"] - arr_kpi["last"]) * factor
        label = "ARR (MRR x 12)" if factor > 1 else "ARR"
        if net_new_arr > 0:
            period_burn = burn * months
            note = "" if months >= 3 else "; one month is noisy, a quarter is the usual basis"
            print(f"  Burn multiple = {money(period_burn)} net burn / {money(net_new_arr)} net new {label}"
                  f" = {period_burn / net_new_arr:.1f}x{note}")
        else:
            print(f"  Burn multiple: not meaningful, {label} did not grow this period")

    print()
    print("Percentage metrics change in percentage points (pp). % change is blank where it is not meaningful"
          " (percentages, or a zero or negative base).")


if __name__ == "__main__":
    main()

Limits

  • It writes from the numbers and notes you give it. It can't check them against your accounts or bank, so the update is only as accurate as your inputs.
  • Runway assumes burn stays flat. To see what revenue or cost growth does to it, use the burn rate and runway calculator.
  • Burn multiple on one month swings with the timing of deals and payments. The script says so; quarterly updates give a steadier figure.
  • It won't invent reasons. If a metric fell and your notes don't say why, the draft says the cause isn't known yet, and Claude asks.
  • It drafts the update; it doesn't send it or keep your investor list.

Questions

Monthly or quarterly?

The skill handles both: set months_in_period to 1 or 3 and it divides burn accordingly. Whichever you choose, keep to it. Updates that arrive on schedule with the same metrics are easier for investors to compare than occasional long ones.

What happens when a number got worse?

The script lists every metric that fell or missed plan, and the skill puts the important ones in the TL;DR and the lowlights, with what happened and what you are doing about it. It won't soften a miss with words like "slight" or "temporary" unless your notes say why.

Can it use the numbers from the SaaS metrics skill?

Yes. If you have an MRR export by customer, Claude runs the SaaS metrics analysis skill on it first and copies MRR, ARR, customers, churn and retention for this period and last into the metrics file exactly as printed, along with that skill's definitions.

Why are churn and retention changes in percentage points?

Because a change in a rate is clearer as a difference than as a percentage of a percentage. Churn moving from 2% to 3% is 1 percentage point; as a percentage change it would be 50%, which sounds alarming and can't be compared with other months.

Does the skill send my numbers anywhere?

The skill is instructions, a template and a Python script with no network calls, so it sends nothing anywhere. What you paste or upload is handled under your Claude plan's normal data terms.

Learn what the skill checks

The skill is built from the same rules and formulas as these StartupReader pages, so you can follow its reasoning step by step.

Pairs well with

The weekly brief

More on investor update writing, once a week

Who raised, who launched and the founder terms worth knowing, in one short email a week.

The StartupReader app

Startup news in 60-second swipes

Funding, launches and founder programs, each on one card with the key numbers. Free, with read-aloud and auto-scroll.

Get it on Google PlayComing soon

Building something? List your startup free.

Written and tested by the StartupReader editorial desk with AI assistance; the example outputs are the skill's scripts run on the made-up figures shown. StartupReader is not affiliated with Anthropic. The skill gives general information, not legal, tax or financial advice. Spotted something wrong? Tell us.