A single SQLite database file owned by the trayapp. WAL mode for crash-safety. Single writer (the trayapp); the dashboard, CLI, and userscript all reach the DB only through the HTTP API.
One row per recorded Claude Code invocation (or per assistant message, depending on source granularity).
| Column | Type | Notes |
|---|---|---|
| id | INTEGER PK | Autoincrement. |
| occurred_at | TIMESTAMP | Wall-clock time of the event. |
| source | TEXT | tailer | cli | hook. Provenance. |
| session_id | TEXT | Claude Code session, if known. |
| message_id | TEXT | Assistant message ID, if known. Used for dedup. |
| project_path | TEXT | Decoded path the session ran in, if known. |
| input_tokens | INTEGER | Required. |
| output_tokens | INTEGER | Required. |
| cache_creation_tokens | INTEGER | Nullable. |
| cache_read_tokens | INTEGER | Nullable. |
| cost_usd_equivalent | REAL | Dollar-equivalent quota cost. Nullable if |
| neither the source nor the price-table | ||
| computation produced a value. | ||
| cost_source | TEXT | reported (came from JSONL/POST) | |
computed (tokens × the model's own rates) | |
||
ceiling (tokens × the price table's |
||
| per-rate maximum, for a model the table does | ||
| not list — a deliberate overestimate). | ||
| model | TEXT | Model name when known. Needed for cost compute. |
| raw_json | TEXT | Original event payload for forensic replay. |
Constraints:
- Unique
(session_id, message_id)where both non-null. Lets the tailer and a hook-driven POST coexist without double-counting. - Index on
occurred_atfor window queries.
One row per authoritative read of the quota state from the dashboard (via userscript or, later, headless scrape).
| Column | Type | Notes |
|---|---|---|
| id | INTEGER PK | |
| observed_at | TIMESTAMP | When the snapshot was read by the source. |
| received_at | TIMESTAMP | When the trayapp received the POST. |
| source | TEXT | userscript | headless | manual. |
| session_used | REAL | "Current session" % used (0–100). Nullable. |
| session_window_ends | TIMESTAMP | When the current 5-hour session resets. |
| weekly_used | REAL | "All models" weekly % used (0–100). Nullable. |
| weekly_window_ends | TIMESTAMP | When the weekly window resets. |
| fable_weekly_used | REAL | "Fable" weekly sub-row % used (0–100). Nullable. |
| session_active | INTEGER | Tri-state limbo signal. Nullable. See below. |
| weekly_active | INTEGER | Tri-state limbo signal. Nullable. See below. |
| continuous_with_prev | INTEGER | Tri-state continuity flag. Nullable. See below. |
| raw_json | TEXT | Full payload for replay. |
session_active is a tri-state column added by migration v4
(add_quota_snapshots_session_active) as a nullable INTEGER.
weekly_active is the same shape, added by migration v6
(add_quota_snapshots_weekly_active):
NULL— the source did not report the field. Treat as "unknown"; do not infer presence or absence of an active window from this.0(false) — the source positively detected "no active window" limbo (Anthropic's UI shows "Starts when a message is sent" instead of a "Resets in …" hint on the corresponding row).1(true) — reserved. The userscript never asserts true; absence encodes "unknown" instead. Other ingestion sources may set 1 explicitly if they have a positive signal.
The window engine consumes these columns to refuse minting phantom
windows. For session_active it also early-closes an active window
when limbo is confirmed and gates event-anchored re-opening; for
weekly_active only the refuse-to-mint behaviour applies, and only
when no snapshot supplies a weekly_window_ends that is strictly in
the future (a stale past boundary counts as "no usable boundary" —
otherwise the engine would loop on close-and-mint of born-expired
weekly windows at every weekly reset). See docs/no-active-session.md
for the end-to-end flow.
fable_weekly_used was added by migration v7
(add_quota_snapshots_fable_weekly_used) as a nullable REAL. The
claude.ai usage page renders the "Weekly limits" section as an aggregate
row ("All models") plus per-model sub-rows; this column holds the "Fable"
sub-row's percentage.
NULL— the source did not report the row. This covers every row written before July 2026, any row from a userscript predating the extractor change, and any account whose page does not render the sub-row. There is no backfill: the value was never observed, so the dashboard simply draws no fable curve over that history rather than a false flat line at 0.
Deliberately not a separate window kind, and deliberately without a
fable_window_ends companion. The page reports the same reset hint on
both weekly rows ("Resets Thu 10:59 PM"), so the series rides the
existing weekly window and borrows weekly_window_ends as its boundary.
Modelling it as its own kind would drag in minting, reset detection,
baselines, and the consumption walker for a line that is purely
informational.
continuous_with_prev is a tri-state column added by migration v5
(add_quota_snapshots_continuous_with_prev) as a nullable INTEGER,
mirroring the session_active shape. It records whether the snapshot
the userscript posted is observed to be continuous with the previous
snapshot the same source had in hand (e.g. same active session window,
no gap that suggests a fresh page load or refresh).
NULL— the source did not report the field. Treated as "start"/"unknown" by downstream consumers; do not infer continuity.0(false) — the source positively detected a discontinuity from its prior snapshot.1(true) — the source observed continuity with its prior snapshot.
Storing NULL on absence and treating NULL as "start"/"unknown" keeps older clients (which never set this field) safe by default — the engine will not assume continuity it cannot prove.
Write-time plateau compaction. When a snapshot arrives with
continuous_with_prev = 1 and every "match" field
(session_used, weekly_used, fable_weekly_used,
session_window_ends, weekly_window_ends, session_active,
weekly_active) is identical
to the most recent row from the same source, the existing row's
observed_at and received_at are slid forward in place instead of
inserting a duplicate. The slide is suppressed when the prior row is itself an
explicit start (continuous_with_prev = 0), so a fresh page load
always anchors a new row.
The audit-trail rules:
-
observed_atis refreshed so the chart sees the latest sighting — otherwise plateaus would visually end at the first observation. -
received_atis refreshed so freshness queries (last_snapshot_age_seconds, slack-gate freshness) stay honest on a stable plateau.Note the resulting shift in semantics for
last_snapshot_age_secondsonGET /api/dashboard/state: it is now computed off the latestreceived_at, which is refreshed by either a brand-new row OR an in-place slide of an identical continuation. Because the userscript dedupes identical observations on the client side (seedocs/userscript.md) and the server slides any duplicates that do arrive, the value reflects time since the last meaningful-change observation, not userscript liveness. A long value means "the page has been frozen" or "the userscript is not running" — those are no longer distinguishable from this field alone. UIs that need a pure liveness signal must use a different source (e.g. process metrics). -
raw_jsonis preserved as the original payload's audit artifact; it is not overwritten across slides.
The slide is scoped to a single source so unrelated ingestion
paths (userscript vs. headless) cannot collapse onto each other.
Derived/cached state for the current and recent session and weekly windows. Maintained by
the trayapp from usage_events + quota_snapshots.
| Column | Type | Notes |
|---|---|---|
| id | INTEGER PK | |
| kind | TEXT | session | weekly. (session = the rolling |
| 5-hour "Current session" window in the Claude UI.) | ||
| started_at | TIMESTAMP | First-use timestamp (session) or week boundary. |
| ends_at | TIMESTAMP | Computed. |
| baseline_percent_used | REAL | "% used" anchor (0–100) at the most-recent in-window snapshot. |
| baseline_source | TEXT | Snapshot ID or default if assumed. |
| closed | INTEGER | 0 while active, 1 once expired. |
This table exists so historical windows can be queried efficiently for charts without recomputing from raw events every time.
Optional: time-series of slack signal values, sampled when the slack endpoint is queried. Useful for tuning the queue heuristics later. Can be turned off.
| Column | Type | Notes |
|---|---|---|
| id | INTEGER PK | |
| sampled_at | TIMESTAMP | |
| slack_fraction | REAL | (percent_expected − percent_used)/100. |
| window_id | INTEGER | FK into windows. |
One row per POST /slack/release. Lets us audit the queue's decisions against the
slack values we exposed at the time. Distinct from slack_samples: this is a discrete
event log, not a time-series.
| Column | Type | Notes |
|---|---|---|
| id | INTEGER PK | |
| released_at | TIMESTAMP | Reported by the queue (its clock). |
| received_at | TIMESTAMP | When the trayapp received the POST. Skew check. |
| job_tag | TEXT | Free-form identifier the queue chose. |
| estimated_cost | REAL | Dollar-equivalent the queue expected the job to cost. |
| slack_at_release | REAL | The slack value the queue saw on its prior /slack. |
| window_id | INTEGER | FK into windows. Which window the queue sized against. |
Per-file resume state for the host JSONL tailer. Lets the trayapp pick up where it left off after a restart instead of re-parsing every transcript from byte zero.
| Column | Type | Notes |
|---|---|---|
| file_path | TEXT PK | Absolute path of the JSONL file being tailed. |
| byte_offset | INTEGER | Number of bytes consumed so far. Persisted on each batch insert. |
| updated_at | TIMESTAMP | When the offset was last advanced. |
This table is purely operational state — it carries no usage data of its
own. Wiping it forces a full re-tail (which is harmless because event
inserts dedupe on (session_id, message_id)).
Anything the tailer or HTTP handlers couldn't make sense of. Helps detect schema drift without losing data.
| Column | Type | Notes |
|---|---|---|
| id | INTEGER PK | |
| occurred_at | TIMESTAMP | |
| source | TEXT | |
| reason | TEXT | |
| payload | TEXT | Raw input that failed. |
usage_events.cost_usd_equivalent is in dollars (raw or computed from token × price
table) — it's the per-event dollar input the consumption report sums.
quota_snapshots.session_used and quota_snapshots.weekly_used are percentages
(0–100) scraped from the claude.ai/settings/usage page, since that's the only
quota figure Anthropic actually exposes there ("6% used", "23% used"). The dashboard
no longer carries dollar-denominated quota totals, because there is no such number
in the source UI to anchor against.
cost_usd_equivalent is not always present in the JSONL — Claude Code computes it from
the model + token counts, and depending on version may or may not serialize it. The
trayapp therefore needs a small price table keyed by model name (input / output /
cache-read / cache-creation per million tokens), and computes cost on ingest if the raw
field is absent. Both the raw and computed values are stored; the dashboard prefers the
raw value when present and labels computed values explicitly so the user knows when
they're seeing our estimate vs. Anthropic's.
The canonical price table is the repo-root prices.yaml, embedded into the binary
at build time (via prices_embed.go) so cost computation works with zero configuration.
It is refreshed by hand when Anthropic updates rates: edit prices.yaml and rebuild. At
runtime the table is selected by ingest.ResolvePriceTable following a precedence chain
(explicit pricing.table_path → local prices.yaml override → embedded default); see
docs/configuration.md "Price table resolution" for the full order and the rationale.
A model the table does not list is priced at the table's ceiling — the
maximum of each rate across every entry (ingest.CeilingPrices) — and marked
cost_source = 'ceiling'.
The alternative, leaving such events NULL, hid them twice over: they were
excluded from every dollar total, and the dashboard's stacked bars skip NULL
costs, so an unrecognized model rendered as a zero-height segment. It was
invisible rather than conspicuous. That is how claude-opus-5 went unnoticed
for four days after release, during which 3062 events carrying 1.9M output and
394M cache-read tokens contributed nothing at all — leaving the Fable sub-row
looking like 93% of all spend when its real share was closer to 9%.
Overstating is the safe direction for a signal whose job is to say how much
quota is left: the pessimistic number withholds slack rather than releasing work
against capacity that has already been spent. The estimate is also visibly an
estimate — the dashboard renders ceiling-priced events in the gray "other"
family whatever their name suggests, names them in the bar's hover text, and
counts them in their own events_with_ceiling_cost bucket. The unknown-model
aggregate (see docs/feedback.md) still fires, so the fix is still prompted.
The ceiling is a per-rate maximum rather than the priciest single model's row.
The two coincide in the shipped table (claude-opus-4-1 / 4-0 hold every
maximum at 15/75/18.75/1.50), but cache reads outnumber output tokens by two
orders of magnitude in real usage, so that rate must not be sampled from
whichever model happened to win on output.
The estimate is provisional. Adding the model to prices.yaml and restarting
makes ingest.BackfillCosts replace every ceiling row with a computed one
at the real rates, so historical dollars come back down rather than staying
inflated forever. The guards on that update are narrow: reported and
computed costs are immovable, and one ceiling estimate never replaces another
(otherwise the same rows would be rewritten on every startup for as long as the
model stays unlisted).
usage_events: keep forever. Volume is small (KB/day at most).quota_snapshots: keep forever.windows: keep forever.slack_samples: optional, default off. If on, retain 90 days.slack_releases: keep forever. Volume is tiny (one row per released job).tailer_offsets: keep forever; one row per transcript file ever tailed. Volume scales with distinct transcript files (small).parse_errors: retain 30 days, with a count-only summary kept indefinitely.
Use a simple schema_version table and apply numbered migration files at startup. No
ORM. Plain SQL is fine at this scale.