Self-hosted observability for Claude Code usage. Reads ~/.claude/projects/**/*.jsonl,
aggregates tokens and cost by session / project / model, and serves a single-binary
dashboard. Pricing is pulled from OpenRouter automatically — no manual price
upkeep required.
| Requirement | Minimum | Notes |
|---|---|---|
| Docker Engine | 20.10+ | docker --version |
git |
any | needed by the installer to clone the repo |
| Claude Code | recent build | the app reads from ~/.claude/projects/; you need at least one logged session |
| OS | Linux or macOS | Windows: run inside WSL2 |
| Free port | 2002 | configurable at install time or via AMATOKEN_PORT |
~/.claude/projectsis usually mode700— the container must run as your UID/GID. The installer and the bundleddocker-compose.ymlalready do that for you.
One-liner — clones the repo to ~/.amatoken, builds the image, starts the container,
waits for /healthz, and (if interactive) offers to open the dashboard:
curl -fsSL https://raw.githubusercontent.com/Bedatty-Engineering/amatoken/main/scripts/install.sh | bashNon-interactive (CI / scripted boxes) — accepts defaults silently:
curl -fsSL https://raw.githubusercontent.com/Bedatty-Engineering/amatoken/main/scripts/install.sh | bash -s -- -yCustom flags — pick a port, branch and install dir:
curl -fsSL https://raw.githubusercontent.com/Bedatty-Engineering/amatoken/main/scripts/install.sh | bash -s -- -y -p 9090 -b main -d ~/apps/amatoken| Flag | Default | Purpose |
|---|---|---|
-y, --yes |
off | non-interactive — skip the port prompt and the open-browser confirmation |
-p, --port PORT |
2002 (or prompted) |
host port to bind |
-d, --dir DIR |
~/.amatoken |
clone destination |
-b, --branch B |
main |
git ref to check out |
Re-running the installer is safe: it
fetches and resets the existing checkout to the requested branch, then rebuilds.
After install:
curl localhost:2002/healthz # → ok
xdg-open http://localhost:2002 # or: open http://localhost:2002Pulls the latest code, rebuilds the image and restarts. Data volume is preserved.
Interactive (shows incoming commits, asks before applying):
curl -fsSL https://raw.githubusercontent.com/Bedatty-Engineering/amatoken/main/scripts/update.sh | bashNon-interactive:
curl -fsSL https://raw.githubusercontent.com/Bedatty-Engineering/amatoken/main/scripts/update.sh | bash -s -- -yStops the container and removes the image. Asks before deleting the SQLite volume and the install dir (defaults to keeping both).
Interactive (prompts about volume and install dir):
curl -fsSL https://raw.githubusercontent.com/Bedatty-Engineering/amatoken/main/scripts/uninstall.sh | bashKeep all data, no prompts:
curl -fsSL https://raw.githubusercontent.com/Bedatty-Engineering/amatoken/main/scripts/uninstall.sh | bash -s -- -yNuke everything (volume + install dir), no prompts:
curl -fsSL https://raw.githubusercontent.com/Bedatty-Engineering/amatoken/main/scripts/uninstall.sh | bash -s -- -y --purge- Dashboard with cost / sessions / messages / tokens cards, all with period-over-period delta (▲ red / ▼ green). Cards are clickable → modal with per-model or per-project breakdown.
- Stacked daily/hourly chart with rich hover tooltips. Bars are clickable → modal with per-model breakdown for that day/hour.
- Top 15 projects (grouped by
cwd, not slug) and top 15 models by spend, side-by-side. Clicking a row toggles a filter without leaving the page. - Tab-scoped filters — Dashboard and Sessions keep independent filter state.
- Sessions tab — paginated table with free-text search across project, branch, model and session id; click any row for a drill-down modal with every assistant message and its individual cost.
- Multiple named budgets (calendar-month). Up to 5 pinnable to the dashboard banner.
- OpenRouter pricing engine — Anthropic-only models, periodic auto-sync, strict idempotent CRUD.
- Container resource monitor in the header — live host CPU %, memory %, Go goroutine count.
- Confirmation modal on every destructive action.
git clone https://github.com/Bedatty-Engineering/amatoken.git
cd amatoken
export AMATOKEN_UID=$(id -u) AMATOKEN_GID=$(id -g)
docker compose up --build -dOverride the host port without editing the file:
AMATOKEN_PORT=9090 docker compose up --build -dDay-to-day:
docker compose logs -f
docker compose restart
docker compose down # stop, keep volume
docker compose down -v # stop and wipe DBIf the Compose v2 plugin isn't available:
git clone https://github.com/Bedatty-Engineering/amatoken.git
cd amatoken
docker build -t amatoken .
docker volume create amatoken-db
docker run -d --name amatoken \
--user "$(id -u):$(id -g)" \
-p 2002:2002 \
-v "$HOME/.claude/projects:/claude-projects:ro" \
-v amatoken-db:/data \
--restart unless-stopped \
amatokenNo Docker, hot-iterate on the code:
CLAUDE_PROJECTS_DIR=$HOME/.claude/projects \
DB_PATH=./amatoken.db \
go run ./cmd/serverStatic assets (HTML/JS/CSS) and migrations are embedded via go:embed — go run
always reflects the current source.
Environment variables (sensible defaults):
| Variable | Default | Purpose |
|---|---|---|
CLAUDE_PROJECTS_DIR |
/claude-projects |
Where the JSONL files live inside the container (set by the volume mount). |
DB_PATH |
/data/amatoken.db |
SQLite file path. |
LISTEN_ADDR |
:2002 |
HTTP bind address. |
RECONCILE_INTERVAL |
60s |
Periodic full re-scan in case fsnotify missed an event. |
PRICING_SYNC_INTERVAL |
12h |
OpenRouter auto-sync cadence (only runs while the toggle is on). |
AMATOKEN_PORT |
2002 |
Host-side port mapping (read by docker-compose.yml). |
In-app settings (persisted in SQLite, editable from the UI):
| Setting | Default | Lives in |
|---|---|---|
pricing_auto_sync |
true |
Toggle in the Pricing tab. |
auto_refresh_enabled |
false |
Toggle next to Refresh now in the header. |
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /healthz |
Liveness. |
| GET | /api/summary?from=&to=&project=&model= |
Tokens + cost USD totals + per-model breakdown. |
| GET | /api/timeseries?bucket=day|hour&... |
Time series — tokens AND cost per bucket. |
| GET | /api/sessions?limit=&offset=&q=&... |
Paginated session list with free-text search. |
| GET | /api/sessions/{id}/records |
Drill-down: per-message records for one session. |
| GET | /api/rankings/projects?... |
Per-cwd cost / sessions / messages, sorted desc. |
| GET | /api/rankings/models?... |
Per-model cost, sorted desc. |
| GET | /api/filters |
Distinct project keys (cwd first, falls back to slug) and models for the UI selects. |
| DELETE | /api/records/{id} |
Remove a single ingested record. |
| GET | /api/pricing |
List all pricing rows. |
| POST | /api/pricing |
Create a new manual row. Returns 409 Conflict if the model already exists. |
| PUT | /api/pricing/{model} |
Update an existing row. Returns 404 if the row doesn't exist. Source is preserved. |
| DELETE | /api/pricing/{model} |
Delete a pricing row. Manual rows are gone for good; OpenRouter rows reappear on the next sync. |
| POST | /api/pricing/sync |
Force OpenRouter sync now. |
| GET | /api/pricing/status |
Last sync time, provider, errors, row count. |
| GET / POST / PUT / DELETE | /api/budgets |
CRUD for budgets (PUT accepts show_in_dashboard). |
| GET / PUT | /api/settings |
Key/value app settings — auto_refresh_enabled, pricing_auto_sync. |
| GET | /api/resources |
Live container metrics: cpu_pct_host, memory_pct_host, memoryMB, host_cpu_count, host_memory_total_mb, goroutines. |
| POST | /api/ingest/refresh |
Force a full reconcile of CLAUDE_PROJECTS_DIR. |
Quick smoke test:
curl localhost:2002/healthz
curl localhost:2002/api/summary | jq
curl localhost:2002/api/pricing/status- Only lines with
type == "assistant"and amessage.usageblock become rows.type=user,tool_result, etc. are ignored — they only contribute to theinput_tokensof the next assistant message. - Synthetic events (
model == "<synthetic>") — context compactions, system prompts — are excluded from every aggregation. - Dedup is by
message.id(INSERT OR IGNORE). - Per-file byte offset is stored in
ingest_state; container restarts don't re-ingest. fsnotifywatches every subdir ofCLAUDE_PROJECTS_DIR(with 500ms debounce). The reconcile tick (default 60s) catches events the watcher missed.
Project identity = cwd, not slug. Claude Code names project directories after the cwd in which a session started, but the cwd inside the JSONL can change as you cd around mid-session. amatoken groups by the per-record cwd (falling back to project_slug when cwd is missing) so subprojects under the same starting directory show up as distinct rows.
Implemented as a clean provider/registry/calculator trio under internal/pricing/.
Three source levels with strict priority:
source |
Origin | Sync behaviour |
|---|---|---|
manual |
Row added from scratch via the UI (POST /api/pricing) |
Never overwritten by sync. |
openrouter |
Pulled from OpenRouter | Always refreshed on every sync. Manual edits stay until next sync. |
seed |
First-run offline fallback | Replaced as soon as OpenRouter sync succeeds. |
POST /api/pricing is strict — it refuses to overwrite an existing row (returns 409). Use PUT to edit.
Model-id matching has fallbacks: exact match → strip -YYYYMMDD date suffix → walk up -N version segments. So claude-haiku-4-5-20251001 resolves to claude-haiku-4-5. OpenRouter's claude-opus-4.7 is auto-normalised to claude-opus-4-7.
amatoken/
├── cmd/server/main.go # entrypoint, wiring, graceful shutdown
├── internal/
│ ├── ingest/ # parser, scanner, fsnotify watcher
│ ├── storage/ # SQLite open + migrations + repo (queries)
│ ├── pricing/ # Provider, OpenRouter, Registry, Calculator
│ ├── seed/ # First-run example budget + manual pricing
│ └── httpapi/ # chi router, handlers, embedded static UI
├── assets/img/ # logo, copied into static/ at build time
├── scripts/ # install.sh, update.sh, uninstall.sh
├── Dockerfile # multi-stage: golang:1.23-alpine → alpine:3.20
├── docker-compose.yml
└── README.md
Stack: Go 1.23, chi, modernc.org/sqlite (pure Go, no CGO), fsnotify.
Frontend: vanilla Alpine.js + Chart.js via CDN, served as go:embed static
files. Final image is ~21 MB.
amatoken uses semantic-release wired
through Bedatty-Engineering/modules-hub@v1.
Versions are derived from Conventional Commits; binaries for
linux/{amd64,arm64} and darwin/{amd64,arm64} are attached to each GitHub
Release. Branch model: main → latest (stable), dev → alpha prereleases.
See docs/RELEASE.md for the full setup (required
secrets, GPG signing, dry-run instructions).
| Symptom | Cause | Fix |
|---|---|---|
open db: unable to open database file |
Container UID can't write to /data. |
Use --user "$(id -u):$(id -g)" (already in compose). |
| Dashboard empty despite JSONL existing | Container can't read ~/.claude/projects (mode 700). |
Same fix: run as your host UID. |
port is already allocated |
Port 2002 taken. | Re-install with -p 9090, or AMATOKEN_PORT=9090 docker compose up. |
unknown flag: --build |
Compose v2 plugin missing. | sudo apt install docker-compose-v2, or use the plain docker run flow. |
| New session not appearing | fsnotify missed the create. | Click Refresh now, wait up to 60s, or curl -X POST localhost:2002/api/ingest/refresh. |
A model shows $0.00 cost |
No pricing row for that exact id, no fallback matched. | Click Sync from OpenRouter, or add the row manually in Pricing. |
| OpenRouter sync fails | Rate limit / network blip. | Cached values keep working; the next periodic tick retries. Check GET /api/pricing/status. |
amatoken is the measurement tool — but here's what tends to move the needle:
- Switch model per task. Haiku for greps and renames, Sonnet for most coding work, Opus for architecture and tough debugging. The Top models by spend panel makes it obvious which model is eating your budget.
- Start a fresh session for unrelated work. As context grows, occasional cache writes (priced ~1.25× input) add up. New session = clean cache.
- Be specific. Vague prompts trigger exploration; precise file/line references skip it.
Readwithoffset/limitinstead of letting Claude pull whole large files.
© 2026 — All rights reserved. Self-hosted Claude usage monitor.
