DEV Community

Nasrul Hazim
Nasrul Hazim

Posted on

I turned my Claude Code sessions into a 3D office my team can watch

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 Hermes3D office: the lead Nasrul and a Laravel dev, Syafiq, at their desks

The same office in Hermes3D's 2D pixel mode, lighter on a laptop:

The same office in 2D pixel mode, with the two staff in the Product team area

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)
Enter fullscreen mode Exit fullscreen mode

In Hermes3D's .env:

HERMES3D_GATEWAY_URL=ws://localhost:18789
HERMES3D_GATEWAY_ADAPTER_TYPE=hermes
UPSTREAM_ALLOWLIST=localhost,127.0.0.1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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, not npm 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" }] }]
}
Enter fullscreen mode Exit fullscreen mode

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"] }
}
Enter fullscreen mode Exit fullscreen mode

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"}}'
Enter fullscreen mode Exit fullscreen mode

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 > 0empty` 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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)