ai-spend-analysis
Turn an organization's Anthropic spend into a person / department / product /
model dashboard. The design rests on a three-surface model — three genuinely
separate pools of money that must stay visually distinct everywhere:
| Surface |
What it is |
Where it comes from |
| A — Committed seats |
Recurring seat cash (constant until seats change) |
config/seats.csv roster × list rate |
| B — Variable overage |
Usage-credit spend beyond seat allotments (in-allotment usage is $0 by design — tokens capture it) |
monthly seat-overage export |
| C — API-platform cash |
Console/Developer-platform cost records |
Anthropic Admin API |
Deeper framing, the plan-tier matrix, the security/privacy model, the data
traps, and the roadmap live in references/dashboard-guide.md — read it once before the first run.
Two paths: is this a first run or a monthly run?
config/pipeline.json does not exist in the workspace → run SETUP (below).
- It exists → run the MONTHLY pipeline (below).
Throughout: $SKILL = this skill's directory (the folder holding this file; its
scripts/ subdir holds the pipeline). $ROOT = the operator's workspace
folder (e.g. AI-Spend-Analytics/), which holds config/, raw/, workbooks/,
artifacts/, .secrets/. The workspace is separate from the skill — it holds
the org's data and is never committed anywhere.
Setup (first run — conversational)
Run it as a friendly first-run setup — ask, confirm, write; the operator mostly
confirms rather than types.
- Pick / confirm the workspace root
$ROOT — a folder for this org's data
(roster, monthly exports, dashboards). Suggest an easy-to-find spot like
~/Desktop/AI-Spend-Analytics, and keep it outside any git repo you might
publish — it holds real per-person spend (a scaffolded .gitignore protects
it, but living outside a repo is belt-and-suspenders). Scaffold it:
python3 $SKILL/scripts/scaffold.py "$ROOT" — creates the folder tree and
seeds config/ from $SKILL/config.example/ templates.
- Ask for: company name; plan tier (
team or enterprise); seat types
and their monthly list prices; then write them into $ROOT/config/pipeline.json
(company_name, plan_tier) and $ROOT/config/thresholds.json
(standard_seat_rate_usd).
- Org id (optional). Only the optional Team browser-automation shortcut uses
it; the manual export path does not, so the operator can safely skip it. If they
want the automation: copy it from their claude.ai admin URL → write it to
pipeline.json org_id. (Team only; Enterprise's Analytics API doesn't use it.)
- Roster — no roster needed up front. On the first run I read the people
straight out of your spend export (
bootstrap_roster.py) and write a fill-in
roster at config/roster.xlsx — Excel, with a standard/premium dropdown
(or config/roster.csv if openpyxl isn't installed). Emails and a name guess
come pre-filled; the operator adds department and seat_type two ways:
edit the sheet, or paste/attach their own roster and I fill it in for them.
Per-seat dollars derive from seat_rates in pipeline.json, so there's no
dollar column to touch. Every run then regenerates config/org_chart.csv +
config/seats.csv from the roster (apply_roster.py). Bringing your own?
Drop a config/roster.csv (email,name,department,seat_type), or hand-maintain
org_chart.csv/seats.csv — with no roster file present the pipeline uses
those CSVs as-is.
- Model families — the shipped
config/models.json classifies models into
frontier | sonnet | haiku | other by regex. Confirm the patterns match the
org's current model lines; it drives the what-if routing panel's colors and
labels (no code changes needed to update).
- Optional — Surface C (API/Console cash). If they want it, the operator
places the key — never paste it in chat. Easiest: open the scaffolded
$ROOT/PASTE_ADMIN_KEY_HERE.txt, paste the whole sk-ant-… key, save. On the
next run secure_key.py moves it into $ROOT/.secrets/anthropic_admin_key
(chmod 600, gitignored) and deletes the drop file — the key never touches chat,
a command, or a log. (Env var ANTHROPIC_ADMIN_API_KEY also works.) Skipping it
is fine — the dashboard shows "Surface C not configured". If a key ever appears
in chat, refuse it, tell the operator to rotate it, and point them at the drop
file. (Cowork has no secrets store yet — run Surface C from a local/CLI session
or leave it off; see the reference guide.)
- Validate by doing a first monthly run (below): pull the export, let me
bootstrap the roster from it for the operator to fill, then run the pipeline and
show the dashboard. (The very first build, before the roster is filled, shows
everyone under "(unmapped)" — expected; it sharpens once the roster lands.)
Monthly run (target period = last full calendar month, YYYY-MM)
Step 1 — Surface B: the seat-overage export
Team plan (default): manual export is the primary, documented path. It works
everywhere, including plain Claude Code with no browser. The operator:
claude.ai → Settings → Analytics → "How much is Claude costing?" → Export spend
report → Last Month, then drops the CSV at $ROOT/raw/<YYYY-MM>/overage_spend.csv
and writes raw/<YYYY-MM>/meta.json:
{"period":"YYYY-MM","days_covered":<n>,"partial":false,"source":"manual export <date>"}.
90-day window — do not skip a month. claude.ai only exports the trailing
~90 days; a missed month is unrecoverable. If a month is missing at run time,
lead the summary with the gap — never backfill silently later.
Optional automation (Team, Claude-in-Chrome only): an authenticated in-page
fetch of the same export over an unofficial endpoint (org_id + pattern in
pipeline.json). It is the operator's own Owner session pulling their own export
— same data as the button — but undocumented, so it can drift. Use only if a
browser is available; fail loud (the validator + banner catch drift); never
treat it as required. Details in references/dashboard-guide.md.
Enterprise plan: no browser step — run_pipeline.py (Step 2) fetches Surface
B via the documented Analytics API (fetch_surface_b_enterprise.py). See the
"Enterprise" caveat in the reference guide (built from docs, awaiting a live
validator — see the roadmap).
On any failure (not logged in, non-200, non-CSV): do not fabricate or skip
silently. Write meta.json anyway so the period registers; validation stamps the
gap; the dashboard and summary lead with the "Surface B missing" banner.
Step 2 — Scripted remainder
python3 $SKILL/scripts/run_pipeline.py "$ROOT" YYYY-MM
In order: secure_key.py (ingest a dropped PASTE_ADMIN_KEY_HERE.txt, if present);
(Enterprise only) fetch_surface_b_enterprise.py; then
fetch_surface_c.py (Admin API via key; gracefully not_configured if unset),
validate_surface_b.py (schema + sanity → surface_b_status.json),
apply_roster.py (only when a config/roster.xlsx|csv exists — regenerates
config/org_chart.csv + config/seats.csv from it),
build_workbooks.py (rebuilds both .xlsx from all periods in raw/; writes
build_summary.json), build_artifact.py (writes artifacts/<period>_ai_spend.html),
draft_slack.py (writes artifacts/<period>_slack_draft.txt).
openpyxl is optional. Without it, the summary, dashboard, and Slack draft
still build; only the two .xlsx workbooks are skipped. Install it for the
Excel files: pip install openpyxl.
Step 3 — Verify before reporting
workbooks/build_summary.json — confirm the new period appears with plausible
row counts and the expected flags.
- If openpyxl is installed, recalculate
ai_spend_output.xlsx (Excel/LibreOffice
recalc on open) and require zero formula errors.
- Confirm
artifacts/<period>_ai_spend.html exists and, if any surface status
isn't ok, carries the right banner.
Step 4 — Deliver
- Present the artifact file and the Slack draft text verbatim to the operator.
- Never send the Slack message; never auto-send anything. If a Slack tool is
connected, you may offer to stage a draft only. The operator sends it.
Hard rules
- Read-only on every financial surface. Never modify seats, plans, billing,
or org settings; sessions/keys are used for GET-equivalent reads only.
- Secrets never leak — the operator places them, not the assistant. API keys
live only in env vars or
$ROOT/.secrets/ (gitignored). The friendly path is the
PASTE_ADMIN_KEY_HERE.txt drop file → secure_key.py (file → file, never through
chat or a command). Never solicit a key in chat; if one appears there, refuse it
and tell the operator to rotate. Never write a key to config, workbook, artifact,
log, or chat.
- Three surfaces stay visually distinct everywhere. Overage is usage-credit
spend beyond allotments; in-allotment usage is tokens-only by design (not a bug).
- Fail loud. A missing surface produces a banner + first-line summary alert,
never a silent gap. Empty months never pollute flag lookbacks.
- Config, not workbooks. New hires / seat changes go in the roster
(
config/roster.xlsx|csv) — or edit config/*.csv directly; new API keys go in
config/api_key_map.csv. The workbooks and artifact fully regenerate every run.
- No real org data, no other vendors. This ships with synthetic fixtures only;
it covers Anthropic/Claude spend, not OpenAI/Gemini (on the roadmap).
Files
| File |
Purpose |
scripts/run_pipeline.py |
Orchestrator (plan-tier aware). Runtime entrypoint |
scripts/fetch_surface_c.py |
Admin API pull (env / .secrets key; graceful-missing) |
scripts/fetch_surface_b_enterprise.py |
Enterprise Surface B via the Analytics API (built from docs, untested live — roadmap) |
scripts/validate_surface_b.py |
Schema + sanity check → surface_b_status.json |
scripts/build_workbooks.py |
Both .xlsx + build_summary.json (openpyxl optional); hosts load()/compute_flags() |
scripts/build_artifact.py |
Self-contained interactive HTML dashboard |
scripts/draft_slack.py |
Draft summary .txt (never sends) |
scripts/scaffold.py |
Create a fresh workspace + seed config from templates |
scripts/bootstrap_roster.py |
Seed a fill-in config/roster.xlsx (or .csv) from the first spend export |
scripts/apply_roster.py |
Turn the roster into config/org_chart.csv + config/seats.csv (rates from pipeline.json seat_rates) |
scripts/secure_key.py |
Move an Admin key from the PASTE_ADMIN_KEY_HERE.txt drop file into .secrets/ (600), then delete the drop |
scripts/artifact_template.html |
The dashboard template (config-driven; single file, no external calls) |
config.example/ |
Templated config (fake values) — the shape setup fills in |
fixtures/ |
Synthetic 3-month dataset + make_fixtures.py (seeded) for tests |
references/dashboard-guide.md |
Three-surface framing, plan-tier matrix, security model, data traps, roadmap |
USER_GUIDE.md |
Friendly end-user walkthrough (setup → export → roster → optional key → reading the dashboard) |
Maintenance notes
- Seat prices in
seats.csv are list-rate assumptions; update if actual invoices differ.
- Endpoint drift (Team automation): if the in-page spend-report fetch 404s,
rediscover the endpoint via DevTools/network capture on the export modal and
update
pipeline.json. The manual export is always the safe fallback.
- Model lines evolve: update
config/models.json patterns — no code change needed.
1---2name: ai-spend-analysis3description: Build an org-wide Anthropic/Claude spend dashboard — person, department, product, and model breakdowns across three cost surfaces (committed seats / variable seat-overage / API-platform cash). Fires when the operator says "run the AI spend analysis", "refresh the AI spend dashboard", "how much are we spending on Claude / AI", or "set up AI spend tracking". First run walks a conversational SETUP (company, plan tier, seat prices, roster, org id) and scaffolds a local workspace; monthly runs pull the surfaces, rebuild the workbooks + interactive HTML dashboard, and draft (never send) a summary. It is a generalized, org-agnostic tool — any Claude for Work organization can run it against its own spend. It NEVER modifies seats, billing, or org settings, NEVER auto-sends anything, and does NOT cover non-Anthropic vendors (OpenAI/ChatGPT etc. — roadmap only).4---56# ai-spend-analysis78Turn an organization's Anthropic spend into a person / department / product /9model dashboard. The design rests on a **three-surface model** — three genuinely10separate pools of money that must stay visually distinct everywhere:1112| Surface | What it is | Where it comes from |13|---|---|---|14| **A — Committed seats** | Recurring seat cash (constant until seats change) | `config/seats.csv` roster × list rate |15| **B — Variable overage** | Usage-credit spend *beyond* seat allotments (in-allotment usage is $0 by design — tokens capture it) | monthly seat-overage export |16| **C — API-platform cash** | Console/Developer-platform cost records | Anthropic Admin API |1718Deeper framing, the plan-tier matrix, the security/privacy model, the data19traps, and the roadmap live in [`references/dashboard-guide.md`](references/dashboard-guide.md) — read it once before the first run.2021## Two paths: is this a first run or a monthly run?2223- **`config/pipeline.json` does not exist in the workspace → run SETUP** (below).24- **It exists → run the MONTHLY pipeline** (below).2526Throughout: `$SKILL` = this skill's directory (the folder holding this file; its27`scripts/` subdir holds the pipeline). `$ROOT` = the operator's **workspace**28folder (e.g. `AI-Spend-Analytics/`), which holds `config/`, `raw/`, `workbooks/`,29`artifacts/`, `.secrets/`. The workspace is separate from the skill — it holds30the org's data and is never committed anywhere.3132## Setup (first run — conversational)3334Run it as a friendly first-run setup — ask, confirm, write; the operator mostly35confirms rather than types.36371. **Pick / confirm the workspace root** `$ROOT` — a folder for this org's data38 (roster, monthly exports, dashboards). Suggest an easy-to-find spot like39 `~/Desktop/AI-Spend-Analytics`, and **keep it outside any git repo you might40 publish** — it holds real per-person spend (a scaffolded `.gitignore` protects41 it, but living outside a repo is belt-and-suspenders). Scaffold it:42 `python3 $SKILL/scripts/scaffold.py "$ROOT"` — creates the folder tree and43 seeds `config/` from `$SKILL/config.example/` templates.442. **Ask** for: company name; **plan tier** (`team` or `enterprise`); seat types45 and their monthly list prices; then write them into `$ROOT/config/pipeline.json`46 (`company_name`, `plan_tier`) and `$ROOT/config/thresholds.json`47 (`standard_seat_rate_usd`).483. **Org id (optional).** Only the optional Team browser-automation shortcut uses49 it; the manual export path does not, so the operator can safely skip it. If they50 want the automation: copy it from their claude.ai admin URL → write it to51 `pipeline.json` `org_id`. (Team only; Enterprise's Analytics API doesn't use it.)524. **Roster** — no roster needed up front. On the first run I read the people53 straight out of your spend export (`bootstrap_roster.py`) and write a fill-in54 roster at `config/roster.xlsx` — Excel, with a `standard`/`premium` dropdown55 (or `config/roster.csv` if openpyxl isn't installed). Emails and a name guess56 come pre-filled; the operator adds **department** and **seat_type** two ways:57 edit the sheet, or paste/attach their own roster and I fill it in for them.58 Per-seat dollars derive from `seat_rates` in `pipeline.json`, so there's no59 dollar column to touch. Every run then regenerates `config/org_chart.csv` +60 `config/seats.csv` from the roster (`apply_roster.py`). Bringing your own?61 Drop a `config/roster.csv` (`email,name,department,seat_type`), or hand-maintain62 `org_chart.csv`/`seats.csv` — with no roster file present the pipeline uses63 those CSVs as-is.645. **Model families** — the shipped `config/models.json` classifies models into65 `frontier | sonnet | haiku | other` by regex. Confirm the patterns match the66 org's current model lines; it drives the what-if routing panel's colors and67 labels (no code changes needed to update).686. **Optional — Surface C (API/Console cash).** If they want it, **the operator69 places the key — never paste it in chat.** Easiest: open the scaffolded70 `$ROOT/PASTE_ADMIN_KEY_HERE.txt`, paste the whole `sk-ant-…` key, save. On the71 next run `secure_key.py` moves it into `$ROOT/.secrets/anthropic_admin_key`72 (chmod 600, gitignored) and deletes the drop file — the key never touches chat,73 a command, or a log. (Env var `ANTHROPIC_ADMIN_API_KEY` also works.) Skipping it74 is fine — the dashboard shows "Surface C not configured". **If a key ever appears75 in chat, refuse it, tell the operator to rotate it, and point them at the drop76 file.** (Cowork has no secrets store yet — run Surface C from a local/CLI session77 or leave it off; see the reference guide.)787. **Validate** by doing a first monthly run (below): pull the export, let me79 bootstrap the roster from it for the operator to fill, then run the pipeline and80 show the dashboard. (The very first build, before the roster is filled, shows81 everyone under "(unmapped)" — expected; it sharpens once the roster lands.)8283## Monthly run (target period = last full calendar month, `YYYY-MM`)8485### Step 1 — Surface B: the seat-overage export8687**Team plan (default): manual export is the primary, documented path.** It works88everywhere, including plain Claude Code with no browser. The operator:89claude.ai → **Settings → Analytics → "How much is Claude costing?" → Export spend90report → Last Month**, then drops the CSV at `$ROOT/raw/<YYYY-MM>/overage_spend.csv`91and writes `raw/<YYYY-MM>/meta.json`:92`{"period":"YYYY-MM","days_covered":<n>,"partial":false,"source":"manual export <date>"}`.9394> **90-day window — do not skip a month.** claude.ai only exports the trailing95> ~90 days; a missed month is unrecoverable. If a month is missing at run time,96> lead the summary with the gap — never backfill silently later.9798*Optional automation (Team, Claude-in-Chrome only):* an authenticated in-page99fetch of the same export over an **unofficial** endpoint (`org_id` + pattern in100`pipeline.json`). It is the operator's own Owner session pulling their own export101— same data as the button — but undocumented, so it can drift. Use only if a102browser is available; fail loud (the validator + banner catch drift); never103treat it as required. Details in `references/dashboard-guide.md`.104105**Enterprise plan:** no browser step — `run_pipeline.py` (Step 2) fetches Surface106B via the documented Analytics API (`fetch_surface_b_enterprise.py`). See the107"Enterprise" caveat in the reference guide (built from docs, awaiting a live108validator — see the roadmap).109110**On any failure** (not logged in, non-200, non-CSV): do not fabricate or skip111silently. Write `meta.json` anyway so the period registers; validation stamps the112gap; the dashboard and summary lead with the "Surface B missing" banner.113114### Step 2 — Scripted remainder115116```117python3 $SKILL/scripts/run_pipeline.py "$ROOT" YYYY-MM118```119120In order: `secure_key.py` (ingest a dropped `PASTE_ADMIN_KEY_HERE.txt`, if present);121(Enterprise only) `fetch_surface_b_enterprise.py`; then122`fetch_surface_c.py` (Admin API via key; gracefully `not_configured` if unset),123`validate_surface_b.py` (schema + sanity → `surface_b_status.json`),124`apply_roster.py` (only when a `config/roster.xlsx|csv` exists — regenerates125`config/org_chart.csv` + `config/seats.csv` from it),126`build_workbooks.py` (rebuilds both `.xlsx` from **all** periods in `raw/`; writes127`build_summary.json`), `build_artifact.py` (writes `artifacts/<period>_ai_spend.html`),128`draft_slack.py` (writes `artifacts/<period>_slack_draft.txt`).129130> **openpyxl is optional.** Without it, the summary, dashboard, and Slack draft131> still build; only the two `.xlsx` workbooks are skipped. Install it for the132> Excel files: `pip install openpyxl`.133134### Step 3 — Verify before reporting135136- `workbooks/build_summary.json` — confirm the new period appears with plausible137 row counts and the expected flags.138- If openpyxl is installed, recalculate `ai_spend_output.xlsx` (Excel/LibreOffice139 recalc on open) and require zero formula errors.140- Confirm `artifacts/<period>_ai_spend.html` exists and, if any surface status141 isn't `ok`, carries the right banner.142143### Step 4 — Deliver144145- Present the artifact file and the Slack draft text **verbatim** to the operator.146- **Never send the Slack message; never auto-send anything.** If a Slack tool is147 connected, you may offer to stage a *draft* only. The operator sends it.148149## Hard rules150151- **Read-only on every financial surface.** Never modify seats, plans, billing,152 or org settings; sessions/keys are used for GET-equivalent reads only.153- **Secrets never leak — the operator places them, not the assistant.** API keys154 live only in env vars or `$ROOT/.secrets/` (gitignored). The friendly path is the155 `PASTE_ADMIN_KEY_HERE.txt` drop file → `secure_key.py` (file → file, never through156 chat or a command). Never solicit a key in chat; if one appears there, refuse it157 and tell the operator to rotate. Never write a key to config, workbook, artifact,158 log, or chat.159- **Three surfaces stay visually distinct** everywhere. Overage is usage-credit160 spend beyond allotments; in-allotment usage is tokens-only by design (not a bug).161- **Fail loud.** A missing surface produces a banner + first-line summary alert,162 never a silent gap. Empty months never pollute flag lookbacks.163- **Config, not workbooks.** New hires / seat changes go in the roster164 (`config/roster.xlsx|csv`) — or edit `config/*.csv` directly; new API keys go in165 `config/api_key_map.csv`. The workbooks and artifact fully regenerate every run.166- **No real org data, no other vendors.** This ships with synthetic fixtures only;167 it covers Anthropic/Claude spend, not OpenAI/Gemini (on the roadmap).168169## Files170171| File | Purpose |172|---|---|173| `scripts/run_pipeline.py` | Orchestrator (plan-tier aware). Runtime entrypoint |174| `scripts/fetch_surface_c.py` | Admin API pull (env / `.secrets` key; graceful-missing) |175| `scripts/fetch_surface_b_enterprise.py` | Enterprise Surface B via the Analytics API (built from docs, untested live — roadmap) |176| `scripts/validate_surface_b.py` | Schema + sanity check → `surface_b_status.json` |177| `scripts/build_workbooks.py` | Both `.xlsx` + `build_summary.json` (openpyxl optional); hosts `load()`/`compute_flags()` |178| `scripts/build_artifact.py` | Self-contained interactive HTML dashboard |179| `scripts/draft_slack.py` | Draft summary `.txt` (never sends) |180| `scripts/scaffold.py` | Create a fresh workspace + seed config from templates |181| `scripts/bootstrap_roster.py` | Seed a fill-in `config/roster.xlsx` (or `.csv`) from the first spend export |182| `scripts/apply_roster.py` | Turn the roster into `config/org_chart.csv` + `config/seats.csv` (rates from `pipeline.json` `seat_rates`) |183| `scripts/secure_key.py` | Move an Admin key from the `PASTE_ADMIN_KEY_HERE.txt` drop file into `.secrets/` (600), then delete the drop |184| `scripts/artifact_template.html` | The dashboard template (config-driven; single file, no external calls) |185| `config.example/` | Templated config (fake values) — the shape setup fills in |186| `fixtures/` | Synthetic 3-month dataset + `make_fixtures.py` (seeded) for tests |187| `references/dashboard-guide.md` | Three-surface framing, plan-tier matrix, security model, data traps, roadmap |188| `USER_GUIDE.md` | Friendly end-user walkthrough (setup → export → roster → optional key → reading the dashboard) |189190## Maintenance notes191192- Seat prices in `seats.csv` are list-rate assumptions; update if actual invoices differ.193- **Endpoint drift (Team automation):** if the in-page spend-report fetch 404s,194 rediscover the endpoint via DevTools/network capture on the export modal and195 update `pipeline.json`. The manual export is always the safe fallback.196- **Model lines evolve:** update `config/models.json` patterns — no code change needed.