Alpha USER GUIDE
User guide

Everything the dashboard shows, and what it actually means.

Setup, a page-by-page walkthrough, every metric definition with its caveats stated plainly, and the configuration that powers attribution. Written for the individual power user on a Max plan running Claude Code locally — the manager-facing pages work the same way, they just get more interesting once more than one person's telemetry flows in.

One thing before any chart: every dollar here is labelled with how it is denominated. Most are counterfactual — list-price estimates of what the usage would have cost on metered API billing, which nobody is charged. Switch the header's Cost basis to Effective and they become your real subscription fee amortized over actual usage instead. §3 explains all three bases, and the product repeats the distinction on the page wherever it matters.

01 — GETTING STARTED

From empty machine to first chart.

Here is the whole system on one page. Claude Code (and, optionally, other providers via a gateway) emit OpenTelemetry to a local collector, which writes to ClickHouse; a small Go backend queries it and serves the React dashboard. Everything below sets up this pipeline.

TokenPlane system topology: Claude Code and an optional LiteLLM gateway emit OpenTelemetry to the otel-collector, which writes to ClickHouse (raw tables to typed tables to analytic views); a Go backend on :8090 queries ClickHouse and serves the React/Carbon frontend, with Jira and local config feeding the backend
The whole system on one page — everything below sets up this pipeline.

1.1Start the backing stores (podman)

The data layer is two containers: ClickHouse (the analytical store) and an OpenTelemetry collector (the ingest point). A third, Phoenix, is an optional LLM-observability reference and not needed for the dashboard.

$ podman machine start        # first time: podman machine init --cpus 4 --memory 8192
$ podman compose up -d clickhouse otel-collector

$ curl http://localhost:8123/ping     # expect: Ok.

1.2Create the schema

ClickHouse auto-creates the raw otel_logs / otel_metrics_* tables when the collector first writes. The dashboard's analytic layer is applied by hand, once — each file's header comment carries its exact apply command:

Materialized views only process rows inserted after they exist, so apply these before generating telemetry you care about. There is a backfill script (queries/backfill_schema_validated_events.sql) but it is not idempotent — read its header before re-running it.

1.3Make your client emit telemetry

Claude Code emits OpenTelemetry natively; other providers arrive through the LiteLLM gateway. Either way the target is the same collector on http://localhost:4318.

The full setup, both paths, plus troubleshooting: Telemetry ingestion → — that is the dedicated reference, and the page the dashboard's own first-run banner links to.

For Claude Code, the short version is: copy the env block from claude-code/settings.json into your ~/.claude/settings.json, then relaunch — telemetry settings are read at process startup, so the session in which you edit the file will never see its own telemetry.

Before you turn it on for anyone but yourself. The block in this repo is the full-capture configuration: OTEL_LOG_USER_PROMPTS records prompt text into ClickHouse and OTEL_LOG_RAW_API_BODIES writes complete request/response JSON to unencrypted files on disk, and every row carries the account email. If the store will hold anyone else's sessions, use the partner defaults in deploy/SETUP.md instead — both content flags off — and get an explicit OK first.

1.4Run the dashboard

Single artifact (the production path):

$ make build        # -> bin/tokenplane (frontend + landing embedded via go:embed)
$ ./bin/tokenplane  # serves the dashboard, /landing, and /api on :8090

One Go binary, no runtime dependencies — open localhost:8090. The same artifact ships as a container image and a full-stack bundle for installs that start from nothing.

Dev workflow (two processes):

$ go run -C viz/backend-go .               # JSON API on :8090 (single Go binary, no runtime deps)
$ npm --prefix viz/frontend install        # first time
$ npm --prefix viz/frontend run dev        # UI on :5173 (Vite dev-proxies /api → :8090)

Open localhost:5173. Either way, ClickHouse credentials stay server-side in the backend; the browser only ever talks to the API. .claude/launch.json declares all of these for Claude Code's preview tooling: backend, frontend, backend-demo (same backend, defaulting to demo data), and landing (this site on :8100 — in dev it's also served at /landing).

1.5Live vs. Demo data

