
Picking Up from the Last Episode
In EP1 — Why I Ditched RDP, I laid out the problems with RDP (as you saw in EP1). In this episode, I’ll dissect the actual architecture of the FastAPI WebSocket dashboard I built as the replacement.
The bottom line: the core insight is splitting WebSocket into 3 channels. Trying to funnel everything through a single WebSocket failed, and separating channels by role became the foundation of this system.
The Architecture at a Glance
The overall structure of the FastAPI WebSocket dashboard breaks down into three layers.
| Layer | Components | Connection |
|---|---|---|
| Browser | React + Vite + shadcn/ui SessionCards, StatsPage, xterm.js, CostOverview |
/ws/dashboard, /ws/terminal, REST API |
| FastAPI Server (port 8420) | ws_hub.py, store.py, database.py (SQLite 7 tables) terminal_routes.py, cost_collectors (Gemini/xAI/Qwen) |
/ws/agent (10s poll interval) |
| Collector Agents | Windows Agent (local process) Mac mini Agent (SSH/launchd) |
WebSocket to server |
Browser → FastAPI server → collector agents. An important detail: the cost collectors (tracking Gemini/xAI/Qwen charges) run as background tasks inside the server — they’re not separate WebSocket agents. This distinction is central to the FastAPI WebSocket dashboard design.
3 WebSocket Channels — Why Three, Not One
The initial design tried to handle all messages through a single WebSocket. Collection data, UI updates, terminal streaming — everything crammed into one connection. The result was a disaster.
When the terminal spewed output at high speed, session state updates got backed up. When a collector agent sent a bulk data payload, the browser UI stuttered. The message-type routing logic devolved into spaghetti.
The fix was straightforward: separate channels by role. A plumbing analogy fits perfectly — if you want to shower, wash dishes, and do laundry all at the same time, you need three separate pipes.

/ws/agent — The Collection Channel (10-Second Polling)
This is the channel where collector agents connect to the server. The protocol is simple:
- The agent sends a
registerevent with its hostname - The server responds with an
ack - From then on, session data is sent as JSON every 10 seconds
# ws_hub.py — Agent connection handshake
async def connect_agent(
self, websocket: WebSocket
) -> tuple[str, str] | None:
await websocket.accept()
raw = await websocket.receive_text()
message = json.loads(raw)
if message.get("event") != "register":
return None
host_name = message.get("host", "").strip()
self._agent_connections[host_name] = websocket
await websocket.send_text(
json.dumps({"event": "ack", "status": "ok"})
)
return host_name, message.get("host_icon", "")
Each host (Windows workstation, Mac mini, etc.) maintains a single WebSocket connection. The _agent_connections dictionary maps hostnames to connections. When an agent dies, it gets cleaned up automatically, and launcher.py restarts it.
/ws/dashboard — The Broadcast Channel
When a browser connects, two things happen. First, it receives the current full state as a full_state snapshot. Second, from that point on, it receives only deltas via delta_update every second.
# main.py — Broadcast loop (1-second interval)
async def broadcast_loop() -> None:
while True:
await asyncio.sleep(BROADCAST_INTERVAL) # 1.0s
updated, removed = store.get_changed_sessions()
if updated or removed:
message = {
"type": "delta_update",
"updated": updated,
"removed": removed,
}
await hub.broadcast_to_browsers(message)
The critical piece here is the delta_update pattern. Rather than sending the full state every second, only changed sessions are transmitted. Even if 10 sessions are running, only the 2 that changed get sent — minimizing bandwidth.
broadcast_to_browsers() pushes the same message to every connected browser. Dead connections are silently removed. This is the decisive difference from RDP — you can open as many browser tabs as you want and view them all simultaneously.
/ws/terminal — The PTY Streaming Channel
The third channel is the most specialized. It bidirectionally streams Claude Code CLI terminal I/O. The browser’s xterm.js attaches to this channel and behaves like a real terminal.
Unlike the other two channels that exchange JSON messages, the terminal channel operates closer to a raw byte stream. Each keystroke travels to the server, and PTY output streams back in real time. This is precisely why mixing it into the same WebSocket would be a mistake — the JSON parsing overhead would eat into terminal responsiveness.
Since xterm.js’s addon-attach binds directly to a WebSocket, a dedicated channel is essential. I’ll cover the PTY implementation details alongside authentication in EP3.
launcher.py — Lessons from the VBS→bat Chain Failure
What consumed more time than the architecture design itself was process management. Running a reliable background service on Windows turned out to be harder than expected.
The initial setup looked like this:
Task Scheduler
→ start.vbs (CreateObject WScript.Shell)
→ launch.bat (set environment variables)
→ python server/main.py
→ python agents/windows_agent.py
I ran into four distinct failure modes:
- VBS
Run ..., 0, False— wscript exited immediately, so Task Scheduler marked the task as “completed.” The server never even started, yet it was reported as a success. - bat’s
.envparsing failure — Thefor /fconstruct couldn’t handle lines starting with#(comments). Environment variables were never set, the server launched in a broken state, and crashed instantly. - Infinite console window flickering — When a port conflict killed the server on start, the bat file’s
goto loopretried after 5 seconds → crashed again → infinite loop. A flash of console windows every boot cycle. - Zombie processes holding the port — Deleting and re-registering the Task Scheduler task left orphaned
pythonwprocesses occupying port 8420.

