I run several Claude Code sessions at once, and each one spawns subagents in the background. From a terminal I could not tell who was doing what, who was stuck waiting on me, or what it all cost. Now I open one page and watch a 3D office instead.
TL;DR — seven Claude Code hooks → one Python script → Agent Bus for routing → Hermes3D for the office. Every session is a named staff member with a role, a live activity line, a kanban card and a token count. No LLM calls anywhere in the pipeline, so it costs nothing to run.
What you get
The same office in Hermes3D's 2D pixel mode, lighter on a laptop:
| In the office | Where it comes from |
|---|---|
| One character per session and per subagent | Claude Code hooks |
Label Danial · Laravel Dev · Standardise listings
|
A staff roster of 30 names, the subagent's role, and the task it was given |
The lead is me: Nasrul · Product Owner
|
The main session, titled with Claude's own session title |
Live activity text: Run PHPStan, Edit app/Actions/...
|
Each tool call |
⏳ Tunggu kelulusan ("waiting for approval") when a permission prompt is open |
The Notification hook |
| Kanban: working → needs attention → done | One card per delegated task |
| Tokens and model per staff member | Read from each transcript |
The labels are in Malay because that is how my team talks; swap the strings and nothing else changes.
Nothing here calls an LLM. It is routing and bookkeeping on top of events Claude Code already emits, so it adds no token cost.
How it fits together
Three pieces, all on localhost: Claude Code fires hooks, Agent Bus turns them into agents, and Hermes3D draws the office.
%%{init: {"theme":"neutral","themeVariables":{"fontSize":"18px"},"flowchart":{"nodeSpacing":30,"rankSpacing":45,"padding":10}}}%%
flowchart TD
CC["Claude Code sessions + subagents"] -->|"7 hooks"| H["office_hook.py"]
H -->|"events"| HUB["Agent Bus hub :4000"]
HUB --> GW["Agent Bus gateway :18789"]
GW -->|"gateway protocol"| ST["Hermes3D Studio :3000"]
H -->|"task cards"| ST
ST --> RO["Read-only proxy :3100"]
RO -->|"ngrok, on demand"| TEAM["Team"]
The hook writes to two places: agent events go to the hub, kanban cards go straight to Studio's task store. Hub, gateway and Studio all bind to 127.0.0.1; only the read-only proxy is ever tunnelled, and only while I am sharing.
Step 1: Hermes3D and Agent Bus, on demand
Clone both, install, and point Hermes3D at the Agent Bus gateway. I do not want daemons running all day, so one script starts everything when I want to look and Ctrl-C stops it.
git clone https://github.com/iamlukethedev/Hermes3D.git ~/Projects/hermes
git clone https://github.com/emiliovos/agent-bus.git ~/Projects/agent-bus
(cd ~/Projects/hermes && npm install && cp .env.example .env)
(cd ~/Projects/agent-bus && npm install)
In Hermes3D's .env:
HERMES3D_GATEWAY_URL=ws://localhost:18789
HERMES3D_GATEWAY_ADAPTER_TYPE=hermes
UPSTREAM_ALLOWLIST=localhost,127.0.0.1
The launcher, trimmed to what matters:
#!/usr/bin/env bash
pids=(); trap 'kill "${pids[@]}" 2>/dev/null' EXIT INT TERM
cd ~/Projects/agent-bus
npx tsx watch src/index.ts & pids+=($!) # hub :4000
AGENT_PRUNE_HOURS=1 npx tsx watch src/gateway/index.ts & pids+=($!) # gateway :18789
cd ~/Projects/hermes
[ -f .next/BUILD_ID ] || npm run build
npm start & pids+=($!) # Studio :3000
wait
Two choices in there are deliberate:
- Start the hub and gateway separately, not with
npm run dev:all. That also starts Agent Bus's own UI on:3000, which collides with Studio. - Run Studio with
npm start, notnpm run dev(see Gotchas).
I aliased it: alias office="$HOME/.agent-bus/office.sh".
Step 2: the hook that hires the staff
One Python script (stdlib only, so it runs under whatever PATH Claude Code gives hooks) handles seven hook events. Register it in ~/.claude/settings.json:
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "command", "command": "bash ~/.agent-bus/hermes3d-hook.sh" }] }],
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "bash ~/.agent-bus/hermes3d-hook.sh" }] }],
"PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "bash ~/.agent-bus/hermes3d-hook.sh" }] }],
"Notification": [{ "hooks": [{ "type": "command", "command": "bash ~/.agent-bus/hermes3d-hook.sh" }] }],
"Stop": [{ "hooks": [{ "type": "command", "command": "bash ~/.agent-bus/hermes3d-hook.sh" }] }],
"SubagentStop": [{ "hooks": [{ "type": "command", "command": "bash ~/.agent-bus/hermes3d-hook.sh" }] }],
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "bash ~/.agent-bus/hermes3d-hook.sh" }] }]
}
The .sh just execs the Python file, so I never touch settings.json again when the logic changes. The hook exits immediately unless the session's cwd is under a folder listed in ~/.agent-bus/roots, so my other Claude sessions stay out of the office. It never blocks Claude: 0.5s timeouts, every error swallowed, always exit 0. It takes about 70ms.
What each hook does in the office:
| Hook | Character | Kanban card |
|---|---|---|
SessionStart |
Lead appears | — |
UserPromptSubmit |
Lead: Kerja baru: <prompt>
|
New lead card → working |
PostToolUse |
Activity text, model, tokens | Subagent card → working |
Notification |
Permission prompt → ⏳ Tunggu kelulusan
|
Card → needs attention |
Stop |
Lead: ✅ Siap — tunggu Nasrul, or keeps supervising if staff are still busy |
Lead card → done |
SubagentStop |
Subagent leaves, its name is freed | Card → done |
SessionEnd |
Lead leaves | — |
Who is who
The hook reads everything from the JSON Claude Code sends on stdin. Subagent tool calls carry agent_type (laravel-developer, tech-writer) and agent_id, which is all it needs:
| Character | Id | Name | Task |
|---|---|---|---|
| Main session | lead-<session6> |
lead in staff.json
|
Latest aiTitle in the session transcript |
| Subagent | <role>-<agent_id6> |
First free name in that role's list |
description in <session>/subagents/agent-<id>.meta.json
|
The roster is a small JSON file; I gave 30 staff names across 17 roles, more names for roles that run in parallel:
{
"lead": { "short": "Product Owner", "names": ["Nasrul"] },
"laravel-developer": { "short": "Laravel Dev", "names": ["Danial", "Farhan", "Iqbal", "Syafiq", "Haziq", "Aiman"] },
"tech-writer": { "short": "Writer", "names": ["Mei Ling", "Hana"] }
}
A name is held until that subagent's SubagentStop, so three Laravel devs in parallel get three different names. Edits to the roster apply on the next event.
The lead is marked busy while its team works: at most once a minute it posts 👀 Menyelia: Danial, Farhan, Iqbal ("supervising"). When the last subagent stops it says ✅ Pasukan siap — tunggu Nasrul ("team done, waiting on Nasrul").
Step 3: kanban, tokens and the model
Hermes3D already has a task board whose columns map straight onto a Claude session: inbox, scheduled, working, needs_attention, done. The hook upserts cards through Studio's own endpoint, only when a card's status actually changes:
curl -X PUT http://127.0.0.1:3000/api/task-store \
-H 'Content-Type: application/json' \
-d '{"task":{"id":"cc-sub-<agent_id>","title":"Standardise listings","status":"working","source":"hermes_event","channel":"claude-code"}}'
The board is under OPEN HQ → Kanban on the right edge. The "Kanban board" desk in the 3D scene opens an install prompt instead — that is upstream behaviour, not your cards missing.
Tokens. Every assistant message in a transcript carries its usage. Claude Code writes one line per content block, each repeating the same usage, so the hook de-duplicates by message id before summing input, output, cache read and cache write. A patched Agent Bus serves the totals through sessions.usage, which Studio's Analytics panel reads. Cost stays at zero on purpose: there is no pricing table in the pipeline, and a wrong cost is worse than none.
Model. The newest "model":"..." in the tail of each transcript, sent on every event. Studio reads it from the agent's main session in sessions.list, not from config.get, because it caches config.get for the whole connection — agents that appear later would never get one.
Office opens populated. Everything the hook sends is also appended to a spool file. When the launcher starts, it waits for hub, gateway and Studio, then replays the last 30 minutes. Without that, the office opens empty and fills only as each session next uses a tool.
Daily report. The same spool feeds office-summary: staff, active time, tool calls, approval waits, output tokens and every task with its final status, as a markdown table.
Gotchas I hit
Most of the work was here. Each looked like "it doesn't work" and had one specific cause.
| Symptom | Cause | Fix |
|---|---|---|
| Every session collapsed into one character | Agent Bus's sample hooks read the agent name from a shell env var, shared by every session | Take identity from the session_id / agent_id in the hook's stdin JSON |
| 15 characters for 4 sessions, some named after vendor packages | I named characters after the cwd folder; subagents roam into worktrees, vendor/, docs |
Identity is the session or subagent, never the folder |
Sample hooks sent Using unknown
|
They read CLAUDE_TOOL_NAME, which current Claude Code does not set |
Read tool_name from stdin |
| A closed session stood at its desk for a day | Agent Bus marks session_end as idle; pruning runs hourly with a 24h TTL |
Patch: remove the agent on session_end; AGENT_PRUNE_HOURS=1 as the backstop |
| Working agents showed as idle | Studio re-infers "running" on every roster reload from sessions.preview — only if the last item is from the user. Agent Bus returned a different shape, all assistant messages |
Return previews[].items[]; append the task as a trailing user item while the agent is active |
| The lead was always idle | It delegates to background subagents and ends its turn; subagent activity only sent heartbeats | Lead posts 👀 Menyelia: … while its team works |
| "Waiting for approval" when nothing needed approving |
Notification also fires for the 60-second idle nudge ("waiting for your input") |
Only messages about permission become approvals |
Kanban cards titled <task-notification>
|
A finishing background subagent is injected into the session as a user prompt | Ignore prompts that start with <task-notification, <system-reminder, <command-
|
| "Connecting to your runtime…" forever in a fresh tab | In dev mode the Next.js hot-reload socket hangs, and Chrome queues the gateway socket behind it | Run Studio with npm start
|
studio.gateway_url_blocked after switching to production |
Production refuses every upstream gateway not listed | UPSTREAM_ALLOWLIST=localhost,127.0.0.1 |
| Hub and gateway reachable from the LAN | Agent Bus listens on 0.0.0.0
|
Patch both to bind 127.0.0.1
|
| A jq filter silently dropped every event without a file path | `(.x // empty) \ | length > 0 — empty` short-circuits the whole object |
The Agent Bus changes live as commits on a local local-patches branch, so a git pull shows exactly what I changed instead of silently undoing it.
Sharing a read-only view for a few minutes
Studio is not a viewer. Anyone who reaches it can change settings, move cards, and hit endpoints that read local files (/api/gateway/media), list your home directory (/api/path-suggestions) or call GitHub with your credentials (/api/office/github). So I never tunnel Studio itself. A 120-line Node proxy on 127.0.0.1:3100 sits in front of it:
| Request | Proxy |
|---|---|
| Pages and static assets | Pass through |
GET /api/* |
Only an allowlist: studio, task-store, health, office, office/layout, office/presence
|
PUT /api/studio (the UI saves view state constantly) |
Answered with the current settings, nothing written |
| Any other write | 403 |
Gateway RPC connect, health, status, *.list, *.get, *.preview, *.history, *.usage
|
Forwarded |
Any other RPC (chat.send, config.set, agents.delete, …) |
Answered with a read_only error |
An allowlist, not a blocklist: when a new Studio endpoint appears, it is closed until I decide otherwise.
Then one command shares it and Ctrl-C ends it:
#!/usr/bin/env bash
curl -sf http://127.0.0.1:3100/api/health >/dev/null || { echo "start the office first"; exit 1; }
pass=$(cat ~/.agent-bus/share-password) # chmod 600
echo "user: team password: $pass"
exec ngrok http 127.0.0.1:3100 --basic-auth "team:$pass"
The link changes every time, and nothing is exposed once I stop it. For named people instead of a shared password, ngrok's Google login works: ngrok http 127.0.0.1:3100 --oauth google --oauth-allow-email ali@example.com.
One thing to say to yourself before sharing: viewers see your prompts, task titles and file paths.
Try it, and what's next
The whole thing is two cloned repos, one hook script, a roster file and a launcher. My day now looks like this:
office # office + read-only view, Ctrl-C stops all
office-share # only while showing the team
office-summary # end of day: who did what
Still open:
- Cost. Tokens are there; dollars need a pricing table I trust.
- Labels. The 3D label shows only the first name. Role and task are in the panels and on the board.
-
One floor. Hermes3D removed multi-floor offices upstream, so several products share one office with
[product]in the label.
Credit where it is due: Hermes3D by LukeTheDev draws the office, and Agent Bus by emiliovos does the routing. I only wired Claude Code into them.
If you build something similar, I would like to see how you name your staff.


Top comments (0)