ClawMux¶
The Hub is a standalone service that spawns and manages multiple Claude Code voice sessions from a single browser tab.
Architecture¶
Browser ←—single WSS—→ Hub (:3460) ←—WS—→ MCP Server A (Claude session 1)
←—WS—→ MCP Server B (Claude session 2)
←—WS—→ MCP Server C (Claude session 3)
How It Works¶
- Hub (
hub.py) runs on port 3460 as a standalone FastAPI service - Browser connects to the hub via a single WebSocket
- When you click "New Session", the hub:
- Creates a temp directory at
/tmp/voice-hub-sessions/{session-id}/ - Writes a
.mcp.jsoninto that directory with the session ID baked in - Starts a tmux session and launches
claude --dangerously-skip-permissionsfrom that directory - Claude Code picks up the project-level
.mcp.jsonand spawnshub_mcp_server.py hub_mcp_server.pyreadsVOICE_HUB_SESSION_IDfrom its MCP env config and connects back to the hub via WebSocket atws://127.0.0.1:3460/mcp/{session-id}- Hub sends
/voice-hubto the tmux session so Claude enters voice mode
- Creates a temp directory at
- Audio flow for
converse():- Claude calls
converse("Hello")→hub_mcp_server.pyforwards to hub via WS - Hub does TTS (Kokoro) using the session's configured voice → sends MP3 to browser tagged with session_id
- Hub sends
assistant_textwith the spoken text for the chat transcript - Browser plays audio → sends
playback_donetagged with session_id - Hub tells browser to record → browser records (or sends silence if mic muted) → sends audio tagged with session_id
- Hub does STT (Whisper) → sends
user_textfor the chat transcript → sends text back tohub_mcp_server.py hub_mcp_server.pyreturns the text to Claude
- Claude calls
Key Insight: Dynamic MCP Config¶
Claude Code MCP servers get their environment only from .mcp.json, not from the shell that launched claude. So we can't pass a session ID via env vars on the command line. Instead, the hub creates a per-session directory with a .mcp.json that has the session ID baked into the env field:
{
"mcpServers": {
"voice-hub": {
"command": "/path/to/.venv/bin/python",
"args": ["/path/to/hub_mcp_server.py"],
"env": {
"VOICE_HUB_SESSION_ID": "voice-1-abc123",
"VOICE_CHAT_HUB_PORT": "3460"
}
}
}
}
Claude is started from this directory, picks up the project-level config, and the MCP server connects to the hub with the correct session identity.
Components¶
| File | Role |
|---|---|
hub.py |
FastAPI service — REST API, browser WS, MCP WS, TTS/STT |
hub_mcp_server.py |
Thin MCP server — proxies converse() to hub via WS |
hub_config.py |
Constants (port, timeout, service URLs) |
session_manager.py |
tmux lifecycle, temp dir creation, health checks |
static/hub.html |
Browser UI — session tabs, audio, mic, controls |
Running¶
# Start the hub
cd ~/GIT/voice-hub-dev
source .venv/bin/activate
python hub.py
# Expose via Tailscale (one-time)
sudo tailscale serve --bg --https=3460 http://127.0.0.1:3460
Open https://workstation.tailee9084.ts.net:3460 and click "New Session".
Browser Controls¶
| Control | Location | Description |
|---|---|---|
| Mic button | Center | Tap to start/stop recording. Pulses red when recording. |
| Mic mute | Left of mic | Mutes microphone input. When muted, sessions receive (session muted) instead of speech. |
| Auto/Manual toggle | Right of mic | Auto: recording starts automatically after Claude speaks. Manual: tap mic to respond. |
| Voice selector | Far right | Choose TTS voice per session (Sky, Alloy, Sarah, Adam, Echo, Onyx, Fable). |
| Session tabs | Top bar | Click to switch sessions. Green dot = ready, yellow = starting, blue = active. |
| Badge (!) | On tab | Appears when a background session needs attention. |
| Close (×) | On tab | Terminates the session and its tmux process. |
Debugging¶
# Hub logs
tail -f /tmp/voice-hub.log
# MCP server logs (per-session)
tail -f /tmp/voice-hub-mcp.log
# See spawned tmux sessions
tmux ls
# Attach to a session
tmux attach -t voice-1-abc123
# Check temp directories
ls /tmp/voice-hub-sessions/
Session Lifecycle¶
| State | Description |
|---|---|
starting |
tmux created, Claude booting, MCP server connecting |
ready |
MCP connected to hub, Claude in voice mode |
active |
converse() call in progress |
dead |
tmux died or session timed out |
Sessions auto-terminate after 30 minutes of inactivity (configurable via VOICE_CHAT_TIMEOUT env var on the hub).
Slash Commands¶
| Command | MCP Server | Use Case |
|---|---|---|
/voice-hub |
voice-hub |
Direct voice chat (main branch, port 3456) |
/voice-hub-dev |
voice-hub-dev |
Direct voice chat (dev branch, port 3457) |
/voice-hub |
voice-hub |
Hub-managed session (used by spawned sessions) |
REST API¶
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/sessions |
List all sessions |
POST |
/api/sessions |
Spawn a new session |
DELETE |
/api/sessions/{id} |
Terminate a session |
PUT |
/api/sessions/{id}/voice |
Set session voice ({"voice": "am_adam"}) |
Protocol¶
Browser ↔ Hub¶
All session messages include session_id:
{"session_id": "voice-1-abc123", "type": "audio", "data": "base64..."}
{"session_id": "voice-1-abc123", "type": "playback_done"}
Hub → Browser session messages:
{"session_id": "...", "type": "audio", "data": "base64..."}
{"session_id": "...", "type": "assistant_text", "text": "Hello!"}
{"session_id": "...", "type": "user_text", "text": "Hi there"}
{"session_id": "...", "type": "listening"}
{"session_id": "...", "type": "status", "text": "Speaking..."}
{"session_id": "...", "type": "done"}
Hub-only messages (no session_id):
{"type": "session_list", "sessions": [...]}
{"type": "session_status", "session_id": "...", "status": "ready"}
{"type": "session_terminated", "session_id": "..."}
Hub ↔ MCP Server¶
Each MCP server connects to ws://hub:3460/mcp/{session_id}:
// MCP → Hub
{"type": "converse", "message": "Hello", "wait_for_response": true, "voice": "af_sky"}
{"type": "status_check"}
// Hub → MCP
{"type": "converse_result", "text": "User said something"}
{"type": "status_result", "connected": true}
Note: The voice field in the converse message from MCP is ignored — the hub uses the session's voice setting from the browser UI instead.