Once you use Claude Code seriously, you stop having a session. You have one writing a blog post, one building an app in a worktree, one idle since yesterday in another project, a handful of subagents, and pull requests waiting in three repos. Claude Code's /usage and /insights report on usage and habits, but not on what's running right now across every project. We wanted one page that answers, at a glance: what is Claude doing right now, everywhere?
So we had Claude Code build it. The Claude Dashboard is a local, read-only web page that shows every open Claude Code session on a Mac, tokens and activity per project, git worktrees that can probably go, open pull requests, and Anthropic's service status. It's Python's standard library plus one HTML page, with no dependencies, no build step and 97 automated tests. This post covers what it shows, how it works, the surprising things we learned from Claude Code's own files, and how to build your own.
It's part of the workflow described in Claude Code Advanced: once agents run on their own, you need a way to watch them.
What the dashboard shows
- Now: six tiles covering sessions working and idle, sessions today (with this week and subagent runs), output tokens today (with this week and this month), open pull requests, and stale worktrees.
- Live sessions: a card per open session with its state (Working or Idle), how long it's been waiting, the project and branch, output tokens, subagent count and how long it's been open. Refreshed every 5 seconds.
- Projects: sessions, subagent runs, output tokens, pushes, open pull requests, a 14-day activity sparkline and the models used, per project, with a live filter.
- Worktrees: every git worktree in a repository Claude worked in this month, with last Claude activity, last commit and uncommitted files, sorted so the removable ones come first.
- Pull requests: open PRs you authored, across every repository.
- Claude status: Anthropic's own service status in a pill beside the theme switch, with components and incidents one click away.
It runs with one command and opens in your browser:
python3 dashboard.py --open # http://127.0.0.1:8765
python3 dashboard.py --no-github --no-status # no network access at all
How it works
Claude Code keeps everything the dashboard needs on disk:
| Panel | Source |
|---|---|
| Live sessions | ~/.claude/sessions/*.json: pid, status, working directory, name, process start time |
| Projects and tokens | ~/.claude/projects/**/*.jsonl: model, token counts and timestamps only |
| Subagents | The subagents/*.jsonl folder beside each session's transcript |
| Worktrees | git worktree list --porcelain, git status, git log |
| Pushes | git reflog entries whose message is exactly update by push |
| Pull requests | gh search prs --author=@me --state=open, every 5 minutes |
| Claude status | status.claude.com/api/v2/summary.json, every 2 minutes |
A small Python server joins these collectors. Fast data (live sessions) is read on every request; slower data (worktrees, PRs, status) goes through a Refresher that serves the last good value immediately and recomputes in the background. The page is plain HTML, CSS and JavaScript that polls two JSON endpoints and renders with textContent only.
One caveat up front: the files under ~/.claude are Claude Code's internal files, not a documented interface. They can change in any release, so the dashboard treats every missing or oddly typed field as "unknown" rather than crashing.
What Claude Code's own files taught us
Building against real data (about 1.3 GB of transcripts on this Mac) surfaced rules that aren't obvious, and each one is now a line in the project's CLAUDE.md so Claude never relearns it:
- Count a reply once per
message.id. One reply is written as several lines, one per content block, and each repeats the full token usage. 2,537 assistant lines held only 951 replies, so summing lines overcounted tokens about 2.7 times. - De-duplicate across files too. A forked session copies its parent's replies into a new transcript.
- Read incrementally from a byte offset. A single transcript reached 317 MB. The dashboard indexes everything once, then reads only what was appended, and stops at any line without a newline.
- Don't count processes. The desktop app alone runs about 17 processes named Claude. A session is live only if its record's pid exists and
psreports the same start time as the record, because macOS reuses pids and a crash leaves stale records. - Use
GIT_OPTIONAL_LOCKS=0on every git call, so a dashboard status check can't grab the index lock from an agent committing in the same checkout. - A push is a reflog entry that says
update by push. Fetches and pulls move the same refs. Reflogs also expire, so the dashboard reports pushes in a window, never a lifetime total. - Remote URLs can contain credentials. The dashboard parses out
owner/nameand never stores, serves or logs the URL itself.
Here's the liveness check at the heart of the Live sessions panel, slightly simplified:
def record_is_live(record, table):
"""True when the record's process is running and is the same process."""
pid = record.get("pid")
entry = table.get(pid) # pid -> (start time, command) from `ps`, run with TZ=UTC
if entry is None:
return False
started, command = entry
proc_start = record.get("procStart")
if isinstance(proc_start, str) and proc_start.strip():
return _normalize(started) == _normalize(proc_start)
# Without a start time we can't prove identity; accept only a Claude process.
return "claude" in command.lower()
Private by design
Transcripts hold whatever passed through a session, which can include secrets. So the dashboard's rules are strict:
- Read-only. It never writes under
~/.claude, and never opens the*.keyfiles there. - Metadata only. Timestamps, models, token counts, folders, branches and session titles. Message text never enters the index or the API.
- Loopback only. It binds to
127.0.0.1with no option to change that, and rejects any request whoseHostheader isn't the loopback address. That blocks a malicious web page from using DNS rebinding to read the JSON. - The page fetches nothing but its own server. The GitHub and status checks are made by Python and re-served, so the page's Content-Security-Policy can be
connect-src 'self'. The status data is sanitized on arrival like any untrusted input. - Tight serving. An exact allow-list of static files, GET and HEAD only, no CORS headers, no inline scripts or styles, and
nosniff,no-referrerandDENYframing headers.
How we built it with Claude Code
The dashboard went from idea to a tested tool over about ten days in September 2026, in a Claude Code worktree. A few practices made the difference:
Rules first, in CLAUDE.md
The project's CLAUDE.md opens with "Rules that override everything else": read-only, metadata only, loopback only, the page fetches nothing but 127.0.0.1, and never claim a panel works before running it against the real ~/.claude and looking at the page. Every data lesson above was added there the day it was discovered. Because CLAUDE.md loads every session, a new session starts with everything the last one learned.
Tests, then real data
The test suite grew to 97 tests using temporary-directory fixtures, so no test touches the network or the real ~/.claude. They cover reply de-duplication, incremental reads, pid reuse, worktree parsing, push counting against a real git init --bare remote, credentials never leaking from a remote URL, and the server's 403, 404 and 405 responses. A test even fails if the static file names, the server's allow-list and the HTML disagree.
Then Claude starts the server against the real data and checks the page in a browser by reading the DOM and computed styles, not by eyeballing screenshots. That caught a real defect: measuring contrast in the running page showed the muted text color failed the 4.5:1 accessibility ratio in both themes (3.64:1 and 4.33:1), so the one piece of small text using it moved to a stronger color.
It also caught a false alarm worth remembering. One measurement pass reported every icon as 0 pixels wide. It looked like a CSS bug, but the browser pane had been collapsed to zero width, so everything measured 0. The lesson went into the changelog: measure the viewport before believing a layout number.
Design from the site's design system
The Human and Cyber themes come straight from ty1er.com's own design tokens, all defined in three blocks at the top of one stylesheet, with no literal colors below them. The thirteen icons, from the gauge in the title to the hourglass on the Idle tile, are small inline SVGs drawn in the repo rather than imported from an icon set. That keeps the third-party dependency list completely empty: nothing to license, nothing loaded from a CDN.
Documentation as a skill
A documentation skill wrote the README, style guide, component list, security checklist and a prompt for asking another AI to review the project. The security checklist records two items honestly as "Needs review" rather than passed: there's no standalone threat model yet, and no pre-commit secrets scanner.
Build your own: a framework and a detailed prompt
We haven't published our code, but the design is reproducible. Here's the framework we'd follow to build it again from scratch, plus a prompt you can paste into Claude Code to start. One warning before you begin: ~/.claude is Claude Code's internal storage and its format can change, so the first job is always to look at your files rather than trust anyone's field list, including ours.
The six phases
| Phase | Build | Done when |
|---|---|---|
| 0. Rules | CLAUDE.md with the non-negotiable rules; inspect a few real session records and transcript lines and write down the fields you actually see | Rules and observed formats are recorded; no code yet |
| 1. Server | dashboard.py and server.py: loopback bind, Host check, strict CSP, static allow-list, GET/HEAD only, two JSON endpoints returning placeholders | Tests prove a foreign Host gets 403, an unknown path 404, and POST 405 |
| 2. Live | sessions.py: read session records, confirm each process is the same one (pid plus start time), Working vs Idle, subagent activity | The cards match the sessions you actually have open |
| 3. Activity | transcripts.py: incremental index from byte offsets, one count per reply id, de-duplicated across forked files, 14-day buckets per project | Totals stay the same across reloads and restarts |
| 4. Git and GitHub | git_worktrees.py, repos.py, prs.py, and a Refresher cache for slow collectors | Stale worktrees are obvious; PRs load without slowing the page |
| 5. Polish | Design tokens and themes, accessibility, a status panel if you want one, README and security checklist | No console errors, no third-party requests, contrast measured at 4.5:1 or better |
Project structure
claude-dashboard/
├── dashboard.py # argparse entry point: --port --open --no-github --no-status --stale-days
├── claude_dashboard/
│ ├── paths.py # where ~/.claude is; safe ids; folder-name rules
│ ├── sessions.py # live sessions, pid identity, subagent activity
│ ├── transcripts.py # incremental index, reply de-duplication, buckets
│ ├── git_worktrees.py # every git call goes through one hardened helper
│ ├── repos.py # pushes from the reflog; owner/name from the remote
│ ├── prs.py # one `gh search prs` call, sanitized
│ ├── refresher.py # serve last good value, refresh in the background
│ └── server.py # loopback HTTP: /, static files, /api/live, /api/summary
├── static/
│ ├── index.html # no inline <script> or <style>
│ ├── theme-v1.js # sets the theme before first paint
│ ├── app-v1.css # all tokens at the top; versioned filename
│ └── app-v1.js # polls the API; renders with textContent only
├── tests/test_dashboard.py # unittest, temp-dir fixtures, no network
├── CLAUDE.md README.md CHANGELOG.md
The API contract
Two GET endpoints keep the page simple. Every slow panel shares one envelope, so the page can say "loading", "refreshing" or "couldn't reach GitHub" instead of showing a misleading zero:
GET /api/live every 5 s → { "sessions": [ { "state": "working|idle", "title", "cwd",
"branch", "tokens", "subagents", "openFor", ... } ],
"generatedAt": 1791900000.0, "activityReady": true }
GET /api/summary every 30 s → { "activity": { "ready", "refreshing", "error", "updatedAt", ...totals },
"worktrees": { "ready", "refreshing", "error", "updatedAt", "items": [...] },
"prs": { "enabled": true, ...same envelope, "items": [...] },
"status": { "enabled": false },
"staleDays": 7, "generatedAt": ... }
Our cache lifetimes, as a starting point: activity 15 seconds, worktrees and pushes 60, Claude status 120, pull requests 300.
Security checklist
- Bind
127.0.0.1only, with no flag to change it. - Return 403 unless the
Hostheader is127.0.0.1:PORTorlocalhost:PORT, which defeats DNS rebinding. - Serve an exact allow-list of files; no generic static handler, no directory listing.
- Answer GET and HEAD only; everything else gets 405. Send no CORS headers.
- Send
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none', plusX-Content-Type-Options: nosniff,Referrer-Policy: no-referrer,X-Frame-Options: DENY,Cross-Origin-Resource-Policy: same-originandCache-Control: no-store. - Never write under
~/.claude, never open*.keyfiles, never read message text into the index or API. - Make every network call from Python, sanitize what comes back, and give each one an off switch.
- Strip credentials from git remote URLs; keep only
owner/name.
Test checklist
- Reply de-duplication by id, within a file and across a forked copy.
- Incremental reads: a half-written last line is ignored until its newline arrives; a truncated file is re-read from the start; a deleted file drops out.
- Pid reuse: a record whose pid exists but whose start time differs is not live.
- Unreadable or oddly typed records are counted as unknown, never raised.
- Worktree parsing, staleness, and push counting against a real
git init --bareremote. - Remote URL parsing rejects lookalike hosts and never outputs credentials.
- Server: 127.0.0.1 bind, 403, 404, 405, CSP present, and the static allow-list agrees with the files and the HTML.
- No test touches the network or your real
~/.claude.
The prompt
Paste this into Claude Code in an empty folder. It sets the rules and asks for phase 0 only, so you review the plan before any code exists. After that, run one phase per session by saying "Do phase 1", and so on.
You are building "Claude Dashboard": a local, read-only web page that shows my
Claude Code activity on this Mac. Work in phases. Do PHASE 0 now, then stop and
show me the plan. Do not start the next phase until I say so.
GOAL
One page at http://127.0.0.1:8765 showing:
- Now: sessions working, sessions idle, sessions today (and this week, plus
subagent runs), output tokens today (and this week and this month), open pull
requests, stale worktrees.
- Live sessions: a card per open Claude Code session with state (Working / Idle),
how long it has been waiting, project, branch, output tokens, subagent count,
and how long it has been open. Refresh every 5 seconds.
- Projects: per project for the last 7 days: sessions, subagent runs, output
tokens, pushes, open PRs, a 14-day sparkline, models used; with a text filter.
- Worktrees: every git worktree in a repo Claude used this month: last Claude
activity, last commit, uncommitted file count; stale ones first.
- Pull requests: my open PRs across all repos (gh), flagged when idle 7+ days.
- Optional: Anthropic service status from status.claude.com.
STACK
Python 3.9+ standard library only, plus static HTML, CSS and vanilla JavaScript.
No pip installs, no npm, no framework, no CDN, no build step.
RULES THAT OVERRIDE EVERYTHING (put these at the top of CLAUDE.md)
1. Read-only: never write under ~/.claude. Never open ~/.claude/sessions/*.key.
2. Metadata only: timestamps, model, token counts, cwd, branch, session title.
Message text must never enter the index, the API or a log.
3. Loopback only: bind 127.0.0.1, no option to change it. Reject any request
whose Host header isn't 127.0.0.1:PORT or localhost:PORT with 403.
4. The page fetches nothing but 127.0.0.1. Any outbound call (gh, status.claude.com)
is made by Python, sanitized as untrusted input, and re-served through the API.
Each has an off switch: --no-github, --no-status. Both together = no network.
5. Serve an exact allow-list of static files. GET and HEAD only (others 405).
No CORS headers. CSP: default-src 'none'; script-src 'self'; style-src 'self';
img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none';
frame-ancestors 'none'. Also nosniff, no-referrer, X-Frame-Options DENY,
Cross-Origin-Resource-Policy same-origin, Cache-Control no-store.
6. Front end: no inline <script> or <style>; render API data with textContent,
never innerHTML; versioned static filenames (app-v1.css -> app-v2.css on any
change, updated in the server allow-list and index.html together).
7. Never say a panel works until you have run the server against my real
~/.claude and checked the page in a browser using DOM reads and computed
styles.
DATA RULES (verify each against my real files; record what you find in CLAUDE.md)
- ~/.claude is an undocumented internal format. Treat missing or oddly typed
fields as "unknown"; never crash on them.
- Open sessions have small JSON records under ~/.claude/sessions/. Do not count
processes. A record is live only if its pid exists AND `ps` (run with TZ=UTC)
reports the same process start time as the record. macOS reuses pids.
- Transcripts are JSONL under ~/.claude/projects/. One reply can span several
lines that each repeat the full token usage: count each reply once by its
message id. Forked sessions copy their parent's replies: de-duplicate across
files too.
- Transcripts can be hundreds of MB. Index incrementally from a stored byte
offset; never load a whole file; ignore a final line without a newline;
re-index a file that got shorter.
- Every git call sets GIT_OPTIONAL_LOCKS=0 and goes through one helper.
- Count a push only for reflog entries whose message is exactly "update by push",
within a time window (reflogs expire).
- Git remote URLs may contain credentials: keep only owner/name.
API
- GET /api/live (poll 5 s): live sessions plus generatedAt.
- GET /api/summary (poll 30 s, faster until every panel has loaded): activity,
worktrees, prs, status. Each slow panel uses one envelope:
{ ready, refreshing, error, updatedAt, ... }, served from a cache that returns
the last good value immediately and refreshes in the background.
- Pause polling while the tab is hidden. After two failed polls, show a
"Can't reach the dashboard server" banner with role="alert".
TESTS
unittest only, temp-directory fixtures, no network, never my real ~/.claude.
Cover: reply de-dup (same file and forked file), incremental reads, pid reuse,
unreadable records, worktree parsing and staleness, push counting against a real
`git init --bare` remote, remote-URL parsing (lookalike hosts rejected,
credentials never output), and server 403/404/405/CSP/allow-list agreement.
DESIGN
Dark and light themes (plus Auto) defined as CSS custom properties at the top of
one stylesheet, set before first paint to avoid a flash. Text contrast of 4.5:1
or better, measured in the running page. Respect prefers-reduced-motion. Real
landmarks, one h1, an h2 per section, visible focus. Icons as small inline SVGs
drawn in the repo, not an icon library.
PHASES
0. Rules: create CLAUDE.md with the rules above. Inspect 2-3 of my session
records and the first lines of one transcript (metadata fields only, do not
print message text) and write down the fields you actually see. Propose the
file layout and the exact JSON shape of both endpoints. Stop for my review.
1. Server shell: dashboard.py (--port --open --no-github --no-status
--stale-days), server.py with every rule in 3-5, placeholder JSON, tests.
2. Live sessions: sessions.py, the Live panel and the Now tiles that use it.
3. Activity: transcripts.py and the Projects panel with sparklines.
4. Git and GitHub: worktrees, pushes, pull requests, the cache.
5. Polish: themes, accessibility, optional status panel, README with how to run,
where data comes from, and limits; a SECURITYCHECK.md.
DEFINITION OF DONE (every phase)
- python3 -m unittest discover -s tests -v passes.
- The server runs against my real data, the new panel is checked in a browser,
the console has no errors, and the only network host the page contacts is
127.0.0.1.
- CHANGELOG.md has a dated entry, and any new data rule is in CLAUDE.md.
Two tips for running it. Use plan mode (Shift+Tab) for phase 0 so nothing is written until you approve the plan. And keep each phase in its own session, or /clear between them: CLAUDE.md carries the rules and the lessons forward, so a fresh session loses nothing and starts with a small, cheap context.
Limits and what's next
- No "waiting for your approval" state. The session records say busy or idle, not "blocked on a permission prompt."
- Subagent activity is a heuristic: a subagent transcript written in the last two minutes.
- Token counts aren't costs, and plan usage limits aren't shown; use
/usagefor those. - This Mac only. Cloud sessions and routines don't write to the local files.
Version 2 would use Claude Code hooks for exact events. Small SessionStart, Stop, Notification and SubagentStop hooks that each append one line to a local log would give precise "working", "waiting on you" and subagent states instead of inferring them. Because a hook runs on every matching event in every session, each must do nothing but append.
Related
- Claude Code Advanced: skills, hooks, subagents, routines and token costs, through our own blog workflow.
- Claude Code cheatsheet: every flag and slash command, including the 5.5 Models & Agents tab.
- Claude Code hooks and cost tracking, in Anthropic's docs.
- graphify and Obsidian: another way to give a project a visual map.