Protocol Basics
The wire-level contract between a native client (Swift / Kotlin / web) and the VSRelay server (extension or relay).
Transports
| Mode | Phone connects to | Endpoint |
|---|---|---|
| Local | The extension directly | wss://<host>/ws (production proxy) or ws://<host>:18100/ws (LAN) |
| Relay | A relay server room | wss://relay.vsrelay.dev/relay/room/<roomId>/<role> where role ∈ { 'extension', 'client' } |
The native client always plays the client role. The extension plays extension. Each room has at most ONE extension; clients are many.
First-frame auth
After the WS opens, the client must send exactly one auth message within 5 seconds or the server closes with code 4001.
Local mode:
{
"type": "auth",
"token": "<32-hex-char token>",
"protocolVersion": "2.0",
"capabilities": ["terminal_observe", "session_subscriptions", ...],
"lastSeqId": 42,
"buildInfo": { "sha": "...", "builtAt": "..." }
}Relay mode:
{
"type": "relay_client_auth",
"roomId": "test-room-001",
"deviceToken": "<device token>",
"protocolVersion": "2.0",
"capabilities": [...],
"lastSeqId": 42,
"buildInfo": { ... }
}The token / deviceToken comes from user setup. The native client stores it in the platform’s secure store (Keychain / Keystore) — never plaintext, never as a query param.
The connected payload
On successful auth the server (or relay, with relay_connected) replies with a payload describing the current world:
{
"type": "connected",
"seqId": 1,
"version": "0.1.0",
"protocolVersion": "2.0",
"capabilities": ["terminal_observe", "session_subscriptions", ...],
"activeSessions": 2,
"workspacePath": "/root/projects/agentic-ides",
"environment": "ssh",
"claudeIntegration": { "status": "healthy", "claudeCodeVersion": "2.1.145", ... },
"ideInstance": { "editorInstanceId": "f3a1...", "editorKind": "cursor", "hostKind": "remote-ssh", ... },
"extensionBuildInfo": { "sha": "...", ... }
}Render screens purely from this payload + subsequent broadcasts. Don’t infer anything from the client’s hardcoded knowledge.
ideInstance? (relay mode: relay_connected.extensionIdeInstance?) is the optional IdeInstanceDescriptor identifying which live IDE instance + Claude Code install this connection controls. Its editorInstanceId is the opaque routing key for recovery actions; raw install paths never appear (only an opaque pathHash). v1 carries exactly one instance. See IdeInstanceRouting for the full shape + fail-closed routing contract.
Capabilities
The capabilities[] in the connected payload echoes the ones the server accepted for this session. If you sent a capability the server doesn’t support, it silently won’t appear. Always:
if (connected.capabilities.contains("terminal_observe")) renderTerminalsTab()Never:
if (clientBuildSupportsTerminals) renderTerminalsTab() // ❌ closed-world thinkingEnvironment
environment ∈ { 'local' | 'ssh' | 'wsl' | 'devcontainer' | 'codespace' | <raw-string> }
Treat as open: any string the server sends, render. Unknown values render as a labelled badge — never crash.
Message anatomy
Every message has type: string. Most also carry:
requestId?: string— set on requests, echoed on responses. Use UUIDs.traceId?: string— your-side correlation across one user action. Formatt-<base36>. The server tags downstream events with it (best-effort; see SessionController for the 10 s TTL caveat).seqId: number— set by the server on server→client messages. Monotonic per session/room. Use the most-recent value aslastSeqIdon reconnect to replay missed messages.
The server NEVER assigns seqId on client→server messages. Never set it yourself on outbound.
Request/response pattern
For one-shot operations (list-files, install-key, terminal-list, etc.):
- Client sends
{ type: 'FOO_request', requestId: 'r-1', ... }. - Server replies once with
{ type: 'FOO_result' | 'FOO_ack', requestId: 'r-1', seqId: N, ... }.
The requestId is how you correlate the response. The server never invents new requestIds for broadcasts.
Broadcasts (server-pushed events)
For live state, the server pushes messages without any requestId:
{ "type": "session_event", "sessionId": "...", "event": { ... }, "seqId": 123, "traceId": "t-..." }
{ "type": "terminal_event", "terminalId": "term-1", "event": "opened", "info": { ... }, "seqId": 124 }The native client stores them in ephemeral state. Don’t persist to disk — they’re rebuilt on reconnect via lastSeqId replay (see Relay).
Reconnect + replay
On reconnect, send lastSeqId: <the highest seqId you saw>. The server replays buffered messages with seqId > lastSeqId from a ring buffer (200 events deep, both extension and relay).
Ring-buffer gap: if a client was offline long enough that the buffer wrapped past their lastSeqId, some messages are unrecoverable. Today this is silent (a known deferred reliability gap). When the protocol surfaces a gap_detected primitive, render it as a “reload” action card.
Errors
The error model is open + human-readable + recoverable:
{
"type": "error",
"requestId": "r-1",
"errorCode": "capability_not_negotiated",
"error": "terminal_observe capability not negotiated",
"recoveryCommand": "npx @vsrelay/doctor repair", // Phase 1.20-onward — was 'pnpm patches:apply' in Phase 1.18
"recoveryActions": [
{ "type": "copy_command", "label": "Copy recovery command", "command": "npx @vsrelay/doctor repair" },
{ "type": "retry_after_reconnect", "label": "Retry after recovery" }
]
}The native client’s renderer obligations:
- ALWAYS show
error(the human string). It’s the safety net. - If you know how to special-case
errorCode, do it (e.g., flip the integration banner torecovery_required). - If you don’t know
errorCode, render a generic “Delivery failed” card with the human string + a Retry affordance. - If
recoveryActions[]is present, render the supported primitive types (copy_command,retry_after_reconnect, futureopen_url,progress_card, etc.). Unknown action types render as disabled placeholders with thelabelshown.
See RecoveryAndErrors for the full failed-prompt model.
Ephemeral vs persisted client state
The native client should persist only:
- The auth token / device token / relay URL / room ID (secure storage).
- The user’s last active session ID (for UX continuity).
- Session titles cache (purely visual; rebuilt on next session list).
Never persist:
- Live
messages,terminals,sessions,outboundPrompts,claudeIntegration,installedKeys, transport state. - These are rebuilt on every connect from
connected+ subsequent broadcasts.
This is the same partialize rule the PWA uses (packages/web/src/store/index.ts:partialize). On native it maps to “don’t put it in UserDefaults / SharedPreferences.”
Unknown-field tolerance
Decoders MUST be additive. In Swift this means Codable types should:
// ✅ tolerate unknown fields
struct ConnectedPayload: Decodable {
let version: String
let capabilities: [String]
// ... no `allKeys must match` discipline
}In Kotlin / kotlinx.serialization:
@Serializable
data class ConnectedPayload(
val version: String,
val capabilities: List<String>,
)
// Use Json { ignoreUnknownKeys = true }If the server adds nextCoolField tomorrow, the existing iOS/Android builds keep working — they just ignore it.
What the native client must NOT send
_requiresCapability— internal relay routing metadata. The server emits it; the relay strips it. Clients never see it and never send it. See Relay.- Raw shell commands — if the user can trigger a command, it must be via a server-provided action description (
copy_command, future allowlist primitives), not a raw string the app constructed. - New
typevalues the server hasn’t documented. The server is the source of truth for what types exist.
Quick sanity test for a new client
Before claiming “the native app works,” verify:
- Auth succeeds with the same token that worked in the PWA.
-
connected.capabilitiesis echoed and renders only those screens. - An unknown field added to
connected(e.g.,mystery: 42) is silently ignored. - An unknown
typein a broadcast is silently logged and ignored. - An unknown
errorCoderenders the humanerrorstring + a retry button if appropriate. - Killing the connection and reconnecting with
lastSeqIddoesn’t lose visible events.
If all six pass, the wire layer is sound. Module-specific UI behavior is then a matter of rendering the per-module guides correctly.