Skip to Content
Native IntegrationRecovery + Errors

Recovery and Errors

The error model is open + human-readable + recoverable. The server tells the client what went wrong (errorCode + error) and how to fix it (recoveryCommand / recoveryActions[]). The native client renders; it does not decide.

The doctrine

When Claude Code ships a breaking change tomorrow, the recovery path must reach the user faster than an App Store / Play Store release. That means recovery details ride on the wire, not in compiled native code.

Server provides recoveryCommand: "npx @vsrelay/doctor repair"Native binary hardcodes "npx @vsrelay/doctor repair" as a constant
Server provides errorCode: 'integration_recovery_required' + human errorNative binary special-cases every possible errorCode value
Native client renders the error string for unknown codesNative client refuses to render anything for unknown codes
Native client displays a manifest the server sentNative client downloads + executes any payload

claudeIntegration — the runtime health field

The connected (and relay_connected) payload carries:

{ "claudeIntegration": { "status": "healthy" | "degraded" | "recovery_required" | "unknown", "claudeCodeVersion": "2.1.145", // optional "sendPromptCommandAvailable": true, // optional "patches": { // optional "sendPromptToInput": "present" | "missing" | "unknown", "webviewAutoSubmit": "present" | "missing" | "unknown" }, "recoveryCommand": "npx @vsrelay/doctor repair", // optional, present when status != 'healthy' "manifestAvailable": true // optional } }

Rendering rule per status

StatusNative UI
healthyNo banner. Prompt send is enabled.
degradedYellow banner with recoveryCommand if present. Prompt send still enabled but warn the user.
recovery_requiredRed banner. Disable Retry / Send affordances until status returns to healthy. Show recoveryCommand as copyable text.
unknownNeutral grey banner. Disable destructive affordances.
Future valueNeutral grey banner with the literal status string. NEVER crash.

Treat the enum as open. The PWA’s 'unknown' value is reserved as the catch-all so future server values map there for older clients. Your native client should still degrade gracefully even if the server invents status: 'mysterious_new_state'.

The error message

Any operation can fail with:

{ "type": "error", "requestId": "r-1", // present iff the error correlates to a request "errorCode": "integration_recovery_required", // OPEN string union "error": "Claude Code integration needs recovery", // ALWAYS present, ALWAYS safe to render "recoveryCommand": "npx @vsrelay/doctor repair", // optional — Phase 1.20-onward; was 'pnpm patches:apply' in Phase 1.18 "recoveryActions": [ // optional { "type": "copy_command", "label": "Copy recovery command", "command": "npx @vsrelay/doctor repair" }, { "type": "retry_after_reconnect", "label": "Retry after recovery" } ] }

Renderer obligations

  1. Always display error (the human string). It’s the safety net for unknown errorCode.
  2. If errorCode is one your renderer knows, you MAY special-case it (e.g., flip the integration banner to recovery_required).
  3. If errorCode is unknown, render a generic “Operation failed” card with the human string + a Retry affordance (if applicable).
  4. If recoveryCommand is a string, render it in a copy-friendly code block.
  5. If recoveryActions[] is present, render the supported primitive types.

recoveryActions[] primitives

The native renderer ships with a fixed, pre-built set of primitives. The server combines them via data. New behaviour ships by adding new data shapes the existing renderer already understands.

Current minimum set (must support):

  • copy_command — render as a “Copy” button + a select-all code block showing command.
  • retry_after_reconnect — render as a button labelled label, gated on claudeIntegration.status === 'healthy'. Tapping retries the failed operation (typically the failed prompt).

Future possible primitives (when they appear, render them; until then they degrade as below):

  • open_url — open url in the system browser.
  • progress_card — show a labelled progress indicator.
  • dismiss — close the error card without action.

Unknown action types

for each action in recoveryActions: if renderer.supports(action.type): render(action) else: renderDisabledPlaceholder(action.label) // labelled but no-op

NEVER throw, NEVER skip silently with no UI — show the label so the user knows the server suggested something the client can’t yet act on.

Failed prompt + retry (G3)

When a prompt fails (typically because integration is recovery_required), the server emits an error with a requestId matching the outbound prompt. The native client must:

  1. Keep the prompt visible in the chat — don’t silently drop it.
  2. Tag it as “Delivery failed.”
  3. Show error (the human string).
  4. Show errorCode as a small chip if present.
  5. Show recoveryCommand if present.
  6. Render a Retry button, disabled when claudeIntegration.status !== 'healthy'.
  7. When claudeIntegration.status flips back to healthy, the Retry button enables. Tapping it re-sends the same prompt with a new requestId (rotate the id; never reuse a stale one).

The PWA implements this in packages/web/src/store/index.tsoutboundPrompts slice and packages/web/src/components/ChatView.tsx. Mirror the contract; the UI shape is up to you.

What the native client must NOT do

  • ❌ Hardcode "npx @vsrelay/doctor repair" (or any recovery command). Use the server’s recoveryCommand string.
  • ❌ Auto-execute recovery commands. Show them as data to copy and paste in a terminal on the desktop — the user runs them on the workstation. The phone is a remote control, not a shell.
  • ❌ Pretend to “know” what errorCode: 'foo_bar_baz' means. Render the human error and the (open) errorCode chip.
  • ❌ Crash on a future recoveryAction.type value. Render the label.
  • ❌ Embed patch manifests, sha256 catalogues, editor-discovery rules, or version-compatibility tables in the binary. Those live in patches/ and scripts/ and ship via extension updates.

Native-app-update scenarios

To stay honest, run this thought experiment for any feature touching errors / recovery:

“If Cursor ships version X.Y.Z tomorrow at 9 AM and breaks something, what does the user on the released iOS app (compiled 4 weeks ago) see?”

If the answer involves “they’re stuck until App Store ships v1.4.2,” the design is wrong. Redesign so the server can describe the new error + new recovery without an iOS rebuild.

Reference implementation