Skip to Content
Native IntegrationProtocol Basics

Protocol Basics

The wire-level contract between a native client (Swift / Kotlin / web) and the VSRelay server (extension or relay).

Transports

ModePhone connects toEndpoint
LocalThe extension directlywss://<host>/ws (production proxy) or ws://<host>:18100/ws (LAN)
RelayA relay server roomwss://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 thinking

Environment

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. Format t-<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 as lastSeqId on 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.):

  1. Client sends { type: 'FOO_request', requestId: 'r-1', ... }.
  2. 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:

  1. ALWAYS show error (the human string). It’s the safety net.
  2. If you know how to special-case errorCode, do it (e.g., flip the integration banner to recovery_required).
  3. If you don’t know errorCode, render a generic “Delivery failed” card with the human string + a Retry affordance.
  4. If recoveryActions[] is present, render the supported primitive types (copy_command, retry_after_reconnect, future open_url, progress_card, etc.). Unknown action types render as disabled placeholders with the label shown.

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 type values 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.capabilities is echoed and renders only those screens.
  • An unknown field added to connected (e.g., mystery: 42) is silently ignored.
  • An unknown type in a broadcast is silently logged and ignored.
  • An unknown errorCode renders the human error string + a retry button if appropriate.
  • Killing the connection and reconnecting with lastSeqId doesn’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.