Architecture — Wiring It All with FastAPI, WebSocket & React


Written by Thierry K (human) · AI-assisted

FastAPI WebSocket dashboard architecture — Claudie and the 3-channel WebSocket structure diagram
The day I designed the 3-channel architecture for the FastAPI WebSocket dashboard. Spent a good while thinking in front of the whiteboard.

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.

FastAPI WebSocket dashboard 3-channel architecture — agent, dashboard, terminal flow diagram
Three WebSocket channels, each handling a distinct role. The key is that they never interfere with each other.

/ws/agent — The Collection Channel (10-Second Polling)

This is the channel where collector agents connect to the server. The protocol is simple:

  1. The agent sends a register event with its hostname
  2. The server responds with an ack
  3. 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:

  1. 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.
  2. bat’s .env parsing failure — The for /f construct couldn’t handle lines starting with # (comments). Environment variables were never set, the server launched in a broken state, and crashed instantly.
  3. Infinite console window flickering — When a port conflict killed the server on start, the bat file’s goto loop retried after 5 seconds → crashed again → infinite loop. A flash of console windows every boot cycle.
  4. Zombie processes holding the port — Deleting and re-registering the Task Scheduler task left orphaned pythonw processes occupying port 8420.
VBS→bat chain vs launcher.py single entry point comparison diagram
Before: a 3-stage VBS→bat→python chain. After: a single launcher.py entry point. Simplicity earned through hard-won trial and error.

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.

WebSocket 3-channel architecture 4-panel comic — Claudie and Siwol's plumbing analogy
Siwol: “Three WebSockets?” Claudie: “What happens if you shower, wash dishes, and do laundry through one pipe?”

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 AnimatePresence handles 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/terminal channel.
  • 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!


Series: Remote-Controlling Claude Code Without RDP

  1. ① The Problem — Why I Ditched RDP
  2. ② Architecture — Connecting with FastAPI + WebSocket + React ← Current post
  3. ③ Auth & CLI Streaming — Secure Remote Terminal
  4. ④ Cost Tracking & MCP Telemetry — Knowing What I’m Spending

Author: Claudie · Claudie’s Session Dashboard v0.9.2 · Built with FastAPI + React


Discover more from AI-Girls Lab

Subscribe to get our latest posts delivered to your inbox.