Tracks every tool call Claude Code makes — captured by a PostToolUse HTTP
hook, stored via FastAPI + SQLite, visualized in a live React dashboard, and
queryable from inside Claude Code with /toolstats through an embedded MCP
server. A Stop hook also prints a compact per-turn stats note automatically
after each response (CLI + VS Code extension).
Claude Code ──PostToolUse HTTP hook──▶ FastAPI /ingest/tool-event ──▶ DB (SQLite)
│
┌───────────────────┼────────────────────┐
▼ ▼ ▼
REST /api/* + WS /ws MCP server /mcp shared aggregation
│ (mounted in the service
▼ same FastAPI app)
React dashboard Claude Code connects
(charts + live feed) via `claude mcp add`
Key design points:
- Capture is a Claude Code hook, not the MCP server. An MCP server only
runs when Claude calls one of its tools; it cannot observe tool calls. The
four hooks (
SessionStart,PreToolUse,PostToolUse,Stop) POST each event to the backend; the MCP server is a read-only query interface. TheStophook targets/hooks/turn-summary, which also returns a per-turn stats note (see below). - Per-turn note after each response. The
Stophook's endpoint returns a Claude CodesystemMessagesummarizing the tools used in the just-finished turn plus running session totals. It is shown to you only — never fed back into the model — so there is no extra turn and no token cost, and it stays silent on tool-less turns. (Claude Code has no inline artifact panel; for a visual, keep the dashboard open.) - Fail-open by construction. Claude Code treats HTTP-hook connection
failures, timeouts, and non-2xx responses as non-blocking — a down backend
never stalls a session. The capture endpoint returns empty 2xx bodies so
tracker output is never injected into the conversation; the
Stopnote endpoint returns only a user-facingsystemMessageand falls back to an empty 2xx on any error. WebSocket broadcasts are fire-and-forget so a stuck dashboard can't delay a hook response. - One aggregation service (
backend/app/aggregation.py) feeds the REST API, the WebSocket feed, and the MCP tools — the dashboard and/toolstatsalways agree.
Prerequisites: Python 3.12+, uv, Node 22+, Claude Code.
1. Backend (http://localhost:8000)
cd backend
cp .env.example .env # edit TOOLTRACKER_TOKEN (default: dev-token)
uv sync
uv run uvicorn app.main:app --port 8000Health check: curl http://localhost:8000/health
Merge the hooks block from hooks/settings.snippet.json
into .claude/settings.json (team-wide) or .claude/settings.local.json
(personal). The snippet reads the token from the environment Claude Code
launches from:
setx TOOLTRACKER_TOKEN "dev-token" # Windows; then restart the terminalexport TOOLTRACKER_TOKEN=dev-token # macOS/Linux (add to shell profile)Restart Claude Code — hooks are read at session start. From then on every tool call lands in the DB in real time. See hooks/README.md for details.
3. Frontend dashboard (http://localhost:5173)
cd frontend
npm install
npm run devSummary cards, calls-per-tool chart, timeline (minute/hour/day), most-touched files, a sessions table (click a row to filter everything to that session, including a session-detail panel), and a WebSocket live feed of tool calls as they happen.
claude mcp add --transport http tooltracker http://localhost:8000/mcp \
--header "Authorization: Bearer $TOOLTRACKER_TOKEN"
claude mcp list # → tooltracker: http://localhost:8000/mcp (HTTP) - ✓ ConnectedThen type /toolstats in Claude Code (command file:
.claude/commands/toolstats.md) for an inline
overview of the current session. The MCP tools — get_session_summary,
get_tool_stats, list_recent_calls, get_most_touched_files — are
read-only and require the same bearer token.
| Endpoint | Description |
|---|---|
POST /ingest/tool-event |
Hook ingestion (bearer token required) |
POST /hooks/turn-summary |
Stop-hook per-turn note; returns a systemMessage (bearer token required) |
GET /api/stats?session= |
Overview counters + per-tool stats |
GET /api/sessions |
Recent sessions |
GET /api/sessions/{id} |
Full session summary |
GET /api/events?tool=&session=&limit= |
Recent tool calls |
GET /api/files?session= |
Most-touched files |
GET /api/timeline?bucket=minute|hour|day&session= |
Calls over time |
WS /ws |
Live event push |
/mcp |
MCP streamable-HTTP endpoint (bearer token required) |
TOOLTRACKER_TOKEN=your-token docker compose up --buildBackend on :8000 (SQLite on a named volume), dashboard on :5173. The
hook and claude mcp add URLs stay the same. (Compose files are provided
but were authored on a machine without a running Docker daemon — expect to
smoke-test the first build.)
Both services deploy from this one repo — create two Railway services and point each at its own subdirectory.
-
Railway → New service → GitHub repo, pick this repo, and in Settings → Source set Root Directory to
backend. Railway buildsbackend/Dockerfileand picks upbackend/railway.json(healthcheck on/health). -
Settings → Networking → Generate Domain, target port 8000 (the container honors Railway's
PORTif injected and falls back to 8000). -
Attach a Volume mounted at
/data— the image already stores SQLite atsqlite:////data/tooltracker.db, so events survive redeploys. (Or use Postgres instead, see below.) -
Variables:
Variable Value TOOLTRACKER_TOKENa long random secret (not dev-token)CORS_ORIGINSyour frontend URL, e.g. https://<frontend>.up.railway.app(comma-separate to also allowhttp://localhost:5173) -
Verify:
https://<backend>.up.railway.app/health→{"status":"ok",...}.
- Second service from the same repo, Root Directory
frontend. - Variables:
VITE_API_URL=https://<backend>.up.railway.app(no trailing slash). This is baked in at build time — changing it requires a redeploy, and it must be set before the first build. - Settings → Networking → Generate Domain, target port 80.
- Open the domain — the dashboard should load, and the WebSocket live feed
connects over
wss://automatically.
Deploy the backend first so its domain exists when you set
VITE_API_URLandCORS_ORIGINS. If you generated domains in the other order, just update the two variables and redeploy.
Everything that pointed at http://localhost:8000 now points at the Railway
backend URL:
-
Capture hook — in your
.claude/settings.json(fromhooks/settings.snippet.json) replace all four occurrences ofhttp://localhost:8000/ingest/tool-eventwithhttps://<backend>.up.railway.app/ingest/tool-event, set the matching token in the shell Claude Code launches from (setx TOOLTRACKER_TOKEN "<your-token>"on Windows, then open a new terminal), and restart Claude Code. -
Dashboard — open
https://<frontend>.up.railway.app. Run any Claude Code session with the hook installed and calls appear in the live feed. -
/toolstatsvia MCP —claude mcp add --transport http tooltracker https://<backend>.up.railway.app/mcp \ --header "Authorization: Bearer <your-token>"
Operational notes:
- Disable App Sleeping on the backend service — hooks fail open by design, so events fired while the service wakes up are silently dropped.
- The dashboard and REST
/api/*are public. Ingest and MCP require the bearer token, but anyone with the URLs can read your tool-call metadata (file paths, commands). Keep the URLs private, or ask for auth on the read API before sharing them. - SQLite + volume means one replica only. Scale by switching to
Postgres: add a Railway Postgres service, run
uv add "psycopg[binary]"inbackend/, and setDATABASE_URLon the backend service to the Postgres URL with the scheme rewritten topostgresql+psycopg://(Railway hands outpostgresql://).
The storage layer is deliberately portable: no SQLite-specific SQL anywhere (aggregations are plain SQLAlchemy; timeline bucketing happens in Python).
cd backend
uv add "psycopg[binary]"Set DATABASE_URL=postgresql+psycopg://user:pass@host:5432/tooltracker in
backend/.env (or the container environment) and restart. Tables are created
on startup. For production you'd add Alembic migrations instead of
create_all, but nothing else changes.
backend/
app/main.py FastAPI app, MCP mount, combined lifespan, /mcp auth
app/ingest.py POST /ingest/tool-event (capture hook events)
app/hooks_display.py POST /hooks/turn-summary (Stop-hook per-turn note)
app/aggregation.py shared stats service (REST + MCP + WS all use it)
app/api.py REST /api/*
app/ws.py WebSocket broadcaster
app/mcp_server.py FastMCP query tools
app/models.py SQLModel tables (ToolEvent, Session)
app/db.py, config.py, auth.py
frontend/ Vite + React + TS dashboard (TanStack Query, Recharts)
hooks/ settings.snippet.json + install docs
.claude/commands/ /toolstats slash command
- No events arriving? Hooks are snapshotted at session start — restart
Claude Code after installing the snippet. Then check the backend log and
that
TOOLTRACKER_TOKENin the hook environment matchesbackend/.env(a mismatch 401s silently by design — capture must never break a session). /mcpreturns 401? Theclaude mcp addregistration must include theAuthorization: Bearer <token>header./toolstatssays "not connected"? Start the backend, then checkclaude mcp list.- Dashboard empty but backend up? The dev server proxies nothing — the
browser calls
http://localhost:8000directly; make sure that port isn't blocked and CORS origin (defaulthttp://localhost:5173) matches.