Skip to Content
Native IntegrationRecovery Actions

RecoveryActions — Native Mobile Integration Guide

Audience: Swift / iOS and Kotlin / Android developers building a native client against the VSRelay protocol.

This guide covers the RecoveryActions v1 protocol surface introduced 2026-05-21. It describes the declarative recovery primitives the server emits when the Claude Code integration (or any other operation) requires user-side action — and the rendering contract every native client MUST follow.

Why this surface exists

When Claude Code updates and breaks the VSRelay patches, when an SSH key install fails, when a file write conflicts — the server has to tell the user how to recover. Today’s PWA renders a hardcoded banner with a single command string. That doesn’t scale to native iOS / Android because:

  • Apple’s App Store Review Guidelines forbid downloading + executing code.
  • App Store updates take 24–72 hours, sometimes weeks.
  • Claude Code can ship a breaking change tomorrow.

The recovery surface must be DATA, not native code. New recovery flows ship from the server; the native binary renders them with a fixed primitive set already approved by Apple/Google.

The v1 primitives

Four RecoveryAction types. Every native client MUST implement renderers for all four. Unknown future types degrade to a labeled disabled chip — never crash.

1. copy_command

{ "type": "copy_command", "label": "Copy recovery command", "command": "pnpm patches:apply", "displayHint": "Run on the laptop where Cursor is open." }

Renderer requirements:

  • Display the command text in a monospace, selectable code block.
  • Render a button labeled label that copies command to the system clipboard.
  • MUST NOT execute the command. No shell invocation, no Process.start, no eval. Clipboard write only.
  • If displayHint present, render it as small secondary text.

2. retry_after_reconnect

{ "type": "retry_after_reconnect", "label": "I fixed it — try again" }

Renderer requirements:

  • Render a button labeled label.
  • On tap: invoke the transport’s reconnect routine. The native equivalent of WebSocket disconnect + reconnect.
  • Wait for the next connected / relay_connected payload — when it arrives, dismiss the banner if the server now reports status: 'healthy'.
  • No timeout state machine in the client. The server’s next payload is the source of truth.

3. open_url

{ "type": "open_url", "label": "Open recovery page", "url": "https://vsrelay.dev/help/recover", "displayHint": "Tap on laptop, not phone" }

Renderer requirements:

  • Render a link / button labeled label.
  • On tap: open url via the OS browser handler (UIApplication.shared.open(url) on iOS, Intent.ACTION_VIEW on Android).
  • MUST validate url.startsWith("https://") before opening. Reject http:, javascript:, file:, custom schemes. If validation fails, render a disabled chip with label and do not enable the tap.
  • No in-app webview — hand the URL to the OS to open in the user’s default browser.

4. show_modal_with_steps

{ "type": "show_modal_with_steps", "label": "Show full recovery steps", "steps": [ { "title": "1. Open a terminal", "body": "Where the VSRelay repo lives." }, { "title": "2. Run pnpm patches:apply", "body": "Re-applies the patches." }, { "title": "3. Reload Cursor", "body": "Cmd/Ctrl+Shift+P → Developer: Reload Window." } ] }

Renderer requirements:

  • Render a button labeled label.
  • On tap: present an expandable section or modal with the ordered steps list.
  • Each step: render title as a heading and body as plain text below.
  • MUST treat body as plain text. No Markdown parsing, no HTML interpolation. XSS-impossible by construction.
  • Step ordering: render in array order. The server populates the titles with leading numbers if numbering is desired.

Open string union — handling unknown future types

RecoveryAction.type is an OPEN string union: 'copy_command' | 'retry_after_reconnect' | 'open_url' | 'show_modal_with_steps' | (string & {}).

Future server values like 'install_extension_update' or 'restart_relay_service' MUST be:

  • Logged (so we can see what unknown types are hitting clients in the wild).
  • Rendered as a labeled disabled chip showing the label field if present, or "Unknown recovery action" otherwise.
  • Never crash the renderer.

This is the same doctrine pattern as DiagnosticSeverity, TerminalLifecycle, errorCode, etc. throughout the VSRelay protocol.

The two surfaces carrying recoveryActions

interface ClaudeIntegrationStatus { status: 'healthy' | 'degraded' | 'recovery_required' | 'unknown' | (string & {}) // ... recoveryCommand?: string // LEGACY — backwards-compat fallback recoveryActions?: RecoveryAction[] // PREFER this when present } interface ErrorMessage { type: 'error' error: string errorCode?: string // ... recoveryCommand?: string // LEGACY recoveryActions?: RecoveryAction[] // PREFER this when present }

Both surfaces carry the SAME array shape so a single renderer handles both.

Precedence rule (every renderer MUST follow)

  1. If recoveryActions array is present and non-empty → render those rows.
  2. Else if recoveryCommand string is present → synthesize a single copy_command action with {label: 'Copy command', command: recoveryCommand} plus a retry_after_reconnect row.
  3. Else → render only a retry_after_reconnect row.

This precedence guarantees:

