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
commandtext in a monospace, selectable code block. - Render a button labeled
labelthat copiescommandto the system clipboard. - MUST NOT execute the command. No shell invocation, no
Process.start, noeval. Clipboard write only. - If
displayHintpresent, 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_connectedpayload — when it arrives, dismiss the banner if the server now reportsstatus: '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
urlvia the OS browser handler (UIApplication.shared.open(url)on iOS,Intent.ACTION_VIEWon Android). - MUST validate
url.startsWith("https://")before opening. Rejecthttp:,javascript:,file:, custom schemes. If validation fails, render a disabled chip withlabeland 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
stepslist. - Each step: render
titleas a heading andbodyas plain text below. - MUST treat
bodyas 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
labelfield 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)
- If
recoveryActionsarray is present and non-empty → render those rows. - Else if
recoveryCommandstring is present → synthesize a singlecopy_commandaction with{label: 'Copy command', command: recoveryCommand}plus aretry_after_reconnectrow. - Else → render only a
retry_after_reconnectrow.
This precedence guarantees:
- New clients prefer the structured array (better UX).
- Stale extensions sending only the legacy string still produce a usable banner.
- No
recoveryActionsAND norecoveryCommandstill produces an action the user can take.
Security requirements (re-stated)
| Action | Forbidden |
|---|---|
copy_command | Executing the command on the device |
open_url | Opening non-https URLs; opening in an in-app webview |
show_modal_with_steps | Parsing Markdown or HTML in body |
| any | Auto-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.
| Phase | What | Status |
|---|---|---|
| v2 | Server-pushed signed patch manifests at https://vsrelay.dev/api/patches/<version>/manifest.json (illustrative; endpoint not yet live) | Planned |
| v3 | Extension-side auto-apply with one-click VS Code consent modal | SHIPPED (Phase 1.19, 2026-05-22) |
| v4 | Local recovery web page at http://localhost:18101/recover served by the extension | Planned |
| v5 | Additional 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
InvokeExtensionCommandRequestMessageover the wire withcommand+argsverbatim. MUST NOT interpretcommand. - Disable the button when the transport is disconnected.
- On
delivered: falseack, displayerrortext verbatim. UnknownerrorCodevalues 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 (sha256of 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
targetEditorInstanceIdis present and ≠ the connected instance’seditorInstanceId(fromconnected.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
errorCodesinstance_mismatch/no_target_resolved; the client renders the humanerrortext 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 mapProductionDeploymentArchitecture— the four-tier trust ladder (Tier 0 plaintext today → Tier 1 E2E encrypted future)RecoveryAndErrors—errorCode+errorgeneric 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)