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 error | Native binary special-cases every possible errorCode value |
Native client renders the error string for unknown codes | Native client refuses to render anything for unknown codes |
| Native client displays a manifest the server sent | Native 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
| Status | Native UI |
|---|---|
healthy | No banner. Prompt send is enabled. |
degraded | Yellow banner with recoveryCommand if present. Prompt send still enabled but warn the user. |
recovery_required | Red banner. Disable Retry / Send affordances until status returns to healthy. Show recoveryCommand as copyable text. |
unknown | Neutral grey banner. Disable destructive affordances. |
| Future value | Neutral 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
- Always display
error(the human string). It’s the safety net for unknownerrorCode. - If
errorCodeis one your renderer knows, you MAY special-case it (e.g., flip the integration banner torecovery_required). - If
errorCodeis unknown, render a generic “Operation failed” card with the human string + a Retry affordance (if applicable). - If
recoveryCommandis a string, render it in a copy-friendly code block. - 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 showingcommand.retry_after_reconnect— render as a button labelledlabel, gated onclaudeIntegration.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— openurlin 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-opNEVER 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:
- Keep the prompt visible in the chat — don’t silently drop it.
- Tag it as “Delivery failed.”
- Show
error(the human string). - Show
errorCodeas a small chip if present. - Show
recoveryCommandif present. - Render a Retry button, disabled when
claudeIntegration.status !== 'healthy'. - When
claudeIntegration.statusflips back tohealthy, the Retry button enables. Tapping it re-sends the same prompt with a newrequestId(rotate the id; never reuse a stale one).
The PWA implements this in packages/web/src/store/index.ts — outboundPrompts 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’srecoveryCommandstring. - ❌ 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 humanerrorand the (open)errorCodechip. - ❌ Crash on a future
recoveryAction.typevalue. Render the label. - ❌ Embed patch manifests, sha256 catalogues, editor-discovery rules, or version-compatibility tables in the binary. Those live in
patches/andscripts/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
- Wire types:
packages/protocol/src/messages.ts—ClaudeIntegrationStatus,ErrorMessage. - PWA store:
packages/web/src/store/index.ts—claudeIntegration,outboundPrompts. - PWA components:
IntegrationHealthBanner.tsx,ChatView.tsx. - Hook:
packages/web/src/hooks/useResendPrompt.ts. - Server side:
packages/extension/src/claude-integration-health.ts— the runtime probe.