# Architecture
Claude Usage Tray is a WPF tray app with one strict split: everything that talks to the outside world or does
arithmetic lives in `ClaudeTrayApp.Core`; the WPF project only composes, binds and draws.
## Layers
| Layer | Project | Responsibility |
|---|---|---|
| Providers | Core | Turn a data source into a `UsageSnapshot` (OAuth endpoint) or local analytics (JSONL logs). Know about transport, never about UI. |
| Aggregator | Core | Merge provider outputs, label the source of every field, decide the stale and unavailable states. Never fabricates a percentage. |
| History store | Core | SQLite time series of snapshots, the deduplicated usage events from the session logs (daily and per-model totals are SQL aggregates over them), JSONL scan offsets and retention pruning. |
| ViewModels | App | Observable adapters over aggregated data via CommunityToolkit.Mvvm source generators. Countdown text and number formatting live here. |
| Views | App | XAML bound to viewmodels. Colours, brushes and fonts come from `Theme.xaml` tokens only. |
Dependency direction is one way: `ClaudeTrayApp` references `ClaudeTrayApp.Core`. Core has no reference to WPF or
H.NotifyIcon; `tests/ClaudeTrayApp.Core.Tests/CoreArchitectureTests.cs` fails the build otherwise.
## Runtime folders
All paths are resolved by `AppPaths` from the environment: `LOCALAPPDATA`, `APPDATA` and `USERPROFILE` when the
process has them set to rooted paths, the shell's known folders otherwise (`Environment.GetFolderPath` ignores the
variables, so a run pointed at a fresh profile needs this order). Nothing is hardcoded to a user, and
`CLAUDE_CONFIG_DIR` relocates the Claude Code home exactly as it does for Claude Code.
| Path | Content |
|---|---|
| `%LOCALAPPDATA%\ClaudeTrayApp\cache.json` | Last successful snapshot. Never contains a token. |
| `%LOCALAPPDATA%\ClaudeTrayApp\history.db` | SQLite: snapshot series per window, deduplicated usage events, JSONL scan offsets. |
| `%LOCALAPPDATA%\ClaudeTrayApp\logs\` | Rolling daily logs, seven days, five MB each. Tokens redacted. |
| `%APPDATA%\ClaudeTrayApp\settings.json` | User settings, hot-reloaded. |
| `%USERPROFILE%\.claude\` | Claude Code home. Read-only for this app. |
## Diagrams
Sources live in [`diagrams/`](diagrams/). Edit the source file and this embedded copy together; a wrong diagram
is worse than none.
### Data flow
```mermaid
flowchart LR
subgraph sources [Sources, read-only]
CRED["Credential discovery
~/.claude/.credentials.json
(CLAUDE_CONFIG_DIR honoured)
expired: nudge the Claude Code CLI to refresh"]
JSONL["Session logs
~/.claude/projects/**/*.jsonl"]
PRICE["pricing.json
(next to binary, overridable)"]
end
subgraph core [ClaudeTrayApp.Core]
OAUTH["OAuth usage provider + poller
GET /api/oauth/usage
300 s poll, 180 s floor, backoff on 429"]
LOCAL["JSONL analytics provider
incremental scan, dedupe by message id"]
CALC["AnalyticsCalculator
today, top projects, 5-hour block, priced"]
AGG["UsageAggregator
percentages from OAuth (authoritative)
tokens, cost, burn rate from JSONL"]
HIST[("history.db
SQLite: snapshot series, usage events, scan offsets")]
CACHE[("cache.json
last snapshot, no token")]
end
subgraph app [ClaudeTrayApp, WPF]
VM["ViewModels"]
TRAY["Tray icon
generated ring + percent"]
FLYOUT["Flyout
windows, resets, burn rate, charts"]
end
CRED --> OAUTH
JSONL --> LOCAL
PRICE --> CALC
OAUTH -->|every fresh snapshot| HIST
OAUTH --> CACHE
CACHE -.->|on launch| OAUTH
LOCAL -->|events, offsets| HIST
HIST --> CALC
OAUTH -->|UsageSnapshot| AGG
CALC -->|LocalAnalytics| AGG
AGG --> VM
HIST -->|chart series| VM
VM --> TRAY
VM --> FLYOUT
```
### Polling state machine
```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Fetching : tick (interval >= 180 s) or debounced manual refresh
Fetching --> Ok : 200, parsed
Fetching --> RateLimited : 429
Fetching --> Unauthenticated : 401 or 403, missing credentials, or token past expiresAt
Fetching --> Stale : network error, timeout, 5xx, unparseable body
Ok --> Idle : write cache.json + history, reset backoff, clear stale flag
Stale --> Idle : keep last snapshot, show "stale", backoff = 2^(n-1) x interval, capped at 30 min
RateLimited --> Idle : keep last snapshot, show "rate limited", same backoff, or Retry-After if longer (also capped at 30 min)
Unauthenticated --> Idle : show "run the Claude Code CLI", re-read credentials next tick
note right of Unauthenticated
The app never refreshes tokens itself.
On an expired token it starts the Claude Code
CLI headless (print mode, input closed) so the
CLI refreshes its own file, then reads it back.
end note
```
### Components
```mermaid
flowchart TB
APP["ClaudeTrayApp (WPF, net9.0-windows)
composition root, views, viewmodels, Theme.xaml"]
CORE["ClaudeTrayApp.Core (net9.0)
domain, providers, aggregator, parsing, pricing, history"]
TESTS["ClaudeTrayApp.Core.Tests (xunit.v3, net9.0)
fixtures with fake tokens only"]
APPTESTS["ClaudeTrayApp.Tests (xunit.v3, net9.0-windows)
tray state, renderer pixels, chart builders, viewmodels"]
UI["WPF, H.NotifyIcon.Wpf, CommunityToolkit.Mvvm"]
BCL["Microsoft.Extensions.*, Microsoft.Data.Sqlite,
System.Text.Json"]
APP --> CORE
TESTS --> CORE
APPTESTS --> APP
APP --> UI
APP --> BCL
CORE --> BCL
classDef forbidden stroke-dasharray: 5 5
CORE -. "never" .-> UI
linkStyle 6 stroke-dasharray: 5 5
```
### Launch sequence
```mermaid
sequenceDiagram
actor U as User
participant App as App (composition root)
participant Settings as settings.json
participant Cache as cache.json
participant Tray as Tray icon
participant Poll as Polling loop
participant API as api.anthropic.com
participant Local as JSONL analytics
U->>App: launch
App->>App: Serilog and crash logging
App->>App: single-instance event + mutex (a second launch signals the first and exits)
App->>App: build host, DI
App->>Settings: load settings.json (defaults written on first run)
App->>Poll: start hosted services (off the UI thread)
Poll->>Cache: load last snapshot (stale until the first fresh fetch)
App->>Local: incremental scan + watcher, history recorder, weekly update check
App->>Settings: settings coordinator starts watching, theme applied
App->>App: Start with Windows default (first run only)
App->>Tray: create icon from the current status
App->>App: warm up the flyout off-screen, load account info
Poll->>API: GET /api/oauth/usage
API-->>Poll: 200 snapshot (or 429 / 401 / error)
Poll->>Cache: persist snapshot
Poll->>Tray: update icon and tooltip
U->>Tray: left-click
Tray->>App: open flyout from cache
App-->>U: flyout visible (< 150 ms)
Poll-->>App: fresh data
App-->>U: flyout updates in place
```
## Status
All eight milestones are delivered and v0.1.0 (2026-09-07) is the first release: solution layout, build settings,
CI, `AppPaths`, the composition root with file logging and crash logging, the usage domain, credential discovery,
the OAuth usage provider, the polling state machine, the snapshot cache (verified against the live endpoint), theme
tokens with dark and light palettes, the generated tray icon with its context menu, the flyout, the local analytics
pipeline with the SQLite history store and the aggregator, the charts, settings with threshold notifications,
autostart and single instance, and the release pipeline below. Later versions are cut by pushing a `v*` tag.
## Release pipeline
`.github/workflows/release.yml` runs on a pushed `v*` tag on a Windows runner: the same pinned checkout and .NET
setup as CI, restore, build with warnings as errors, tests, then `dotnet publish` for win-x64 and win-arm64 with
`-p:Version=`, so the assembly and file version, the About dialog and the first log line carry
the tag's version (a tag that is not `v..[-prerelease]` fails the first step). Each publish
folder (the single-file, self-contained, ReadyToRun, untrimmed exe: about 65 MB on x64 and 61 MB on Arm64, plus
`pricing.json`) is zipped with `LICENSE.txt` and `.github/release/README.txt` as
`ClaudeUsageTray--win-.zip`, and `SHA256SUMS.txt` lists both in `sha256sum` format. The release body
is `.github/release/notes-template.md` with the disclaimer, the checksums, the CHANGELOG section for the version
and GitHub's generated notes filled in; `gh` from the runner (`contents: write`) creates the release with the three
files attached, or replaces the assets and notes of an existing release when the job is re-run. The zips are also
kept as a workflow artifact. The exe is not code-signed.
## Local analytics pipeline
`LocalAnalyticsProvider` runs as a hosted service: one incremental scan at start, a rescan two seconds after the
session logs change (debounced `FileSystemWatcher`), and a safety-net rescan every five minutes. `JsonlScanner`
keeps a byte offset, length and mtime per file in `SqliteStore`, reads only appended bytes, leaves a trailing
partial line for the next pass, restarts a shrunken file from zero, and skips locked files until the next pass.
`JsonlLineParser` turns assistant records into `UsageEvent`s; the store's primary key on (message id, request id)
deduplicates the several lines one API response produces. On the probed machine the first scan of 530 files
(452 MB) stored 29,063 events in 4.3 s; later scans take under 100 ms. `AnalyticsCalculator` answers questions
on demand from SQL aggregates: today's totals by model priced through `PricingTable`, top projects, and the
current 5-hour block anchored on the endpoint's reset time, with a projection computed only from the endpoint's
percentage. `HistoryRecorder` appends every fresh snapshot per window and prunes past the retention window once a
day. `UsageAggregator` hands the flyout one object whose two halves name their sources.
## Flyout
`FlyoutWindow` is a WPF tool window (`WS_EX_TOOLWINDOW`, no taskbar button, not in Alt-Tab) created once at start
and warmed up off-screen; opening it is a show plus placement, measured at about 100 ms. Placement is pure maths in
`FlyoutPlacement`: the monitor under the cursor when the flyout opens (kept as its anchor, and reused after a DPI change) and its work area decide the taskbar edge, and the window is moved
with `SetWindowPos` in physical pixels, so mixed-DPI setups work with the PerMonitorV2 manifest. The backdrop is
DWM's transient-window acrylic when available, with a solid surface fallback. Click-outside is handled by the
window's `Deactivated` event and Esc by key handling. `FlyoutViewModel` splits the snapshot into the primary window
(the hero ring, `RingArc`) and the secondary rows, formats every string, and re-renders time-dependent text every
30 s while the flyout is visible.
## Charts
There is no charting package. `Controls/Sparkline`, `Controls/LineChart` and `Controls/BarChart` are small
`FrameworkElement`s that draw with a `DrawingContext` from the brushes they are given, so they follow the palette
like every other view: one hairline baseline, edge labels, a top value, optional vertical markers, and a hover
readout drawn only while the pointer is over the chart. `Charts/ChartDataBuilder` is pure: it downsamples a series
to at most 240 points keeping the highest value per bucket (peaks survive), builds the 5-hour block chart from the
recorded percentages plus a straight projection at the calculator's pace that stops at the reset, and rolls daily
token totals into the top four models plus "other". `Charts/ChartDataLoader` runs the SQLite queries on a
thread-pool thread; `ChartsViewModel` applies the result on the dispatcher and exposes one chart at a time (this
block, history over 24 h, 7 d or 30 d, daily tokens with a by-model toggle). Every chart also produces a sentence,
used as its automation name and shown under it, and a chart without data collapses to that sentence. Sparklines
sit in the hero (the current block) and in each window row (its own period); they stay hidden until readings
cover at least 5 % of the period.
## Settings, notifications and startup
`SettingsStore` (Core) owns `%APPDATA%\ClaudeTrayApp\settings.json`. It writes the defaults on first run so the
file is there to edit, saves atomically (temp file plus move), normalises every value on the way in (the poll floor,
retention bounds, sorted thresholds), and reloads on external edits through a debounced `FileSystemWatcher`,
ignoring the echo of its own writes. A file that does not parse is left untouched and reported through
`LastError`; the previous settings stay in force. `SettingsCoordinator` (app) applies the current settings once at
start and then every change: `UsagePoller.UpdateOptions` re-times a wait in progress from the last attempt (a
backoff keeps its plan), `HistoryOptions` and the scanner's age cutoff follow the retention, `PricingProvider`
reloads when the pricing path changes, and `ThemeManager.Override` follows the theme setting. The flyout, tray
and settings view models subscribe to the store themselves and marshal to the dispatcher; the flyout writes back
its two toggles (email mask, chart range) so the file always says what the screen shows.
`ThresholdNotifier` (Core) is pure: given a snapshot and the enabled thresholds it returns the crossings not yet
announced, keyed by window and period (the window's reset time), reporting only the highest threshold when several
are crossed at once. `TrayIconViewModel` evaluates it on every fresh snapshot and shows the result as a Windows
notification through the tray icon.
`AutostartManager` reads and writes the per-user `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` value
for the running executable; it is not in settings.json because the registry is the truth. `SingleInstance` holds a
named mutex; a second launch signals a named event and exits, and the first instance shows its flyout.
## Threading
Core is a library and every `await` in it uses `ConfigureAwait(false)`; CA2007 enforces that under
`src/ClaudeTrayApp.Core`. The app starts and stops the generic host off the UI thread, so the polling loop never
inherits the dispatcher context, and view models marshal `StatusChanged` to the dispatcher themselves.
## Tray icon pipeline
`UsagePoller.StatusChanged` (background thread) → `TrayIconViewModel.Apply` on the dispatcher → `TrayIconState`
(percent, status, stale) and tooltip → `TrayIconController.Redraw` → `TrayIconRenderer.Render` at the system DPI
pixel size → `IconConverter.ToIcon` → `TaskbarIcon.Icon`. Theme and display changes re-enter at `Redraw`.