I use the Pi coding agent in the terminal constantly. It has full system access — bash, file read/write/edit, extensions — and it’s become my default way to interact with local LLMs for anything that touches my actual machine. But the TUI has limits. I can’t use it from my phone, I can’t share a session link with someone, and I can’t easily switch between conversations without juggling terminal tabs.

So I built Pi Web UI — a full-stack web interface that runs the real Pi SDK server-side and exposes it through the browser using the same @mariozechner/pi-web-ui components the official project provides.

Architecture

Browser (Lit + pi-web-ui components)
  │  WebSocket (/api/ws)

Node.js server (Express + ws)
  │  Pi SDK (createAgentSession)

System tools (bash, files, skills, extensions)


LiteLLM → llama-swap / Lemonade Server → GPU / NPU

The key design decision was running the Pi SDK entirely server-side. The official pi-web-ui example runs the agent in the browser, which means it can only call LLM APIs — no file system, no shell, no extensions. That’s a non-starter for how I use Pi.

Instead, the server creates a real AgentSession via createAgentSession() from @mariozechner/pi-coding-agent. This session has all the same tools as the TUI: bash, read, edit, write, plus whatever extensions are configured in ~/.pi/agent. The client never touches the Pi SDK directly — it sends JSON messages over a WebSocket, and the server translates those into SDK calls.

The WebSocket Protocol

The client and server communicate through a typed JSON protocol defined in a shared protocol.ts file. Client messages are actions like prompt, abort, setModel, newSession, loadSession. Server messages are events: agentEvent (streamed from the Pi SDK’s event subscription), stateSync (full state snapshots on major transitions), models (the merged model list), and sessionChanged.

The trickiest part was serializing AgentSessionEvent objects. The Pi SDK emits rich event objects that sometimes contain circular references or non-serializable data. A safeSerializeEvent wrapper catches serialization failures and sends a simplified fallback rather than dropping the event entirely.

Model Integration

The server merges models from two sources: the Pi ModelRegistry (which reads ~/.pi/agent/models.json) and my local LiteLLM instance. At startup it fetches the LiteLLM model list from /v1/models and deduplicates against the registry. The client gets a single sorted list with a filter input — useful when you have 30+ models and need to find a specific one quickly.

Switching models sends a setModel message to the server, which calls session.setModel() on the Pi SDK. The change takes effect on the next prompt. Thinking level works the same way — the dropdown maps directly to session.setThinkingLevel().

Session Management

Earlier iterations used SessionManager.inMemory(), which meant every server restart wiped the conversation. Now the server uses SessionManager.create(os.homedir()), which persists sessions to ~/.pi/agent/sessions/ — the same location the TUI uses. This means:

  • Sessions survive server restarts
  • The sidebar shows all historical sessions, sorted by last modified
  • You can resume any previous conversation by clicking it
  • Sessions created in the TUI show up in the web UI and vice versa

The session sidebar is a collapsible panel — inline on desktop (pushes the chat area over), fixed overlay on mobile (with a backdrop that dismisses on tap). Each entry shows the first message as the title, a message count, and a relative timestamp.

The Client

The frontend is intentionally simple. No framework, no component library beyond what Pi provides. It’s plain Lit html templates with module-level state variables and a single renderApp() function that re-renders on every state change. The three main components — MessageList, MessageEditor, StreamingMessageContainer — come from @mariozechner/pi-web-ui and handle all the complex rendering of messages, tool calls, thinking blocks, and streaming output.

One gotcha: those components are custom elements that register themselves on import. Vite’s tree-shaking will strip them if they’re not referenced. The fix is importing the classes and adding void MessageList; void MessageEditor; void StreamingMessageContainer; to force retention.

Deployment

The server runs as a systemd user service on port 8085. A Caddy reverse proxy maps pi.zetaphor.space to that port with internal TLS, accessible from any device on my Tailscale network. The build step is just vite build for the client — the server runs TypeScript directly via tsx.

npm run build
systemctl --user restart pi-webui.service

The whole thing — server, client, protocol types — is about 1,100 lines of TypeScript across four files. No database, no auth layer, no build pipeline beyond Vite. It does exactly what I needed: Pi in the browser, with real system access, from any device.