The solution was a single Python entry point. One launcher.py manages everything:
# scripts/launcher.py — Single entry point
def main():
kill_all() # Clean up existing processes via WMIC
load_env() # Load .env file
server = start_server() # subprocess, CREATE_NO_WINDOW
time.sleep(5) # Wait for server readiness
agent = start_agent() # subprocess, CREATE_NO_WINDOW
time.sleep(3)
restart_mac_mini() # Restart launchd via SSH
while True:
time.sleep(10)
if server.poll() is not None:
log("Server crashed. Restarting...")
server = start_server()
if agent.poll() is not None:
log("Agent crashed. Restarting...")
agent = start_agent()
The key is the CREATE_NO_WINDOW flag. Passing this flag when spawning a subprocess prevents the console window from appearing at all. The .env loading is handled by Python’s python-dotenv, eliminating the bat parsing bugs entirely. And every 10 seconds it checks process health — if something died, it restarts automatically. All four failure modes, resolved in a single file.

SQLite Schema: The Role of 7 Tables
The FastAPI WebSocket dashboard stores its data in SQLite. Why not PostgreSQL? This dashboard is a single-machine service. Write concurrency is on the order of dozens per second, and deployment is as simple as copying one file. SQLite is a perfect fit.
Seven tables, each with a clearly defined role:
| Table | Role |
|---|---|
sessions |
Claude Code session tracking — host, status, per-model token usage archive |
session_tokens |
Per-session token usage detail — raw data for session-level aggregation |
token_daily |
Daily token statistics — data source for the CostOverview card |
mcp_events |
MCP tool call telemetry — raw events with server, tool, duration, and cost |
mcp_daily |
Daily MCP call aggregation — data source for the StatsPage MCP charts |
cost_snapshots |
Cost tracking snapshots — external API charge collection for Gemini/xAI/Qwen |
users |
User management — JWT authentication, bcrypt hashes, TOTP 2FA secrets |
-- 7 tables, each with a clear role
CREATE TABLE sessions ( -- Completed session archive
id TEXT PRIMARY KEY,
host TEXT, status TEXT,
token_json TEXT, -- Per-model token usage
agents_count INTEGER
);
CREATE TABLE token_daily ( -- Daily token aggregation
date TEXT, host TEXT, model TEXT,
provider TEXT DEFAULT 'claude',
input_tokens INTEGER,
output_tokens INTEGER,
cost_usd REAL,
PRIMARY KEY (date, host, model, provider)
);
CREATE TABLE mcp_events ( -- MCP tool call telemetry
id INTEGER PRIMARY KEY,
server TEXT, tool TEXT,
cost REAL, duration_ms INTEGER
);
CREATE TABLE users ( -- JWT + bcrypt + TOTP
username TEXT PRIMARY KEY,
password_hash TEXT,
totp_secret TEXT
);
-- + collector_state, session_agents, mcp_daily
The most frequently queried table is token_daily. The CostOverview card pulls date-range aggregations from it. mcp_events holds the raw telemetry data that I’ll explore in detail in EP4.
Task Scheduler + launchd Service Management
On Windows, Task Scheduler runs launcher.py. A single task named “Claudie Dashboard” launches automatically at logon and runs via pythonw.exe so there’s no console window.
On Mac mini, macOS’s service manager launchd handles the collector agent. It serves the same role as Task Scheduler on Windows, but works differently — services are registered with plist (Property List) files instead of XML. Drop ~/Library/LaunchAgents/com.claudie.collector.plist in place, and it auto-starts at login, restarting the process immediately if it crashes.
The two key options are KeepAlive (auto-restart on crash) and RunAtLoad (start as soon as the plist is loaded). You register with launchctl bootstrap and force-restart with launchctl kickstart — launcher.py executes these commands remotely via SSH to manage the Mac mini agent in one shot.
<!-- com.claudie.collector.plist (macOS launchd) -->
<key>KeepAlive</key>
<true/>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/claudie-collector.log</string>
Thanks to this setup, the dashboard automatically recovers from Windows reboots, Mac mini reboots, and process crashes — any scenario.
React + Vite + shadcn/ui Frontend Stack
Component Tree and Design Principles
The Claudie’s Session Dashboard frontend is divided into four main views.
- SessionCards — Displays each host’s Claude Code session status as cards. framer-motion’s
AnimatePresencehandles smooth transitions when cards are added or removed. - StatsPage — Aggregates token usage, MCP telemetry, and cost tracking into charts.
- CliPage — The view where the xterm.js WebSocket terminal attaches. Connects directly to the
/ws/terminalchannel. - SettingsPage — JWT user management, TOTP 2FA configuration, host registration management.
All UI components are built with shadcn/ui. Rather than importing from a library, shadcn/ui copies component source code directly into your project — giving you full freedom to customize dark themes and a single Tailwind variable change to propagate a consistent theme across the board. The entire dashboard’s dark background (#0F172A family) is unified thanks to shadcn/ui’s CSS variable system.
framer-motion is used in two key places. First, SessionCards’ enter/exit animations (layout + AnimatePresence). When a new session appears, its card slides in naturally; when it completes, it fades away smoothly. Second, page transition effects — tab switches use slide in/out so the UI flows without jarring cuts.
The frontend technology choices are part of the architecture too. Let me explain why Vite over Next.js.
This dashboard has FastAPI serving the SPA (Single Page Application) static files directly. No SSR is needed, and the API lives on the same server. Build with Vite, mount the dist/ output via FastAPI’s StaticFiles, and you’re done. Next.js’s server-side features were simply unnecessary.
Here’s the stack breakdown:
| Category | Choice | Reason |
|---|---|---|
| Framework | React 19 + TypeScript 5.9 | Type safety + ecosystem |
| Build | Vite 8 | Fast HMR, optimized for SPA |
| Styling | Tailwind CSS 4 | Utility-first |
| UI Components | shadcn/ui | Full customization freedom |
| Terminal | xterm.js 6.0 | WebGL rendering, direct WebSocket binding |
| Animation | Framer Motion 12 | Session card transition effects |
FastAPI’s native WebSocket support is asyncio-based, requiring no additional libraries. According to Coditation’s benchmark, FastAPI handles 3,200 concurrent WebSocket connections — significantly outperforming Django Channels (1,800) and Flask-SocketIO (2,100). This FastAPI WebSocket dashboard doesn’t need 3,200 connections, but it’s comfortable headroom.
Next Episode Preview
In this episode, I covered the skeleton of the FastAPI WebSocket dashboard: the 3-channel WebSocket separation, launcher.py process management, SQLite schema, and frontend stack selection.
But there’s still a missing piece. “What if anyone can connect?” In EP3, I’ll tackle the JWT + TOTP 2FA authentication system and the CLI streaming implementation that puts a real terminal in the browser via xterm.js. From Korean encoding issues to claude --resume session recovery, I’ll share every hard-won lesson.
If you have questions or similar experiences, let me know in the comments!
Author: Claudie · Claudie’s Session Dashboard v0.9.2 · Built with FastAPI + React