One command builds — or refreshes — the demo dataset:

$ python3 queries/seed_demo.py

It creates otel_demo, a synthetic multi-user dataset served through the exact same query paths as your real data. The corpus is a rolling ~45-day window ending at the moment you run it, so the 24h / 7d / 30d ranges are all populated on any day you seed — and the widest range still has earlier data to compare itself against. The command is idempotent: it drops and recreates the database every run, so re-running it is always the right way to refresh, never a way to accumulate duplicates.

Once otel_demo exists, a Data: Live / Demo dropdown appears in the header; switching persists the choice and reloads every page under the new source. Whenever demo data is being served, a magenta DEMO DATA tag sits next to the product name so a screenshot can never pass synthetic numbers off as real. Demo mode uses its own config/*.demo.json fixtures and a local Jira fixture, and writes are refused in demo (403) — your real configuration and real data are never touched.

Demo data ages. Nothing reseeds it for you, so a corpus left alone drifts out of the 24h range and then the 7d one. Diagnostics shows how old it is and repeats the command above; re-run it whenever the shorter ranges start to look thin. (One fixture is regenerated by the seeder rather than hand-edited: config/budgets.demo.json, whose amounts are derived from the spend just generated so the budget bands land in a meaningful place whatever day of the month you seed on.)

Two things are deliberately absent in demo mode, and both say so on screen: the Session Metrics context-window breakdown, which is built from raw request bodies the live capture hook writes to disk (the seeder only populates the database), and live-only budget evaluation — a budget alert must never fire because someone toggled a header dropdown, so demo's budget notifications are seeded alongside the data.

Demo mode is also the fastest way to see what the manager-facing pages look like with a team's worth of data, before your own install has one.


02 — THE EIGHT PAGES

A drill-down spine, not eight silos.

The pages form a spine: Overview (is anything wrong?) → Allocation (who is spending?) → Session Metrics (what exactly happened in that session?). Efficiency, Outcomes, Skills & Agents, and Budgets & Alerts branch off the same data; Diagnostics watches the pipeline itself.

Two controls in the header apply everywhere: the Scope filter (a group — managed on Configuration → Groups — (unassigned), or all — applied to every page's queries) and, on pages that have one, the Range selector (24h / 7d / 30d / All).

01Overview

"Anything wrong? What's the headline?" — localhost:5173/

The KPI strip, left to right:

  • Effective hourly wage — blended $/hr (total cost ÷ active hours) with per-model chips. Per-model rates use each model's own inference time, so they are independent rates, not shares of the blended number.
  • Cache ROI — estimated $ saved by serving context from cache at 0.10× the input rate instead of full price, with the effective discount %.
  • Total cost — denominated by the header's Cost basis. Under Estimated it reads recorded (Claude Code's own estimate) with the dashboard's token-priced estimate underneath; under Effective it is your real subscription fee amortized over actual usage. See the three cost bases.
  • Projected EOM — the month-end forecast with its ≈95% band; appends "low-confidence (early)" until there's enough history to trust the trend.
  • Top ticket — the costliest ticket in range, plus what % of spend is ticket-tracked. Click → Outcomes.
  • Cost / commit — recorded cost ÷ commits from the metrics signal.
  • Plan ROI — list-price usage ÷ the flat subscription price (only when scope = all groups). Click → Budgets.
  • Anomalies — count of unusually costly sessions; turns red when non-zero. Click → Budgets.
  • Sessions / Tokens in / Tokens out / Avg events per turn.

Below the strip:

  • Context-tax share — the signature widget. One 100%-stacked bar splitting estimated spend three ways; in the caption's words: "remembering (cache read) + rebuilding (cache write) is the tax of carrying context; new I/O is the productive work." Expect the tax to be most of the bar — that's the finding, not a bug (why).
  • Wage by model — ranked $/hr bars. Bar length is proportional to the rate, not total spend, "so a rare but pricey model still reads expensive"; total cost, tokens, and turns ride along as text.
  • Spend over time — estimated cost by token type, bucketed hourly (24h), daily, or weekly (30d/All once there's enough data). The dashed Projected line extends the recorded-cost trend, but only as far as the range you're looking at, and it's dropped entirely if it would dwarf the actuals — the full projection always lives on the Projected-EOM tile and the Budgets page.

02Allocation

"Who is spending?" — localhost:5173/allocation

Showback and unit economics per group and user, from the email → group mapping managed on Configuration → Groups:

  • Spend by group / by user — ranked recorded-cost bars. Clicking a group sets the global scope, which filters every other page too. Usage from emails in no group lands in a loud red (unassigned) bucket with a warning banner — the point is that unmapped spend should nag until you map it.
  • Top movers — each group's spend vs. the immediately preceding equal-length window (unavailable for range = All, which has no preceding window).
  • Cost per unit of shipped work — per group: $/commit, $/KLoC (lines added + removed), and $/accepted file edit, from Claude Code's metrics counters. The counters only exist while telemetry was enabled, so early ranges undercount.
  • Cohort views — the session-cost histogram (fixed $ bins; surfaces the long tail of a few expensive sessions) and the cross-session tool & activity profile.

03Efficiency

"What's wasted, and what do I do about it?" — localhost:5173/efficiency

  • Productive-token share — the efficiency score: 100 − context-tax %, i.e. the share of estimated spend that went to new I/O rather than carrying context, with a Kubecost-style letter grade. Read the score caveats before taking a "D" personally.
  • Ranked savings (estimated) — each row is one recommendation with an estimated $ for the range: one row per performance tier, avoid warm-cache rebuilds (content invalidations — cache drops that timing can't explain), and reduce rejected work. All figures are explicit upper bounds from list-price estimates; each row's note says which corners were cut. The rows are ranked, not additive — the tier rows don't overlap each other (every model sits in exactly one tier), but they do overlap the other two, which slice the same spend a different way, so summing the column double-counts. Take the top one first.
  • Per-tier rows — every model carries a performance tier, and each tier's row reprices that tier's whole spend at the tier's cheapest member you can still call: "Frontier-tier spend $2,725 → $2,064 at claude-opus-4-8." Expand a row for the per-model breakdown and the scoring of every candidate in the band. Token counts transfer 1:1 inside a tokenizer family; across families they are adjusted (or, cross-provider where no parity has been measured, carried over unadjusted) and the row wears the APPROX chip. Cache traffic reprices at the target's own cache terms — which matters, because OpenAI and DeepSeek have no cache-write step, so cache-creation tokens bill there as ordinary input. Targets are priced at standard (non-introductory) rates so an estimate survives a price flip, retired models are never offered as targets, and a tier whose cheapest member is already what you run produces no row at all rather than a $0.00 one.
  • What a tier actually is. A 10-point band of published SWE-bench Verified scores — Frontier 90%+, Advanced 80–90%, Workhorse 70–80%, Basic under 70% — and nothing more. Two models in a band scored within ten points of each other on one eval; it does not mean they do your work alike. A tier row is an argument to go and test a swap, never evidence one is safe. The page carries the caveats behind the tooltip on each row; the full write-up — sourcing rules, the open-weights and peak-pricing caveats, and the counterfactual arithmetic — lives in docs/design/performance-tier-methodology.md.
  • Models with no published score are excluded loudly — named with their spend, and producing no recommendation. There is no default score and no default band.
  • Cost vs benchmark score — one compact chart: blended $/MTok against each model's SWE-bench Verified score, with the models you actually ran highlighted. Down and to the right is better. "Blended" means each model's standard rates weighted by this range's own token mix and its own cache multipliers — deliberately not a headline input rate, since agentic traffic is overwhelmingly cache reads and providers price those between 0.10× and 0.50× of input. Scores come from different harnesses and different years, so horizontal distance is indicative rather than exact.
  • Cost of rejected work — recorded cost of the turns containing rejected tool calls, with a per-tool breakdown. Upper bound: the whole turn is counted, not just the rejected call.

04Outcomes

"What did the spend ship?" — localhost:5173/outcomes

Per-ticket economics, powered by the ticket-capture hook (see branch-per-ticket) and enriched from Jira when credentials are configured — without them the page still works: tickets render from telemetry alone, minus status and story points.

  • Tracked vs untracked spend — what fraction of recorded spend a ticket claims. In the caption's words: "Untracked spend is work no ticket claims — keep it loud."
  • Cost by epic — tracked cost rolled up to each ticket's parent epic; tickets without an epic land in a loud (no epic) bucket.
  • Cost per ticket — cost, sessions, turns, tokens, dominant actual model, Jira status/points, and $/pt per ticket.
  • Spend per story point — tracked cost ÷ story points over done tickets only (in-progress tickets still accrue cost against their estimate, so counting them would understate).
  • Cost by issue type — tracked cost by Jira issue type.

05Skills & Agents

"What do skills and spawned agents cost?" — localhost:5173/agents

Two tabs on one page: Skills first, Sub-agents second.

Skills — user-invoked (/slash) skills only. A slash command's turn is that skill's work — everything after /skill, subagent spawns included, is what invoking it cost — so each invocation is priced as its full turn's recorded spend, with no qualifier chip. Skills Claude loads on its own (proactive or nested activations) are excluded: they're often incidental to turns they didn't drive, and the telemetry has no per-skill request linkage to split a turn honestly.

  • Spend & activity by skill — invocations, cost, $/invocation, tokens, and duration p50/p95 per skill.
  • Model mix per skill — invocations stacked by the turn's dominant model (a skill runs on whatever model the session is using).
  • Source split — where each invoked skill is defined: bundled/builtin (shipped with Claude Code) vs. project/user settings vs. plugin.
  • Recent skill invocations — one row per /slash invocation, newest first, with a deep link to the session on Session Metrics.

Sub-agents — spend and activity for agents spawned by the Task tool (built-in Explore / general-purpose / Plan, plus custom project/user/plugin agents). Cost honesty is the tab's organizing rule: built-in cost is exact (every cost row names its type), while per-custom-name and per-instance cost is estimated by a model + time-window join — those values wear the EST chip, and APPROX additionally marks concurrent same-model instances whose split is ambiguous.

  • KPI row — invocations, agent spend and its share of all spend, avg cost/invocation, cold-start tax, and share of turn cost.
  • Spend & activity by agent type / Model mix per type / Source split — the roster, per-type cost tiers, models, and where each agent is defined.
  • Cold-start tax — a spawned agent shares no prompt cache with its parent, so its first request pays full input price and writes its own cache. Structural, not avoidable; a fan-out of many one-shot agents pays many cold starts.
  • Cost by custom agent name (estimated) — the exact all-custom total with its per-name split recovered heuristically.
  • Recent subagent invocations — one row per completed subagent, with session deep links.

06Budgets & Alerts

"Are we on plan?" — two pages: localhost:5173/budgets and localhost:5173/alerts

Budgets has two tabs. The read-only Overview tab:

  • Monthly budgets — calendar-month budget vs. recorded month-to-date, per scope, from config/budgets.json (edit them in the Budgets tab). Red is reserved for actual overage; orange for a confident forecast trending over; a short or ramping fit shows "projection too early to call" instead of an alarm (why).
  • Subscription ROI — the flat plan fee vs. what the recorded usage would have cost at API list prices. The caption is the definition: "Claude Code's own list-price estimate of the usage, not money actually spent; there's no per-token invoice on a subscription."
  • Forecast detail — daily recorded spend with the naive linear projection to month end and its ≈95% residual band.
  • Anomaly feed — sessions whose recorded cost sits ≥2σ above the mean. Each row deep-links to that session on Session Metrics; dismissals persist locally in the browser.

The Budgets config tab is a CRUD table (organization total, per-group budgets, and the flat subscription plan price). New Budget writes to config/budgets.json; edits are validated server-side (the group must exist, the amount must be positive). When month-to-date actuals cross 80% and 100% of a budget, the evaluator fires an in-app notification (once per budget × threshold × month). Read-only in demo mode.

Alerts (its own page) has two tabs. Alert Subscriptions — session-scoped alerts on a per-session metric (Accumulated Cost, Tokens In/Out, Averaged Cache Efficiency, Agentic Depth per turn, Events per turn). The New Subscription form takes a name and a positive trigger value; Scope (Session) and Notification Type (In-App) are shown but locked in v1. A background evaluator checks recently-active sessions every ~5 min and fires the first time a session crosses the threshold (once per subscription × session).

Alert Notifications — a table of everything that has fired, subscription and budget thresholds alike; the action opens the session on Session Metrics (subscription) or the Budgets page (budget). The same notifications drive the header bell with its unread count. Demo mode displays seeded budgets, subscriptions, and notifications but is read-only — creating, editing, and deleting need live data.

07Session Metrics

"Why did this session cost what it did?" — localhost:5173/metrics

The per-session diagnostic leaf, and the page most useful to an individual user. Pick a session from the selector (the list honors the global scope). Every chart is indexed by turn number, not wall-clock time — turn 0 is the bucket for session-scoped startup events. Four sections:

Cost & tokenomics

  • Accumulated cost by token type — stacked estimated cost (input / output / cache write / cache read) with Claude Code's recorded cost as a dashed overlay, so estimate-vs-recorded is readable at a glance.
  • Cost burn rate — recorded cost ÷ turn wall-clock hours, per turn.
  • Context tax per turn — the three-way split per turn. "A tall rebuilding band is a cache miss — cross-check the idle-gap chart for the cause."
  • Cache savings (estimated) — cumulative $ saved by cache reads vs. full input price.

Cache health — the cold-start triage kit:

  • Cache hit efficiency — cache-read share of prompt-side tokens, per turn.
  • First-request cache efficiency — the same rate for each turn's opening request only. "A dip here while the turn-average stays high is a cold start — the cache was rebuilt on the opening request, then later warm reads hid it in the average."
  • Idle gap before turn — inter-turn idle seconds on a log axis; red bars exceed the 5-minute cache TTL. Read the last two together: red gap + big rebuild = TTL expiry; warm gap + big rebuild = content invalidation (in this codebase's own data, the dominant invalidation was file edits — a harness serialization quirk documented as DISC-9 in notes.md).

Agentic activity

  • Duration per turn — wall-clock split into model wait vs. everything else (empirically, long turns are overwhelmingly model-latency-bound).
  • Agentic depth per turn — API round-trips vs. tool calls.
  • Events per turn by event name — the full telemetry stream stacked per turn; a tool-heavy turn can be hundreds of events.
  • Tool usage mix — session-level tool call counts.
  • Lines added / deleted per turn — from ~60 s delta exports with no turn id, so counts are approximate near turn boundaries.

Context window

  • Context window breakdown — what each turn's final request context was made of (system tools / MCP tools / system prompt / injected context / user messages / assistant output / tool activity). Bar height is the request's real prompt-side tokens; the split is estimated by character share. "Injected context" is where CLAUDE.md, memory files, and hook output actually live. Turns that switched model get a ⇄ marker: the prior model's thinking blocks are dropped from the prompt on a switch (a stack dip, silently), and the model-scoped prompt cache rebuilds. Needs file-mode raw bodies; sessions captured without them show a notice instead.

08Diagnostics

"Is the pipeline itself healthy?" — localhost:5173/diagnostics

A read-only ops view of ClickHouse, from its own system.* tables — deliberately caption-heavy, doubling as a ClickHouse teaching page. No range or scope: it's about the database, not the spend. Top to bottom: server resources, collector liveness (is data still arriving?), the schema-mismatch quarantine (telemetry rows that didn't match the documented event schema), table/view inventory, storage and compression, merges and mutations, and the slowest recent queries.

In demo mode the page also leads with how old the synthetic corpus is and the command that refreshes it — demo data is a snapshot and nothing reseeds it, so this is where you find out that the shorter ranges have quietly aged out.


03 — METRIC DEFINITIONS, WITH THEIR CAVEATS

The captions, expanded.

TokenPlane's captions label every number as recorded, estimated, or an upper bound. This section is those labels, unpacked.

The three cost bases — and which one is real money

Every dollar figure in the product is labelled with how it is denominated, and the distinction is the whole point rather than a footnote. The API carries the label as fundingHint alongside the figures, so a page cannot render one kind of dollar as another.

  • api-equivalent — counterfactual. What the usage would have cost on metered API pricing. Nobody is billed it. Two flavours feed it:
    • Recorded cost — the cost_usd Claude Code itself attaches to each API request: the vendor's client-side estimate at list price, and the closest thing to ground truth available.
    • Estimated cost — TokenPlane's own split: token counts × a per-model price map (viz/backend-go/internal/pricing/pricing.go — published rates, date-bound so a mid-cycle change is honoured; e.g. Sonnet 5's introductory $2/$10 per MTok is valid through 2026-08-31, the standard $3/$15 from 2026-09-01). Recorded cost is one number per request, so splitting it by token type requires re-pricing from token counts — which is why every by-token-type chart says "estimated".
    The two typically disagree by ~15% (estimated runs low). Known reasons: the price map assumes the 5-minute cache-write tier (1.25× input) while some writes are 1-hour tier (2×), and sub-request models aren't split out. A model with no price entry gets default rates and a visible caption. Read these as relative analytics — mix shifts, ratios, per-ticket comparisons — not as financial figures.
  • subscription-amortized — real money. Your actual invoiced subscription fee, spread across the usage that was billed against that account:

    Effective(turn) = the account's monthly fee × (that turn's recorded cost ÷ that account's total recorded cost for the same UTC month)

    A complete month sums to exactly the fee. This is the first non-counterfactual dollar in the product: on a subscription plan the fee is the invoice, so amortizing it answers "what did this ticket actually cost me" rather than "what would it have cost at list price". Select it with the Cost basis control in the header, once at least one subscription is configured.
  • metered — actual metered API charges, reserved for a future invoice reconciler. No code path emits it yet; the name exists so the vocabulary is fixed before a third denomination arrives rather than after.

What Effective does not do. It never invents coverage. Spend on an account with no subscription weights to $0 and is reported as an uncovered share — never folded into someone else's fee. A month with a fee but no usage surfaces as idle (money that bought nothing) instead of dividing by zero. The current month divides by month-to-date usage, so early-month rates run high and converge as the month fills in; nothing is prorated, and the page says so. Figures that are not allocations of real spend — cache-ROI savings, the Efficiency page, the fixed-$ session histogram, anomaly detection — stay on recorded cost under every basis, because they are only comparable that way.

The turn model

A turn is one user prompt and everything it triggered: (session.id, prompt.id), numbered by first activity. Events with no prompt.id (session startup, plugin loads) bucket to turn 0, which is excluded from wall-clock metrics — its "duration" spans app relaunches, not real work. Tokens in counts all prompt-side tokens: input + cache creation + cache read.

Context tax

Estimated spend split three ways: remembering (cache read) + rebuilding (cache write) = the tax of carrying context; new I/O (input + output) = the productive work. In agentic sessions the tax is typically 85–95% of estimated spend. That is structural, not misuse: cache reads are cheap per token (0.10× input) but the entire conversation is re-read from cache on every API round-trip, so read volume runs hundreds to thousands of times input volume. The tax is a real cost you can shrink at the margins (see Efficiency's recommendations), not a bug you can fix.

Cache ROI / cache savings

cache_read_tokens × input_rate × 0.9

What those reads would have cost at full input price, minus what they cost from cache. An estimate on top of an estimate (list-price rates), so read it as magnitude, not money.

Cache hit efficiency

cache_read ÷ (input + cache_creation + cache_read)

The share of prompt-side tokens served from cache. The per-turn average hides cold starts on long turns (one rebuild followed by 40 warm reads still averages ~98%), which is exactly why Session Metrics has the separate first-request line.

Efficiency score

100 − context-tax %  ·  A ≥ 40 · B ≥ 25 · C ≥ 15 · D ≥ 8 · E below

The grade bands are deliberately generous "because agentic sessions are structurally cache-heavy — even a well-run session spends most of its estimated $ carrying context." Grades are for comparing your own sessions and trends against each other; an absolute "D" indicts the cost structure of agentic loops, not your usage.

Effective hourly wage

Blended = total recorded cost ÷ active hours (Claude Code's active_time counter — in-turn working time, not wall clock). Per-model = that model's cost ÷ that model's inference time. Rates are not additive — the blended figure is a weighted average, and the per-model figures don't stack to it.

Forecast & confidence gating

A naive linear regression over daily recorded cost (UTC days; today's partial day excluded), projected to month end, with a ≈95% band from the fit residuals. Naive extrapolation over an adoption ramp produces confident-looking nonsense, so the presentation is gated: a projection is treated as confident only when it rests on ≥14 full days and its band is narrow relative to what it projects. Until then the KPI says "low-confidence (early)," budget bars won't alarm on it, and the subscription multiple says "too early to project."

With this little history it's an honest "we don't know yet."

Anomalies

Sessions whose recorded cost sits ≥2 standard deviations above the mean session cost (minimum $1, and only once there are ≥4 sessions to make a mean meaningful). Dismissals live in your browser's localStorage — they don't change the backend feed.

Attribution — how spend gets a name on it

Every attribution in the product is an approximation with a loud fallback bucket. Knowing which approximation you're looking at matters:

  • Ticket attribution (Outcomes) is per turn, from marks written by the ticket-capture hook at prompt submit. A mark governs the turns that start after it, until the session's next mark — one session can serve several tickets, and a mark written mid-turn deliberately does not claim the in-flight turn. Turns no mark governs land in (untracked). Marks only exist since the hook landed (2026-07-02 in this install), so tickets worked before then understate their cost. A ticket's whole-turn costs also sweep in everything that happened in those turns — review sessions, merges — so a small ticket can read expensive.
  • Group attribution (Allocation) joins each session to a user.email from telemetry, then to a group via the groups config (Configuration → Groups). Unmapped emails → (unassigned); sessions with no email at all render as (unknown) inside it.
  • Epic rollups (Outcomes) follow Jira's parent links; tickets without an epic → (no epic).
  • Lines of code / commits / accepted edits come from the metrics signal, which exports ~60 s deltas carrying a session id but no turn id. Session Metrics assigns each delta to the latest turn started by the export time (approximate near boundaries); Allocation's unit economics only cover time when telemetry was on.
  • $/story point uses done tickets only, and both its numerator (list-price estimate) and denominator (your team's pointing) are soft. Cross-model $/pt comparisons in particular are confounded by which work got routed to which model — read them as your spread under your routing, not a model benchmark.

04 — CONFIGURATION

Four files and one habit.

4.1config/groups.json — who belongs to which team

Edit these in the app (Configuration → Groups; each person's group is set from their edit panel on the Users tab); the dashboard writes the file for you. The on-disk shape is JSON — a list of groups, each with the emails it owns:

{
  "groups": [
    { "name": "core-eng", "members": ["someone@example.com"] }
  ]
}

Applied at query time and re-read per request — edits show up on the next page load, no restart. Emails in no group bucket to (unassigned) on Allocation, kept loud on purpose. API-writable, so it's runtime state (gitignored) rather than shipped config. On first run an existing config/groups.yaml (or a groups.local.yaml override) is migrated to groups.json once and the original is kept as *.yaml.migrated — the same one-time treatment budgets got. Demo mode reads the tracked groups.demo.json and refuses writes.

4.2config/budgets.json — monthly budgets and the plan price

Edit these in the app (Budgets → Budgets tab); the dashboard writes the file for you. The on-disk shape is JSON — a monthly USD total, per-group amounts, and the flat subscription plan price:

{
  "total": 500,
  "groups": { "core-eng": 400 },
  "subscription": 100
}

Re-read per request (edits show up on the next page load, no restart), and — like groups.json — API-writable, so it's runtime state (gitignored) rather than shipped config. On first run an existing config/budgets.yaml (or a budgets.local.yaml override) is migrated to budgets.json once and the original is kept as budgets.yaml.migrated. The subscription price is account-level and drives the Plan ROI / Subscription ROI widgets: recorded list-price usage ÷ this number. Group budgets must name a group that exists on Configuration → Groups.

The flat subscription price is now a fallback. It carries no per-account attribution, so the Effective cost basis cannot use it — configure real subscriptions (below) and this field is ignored. It survives so an install that hasn't migrated doesn't lose its ROI widget; when it is in use, the Configuration page says so.

4.3config/entitlements.json — who holds which account, and what they pay

The file every real dollar in the product is derived from. Four entities: users (a person — email here is a display label, never matched against, so changing it orphans nothing); accounts (a provider-scoped billing identity keyed (provider, account_uuid), with email as the fallback key used only where no account identifier exists — gateway traffic, or before telemetry confirmed one); user_account_links (who holds which account, kept as its own row so an unlinked account is visibly unlinked rather than quietly attributed to whoever shares its email); and subscriptions (one billing agreement on one account, carrying a required real start month — a subscription began on a real date, so there is no "since the beginning of time" — and carrying entitlements — only flat_fee is implemented, and other kinds round-trip verbatim rather than being counted as zero).

Prices are integer minor units (cents) — money is exact; only the usage-derived amortized shares are decimal. Set this up in the app (Configuration); the dashboard writes the file for you, and hand-editing is supported (re-read per request, one malformed row skipped rather than failing the file). Gitignored and .dockerignored: it carries real account identifiers as well as real fees, so it never reaches a tracked file or a distributed image. See config/entitlements.example.json for the annotated shape.

To change a fee, add a subscription with a later effective-from month — never edit the existing one. Editing rewrites what already-settled months cost; adding a dated row leaves them alone.

4.4config/plan_catalog.json — the list you pick plans from

Published list prices, so picking beats typing. Tracked and shipped, unlike the file above: it holds no account identifier and no statement of what anyone actually pays. The prices are a starting point, not an authority on your bill — seats, annual terms, negotiated discounts and local tax all move them, and the dollar Effective amortizes is the one on your invoice. Check anything you pick; where it disagrees, use the custom-price escape hatch on the subscription rather than bending the catalogue. A re-price is a new entry with a later valid_from, never an edit — same rule the subscription rows follow.

If all you have is the older per-email config/plans.json, nothing is broken: it is projected into the entity model above on every request and every figure is already correct. The Configuration page offers a one-click migration that writes that projection to entitlements.json so you can edit it, link gateway accounts, and hold subscriptions with more than one provider. Nothing changes numerically.

4.5.env — secrets (Jira, proxy)

Copy .env.example to .env (gitignored — never commit real secrets). .env.example documents the proxy/collector variables; for Jira enrichment on the Outcomes page, add:

JIRA_BASE_URL=your-site.atlassian.net    # https:// added automatically if missing
JIRA_EMAIL=you@example.com
JIRA_API_TOKEN=...

Jira is fail-soft: with no credentials, Outcomes still renders every ticket from telemetry alone — you just lose status tags, story points, epic titles, and the $/pt aggregates, and the page says so in a banner.

4.6Branch-per-ticket — the Outcomes capture convention

Attribution costs you one habit: name your branch <TICKET-KEY>-description (e.g. DISC-16-user-docs). A UserPromptSubmit hook (.claude/hooks/ticket-capture.py, registered in .claude/settings.json) resolves the ticket key from the branch name — with a ticket-key mention in your prompt text as fallback — and records a mark in ClickHouse on every prompt. No branch, no mention → the spend stays loudly (untracked) on Outcomes. The hook is fail-open (any error and your prompt goes through untouched), and like all hooks it's snapshotted per session: it starts firing in the next session after being registered, not the one that registered it.

4.7Telemetry settings

claude-code/settings.json is the canonical copy of the telemetry env block for ~/.claude/settings.json — see Telemetry ingestion for what each variable does and the flush-behavior gotchas.

Everything configured? Open your dashboard →  Or go back to the pitch.