# web-terminal → Rendezvous-Relay Service: A Decision-Ready Product Exploration *Turning a single-host byte-shuttle into a multi-tenant "bridge external devices to each customer's self-hosted terminal" service. This is exploration, not a commitment.* --- ## 0. LOCKED DECISIONS (2026-07-01) — prerequisites for the implementation plan All six §8 decisions are now resolved by the user: | # | Decision | Choice | |---|----------|--------| | 1 | Tunnel substrate | **frp for the MVP scaffold → self-built native WS mux for the real product.** E2E is the driver (frp can't cleanly host per-stream E2E), not billing. Coarse billing (hosts/viewers) works on frp; native mux is for E2E + per-stream control. | | 2 | Relay trust model | **End-to-end encryption (E2E).** Relay is a *ciphertext-shuttle* — it never sees plaintext keystrokes/output. Steal the sshx crypto model (per-session key via authenticated X25519 ECDH through the relay, AEAD frames via Web Crypto). **The load-bearing security decision.** | | 3 | Routing key | **Per-tenant subdomain** (`alice.term.`) — own browser origin per tenant → cookie/localStorage/`sessionId`/CORS isolation for free. Capability token layered on top (subdomain = which host; token = may-you + which PTY). No shared-origin path routing. | | 4 | Product form | **Both** — open-source the relay + agent (free self-host = competitive necessity + funnel) AND run a hosted SaaS (revenue). Price on **paired hosts + concurrent viewers**, not bandwidth; Hetzner-class infra. | | 5 | Auth model | Humans: **Passkey/WebAuthn primary** (TOTP fallback, never SMS; OIDC SSO for teams). Agents: **per-host Ed25519 key via single-use pairing code, mTLS with SPIFFE-style short-lived rotating certs** (DB stores only public keys). MVP may start with a shared `clientToken` gate but MUST reach real accounts before the first paid signup. Step-up auth *before opening a session*. | | 6 | Liability | **Contract states it explicitly** — the agent runs a shell with the customer's own privileges (their accepted risk). Your liability = relay-as-injection-vector, which E2E bounds to "we can disrupt connectivity but cannot read or inject." (Only truthful *because* of decision 2.) Pair with hard revocation + audited operator access; SOC 2 + published security model at business scale. | **Accepted consequence of E2E (decision 2):** server-side preview thumbnails / manage-page grid **move to CLIENT-SIDE rendering** (the key-holding browser decrypts + renders); relay can't render ciphertext. Ring-buffer replay and multi-device mirror survive (relay stores/forwards ciphertext). *(Assumption pending explicit user confirm; correct here if wrong.)* --- ## 1. Reframe: DDNS vs rendezvous-relay Your instinct ("like DDNS, my middle server is the bridge") is directionally right about the *stable-name* part and wrong about the *reachability* part — and the gap between them is the whole engineering problem. Plain DDNS only maps a name to an IP; it assumes something can then connect **inbound** to that IP. Your customers can't offer that: they're behind NAT, and increasingly CGNAT (carrier-grade NAT, where the ISP shares one public IP across many subscribers), so there is **no inbound port to forward** — the customer doesn't even own a routable address. The pattern that actually works is a **rendezvous / reverse-tunnel-as-a-service** (the self-hosted ngrok / frp / Cloudflare-Tunnel model): the customer's machine runs a small **host-agent that dials OUT** to your relay over TLS/443 and holds that connection open; external devices connect to *your* relay, which splices them onto the right host's already-open outbound tunnel. Outbound-443 traverses NAT/CGNAT/corporate firewalls exactly the way a browser does, which is why it works where DDNS fundamentally can't. The "DDNS-like" element survives only as a cosmetic convenience: a **stable per-tenant name** (`alice.term.yourdomain.com`) that stays constant while the underlying tunnel reconnects and moves between relay nodes. Name = stable identity; tunnel = reachability. Don't conflate them. --- ## 2. Reference architecture & build-vs-reuse **The single most important build decision: do NOT hand-roll the tunnel transport.** NAT-traversing, reconnecting, multiplexed, authenticated tunnels are a *commodity* solved by multiple mature open-source projects. Hand-rolling one is 6–12 months of undifferentiated infra, and because you're bridging **full interactive shells for many tenants**, every transport bug is catastrophic (RCE-shaped). Your moat is **not** the byte pipe — it's the persistent, agent-aware, phone-first session product (ring-buffer replay, walk-away/approve-from-phone, zero customer networking setup) layered on top. Invest ~90% of engineering there and treat the tunnel as a dependency you *configure*, not code you *write*. **Substrate options, ranked for your exact topology (host dials out → your relay → route external clients by tenant → you provision/deprovision):** | Candidate | What it is | Fit for you | Verdict | |---|---|---|---| | **frp** (fatedier/frp, Apache-2.0) | Go, ~90–100k★, `frps` server + `frpc` client, yamux multiplexing, subdomain/vhost routing, server-plugin HTTP hooks (`Login`/`NewProxy`/`NewUserConn`) | Canonical "host dials out + relay routes by subdomain." Hooks let your control plane authorize/provision. You build the tenancy model; frp gives the transport + hooks. | **Default recommendation for the owned substrate.** Free, vendor-independent, preserves the byte-shuttle. | | **rathole** (Rust, Apache-2.0) | Leaner frp-alike, lower mem/CPU, Noise encryption, high connection density | Great raw pipe, **thinner control surface** (fewer provisioning hooks) | Use *only* if host hardware footprint (Raspberry-Pi-class agents) forces it. | | **Pangolin** (2026) | Self-hostable tunneled reverse-proxy *management server*: WireGuard "Newt" agents, dashboard, auto-SSL, identity/access | Closest existing *product* to what you're building — already solves enrollment + per-host routing + control-plane UI | **Study or fork it.** Specialize its data path for the shell-mirror/`sessionId` model. | | **Teleport** (Community OSS) | Identity-aware access proxy; agents **dial out** to a central proxy, browser terminal, RBAC, session recording, tenant isolation, host↔account pairing — *your pattern, generalized* | Solves exactly the first-order concerns your base app lacks — but it's enterprise IAM (short-lived certs, RBAC ceremony) that fights your "zero-setup consumer" goal | **Read its reverse-tunnel + web-terminal source before designing.** Adopt as substrate only if you'll tolerate its weight. | | **inlets-uplink** (commercial) | Explicitly "multi-tenant tunnels for service providers" — your business, productized | Topological bullseye, but **per-tunnel licensing** (~$250/mo/cluster + per-tunnel) taxes your unit economics and couples margins to a vendor | Great to *feel* the provisioning API in a prototype; wrong to build the company on. | | **Cloudflare Tunnel / ngrok** | Production tunnels, subdomain routing, provisioning APIs | **You don't own the relay** — they *are* the middle server. Contradicts your entire "I am the bridge" thesis; customers' shells transit them. | **v0 shortcut to prove the UX only.** Never the permanent substrate. | | chisel / bore | Minimal single-binary tunnel primitives | Point-to-point, no multi-tenant routing model — you'd build all tenancy yourself | Fine for a *minimal* demo; more DIY than frp for the routing you need. | | Tailscale/Headscale | WireGuard mesh + self-hosted control server | **You explicitly ruled this out** — requires every customer (and every viewing phone) to install/enroll a client into your mesh. Wrong for productized zero-setup. | Right for *your own* infra access; wrong as the *customer-facing* transport. | **Is this already a product?** Close cousins exist — **sshx** (E2E collaborative browser terminals, ephemeral), **tmate** (ephemeral SSH sharing), **Coder/Gitpod** (they *provision* the machine; you bridge to the customer's *own* machine). But **none is "hosted, multi-tenant, persistent, mobile-first tunnel for Claude Code walk-away sessions."** That specific bundle — survive-the-disconnect sessions + phone-first cockpit + remote approvals + zero customer networking setup — is **unoccupied**. That's your opening. **Recommendation:** Prototype on Cloudflare Tunnel *or* frp to validate the UX (weeks), then **own the substrate on frp** and build your multi-tenant control plane against its plugin hooks. Borrow Teleport's security model; don't reinvent it. --- ## 3. System design ### The core split: control plane vs data plane Physically separate the two so (a) the byte-forwarding hot loop has zero DB/auth logic, and (b) a slow or compromised control plane can't leak bytes between tenants. - **Control plane** (stateful, low-QPS, strongly-consistent — Postgres + Redis): account registry, host registry (`host_id`, `account_id`, `subdomain`, `agent_pubkey`, `status`, `last_seen`), enrollment/pairing, the **live routing table** `host_id → relay_node_id` (ephemeral → Redis with heartbeat TTL; Postgres is source-of-truth for *ownership*, Redis for *where the live tunnel is right now*), and auth issuance (browser cookies/JWT, agent credentials). - **Data plane** (stateless, dumb, fast — the relay nodes): terminate inbound browser TLS, hold outbound agent tunnels, look up routing in Redis, verify a short-lived signed token, and shovel frames. **No tenant business logic in the pipe.** If a node crashes, agents reconnect and re-register; nothing durable is lost because there was none. ### Host-agent lifecycle - **Enrollment (one-time):** customer clicks "Add machine" → control plane mints a **single-use, short-TTL pairing code** → on the host, `web-terminal-agent pair ABCD-1234` generates an **Ed25519 keypair locally**, sends the *public* key + code redemption over TLS → control plane binds `host_id → account_id`, stores the public key, assigns the stable subdomain. **Private key never leaves the machine.** (This is the `cloudflared`/Pangolin-Newt model, proven in 2026 production.) - **Steady state:** agent dials OUT `wss://relay/agent` (443, CGNAT-friendly), authenticates by proving key possession (mTLS client cert / signed challenge), relay writes `host_id → this_node` into Redis with heartbeat TTL. - **Liveness/reconnect:** ping/pong every ~15s; exponential backoff with jitter (1s/2s/4s… cap 30s — the *same policy the frontend already ships*). On reconnect the agent may land on a different node and re-registers; the subdomain is stable throughout — that's the DDNS-like guarantee the customer sees. - **Forwards to `127.0.0.1:3000`** — the *unmodified* web-terminal, speaking plain HTTP/WS. The base app thinks it's being hit by a local browser. ### Routing external clients **Use per-customer subdomain as the primary routing key** — not path-prefix. `alice.term.yourdomain.com` → wildcard DNS `*.term...` → ingress → node holding Alice's tunnel. Subdomain wins because it gives each tenant its **own browser origin**, which naturally partitions cookies, `localStorage` (where web-terminal keeps `sessionId`!), and CORS/Origin. A shared-origin path scheme (`app.com/alice/...`) would let one tenant's page script reach another's WebSocket — catastrophic for a shell bridge. **Two layers of identity, never conflated:** *subdomain = which host*; *`sessionId` in the first `attach` frame = which PTY on that host* (base-app concept, stays entirely inside the tunnel — the relay never parses it; `?join=` and share-QR keep working as passthrough query params). ### Multiplexing One physical outbound connection per host carries N sessions × M mirrored clients + heartbeat. Run **yamux (or HTTP/2 streams) over a single `wss://`** — each inbound browser connection ⇒ one new logical stream to the agent ⇒ one fresh `localhost:3000` connection. Per-stream flow control means one heavy `vim`/`top` redraw can't starve another. **frp v0.69 ships exactly this** (TCP stream mux + work-conn pool), so you adopt it rather than hand-rolling the mux. ### Scaling / HA - **Sticky routing:** a host's tunnel terminates on *one* node; its browsers must land there. Solve with **Redis routing table (source of truth) + L7 ingress (Envoy/Traefik/Nginx) that reads it** and forwards to the correct node's internal address. Consistent hashing is an optional latency optimization, not a replacement for Redis. - **Stateless nodes ⇒ trivially horizontal.** Graceful drain: mark node "draining" → send agents a `reconnect` control frame → they backoff-reconnect elsewhere → drain routing keys → take it down. **The PTY survives the whole time on the customer's machine** — a relay-node bounce is invisible to the running Claude Code task. This falls out *for free* from the existing PTY≠WS decoupling. - **Control-plane down blocks new connects/enrollment but does NOT drop existing byte-forwarding** (data plane already holds its tokens) — another payoff of the split. Postgres primary+replica; Redis Sentinel/cluster. Regional relay clusters + anycast when geography demands. ### End-to-end data path ``` ┌─────────────┐ ┌──────────────── YOUR CLOUD ─────────────────┐ ┌── CUSTOMER MACHINE (behind NAT/CGNAT) ──┐ │ Phone / │ │ │ │ │ │ Browser │ wss │ ┌──────────┐ subdomain→ ┌─────────┐ │ │ ┌────────────┐ ┌──────────────┐ │ │ alice.term │═════▶│ │ Ingress │──authz────── │ Control │ │ │ │ host-agent │ http/ │ web-terminal │ │ │ /ws │ 443 │ │ L7 route │ Redis:host→ │ Plane │ │ │ │ dials OUT │ ws │ (UNCHANGED) │ │ └─────────────┘ │ └────┬─────┘ node lookup └─────────┘ │ │ │ holds mux │──────▶ │ 127.0.0.1: │ │ ▲ │ │ forward to node holding Alice tunnel│ │ │ tunnel │ local │ 3000 │ │ │ │ ▼ PG: accounts/hosts │ │ └─────▲──────┘ │ node-pty+ring│ │ │ output │ ┌──────────────┐ │ │ │ └──────────────┘ │ └──────────────│──│ Relay node 7 │ 1 mux stream per browser │ │ │ (outbound only; NO inbound port)│ (verbatim │ │ (DATA PLANE) │═══ single wss + yamux ════│══════│════════┘ │ bytes) │ │ stateless │ many streams / 1 link │ │ │ │ └──────────────┘ │ └──────────────────────────────────────────┘ └─────────────────────────────────────────────┘ Byte hot-loop: Phone ─ Ingress ─ Relay7 ═yamux═ Agent ─ 127.0.0.1:3000 ─ pty.write() keypress → xterm onData → wss → ingress → relay → yamux → agent → local WS → pty.write() → shell stdin Nobody between the phone and pty.write() parses the terminal stream. attach/input/resize/output/exit stay end-to-end. ``` ### What stays UNCHANGED (the load-bearing claim) The **server stays a byte-shuttle** — no ANSI/terminal parsing in relay or agent. **PTY≠WS decoupling, ring-buffer replay, multi-client mirror, latest-writer-wins sizing, orphan reclaim/`IDLE_TTL`, `pty.kill()` on exit** — all local to the customer's server, untouched. **`sessionId` first-frame, `?join`, share-QR, manage-page previews, hooks** — pure passthrough. **`protocol.ts` and the session model — byte-for-byte identical.** All 212 tests keep passing. The relay adds a transport layer *underneath* the existing WebSocket; it is not a rewrite of it. **The one base-app touch-point (config, not code):** the Origin check. Traffic arriving via the loopback agent will carry Origin `https://alice.term.yourdomain.com` instead of a LAN IP. **Do not weaken the check.** Set the existing `ALLOWED_ORIGINS` env var (already a knob!) to include the assigned subdomain during agent install. Zero code change; CSWSH protection stays meaningful because the real browser Origin is validated end-to-end. --- ## 4. Security & tenant isolation (the crux — no hand-waving) You are proposing to operate a central relay that bridges **full interactive shells for many customers**. That is one of the highest-blast-radius services you can build: a full relay compromise is, worst-case, **RCE as the logged-in user on every connected customer machine**. The base app was *deliberately* built with none of the required controls (no auth, no multi-user isolation, Origin-check only). Treat every control below as net-new, and **treat the shell bytes as radioactive**. ### 4a. Account & pairing — three distinct trust relationships, never conflated 1. **Host-agent → account.** **Never a shared static token in a config file** (leaks via dotfiles/backups/screen-share → attacker registers as the victim's host). Use the pairing-code → per-host Ed25519 keypair flow (§3). Then authenticate the tunnel with **mTLS using that per-host key**, not a bearer token. Issue **short-lived, auto-rotated certs (SPIFFE/SPIRE model)** — revocation becomes "just stop renewing" + kill the live tunnel, no CRL/OCSP distribution nightmare. *Why asymmetric matters:* a bearer token is a symmetric secret your DB also holds — a relay DB breach leaks every host's credential. A per-host **public** key in your DB is useless to a thief. 2. **External device → account.** Boringly standard, strong, not invented by you: **Passkeys (WebAuthn) as primary** (phishing-resistant, GA across major IdPs in early 2026 — not optional when the payload is a root-capable shell); fallback email+password **with mandatory TOTP or a second passkey**; **never SMS OTP** (SIM-swap → shell = catastrophic); OIDC SSO for business customers. Short-lived access tokens + refresh, device-bound (DPoP) where possible. 3. **Relay authorizes device → host (the actual gate).** On **every** connection: `session.account_id == host.account_id`? Only then splice the WebSocket. **Capability-scoped**, not just ownership: a token names the exact host(s) and rights (attach vs manage vs kill), so a QR share link can grant *one session, read-or-write, expiring* without granting the whole account. ### 4b. The hard isolation guarantee **Invariant: customer A's browser can NEVER reach customer B's host.** Everything else is negotiable; this is not. Enforcement: - Ownership join is the *only* path — **no code path resolves a host by raw address/port/user-supplied hostname**; the client names only a `host_id` it's authorized for. - **Deny-by-default, server-side.** `account_id` is derived from the *authenticated session*, never from a client-supplied field (client-supplied `tenant_id`/`host_id` as source-of-truth is the classic multi-tenant IDOR). - Push toward **silo for the data plane**: tunnels for different tenants must not share mutable in-process state. **Cross-tenant failure modes to design against explicitly:** (1) **IDOR on `host_id`** → ownership join + capability tokens + a **CI test asserting cross-tenant attach → 403 as a permanent tripwire**; (2) **`host_id` guessing/reuse** → unguessable UUIDv4, never recycled; (3) **cross-tenant buffer bleed** — the base app's ~2MB ring buffer + `RingBuffer.tail()` previews become cross-tenant memory at a relay; a shared/global buffer pool or off-by-one can splice A's stdout into B's stream → **no global mutable buffers, per-connection allocation** (E2E makes this class *impossible*, not just unlikely); (4) **reconnect races** — reattach must re-validate `session_id` against `account_id`, never resolve purely by client-provided id; (5) **Origin/CSWSH is now insufficient** — keep it *and* require an authenticated capability token on the WS upgrade; (6) **wildcard-subdomain / Host-header confusion** → route by authenticated session, not the Host header. ### 4c. The trust decision: relay-sees-plaintext vs E2E This is *the* decision — it determines your liability, your feature set, and whether a breach is "embarrassing" or "company-ending." - **Option A — relay sees plaintext.** Cheapest; keeps *every* server-side feature (preview thumbnails, relay-side replay, server search, session recording). But you'd be **operating a live keylogger-shaped honeypot** for hundreds of developer machines — `sudo` passwords, `.env` secrets, API keys, and pointedly **Claude/Anthropic tokens** scrolling through in plaintext. You inherit SOC 2 / DPA obligations, encryption-at-rest, insider-access controls, breach-notification, and the standing reality that **one relay compromise = every customer's live credentials exfiltrated in real time.** For a small operator this is a near-uninsurable posture. **Not recommended as default.** - **Option B — End-to-end encryption (RECOMMENDED).** Browser and host-agent negotiate a **per-session symmetric key the relay never learns**; the relay moves only ciphertext. This is exactly what **sshx** does (Argon2 + AES, key material in the URL fragment `#` which browsers never send to the server) — **steal its crypto model wholesale.** Concretely: authenticated **X25519 ECDH through the relay** (relay forwards the handshake, can't derive the secret), **bound to the host's enrollment key and the device's auth** so it's not a bare DH the relay could MITM (browser verifies the host's pubkey fingerprint, TOFU or pinned at enrollment); all `input`/`output`/`resize` frames **AEAD-encrypted** (AES-GCM / XChaCha20-Poly1305, per-message nonces) via Web Crypto. **This is a marketing asset too:** "we cannot see your terminal" is a *verifiable* claim. **What E2E costs you (decide consciously):** - **Preview thumbnails / manage-page grid die server-side** — the relay can't render a screen it can't read. Move preview rendering **client-side** (the authorized, key-holding browser decrypts and renders), or drop live previews for unattended sessions. **This is the biggest product hit.** - **Server-side ring-buffer replay SURVIVES** — the relay stores/replays *ciphertext* (doesn't need to read it); the reconnecting browser presents the session key (re-derivable from account/passkey, not lost on refresh) to decrypt. "Refresh and the session is still there" still works. - **Server-side search & recording → client-side only** (i.e., not server-side at all). - **Multi-device mirror still works** — every authorized device derives the same key, but the key must reach each joining device through an **authenticated channel** (derived from account auth), *not* a plaintext QR a shoulder-surfer or the relay could reuse. Even with E2E you still own **routing and availability** — so tenant isolation, auth, DoS, and MAC+sequence-numbering frames (to prevent replay/injection) remain your problem. ### 4d. Blast radius & liability — stated honestly There is **no way to make this risk zero** while offering the product — the agent runs a shell with the user's own privileges (that *is* the product; it's the customer's risk to accept). The engineering job is to **bound the relay's blast radius** so a breach degrades instead of detonating. Controls, ranked by impact: (1) **E2E** — the biggest lever; a relay compromise yields ciphertext + routing metadata, not a live credential feed, and the attacker can't inject valid encrypted frames — *this is the difference between "we had an incident" and "we ended every customer";* (2) **per-host asymmetric keys, no shared secrets** — a DB dump can't impersonate a host; (3) **no shell bytes at rest** — ciphertext-only buffers, short TTL; (4) **least-privilege signed auto-updating agent** — runs as user not root, so you can push emergency patches (a compromised update = same blast radius, so sign + pin the channel); (5) **rapid global revocation** — one control to kill all tunnels + stop cert issuance + rotate relay keys; (6) **MFA-gated, time-boxed, audited operator access** — under E2E your own staff *cannot* silently read a session by construction. The **honest customer-facing statement** — defensible *only if you ship E2E*: *"We route your session; with E2E we cannot read it. The agent runs on your machine with your privileges — that access is inherent to the product. A breach of our relay cannot read your terminal, but can disrupt connectivity; we cannot inject commands into an E2E session."* ### 4e. Minimum bar to operate responsibly (checklist) **CRITICAL — block launch / do not onboard a second paying customer until all true:** - [ ] Auth on **every** device connection (passkey primary; TOTP fallback; no SMS). No anonymous shell access, ever. - [ ] Per-host asymmetric identity via single-use short-TTL pairing code; private key never leaves host; relay stores only the public key. - [ ] Server-side, deny-by-default tenant authz: `account_id` from the authenticated principal (never client-supplied), joined against `host.account_id` on every connect **and every reattach**. - [ ] Automated cross-tenant isolation test in CI (device A → host B = 403) as a permanent tripwire. - [ ] E2E encryption of terminal payload (per-session key via authenticated ECDH through the relay; relay routes ciphertext only). *If you launch Option A instead, you've accepted operating a secret-exfiltration honeypot and need the compliance program to match — advised against.* - [ ] No plaintext shell bytes at rest on the relay (ciphertext buffers, short TTL). - [ ] Unguessable, non-recycled `host_id`/`session_id`; reattach re-validated against account. - [ ] Global + per-host revocation that kills live tunnels within seconds. - [ ] Authenticated WS handshake (retain Origin/CSWSH **and** require a capability token). - [ ] Secrets in a manager/HSM, validated at startup, never logged. No public-internet exposure of raw host ports. **HIGH — before scaling past a handful:** per-tenant rate limits/quotas (sessions, bytes, enrollments); SPIFFE-style short-lived auto-rotated host certs; immutable control-plane audit log with cross-tenant-crossing alerts and zero payload logging; step-up auth *before opening a session* (not just at login); signed/pinned agent auto-update; least-privilege agent; MFA-gated time-boxed operator access; "assume relay compromised" IR runbook. **MEDIUM — maturity:** SOC 2 Type II; third-party pentest targeting tenant crossing + E2E MITM-by-malicious-relay; client-side preview rendering to recover the manage-page UX; login/session anomaly detection; published verifiable security-model doc. **The one-line upgrade to the base app's philosophy:** "the server is a byte-shuttle" is the *right* instinct to carry into the relay — but upgrade it from **byte-shuttle to ciphertext-shuttle.** The relay should move opaque, authenticated, per-tenant-routed blobs and know as little as physically possible about what's inside. --- ## 5. MVP & codebase impact **The MVP demo IS the pitch:** *"I'm at a café, I open `alice.term.yourdomain.com` on my phone, type my token, and I'm in the shell running on my laptop at home — and I never configured a router, a VPN, or a static IP."* Everything else is hardening. **Thinnest end-to-end system (v0.8, single-node relay):** 1. **One relay node** — one small VPS, wildcard DNS `*.term.yourdomain.com`, wildcard TLS (LetsEncrypt DNS-01 or terminate at Cloudflare in front). One process, no clustering. 2. **Flat account table** (SQLite): `{ accountId, subdomain, agentToken(hashed), clientToken(hashed) }`. Two tokens because agent and browser authenticate on different legs. Manual provisioning is fine. 3. **Host-agent** (~300–600 LOC) — outbound WSS to relay, present `agentToken`, register `subdomain`, hold open with the backoff logic you already ship, open loopback streams to `:3000`. 4. **Data plane** — relay routes by subdomain to the agent's control connection, asks it to open a new logical stream; agent dials `ws://127.0.0.1:3000` (unmodified web-terminal) and splices. Relay sees opaque stream-tagged frames, never terminal semantics. 5. **Browser auth** — relay demands `clientToken` at the subdomain root, sets a signed cookie, then upgrades. **This is the auth the base app deliberately never had, and it must live at the relay edge** — once you're on the public internet, Origin-checking stops CSWSH but not "a stranger with the URL." Total: **1 VPS + wildcard DNS/TLS + ~300-line agent + ~500-line relay + a 4-column table + a password gate.** Two legit shortcuts: **wrap `frpc` as the agent** (fastest to demo, zero transport code) or **hand-roll WS-over-WS multiplex** (keeps everything in your TS stack, gives you the auth/metering hooks frp won't). **Decision: prototype on frp to prove demand, port to native multiplex once you charge money.** **Codebase impact — what changes in `src/`:** *Nothing structural.* `src/server.ts` byte-shuttle, `wss.handleUpgrade`, PTY/session decoupling, ring-buffer replay, `src/session/*`, `src/protocol.ts`, `src/config.ts` — **100% unchanged.** The relay path terminates at `ws://127.0.0.1:3000` — the agent is *just another local WS client*. The **one subtlety** (`src/server.ts` Origin check, ~`:646`): the agent connects from loopback presenting a loopback-allowed Origin, or set `ALLOWED_ORIGINS` to include the subdomain at install — net change to `server.ts` is **a few lines, ideally zero.** Frontend (`public/main.ts`, same-origin scheme-following M6) needs **no change** — served from `alice.term...`, the browser connects same-host and the relay proxies. **New components live in new top-level packages/repos, NOT edits to any task's `Owns:`:** `agent/` (host-agent) and `term-relay/` (control+data plane + account store). Keep the immutability discipline from `src/session` — routing/account records are immutable snapshots swapped atomically, not mutated in place. --- ## 6. Cost, distribution & competitive framing **Cost physics: interactive terminal is cheap; persistence and previews are the tax.** A human typing + reading a shell is kilobytes/minute — a power user's *interactive* month is well under 1 GB. The real cost is **always-on connections**: every agent holds a persistent socket 24/7, and v0.4 **live-preview thumbnails stream even when idle** if a manage-page is open. **Budget the relay by concurrent connections (memory/FD), not GB.** Host on **Hetzner/bare VPS, not Fly/AWS** — Hetzner ~€5.49/mo with 20 TB included / ~€1/TB overage; egress on Fly/AWS is the hidden bill-killer for a persistent-bandwidth product. One Hetzner CX node fronts hundreds of idle agents comfortably; egress is a rounding error dominated by preview streams. **Product mitigations baked in:** pause preview streams when no viewer is focused (extend the existing attachment-gating in the session model), and idle-disconnect viewer legs while keeping the agent control connection alive. **Pricing — offer both:** - **Free self-hosted relay (open-source relay + agent)** — competitive necessity. frp/Cloudflare-Tunnel/Tailscale all have free self-host; a closed relay with no free path is dead on arrival. Captures goodwill + funnel. - **Hosted SaaS** — the revenue. Tier on the two real cost drivers (**paired hosts** + **concurrent viewers/preview**), not bandwidth: *Free* (1 host, 1 viewer, random subdomain, no preview); *Personal ~$6–8/mo* (1–2 hosts, custom subdomain, preview, 30-day persistence — anchored to ngrok Personal $8 / Tailscale $6–8); *Pro ~$20/mo* (up to ~5 hosts, multiple viewers, IP allow-list — ngrok Pro is $20); *Team/Enterprise quote* (SSO, audit log, dedicated relay — Teleport's $12–40/resource + $50k self-host floor is your *ceiling*, not competitor). **Margin trap:** a customer leaving a 4-thumbnail manage page open on a big monitor 24/7 streams continuously — the preview-pause mitigation above is a *business* control, not just a UX one. **Distribution (agent install), ranked by MVP-fit:** (1) **`npx web-terminal-agent pair `** — customers already `npm install` the app; zero new toolchain (MVP default); (2) **single static binary** (`bun --compile`/`pkg`, or rewrite in Go like frpc) — one `curl | sh`, trivial `systemd`/`launchd` service (do at v0.9 once the protocol stabilizes); (3) Homebrew/Docker for power users. **Pairing UX is make-or-break** — the whole promise is "no networking setup," so onboarding must match: sign up → short pairing code → `npx web-terminal-agent pair ABCD-1234` (exchanges code for durable token, installs as a service, connects) → dashboard flips to "● online." **KPI: first-shell-in-under-2-minutes.** **Differentiation (you compete with "what a dev reaches for to get their phone into their home shell," not with tunnel primitives):** - **vs ngrok** — ngrok is a generic tunnel; you'd still run web-terminal behind it and hand-roll auth. You sell **the terminal + tunnel + phone UI as one paired appliance.** ngrok's tightening 2026 per-endpoint pricing is actively pushing devs to alternatives — tailwind. - **vs Cloudflare Tunnel** — free and excellent, but raw infra the customer must configure (`cloudflared` + DNS + Access). Your wedge: **"you configure nothing."** (You can even run CF in front of your relay for TLS/DDoS.) - **vs Teleport** — the enterprise PAM ceiling ($12–40/resource, $50k+ self-host). **Don't fight it** — overkill/unaffordable for your walk-away vibe-coder persona; it's your future enterprise-tier *roadmap*, not your competitor. - **vs "just use Tailscale"** — your **sharpest, most honest wedge.** Tailscale makes the *customer* the network admin (install on laptop *and every phone*, understand the tailnet). Your pitch: **"the viewing device needs nothing but a browser and a URL"** — zero install on the phone in your hand, no mesh to reason about. That café/borrowed-device/hand-it-to-a-teammate moment is exactly what Tailscale *can't* do. **Lead every marketing sentence with it.** **One-line positioning:** *"Ngrok's zero-config reachability + a real terminal + a phone UI, paired in one command — so you (and only you) can reach your own machines' Claude Code sessions from any browser, with no VPN and nothing to install on the device in your hand."* --- ## 7. Phased roadmap | Phase | Scope | Effort | |---|---|---| | **v0.8 — MVP single-node relay** | 1 VPS, wildcard DNS+TLS, host-agent (wrap `frpc` or native WS-over-WS), 4-col account table (manual provisioning), password gate + signed cookie at the relay edge, `npx …agent pair`. Prove the café demo. | **1–2 wks** (small on frp; ~2 wks native). Biggest risk: TLS/wildcard + agent reconnect edge cases. | | **v0.9 — Multi-tenant SaaS** | Native WS multiplex (drop frp), self-serve signup + dashboard, pairing-code flow, per-account subdomain issuance, plan tiers + metering (hosts/viewers), preview-pause-when-unfocused, single static-binary agent + service install, basic rate-limit/abuse controls. | **3–5 wks.** The account/billing/dashboard plane is most of it; transport is small. | | **v0.10 — Security hardening** | Real accounts (passkey/OIDC) replacing shared `clientToken`, per-session capability tokens, IP allow-list, audit log of every attach, agent cert rotation, **E2E encryption phone↔agent (relay routes ciphertext by subdomain only)**, third-party pentest. | **4–8 wks.** **E2E is the hard, non-negotiable item** — the difference between "one bad day" and "every customer's shell." Do it **before** scaling users, not after. | | **v0.11+ — Scale** | Multiple relay nodes + geo/subdomain-sharded routing, horizontal control plane (Postgres/Redis), connection-count autoscaling, HA/failover, per-tenant observability, SLA + status page, optional enterprise tier. | **Ongoing.** Only when concurrent-agent FD/memory limits force it (hundreds→thousands). **YAGNI until the socket count forces it.** | **Sequencing opinion:** ship v0.8 on frp *this month* — the demo, not the architecture, proves demand. But **gate paid signups (v0.9) behind the auth edge, and gate any growth push behind E2E (v0.10).** The base app's "no auth, no isolation" non-goals become CRITICAL the instant strangers can reach the port; a relay that can read every tenant's shell is a single catastrophic blast radius. Treat "keep the relay a dumb (cipher)pipe" as a *security* property — it can't leak what it can't parse — not just a simplicity one. --- ## 8. DECISIONS FOR USER 1. **Reuse a tunnel vs bespoke relay?** → **Reuse.** Prototype v0.8 on **frp** (or Cloudflare Tunnel purely for a throwaway demo), own the substrate on **frp** long-term (free, hooks, subdomain routing), port to native WS-multiplex by v0.9 when you charge money. Do **not** hand-roll NAT-traversal/mux, and do **not** build the *business* on Cloudflare/ngrok/inlets (being "the bridge" is your value prop — you must own the relay). *Open sub-question: adopt Teleport Community wholesale to inherit enterprise auth/isolation/audit, accepting its weight — or stay lightweight and build the tenancy glue yourself? Recommendation leans lightweight for the consumer persona, but read Teleport's source first.* 2. **Relay sees plaintext vs E2E?** → **The pivotal call.** Recommend **E2E (sshx model)** before scaling users. Plaintext is cheaper and keeps server-side previews/search/recording but makes you a live secret-exfiltration honeypot with an uninsurable breach posture. **Consciously accept the cost:** server-rendered previews die (move client-side), server search/recording die; replay + multi-device mirror survive. Decide this *before* v0.10, ideally design for it from v0.8. 3. **Subdomain vs token routing?** → **Per-tenant subdomain** as the routing key (own browser origin per tenant → cookie/localStorage/`sessionId`/CORS isolation for free). A capability *token* still authorizes the specific host+rights on top; the two layers coexist (subdomain = which host, token = may-you + which-PTY). Avoid shared-origin path-prefix routing. 4. **Self-host-relay product vs hosted SaaS?** → **Both.** Open-source the relay+agent (free self-host = competitive necessity + funnel) **and** run a hosted SaaS (the revenue), priced on **paired hosts + concurrent viewers**, not bandwidth, on **Hetzner-class infra**. 5. **Auth model?** → **Passkeys (WebAuthn) primary** for humans (TOTP fallback, never SMS; OIDC SSO for teams) + **per-host Ed25519 keys via single-use pairing codes, mTLS with SPIFFE-style short-lived certs** for agents. MVP may start with a shared `clientToken` gate but must reach real accounts before paid signups. **Step-up auth before opening a session**, not just at login. 6. **Who owns liability for bridged shells?** → Decide and put it in the contract. The agent runs a shell with the customer's own privileges — **that access is inherently the customer's accepted risk.** Your liability is the *relay as injection vector*, which **E2E bounds** to "we can disrupt connectivity but cannot read or inject." You cannot make this statement truthfully under the plaintext option — which is itself an argument for E2E. Pair it with a hard revocation control, audited operator access, and (at business scale) SOC 2 + a published, verifiable security-model doc.