Relay
The relay is a transparent forwarder. It does not understand product semantics. It exists so the phone (off-LAN) can reach the user’s extension (on the workstation) through a single TLS endpoint.
What the relay knows
| Concept | Relay knows | Relay does NOT know |
|---|---|---|
| Rooms | Yes — each room is one workstation/extension pair plus 0..N phone clients | — |
| Auth | Yes — validates extension token + device token at first frame | What the auth represents downstream |
Sequence numbers (seqId) | Yes — assigns + buffers | What a message’s type field means |
| Capabilities (per client) | Yes (since TPC v0.1 Task 3) — stored on the client record from relay_client_auth.capabilities | What any individual capability means semantically |
| Per-message routing hint | Yes — reads _requiresCapability from extension-emitted messages | Anything else inside the payload |
The relay is deliberately dumb about content. Adding logic that inspects terminal payloads, prompt content, session events, or any other payload field is a doctrine violation. The relay forwards bytes.
Auth
Two roles. The path determines the role:
wss://relay.vsrelay.dev/relay/room/<roomId>/extension— extension authwss://relay.vsrelay.dev/relay/room/<roomId>/client— phone (or any thin renderer)
First frame for the client role:
{
"type": "relay_client_auth",
"roomId": "test-room-001",
"deviceToken": "<device-token>",
"protocolVersion":"2.0",
"capabilities": ["terminal_observe", "session_subscriptions", "workspace_activation", ...],
"lastSeqId": 42, // optional — request replay from this seqId
"buildInfo": { ... } // optional
}The relay validates the deviceToken against Supabase before admitting the WS upgrade (see Room admission / validation below). On success the client receives a synthetic relay_connected payload carrying the extension’s last-known build info / environment / claudeIntegration so the renderer has the same shape it gets from a direct connected message.
Room admission / validation
The relay performs authenticated room admission before any message is forwarded. A WS upgrade is admitted only when:
- The
roomIdexists in Supabase (the extension registered it when it signed in). sha256(presented_deviceToken)matches the hash stored in Supabase for that room/device pair.
Validation uses a secret-gated relay_lookup_room(p_room_id, p_relay_secret) SECURITY DEFINER RPC that returns the per-room token hash. The result is cached briefly to avoid a Supabase round-trip on every reconnect. Connections that fail either check are rejected fail-closed — no partial admission.
What this means for a native client: just connect with the deviceToken you received from claim_pairing_code (see SupabaseAuth §5 for the pairing flow). The client presents the token; the relay hashes and compares it internally. The native client never hashes, never touches RELAY_SECRET (relay-host-only), and never calls Supabase directly for admission.
Message bodies are still forwarded transparently. Room admission validates who may enter the room; it does not inspect any payload content. The relay remains a transparent forwarder once a connection is admitted.
Capability tracking (since Task 3 of TPC v0.1)
The relay stores each client’s capabilities[] on the connection record. This is used only for the generic _requiresCapability routing primitive (below). The relay does not interpret what 'terminal_observe' means — it just compares strings.
_requiresCapability — internal relay routing metadata
This is the one thing the relay reads from extension→client messages besides type and seqId. It is internal routing metadata, never part of any protocol wire type.
How it works (from the extension’s perspective)
The extension’s RelayClient.sendToRelay accepts an optional opts.requiresCapability: string. When set, it adds _requiresCapability: '<cap>' to the wire frame:
// What the extension sends to the relay:
{ "type": "terminal_event", "terminalId": "term-1", "event": "opened", "info": {...}, "_requiresCapability": "terminal_observe" }How it works (relay-side)
On receipt, the relay:
- Parses the message.
- Extracts
_requiresCapabilityif present. - Strips it from the parsed object (so it never reaches any client).
- Re-serialises the stripped object.
- Assigns a fresh
seqId. - Buffers (with
requiresCapabilitystored separately on the buffer entry, not on the payload). - Forwards to clients whose
capabilities[]includes the named string. - On reconnect-replay, re-applies the same per-client filter.
What the native client sees
// What the client receives:
{ "type": "terminal_event", "terminalId": "term-1", "event": "opened", "info": {...}, "seqId": 17 }No _requiresCapability field. Ever. The native client never sees it on the wire. The native client never sends it.
Pure helper
The decision is one line:
function shouldForwardToClient(
clientCapabilities: readonly string[],
requiresCapability: string | undefined,
): boolean {
if (requiresCapability === undefined) return true;
return clientCapabilities.includes(requiresCapability);
}The source lives in packages/relay/src/room.ts as an exported named function — exported so its behaviour is unit-testable and auditable, but not exposed via any user-facing module entry.
Why this exists
Without it, the relay would have to either (a) broadcast every server message to every client and rely on the client to filter (security-fragile, wastes bandwidth, leaks “this terminal exists” to clients that didn’t ask for terminal observation) or (b) hardcode terminal-specific logic into the relay (couples the relay to a domain concept). The generic _requiresCapability primitive lets new capability-scoped server messages ship by adding data at the extension, with zero relay code changes.
Replay and reconnect
Each room maintains a 200-entry ring buffer of forwarded messages. On client reconnect with lastSeqId: N, the relay sends every buffered entry where seqId > N, with the same capability filter applied per buffered entry.
For native clients:
- Store the highest
seqIdyou’ve received in memory. - On reconnect, send it back as
lastSeqId. - The relay handles the replay transparently. You receive messages in order with no special handling.
If the ring buffer wrapped past your lastSeqId (only happens for very long offline windows), some messages are unrecoverable. This is a known deferred reliability gap (same as the direct-mode case in SessionController).
What the relay does NOT do
- ❌ Inspect payload bodies (terminal cwd, prompt text, session event content, SSH keys).
- ❌ Apply any session-level subscription logic. That’s the extension’s job (
session_subscriptionscapability is interpreted server-side). - ❌ Persist anything to disk. Everything is in-memory per room.
- ❌ Re-route based on payload content. Routing is purely (a) extension → room broadcast, (b) client → extension via room.
What the native client does NOT do
- ❌ Send
_requiresCapabilityupstream. It’s a downward-only routing hint. - ❌ Try to read
_requiresCapabilityfrom any incoming message. The relay strips it; if you ever see one, your relay build is bugged — report it. - ❌ Cache the relay URL across user sessions. Treat it as a setup-time configuration.
One-extension-per-room rule
The relay enforces setExtension as keep-first (since IDE Instance Routing v1, Phase 1.21): if a new extension with a different editorInstanceId tries to auth into a room already occupied by another, the relay rejects the new connection with relay_instance_rejected + close 4001 (room_occupied). The same editorInstanceId may reconnect/replace normally.
The native client sees a relay_instance_rejected message when it is the client side of a room-occupied conflict. Render it as a “This workspace is already controlled by a different instance” banner with the occupying instance descriptor if available. Transient “Extension reconnecting…” banners should clear when the next normal message arrives.
Reference implementation
- Relay package:
packages/relay/src/room.ts,packages/relay/src/index.ts. - Relay tests:
packages/relay/test/room.test.ts— capability filter unit tests. - Extension-side wiring:
packages/extension/src/relay-client.ts—sendToRelay(payload, opts?: { requiresCapability?: string }).