A rebuild of the console's Usage & cost tab into a real dashboard:
daily / weekly / monthly views, cost per function (persona msg · Floor Producer ·
Prop Master · Act · memory writer · dreamer · Vibes · persona updater · …), cost by model
tier, daily token volume, and a double-click drill-down for any period.
Status: MOCKUP — awaiting owner review. Everything below runs on
deterministic fake data. Nothing is shipped; the ledger change in §2 is the build
prerequisite and is not written yet.
1 · The mockup
All controls work: switch the range, switch the lens, hover a bar for
every value, click to select, double-click (double-tap on a phone) to open the
full detail drawer for that day / week / month.
Admin console · Usage & costMOCK · GENERATED DATA
hover = every value · click = select · double-click = details (double-tap on touch)
Spend by model tier
By function
By function — every call kind
By model
Top rooms
Three design notes on what you just used:
The stack is your eight names + a gray "Other". The eight colored segments are
exactly the functions you listed (persona msg, FP, prop master, act, mem writer, dreamer,
vibes, persona updater). Everything else — dispatch, Studio, Seen, translate, autotitle,
STT, … — folds into gray, and unfolds in the drill-down. Eight is the ceiling a stacked
bar stays readable at (and the ceiling the palette is validated at).
The gray left edge of Weekly / Monthly is honest history. Before the new ledger
ships there is no per-function record (§2), so old days can only ever show an unsplit
gray bar. The mock renders that era so you can see what the real tab will look like on
day one, not after a year.
The Tokens lens stacks by token class, not by function — cache read vs new
input vs cache write vs output. "We use more and more tokens" is a cache question before
it is a function question: cache reads are ~10–30× cheaper than fresh input, so the same
token count can be wildly different money. One blue ramp, palest = cheapest.
2 · Why this needs one new table (the ledger gap)
Today the per-function split never reaches the database.Room._tally() already sees every provider call with its kind — but
only accumulates it in memory, per room, all-time. The DB ledger turn_events
books one row per whole turn (speech + FP + prop + act + dispatch summed), with no
kind and no cache-token columns. And two functions bill nowhere at all today: the
dreamer and the growth sweep only print their cost to stderr.
the fix is one INSERT at a seam that already exists — no call site changes, the smoketest invariant keeps guarding it
CREATE TABLE call_events (
id INTEGER PRIMARY KEY,
ts TEXT NOT NULL, -- ISO UTC, second precision
room_id TEXT, -- real room, or a function pseudo-room
user_id INTEGER, -- who pays (NULL for system sweeps)
kind TEXT NOT NULL, -- speech · staging · prop · act · memory · dream · growth · …
model TEXT,
in_tok INTEGER DEFAULT 0, -- fresh input
out_tok INTEGER DEFAULT 0, -- output
cw_tok INTEGER DEFAULT 0, -- cache write
cr_tok INTEGER DEFAULT 0, -- cache read
cost REAL DEFAULT 0
);
CREATE INDEX ce_ts ON call_events(ts);
CREATE INDEX ce_kind ON call_events(kind, ts);
Volume: a busy day is a few thousand calls — SQLite
yawns. A year is ~1M rows; if it ever matters, a monthly rollup table is a follow-up, not a
prerequisite. The dashboard's aggregates are three GROUP BY substr(ts,1,10)
queries.
3 · The function taxonomy
What each dashboard slot means, in today's code. "by_kind only" = the split
exists in memory but is never dated or persisted; those all start writing
call_events rows through _tally on day one.
every turn_events row older than the ship — one unsplit sum per turn
yes, unsplit
4 · What ships (when you approve the mock)
The ledger.call_events table + one INSERT inside _tally
(best-effort, never breaks a turn) + the six direct sites (Studio · Seen · Convene ·
Vibes · STT already book turn_events; they add a kind) + first-time booking for the
dreamer and the growth sweep. turn_events untouched.
The endpoint.GET /api/admin/usage2?gran=day|week|month — periods
with per-kind, per-model, per-token-class sums; plus a ?detail=<period>
drill-down (adds rooms + users for that period). Old days served from
turn_events as "unsplit".
The tab. The mock above becomes the top of the Usage & cost tab; today's
by-model / by-user / by-room / backfill cards stay below it unchanged.
Verification.python lib/smoketest.py (it already counts _tally
call sites); a probe that one fake turn produces N call_events rows whose costs sum to
the turn row.
The doc. This page flips from MOCKUP to LIVE and tracks the shipped tab
(room2-docs-sync picks it up).
5 · Open questions for you
The eight stack slots — I used exactly your list. Happy to swap any (e.g.
Dispatch tends to out-spend Prop Master in real rooms; it currently sits in Other).
Retention — keep call_events forever (~1M rows/yr), or roll up to
monthly after 12 months?
Double-click granularity — the drawer shows the period. A second double-click
target (a row in "By function" → that function's per-model split) is cheap to add if you
want to go one level deeper.
Spend caps — unchanged (they read turn_events). If you ever want per-function
caps ("Vibes may spend ≤ $0.50/day"), call_events makes that a one-query feature. Not in
scope unless you say so.
Dialogue · design docs — Usage dashboard · mockup 2026-08-21 · chart palette
validated (dataviz six checks) against both app surfaces