A self-hosted web dashboard that shows every tmux-based Claude Code session as a named "team" you can glance at, answer, and manage — from your phone or laptop — over your private Tailscale network. Nothing is exposed to the public internet; your tailnet is the trust boundary.
It reads tmux + Claude Code transcripts to tell, per session, whether Claude is working, waiting on you, or finished (your move) — so a wall of parallel sessions becomes glanceable.
- Live floor — rooms (ventures / clients / personal) of desks, one per tmux session: face, name and project, the most recently active first (pinned before all). Its state is what Claude Code itself writes (its status file under
~/.claude/sessions: busy, idle or waiting, and for what), never a guess from the screen or the reply's wording: a session waiting on you gets an amber note, Answer for an open question (with its choices), Allow for a permission prompt (with the command or file it is for), Waiting for any other dialog, and a chip in the Your move row. The look is written down inDESIGN.md. - Questions and permissions — Claude Code writes an open question to its transcript only once it is answered, so a hook hands it over while it is open:
hooks/ask.pykeeps the JSON ofPreToolUse(AskUserQuestion)andPermissionRequestin~/.claude/office-asks/<session id>.json. Install it once withpython3 hooks/install.py(adds three entries to~/.claude/settings.json, keeps a backup). Without it a question still shows as waiting, answered in the terminal. - A person's panel — tap a desk: the last ten exchanges from its transcript (
GET /api/turns?session=…, read only while the panel is open), each of your messages followed by everything Claude wrote after it, whole and in order, an open question's choices as buttons (each presses that choice's key in the terminal; a permission prompt gets Allow / Deny), a box to answer (typed in viatmux send-keys) and one ⋯ menu for terminal, pin, mute, focus note, move, icon, rename, close and erase. A desk's icon comes from its project name (keywords inemoji.local.json); Change icon… sets your own for that desk only (icons.json), Reset goes back to the guess. On a laptop a desk can also be dragged to another room. A side panel on a laptop, a full-screen sheet on the phone; double-click a desk to go straight to its terminal. - Full terminal — a real interactive terminal per session (via ttyd), with seamless select-to-copy, an open-in-new-tab button and, on a laptop, a rail of faces to hop between desks. Each terminal has its own address (
/<session-name>), so a refresh, a bookmark or a shared link lands straight in it. - Phone terminal — on touch devices, swipe to scroll and a key bar above the keyboard with esc, tab, arrows, enter and the Ctrl keys (^B works as the tmux prefix). Paste uses the clipboard when the browser allows it and falls back to a paste box when it doesn't.
- Organize — pin, categorize (clients / ventures / personal), and attach a focus note (main task + sub-tasks) that stays on the desk.
- Manage — rename a session (tmux and its Office state move together), kill it, or spawn a new one from a form (person, project, folder, category) confined to your dev root. Sessions that died or were killed stay in history, so "New desk" can bring one back by name with its old folder and category — and resume its last Claude conversation. When Claude exits inside a session, the pane drops to a shell instead of closing.
- Erase (in a desk's ⋯ menu) — removes an agent completely: the tmux session, its folder, its Office entries, and everything Claude keeps about that folder (transcripts, memory directory, file-history snapshots, prompt-history lines). It is a dry run first: the dialog lists what would go and, read from the agent's own transcripts, every file it wrote outside its folder and every shell command that touched outside paths — you tick what should go too, type the session name, then it runs. A folder shared with another session is kept.
- ⟳ Restore / Update agents — the server keeps
live.json(which sessions are alive, with folder, group and conversation id). After a reboot or a tmux crash the header offers Restore N agents: every session comes back in one click, each on its own conversation (claude --resume). When a new tmux server starts, the old server's set is kept as a restore baseline, so restoring one agent by hand or starting a new one first doesn't drop the others from the list; an agent closed or erased through the Office is never offered again (restore.py). When Claude Code installs a new version ("Restart to update"), the same button offers Update N agents: each idle agent is quit (/exit) and started again in the same pane on the same conversation, with the same permission mode and any unsent draft typed back in; agents that are working or showing a question are left alone and listed.GET /api/restartis the plan,POST /api/restart {restore:[…], update:[…]}runs it, and the plan'sjob.logis the report. - Settings (⚙) — per device, kept in the browser: appearance (system / dark / light — the terminal itself stays dark so the agents' own colours stay readable), terminal text size (also reachable from the
Aakey on the phone's key bar, applies to the open terminal at once), notifications, and the agents' restore/update row. After a reboot a banner under the header offers to bring the lost agents back in one tap. The Team/Status chips fold out behind a Filters button next to the search box. - Share from the phone —
GET /api/droplists the live agents ("Demo App | Max": "max-demo-app", most recent first) andPOST /api/drop?session=<s>&name=<file>with the file as the body saves it to that agent'sinbox/and tells the agent in its terminal;POST /api/drop?session=<s>&text=1types the body as a message. The iPhone app's Share Sheet entry (ios/) sends a whole share in one request (POST /api/share);/api/dropserves anything else that can post a file, e.g. an iOS Shortcut. Several items shared at once (WhatsApp messages and photos, a set of screenshots) arrive as one message, in order: each upload joins a batch for that agent, which goes out when itemnofnarrives (&i=<index>&n=<count>), or after a few quiet seconds when the Shortcut doesn't send a count. A batch waits while the agent has a question open or an unsent draft, and pushes a notification if it waits long. - iPhone app (
ios/) — an optional native shell around the same page, for one reason: iOS puts a keyboard accessory bar above every web text field and only a native app can remove it. Build it once withsh ios/make.shon a Mac with Xcode; the Office keeps working the same for everyone without it. Seeios/README.md. - Secretary — a chief of staff at the front desk (the bottom of the floor on a laptop, the top on the phone): type "ask Lena to run the risk file, then have Mara draft the section" and it finds those sessions and relays the instruction (real
send-keys). - Usage dashboard — tokens, spend, and rate-limit meters at
/status/. - Phone push — optional: when a session turns to you with a question, the server posts "Name needs you" plus the question to an ntfy topic, with a tap target straight into that terminal; also when it finishes, when a Secretary follow-up fires, and when a session disappears. Mute phone pushes in a desk's ⋯ menu mutes them for that session. Point it at your own ntfy server and nothing leaves your network (iOS still gets its wake-up through ntfy.sh, carrying only the topic name and a message id). See
OFFICE_NTFY_*in.env.example.
| File | Role |
|---|---|
server.py |
Python stdlib HTTP server: serves the app + JSON API, reverse-proxies the terminal. Binds the Tailscale IP only; refuses to start without one. |
team.py |
The detector: reads tmux panes + ~/.claude/projects/*/*.jsonl transcripts and classifies each session. |
app.html |
The entire client — one file, inline CSS + JS. |
status/ |
Usage dashboard: build.py aggregates transcripts → data.json; index.html renders it. |
serve.sh / serve-term.sh |
Launch the web server / the ttyd terminal. |
keepalive.sh |
Cron watchdog — keeps both running (tailnet-only), self-heals on reboot/crash. |
reload |
Hot-reload the running server in place (SIGHUP re-exec). |
Person + project come from the tmux session name (lena-payments → Lena / payments). tmux is the single
source of truth for identity — there is no roster file.
- Python 3.10+ — standard library only, no
pipdependencies. - tmux — your sessions live in tmux.
- ttyd — for the interactive terminal:
sudo apt install -y ttyd. - Tailscale — the app binds your tailnet IP and is reachable only from your own devices.
-
Configure (all personal/instance settings live in
.env, which is gitignored):cp .env.example .env $EDITOR .env # set OFFICE_TITLE; optionally OFFICE_EMAIL / OFFICE_TAILNET_HOST
Optional: create
emoji.local.json— a{ "project-keyword": "emoji" }map for per-project avatars, e.g.{ "web": "🌐", "api": "🔌" }. -
Tailscale up:
tailscale up. Optionally front it with a clean HTTPS URL (valid cert, no port):tailscale serve --bg <port> # serves https://<machine>.<tailnet>.ts.net/
-
Run it (both bind tailnet/localhost only):
./serve.sh & # web + API (binds your Tailscale IP) ./serve-term.sh & # ttyd terminal (private unix socket, proxied at /terminal)
Or let the watchdog run them from cron so they survive reboots and crashes:
@reboot /path/to/office/keepalive.sh * * * * * /path/to/office/keepalive.sh
-
Open
http://<machine>.<tailnet>.ts.net:<port>/(or yourtailscale serveHTTPS URL) from any device on your tailnet.
| Key | Default | Purpose |
|---|---|---|
OFFICE_TITLE |
Office |
Header + browser-tab title |
OFFICE_EMAIL |
(empty) | Optional, shown on the status page |
OFFICE_TAILNET_HOST |
(auto) | Optional; the machine's tailnet name is auto-detected from tailscale status |
OFFICE_DEV_ROOT |
~/develop |
Where "New desk" may create project folders |
OFFICE_CLAUDE_BIN |
$(which claude) |
Path to the claude binary |
OFFICE_PORT |
8899 |
Port for the web server |
OFFICE_STATUS_URL |
/status/ |
Header "Usage & limits" link; empty hides it |
OFFICE_FILES_URL |
(off) | Header "Files" link, e.g. a /dufs/ mount; empty hides it |
OFFICE_SECRETARY_NAME |
Secretary |
Display name for the dispatcher panel |
OFFICE_ELEVENLABS_KEY |
(off) | ElevenLabs API key: the secretary reads What's new aloud |
OFFICE_SECRETARY_VOICE |
(off) | ElevenLabs voice id she speaks with |
OFFICE_TTS_MODEL |
eleven_multilingual_v2 |
ElevenLabs model |
OFFICE_BRIEF_LANGUAGE |
Egyptian Arabic | Language of the spoken briefing (the rows on screen stay English) |
OFFICE_BRIEF_OPENER |
Egyptian "let me see what the team is up to" | Said the moment you tap, while she reads the floor; {name} is your name, empty turns it off |
OFFICE_OPERATOR_NAME |
(none) | What she calls you when she speaks, title included; vowel marks (e.g. Arabic tashkeel) steer how the voice says it and are kept |
OFFICE_DEMO |
(off) | 1 (or a path) → serve demo.json fixtures instead of live tmux |
Want a populated floor without wiring up real tmux sessions — for a screenshot, a talk, or
just to try it? Turn on demo mode; it serves the fictional team in demo.json:
OFFICE_DEMO=1 ./serve.shSet OFFICE_DEMO=1 in .env to make it stick, or point it at your own fixture
(OFFICE_DEMO=/path/to/my-demo.json). demo.json is just a list of session cards — copy it and
edit the names, messages, and group (clients / ventures / personal). An ago_s field on a
card sets "how long ago" it last replied, so the demo always looks live. All bundled demo data is
fictional.
The Secretary sits at the front desk — your chief of staff. Type a plain-language instruction and it routes it to the right session:
ask Lena to run the risk file, then have Mara draft the key-usage section
It matches the names against the live team, relays a polite instruction (Please run the risk file) into that session via tmux send-keys, and confirms who it went to. In demo mode there's
no real tmux, so it simulates the dispatch and the target desk visibly reacts on the floor. Rename
the assistant with OFFICE_SECRETARY_NAME.
🔊 What's new next to Ask catches you up: first whoever waits on you (a question, a permission,
a dialog), then every finished reply you haven't read, newest first, then who is still working. Each
row opens that desk. Who is in it, in what order and why comes from the floor alone; the model only
writes one line per row and the spoken text, and ElevenLabs reads it out (OFFICE_ELEVENLABS_KEY,
OFFICE_SECRETARY_VOICE). The same floor gets the same briefing back without a second model call.
A reply counts as not read (a green dot on the desk) when Claude finished it after the last time you
looked: opened its panel, sat in its terminal (tmux says attached), or sent it something. Those times
live in seen.json.
- Tailnet-only. The server binds your Tailscale IP and refuses to start without one; never
the LAN or the public internet. ttyd listens on a private unix socket (no TCP port) and is
reverse-proxied at
/terminal. A browser cannot open a websocket to a unix socket, so no foreign page can reach the shell. - CSRF Origin allowlist gates every state-changing
POSTand the terminal websocket; the machine's own MagicDNS name is detected live, so a tailnet rename self-heals. - Static serving is allowlisted — only the dashboard's own assets are served as files;
.env, runtime state, source, and notes are never web-served. - No app-layer auth, by design. Anyone on your tailnet can drive the sessions, and the terminal is a real shell. Treat tailnet access as full trust: keep your tailnet private and don't share the node casually.
.env, emoji.local.json, and the runtime state (groups.json, pins.json, tasks.json,
history.json, mutes.json, status/data.json) hold your own config and session data — they are gitignored and never committed.
Pure-stdlib, no dependencies and no tmux required — the detector's helpers are tested against synthetic transcripts in a temp dir:
python -m unittest discover -s tests -vCI runs the same suite on Python 3.10–3.13 for every push and pull request
(see .github/workflows/ci.yml).
The browser tests in tests/ui/ (settings, the phone header, the terminal key bar) run against a
live Office and are skipped unless both are present:
pip install -r tests/requirements-ui.txt && playwright install chromium # once
OFFICE_URL=http://<your-tailscale-ip>:8899 python -m unittest discover -s tests -vThis project follows semantic versioning. The current version is
exposed as server.__version__ and in the /api/floor response. Releases are git-tagged
(vMAJOR.MINOR.PATCH).
MIT — see LICENSE.