IDE Instance Routing — Native Mobile Integration Guide
Audience: Swift / iOS and Kotlin / Android developers building a native client against the VSRelay protocol.
This guide covers the IDE Instance Routing v1 surface introduced 2026-05-24. It describes the
IdeInstanceDescriptorthe server attaches toconnected, how a client renders “what will be patched,” and the fail-closed routing contract every native client MUST follow.
Why this surface exists
A developer can have several live IDE instances at once:
- Cursor local UI on one machine (e.g. an M1 laptop)
- A Cursor remote-SSH extension host on another (e.g. an M4 server)
- VS Code on the same remote
- cursor-server on the remote
- (future) the native iOS / Android client itself rendering the same protocol state
A recovery action — “Apply patches in Cursor” — must land on the exact instance the user is looking at. Before v1, the health banner diagnosed one Claude Code install (the in-process loaded one) while the Apply button could re-discover a different install from disk, possibly on a different machine, and pop the desktop consent modal on an unattended screen.
v1 fixes this with a stable instance identity on the wire, a single resolver shared by diagnosis + repair, a visible target line in the UI, and fail-closed behaviour on any mismatch.
The routing key: editorInstanceId
editorInstanceId is an opaque, persisted, random UUID. It is the only routing key.
- It is NOT the Claude Code version. Versions are not unique — two instances can run the same version.
- It is NOT a filesystem path. Raw install paths never cross the wire (see the opaque-
pathHashrule below). - It is NOT the machine label, workspace root, or any display field. Those churn; the routing key must not.
The extension mints it once via crypto.randomUUID(), persists it in context.globalState, and reuses it across restarts and extension updates. Reinstalling the extension mints a new id — correct, because that is a new instance.
A native client treats editorInstanceId as an opaque token: store it, compare it for equality, echo it back. Never parse it, never derive meaning from it.
The IdeInstanceDescriptor shape
| Field | Type | Meaning / renderer note |
|---|---|---|
editorInstanceId | string | Opaque routing key. Compare for equality; echo verbatim; never parse. |
editorKind | open union: 'cursor' | 'vscode' | 'unknown' | (string & {}) | Display. Unknown values render as a neutral labelled chip. |
hostKind | open union: 'local-ui' | 'remote-ssh' | 'remote-workspace' | 'server' | 'unknown' | (string & {}) | Display. Unknown values render with the literal label. |
machineLabel? | string | Sanitized hostname for display (e.g. “M4”). Low-sensitivity. May be absent. |
machineIdHash? | string | A non-sensitive stable hash of the machine id. Never the raw machine id. Use only for stable de-dup if needed; do not display. |
remoteName? | string | null | Raw vscode.env.remoteName, diagnostic only. |
workspaceRoots | string[] | Display only — mirrors the pre-existing connected.workspacePath precedent. Never a routing or verification key. |
extensionVersion | string | Display. |
capabilities | string[] | What this instance negotiated. Same open-union tolerance as connected.capabilities. |
loadedClaudeCode? | { pathHash, version, source } | The Claude Code install this instance actually drives prompts into — the same probe the health banner uses. pathHash is opaque (see below). Display version; never pathHash. |
discoveredClaudeCodeCandidates? | Array<{ editorKind, pathHash, version }> | Informational candidate list from a filesystem scan. Display only — never a target selector. |
canApplyPatches | boolean | Whether this instance can apply patches at all. |
lastHeartbeatAt | number | Epoch ms of the last descriptor refresh (30 s health cycle). |
Where the descriptor arrives
The server attaches the descriptor to the existing connect payloads — no separate request:
- Local mode:
connected.ideInstance?: IdeInstanceDescriptor - Relay mode:
relay_connected.extensionIdeInstance?: IdeInstanceDescriptor(mirrors the existingextensionClaudeIntegrationpassthrough pattern)
It is also carried on relay_extension_auth.ideInstance? so the relay learns identity at connect time — but that is an extension→relay frame the client never sends.
The field is optional: older extensions won’t send it. A native client treats its absence as “instance routing unknown” and falls back to the legacy behaviour (render the recovery action without a target line, but never fabricate a target).
Store the descriptor as an ephemeral singleton (ideInstance?), not a list — v1 is single-active. Do not persist it; it is rebuilt on every connect. (See the PWA store’s partialize exclusion in packages/web/src/store/index.ts.)
The opaque-pathHash rule (mandatory)
No raw Claude Code install path ever appears on the wire. The protocol uses an opaque pathHash (sha256 of the canonical realpath) as the install’s identity for display and round-trip verification. The extension keeps the pathHash → realpath mapping internally and resolves + verifies the real path locally.
A native client therefore:
- Displays
version,editorKind,machineLabel,hostKind. These are the human-meaningful target fields. - NEVER displays
pathHashto the user, and never tries to derive a path from it. - Echoes
pathHashback verbatim astargetClaudeCodePathHashon the invoke request — it is an opaque verification token, nothing more.
This keeps the mobile protocol free of usernames and machine layout, and decouples the wire from local disk structure.
Rendering the “will patch” target
Before any “Apply patches” button, render a target line derived purely from the descriptor:
Will patch: Cursor Server on M4 (remote-ssh) · Claude Code 2.1.150
Compose it from editorKind + machineLabel + hostKind + loadedClaudeCode.version. Do not hardcode “Cursor” or “your laptop” — the wording comes from the descriptor so it stays correct across machines and editors.
Unknown editorKind / hostKind values render with their literal label (open-union doctrine — same as environment, DiagnosticSeverity, RecoveryActionKind). Never crash on an unknown value.
Fail-closed on instance mismatch (UI + server)
A recovery action may carry a targetEditorInstanceId (see the echo contract below). The client MUST fail closed when it doesn’t match the connected instance:
if action.targetEditorInstanceId != nil
&& action.targetEditorInstanceId != ideInstance?.editorInstanceId {
// render the row DISABLED with:
// "This recovery was issued for a different instance"
}This is defence-in-depth: the extension also fails closed server-side (instance_mismatch), but the client should never even send a request it can see is mis-targeted.
The echo contract on invoke_extension_command_request
The invoke_extension_command recovery action (and the InvokeExtensionCommandRequestMessage the client sends in response to a tap) gained four optional fields:
| Field | Type | Client obligation |
|---|---|---|
targetEditorInstanceId? | string | Echo verbatim. Disable the row if it ≠ the connected editorInstanceId. |
targetClaudeCodePathHash? | string (opaque) | Echo verbatim. Never a raw path; never displayed. |
targetClaudeCodeVersion? | string | Echo verbatim. May be displayed in the target line. |
requestedPatchManifestVersion? | string | Echo verbatim. |
The client echoes these fields verbatim — it never interprets them, never invents them, and never sees a raw path. The server stamps the action with this instance’s editorInstanceId + loaded pathHash + version (via buildPatchRecoveryActions()), so the round-trip target is verifiable locally without exposing the path.
The extension’s resolver then:
- Resolves the target as the in-process loaded Claude Code — the same source the health banner uses. It never re-discovers a target from disk.
- Requires
targetEditorInstanceId(if present) to equal this instance’s id, else fails closed withinstance_mismatch. - Recomputes
sha256(realpath(loaded extensionPath))and requires it to equaltargetClaudeCodePathHash(if present), else fails closed. It patches its own locally-resolved real path — never a path taken from the wire. - If no loaded Claude Code resolves, fails closed with
no_target_resolved.
The new open-union errorCodes
Both are open-union errorCode values on the invoke_extension_command ack / error message. Render the human error string; degrade gracefully (these are not closed enums):
errorCode | When | Suggested render |
|---|---|---|
instance_mismatch | The inbound target doesn’t match the running instance. | ”This recovery was issued for a different instance.” Offer reconnect / re-fetch. |
no_target_resolved | No in-process loaded Claude Code to patch. | Show error; suggest checking the desktop integration. |
Per the open-union doctrine, an unknown future errorCode still renders the human error text + a generic retry — never a crash.
Relay admission: keep-first
The relay holds one IDE instance per room and is keep-first:
- A second connection with a different
editorInstanceIdis rejected. The relay sendsrelay_instance_rejectedthen closes the newcomer with code4001. The incumbent stays in control. - A connection with the same
editorInstanceIdis allowed to replace the incumbent (the same instance reconnecting after a flap — not a competitor).
The relay_instance_rejected message:
{
"type": "relay_instance_rejected",
"roomId": "test-room-001",
"code": "room_occupied",
"occupiedBy": { /* IdeInstanceDescriptor of the instance already in the room */ },
"timestamp": 1716500000000
}A native client that is itself only ever a client role never triggers this (only extension-role connections compete for the room slot). But a client MAY receive enough context via occupiedBy to display “another IDE instance already controls this room” if the product surfaces it. Render occupiedBy with the same descriptor renderer (display editorKind / hostKind / machineLabel / version; never pathHash).
This is connection-lifecycle logic the relay already owns — not payload-semantic inspection — so it stays within the transparent-forwarder doctrine (see Relay).
Scope: v1 = single active instance
v1 shows exactly one connected instance, correctly identified. The phone controls one active instance at a time, with reject-with-identity (keep-first) for switching control.
A live, simultaneous list of all of a user’s instances with per-action routing — e.g. M1 local Cursor and M4 remote cursor-server displayed side by side, each independently patchable — is deferred to v2. v2 also brings the relay topology rework (Map<editorInstanceId, extension>). v1 deliberately builds the identity + descriptor primitives v2 consumes, so there is no throwaway work.
Native renderers built against v1 stay forward-compatible: the descriptor is already a self-contained object, so v2’s list is “an array of the thing you already render.”
Doctrine alignment (Native Mobile / App Store Resilience)
| Rule | How v1 satisfies it |
|---|---|
| App fetches data, not code. | The descriptor + target fields are data the pre-built renderer consumes; no native code ships to support a new instance. |
| Unknown future states degrade gracefully. | editorKind, hostKind, and the new errorCodes are open unions; unknown values render as labelled chips. |
| Mobile renders + sends user-approved actions; owns no policy. | The phone renders the server’s descriptor and echoes a server-stamped target; routing policy (resolver, admission, fail-closed) lives in the extension + relay. |
| No raw paths on the wire. | Only opaque pathHash + non-sensitive version cross the wire; the extension verifies the real path locally. |
Cross-references
README— overview + per-module guide mapProtocolBasics—connectedpayload, capability negotiation, unknown-field toleranceRecoveryActions— theinvoke_extension_commandprimitive these target fields extendRelay— transparent-forwarder doctrine + reconnect-replay (relay admission lives here)- Spec:
docs/superpowers/specs/2026-05-23-ide-instance-routing-v1-design(source repo) - Plan:
docs/superpowers/plans/2026-05-23-ide-instance-routing-v1(source repo)