Son of Anton, reimagined as a web app.
A browser-native terminal UI that streams real PTY sessions over WebSocket. No Electron, no installer, no auto-updater — one Node process serving static assets and a /ws channel that owns the shells on your behalf.
Derived from SoA-Prod (the desktop Son of Anton). The desktop app already speaks this protocol between its Electron main process and the phone client, so the web port is mostly a matter of making the server side the primary entrypoint instead of a side-feature.
| Desktop feature | Web equivalent |
|---|---|
node-pty in Electron main |
node-pty in Node server |
Renderer via require() |
Browser ESM — all Electron calls removed |
| IPC | WebSocket framed JSON (protocol.js) |
shell.openExternal |
Standard anchor / window.open |
| Caps-Lock wake word / SFSpeech / local Whisper | Gone — Web Speech API is the planned path |
| Auto-updater, DMG, notarize | Gone |
| Mobile pairing, QR, LAN-bridge | Cloudflare Quick Tunnel — auto-started on boot; sidebar shows the QR |
Can this replace the desktop app on the web? Yes, functionally. The scary parts all have solutions:
- Multi-tenant isolation. Each browser session owns PTYs on the server. For a single-user self-hosted deploy (the default this repo targets), that's fine. For a shared deploy, drop each session into a container/VM per user.
- Auth.
SOA_WEB_AUTH=sharedwithSOA_WEB_PASSWORDgates access behind a signed HttpOnly cookie.nonedelegates to an upstream proxy (Cloudflare Access, tailscale funnel, oauth2_proxy).openis localhost-only. - Claude Code, etc. Any CLI you want to use runs server-side inside the PTY. Install it on the host; your browser is just the glass.
Terminals live on the server, not in the browser tab, and every tab's directory and scrollback is on disk — so a dark dashboard is almost never lost work. RECOVERY.md walks through it from "wait a second" to rebuilding the fleet by hand. The dashboard shows the same steps itself, in a panel that appears once the connection has been down for about eight seconds.
npm install
SOA_WEB_PASSWORD=correct-horse-battery npm startOpen http://127.0.0.1:7332. The default shell is $SHELL (override with SOA_WEB_SHELL).
| Var | Default | Meaning |
|---|---|---|
SOA_WEB_HOST |
127.0.0.1 |
Bind address. Use 0.0.0.0 for LAN/cloud. |
SOA_WEB_PORT |
7332 |
Listen port. |
SOA_WEB_AUTH |
open / shared |
open | shared | none. See server/src/auth.js. |
SOA_WEB_PASSWORD |
— | Required when SOA_WEB_AUTH=shared. |
SOA_WEB_SIGN_KEY |
random, per-process | HMAC key for cookies. Set this in prod to survive restarts. |
SOA_WEB_SHELL |
$SHELL |
Shell binary to spawn. |
SOA_WEB_SESSION_TTL_MS |
6h |
Idle session expiry. |
SOA_WEB_SECURE_COOKIE |
0 |
Set 1 behind HTTPS so cookies are Secure. |
SOA_WEB_DEV |
unset | Dev mode — disables static caching. |
SOA_WEB_AUTOPAIR |
1 |
Auto-start the Cloudflare tunnel on boot. Set 0 to skip. |
SOA_WEB_SCROLLBACK_BYTES |
262144 |
Per-tab replay buffer. Restores scrollback on reconnect. |
The server refuses to start with SOA_WEB_AUTH=open on any non-loopback host. That's by design — a web terminal with no auth on a public IP is a root shell for everyone on the internet.
soa-web/
├── server/
│ ├── src/
│ │ ├── index.js # HTTP + WS entry
│ │ ├── auth.js # shared-secret + signed cookies
│ │ ├── sessionStore.js # per-browser session + PTY pool
│ │ ├── tabManager.js # node-pty wrapper
│ │ ├── tabPersist.js # tabs/scrollback survive a restart
│ │ ├── sessionManager.js # fleet supervisor + the /api/sessions action surface
│ │ ├── meetStore.js # group-meeting ledger (append-only JSONL, local only)
│ │ ├── meetings.js # /api/meetings — the human's meeting surface
│ │ ├── tabApi.js # /api/tabs + /api/browse
│ │ ├── claudeUsage.js # token-spend engine behind the usage panel
│ │ ├── tunnel.js # public channel (cloudflared / ngrok) + failover
│ │ └── protocol.js # wire schema (also served to browser)
│ └── test/ # node:test suites (see Testing)
├── web/public/ # static browser bundle (xterm.js over CDN)
│ ├── index.html
│ ├── assets/
│ │ ├── app.js # SPA entry
│ │ ├── bridge.js # WS client
│ │ ├── widgets.js # sidebar widgets (fleet, usage, QR)
│ │ └── styles.css # TRON palette
│ └── m/ # dependency-free mobile client (PWA, served at /m/)
├── mobile/ # mobile companion PWA packaging
├── mobile-ios/ # native iOS shell (Capacitor) bundling /m/
├── deploy/launchd/ # supervision plists (daemon, watchdogs, timers)
└── scripts/
├── fix-pty-perms.js # postinstall: chmod +x prebuilt spawn helper
├── smoke-ws.js # end-to-end PTY round-trip check
├── soa-sessions # manager-agent CLI: see/drive every other session
├── soa-meet # agent CLI for group meetings (say/read/rooms/who)
├── soa-bus # local-only agent-to-agent message bus
├── soa-work # local-only work-claim ledger (no double edits)
├── soa-msg # message the user's phone (mobile CHAT)
└── soa-watchdog # liveness probe used by the launchd timer
npm test # node:test suites in server/test/*.test.js
node scripts/smoke-ws.js # boot server first; verifies PTY round-trip
node scripts/manager-smoke.js # manager loopback surface, against a THROWAWAY daemon
node server/test/stress/stress.js # isolated mock daemons hammered for persistence/clobber bugsnpm test covers auth (cookie signing), the protocol framing, session
create/GC, tab titling + scrollback, tab persistence, and the fleet manager —
including group meetings (roster resolution, the poke gate, the relay budget,
the ledger cursor). The stress harness spins up its own throwaway daemons on a
temp state dir and never touches a running fleet.
manager-smoke.js does not — read this before running it. It spawns no
daemon of its own; it drives whatever daemon answers on SOA_WEB_PORT (falling
back to :7700). Pointed at a live fleet it really will open and stop tabs and
convene and adjourn a meeting inside it. Boot a throwaway daemon first and give
it its own state dir:
SOA_WEB_PORT=7700 SOA_WEB_STATE_DIR=~/.soa-web-mgrtest SOA_WEB_HOST=127.0.0.1 \
SOA_WEB_AUTOPAIR=0 SOA_WEB_NO_AUTO_RESUME=1 SOA_WEB_MANAGER_ENABLED=1 \
node server/src/index.js &
SOA_WEB_PORT=7700 node scripts/manager-smoke.js- Voice input. The desktop's Picovoice wake-word and local Whisper don't port. Plan for a web build: Web Speech API on click, or a server-side Whisper endpoint.
- Native menus, tray, global hotkeys, file-icon generator. All Electron-only — dropped.
- Auto-updater, notarization, DMGs. Irrelevant for a web deploy.
On boot the server opens a Cloudflare Quick Tunnel and prints the public URL. The sidebar's MOBILE LINK widget renders a QR for the same URL — scan it and the phone lands on the web UI. Requires cloudflared on PATH; falls back to ngrok or localtunnel if not. Disable with SOA_WEB_AUTOPAIR=0.
Vercel deploys ship only the SPA. The Node server (PTYs, WebSockets, sessions) can't run on serverless. Point
SOA_WEB_BACKENDat a tunneled self-hosted backend instead — see below.
Split-deploy setup: Vercel hosts the static SPA on your public domain, and the Node server keeps running on a box you control. The SPA talks to the backend over a Cloudflare Tunnel (or any public HTTPS URL).
-
Start the backend with an allowlist for the Vercel origin so CORS and the WebSocket upgrade accept the cross-site traffic, and so the cookie flips to
SameSite=None; Secure:SOA_WEB_PASSWORD=… \ SOA_WEB_ALLOWED_ORIGINS=https://your-app.vercel.app \ SOA_WEB_SECURE_COOKIE=1 \ npm start
Autopair prints the public tunnel URL (e.g.
https://foo-bar-baz.trycloudflare.com). Note it. -
Configure Vercel project environment variables (Settings → Environment Variables, Production + Preview):
Key Example value SOA_WEB_BACKENDhttps://foo-bar-baz.trycloudflare.comSOA_WEB_AUTHshared(match the backend) -
Redeploy.
scripts/vercel-build.jsruns automatically and rewritesweb/public/_config.jswith the baked-in backend origin. The SPA then pointsfetch('/api/…')and the/wsupgrade at that host.
Quick-tunnel URLs change on every backend restart — either use a named
Cloudflare Tunnel with a stable subdomain, or redeploy Vercel after each
restart to refresh SOA_WEB_BACKEND.
GPL-3.0, matching the desktop project.