  • New clients prefer the structured array (better UX).
  • Stale extensions sending only the legacy string still produce a usable banner.
  • No recoveryActions AND no recoveryCommand still produces an action the user can take.

Security requirements (re-stated)

ActionForbidden
copy_commandExecuting the command on the device
open_urlOpening non-https URLs; opening in an in-app webview
show_modal_with_stepsParsing Markdown or HTML in body
anyAuto-running any action without user tap

App Store / Play Store submission text

Paste this into the reviewer notes / privacy questionnaire when shipping a native client that consumes this protocol:

VSRelay is a remote control surface for a desktop AI coding session running on the user’s own computer. The native app communicates with the user’s desktop only via authenticated WebSocket. It does not download, install, modify, or execute any code on the device beyond what was reviewed. The app renders structured state and human-readable recovery instructions provided by the server; users perform any required maintenance actions on their own laptop, through software channels independently approved by their operating system vendor (e.g., the VS Code Extension Marketplace).

All recovery flows are expressed via a fixed set of declarative primitives the app’s pre-built renderer understands (copy_command, retry_after_reconnect, open_url, show_modal_with_steps, plus future generic primitives that may be added through normal App Store updates). The server combines these primitives via data. Unknown future primitives degrade to a labeled disabled placeholder; they never alter the app’s compiled behavior.

Future phases (NOT in v1)

Documented here so native developers can build forward-compatibly. None of these are required for v1; renderers MAY ignore them if unimplemented.

PhaseWhatStatus
v2Server-pushed signed patch manifests at https://vsrelay.dev/api/patches/<version>/manifest.json (illustrative; endpoint not yet live)Planned
v3Extension-side auto-apply with one-click VS Code consent modalSHIPPED (Phase 1.19, 2026-05-22)
v4Local recovery web page at http://localhost:18101/recover served by the extensionPlanned
v5Additional RecoveryActionKind values (e.g., install_extension_update)Planned (additive)

Native renderers built today will continue working through all these phases because v1’s contract is strict: open-string-union discriminator, unknown types render gracefully, no client-side recovery decisions.

Reference implementation (PWA)

packages/web/src/components/RecoveryBanner.tsx is the canonical TypeScript / React reference. The four sub-components (CopyCommandRow, RetryRow, OpenUrlRow, StepsRow) map 1:1 to the four primitives in this guide. Use it as the spec for Swift / Kotlin implementations.

v2 (Phase D-α, 2026-05-22) — invoke_extension_command primitive

A fifth RecoveryAction kind for phone-triggered desktop command invocation.

{ "type": "invoke_extension_command", "label": "Apply patches in Cursor", "command": "vsrelay.applyPatches", "args": { "version": "2.1.146" }, "displayHint": "One click — the laptop will pop a confirm." }

Renderer requirements:

  • Render as a tappable button labeled label.
  • On tap: send InvokeExtensionCommandRequestMessage over the wire with command + args verbatim. MUST NOT interpret command.
  • Disable the button when the transport is disconnected.
  • On delivered: false ack, display error text verbatim. Unknown errorCode values degrade gracefully (do not crash on future codes).
  • MUST NOT auto-tap. Explicit user gesture only.

Server-side security gate: the extension enforces a deny-by-default prefix allowlist (vsrelay.*; transitional: agentic-ides.* also accepted). Any other command value returns errorCode: 'command_not_allowed'. Native clients do not need to enforce this themselves — they just send what the server told them to send.

Use cases for this primitive (the design is generic):

  • Apply Claude Code patches (the v1.19 driver of this primitive)
  • Revoke patch consent
  • Future: install SSH key, reload window, open settings, any other phone-triggered desktop operation

IDE instance routing fields (IDE Instance Routing v1, 2026-05-24)

invoke_extension_command and the InvokeExtensionCommandRequestMessage the client sends in response gained four OPTIONAL target fields so a recovery action is routed to a specific IDE instance + Claude Code install:

{ "type": "invoke_extension_command", "label": "Apply patches in Cursor", "command": "vsrelay.applyPatches", "targetEditorInstanceId": "f3a1...-opaque-uuid", // opaque routing key "targetClaudeCodePathHash": "9c2e...", // OPAQUE — never a raw path "targetClaudeCodeVersion": "2.1.150", "requestedPatchManifestVersion": "2.1.150" }

Renderer requirements:

  • Echo all four fields verbatim on the outgoing request. The client never interprets them, never invents them, and never derives a path from targetClaudeCodePathHash — it is an opaque verification token (sha256 of the canonical realpath), never a filesystem path.
  • Never display targetClaudeCodePathHash. Display the target line from the descriptor instead (editorKind + machineLabel + hostKind + version).
  • Disable / fail closed when targetEditorInstanceId is present and ≠ the connected instance’s editorInstanceId (from connected.ideInstance / relay_connected.extensionIdeInstance). Render the row disabled with “This recovery was issued for a different instance.”
  • The extension also fails closed server-side with the open-union errorCodes instance_mismatch / no_target_resolved; the client renders the human error text and degrades gracefully on unknown codes.

Full contract — the IdeInstanceDescriptor shape, the opaque-pathHash doctrine, the “will patch” target line, relay keep-first admission (relay_instance_rejected), and the v1-single-instance / v2-multi-instance scope boundary — is in IdeInstanceRouting.

Cross-references

  • README — overview + per-module guide map
  • ProductionDeploymentArchitecture — the four-tier trust ladder (Tier 0 plaintext today → Tier 1 E2E encrypted future)
  • RecoveryAndErrorserrorCode + error generic error model (RecoveryActions extends this)
  • ClaudeCodePatching — native-developer patch lifecycle doc (Phase D-α / Phase D-β)
  • IdeInstanceRouting — IDE instance routing v1: IdeInstanceDescriptor, opaque-pathHash, fail-closed target routing
  • Spec: docs/superpowers/specs/2026-05-21-recovery-actions-protocol-v1-design (source repo)