Skip to Content
Native IntegrationIDE Instance Routing

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 IdeInstanceDescriptor the server attaches to connected, 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-pathHash rule 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

FieldTypeMeaning / renderer note
editorInstanceIdstringOpaque routing key. Compare for equality; echo verbatim; never parse.
editorKindopen union: 'cursor' | 'vscode' | 'unknown' | (string & {})Display. Unknown values render as a neutral labelled chip.
hostKindopen union: 'local-ui' | 'remote-ssh' | 'remote-workspace' | 'server' | 'unknown' | (string & {})Display. Unknown values render with the literal label.
machineLabel?stringSanitized hostname for display (e.g. “M4”). Low-sensitivity. May be absent.
machineIdHash?stringA 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 | nullRaw vscode.env.remoteName, diagnostic only.
workspaceRootsstring[]Display only — mirrors the pre-existing connected.workspacePath precedent. Never a routing or verification key.
extensionVersionstringDisplay.
capabilitiesstring[]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.
canApplyPatchesbooleanWhether this instance can apply patches at all.
lastHeartbeatAtnumberEpoch 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 existing extensionClaudeIntegration passthrough 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 pathHash to the user, and never tries to derive a path from it.
  • Echoes pathHash back verbatim as targetClaudeCodePathHash on 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:

FieldTypeClient obligation
targetEditorInstanceId?stringEcho verbatim. Disable the row if it ≠ the connected editorInstanceId.
targetClaudeCodePathHash?string (opaque)Echo verbatim. Never a raw path; never displayed.
targetClaudeCodeVersion?stringEcho verbatim. May be displayed in the target line.
requestedPatchManifestVersion?stringEcho 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:

  1. 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.
  2. Requires targetEditorInstanceId (if present) to equal this instance’s id, else fails closed with instance_mismatch.
  3. Recomputes sha256(realpath(loaded extensionPath)) and requires it to equal targetClaudeCodePathHash (if present), else fails closed. It patches its own locally-resolved real path — never a path taken from the wire.
  4. 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):

errorCodeWhenSuggested render
instance_mismatchThe inbound target doesn’t match the running instance.”This recovery was issued for a different instance.” Offer reconnect / re-fetch.
no_target_resolvedNo 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 editorInstanceId is rejected. The relay sends relay_instance_rejected then closes the newcomer with code 4001. The incumbent stays in control.
  • A connection with the same editorInstanceId is 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)

RuleHow 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 map
  • ProtocolBasicsconnected payload, capability negotiation, unknown-field tolerance
  • RecoveryActions — the invoke_extension_command primitive these target fields extend
  • Relay — 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)