Skip to content

Protocol ​

The source of truth for the protocol is packages/protocol/src/index.ts. All SDKs (JS, Swift, Kotlin, Flutter) follow this document. The transport is WebSocket with JSON text messages. The current version is PROTOCOL_VERSION = 2.

If you are implementing a new SDK, read Writing an SDK alongside this page.


1. Endpoints ​

PathWho connects
/ws/deviceSDK (app)
/ws/uiUI (desktop or browser)
GET /healthMonitoring: { ok, sessions, uptimeSec }
GET /discoveryisDevelopment discovery: { name: 'remote-devtools', version, protocol: 2, host, connectRequests }

/health and /discovery contain no secrets and return Access-Control-Allow-Origin: * (so SDKs running in a browser can read them too).

mDNS (optional): service type _remotedevtools._tcp. The TXT record contains v=2, path=/ws/device and name. mDNS is not a mandatory part of the protocol; an SDK that only supports the QR, code and localhost paths is still compliant.

2. Envelope ​

ts
type Envelope<T extends string, P> = {
    v: 2;
    type: T;
    id: string;            // generated by the sender
    ts: number;            // epoch ms
    replyTo?: string;      // if this is a reply to a request, the request's id (including the relay's own replies and errors)
    deviceId?: string;     // which device on the UI ↔ relay leg (target when going from UI to device, source when coming from the device)
    payload: P;
};

Rules:

  • If v does not match, the relay sends error{code:'UNSUPPORTED_VERSION'} and closes the connection with 4005.
  • An unknown module.* type is not an error for the relay; it is forwarded. An unknown core type gets an UNKNOWN_TYPE error.
  • On messages coming from the device, deviceId is filled in by the relay. Any value sent by the device is overwritten.
  • payload must be a JSON object (use {} for messages without a payload).
  • Errors: error { code, message }, with replyTo if tied to a request.

Error codes (ErrorCode):

BAD_MESSAGE, UNSUPPORTED_VERSION, NOT_AUTHENTICATED, SESSION_NOT_FOUND, SESSION_LIMIT, DEVICE_LIMIT, INVALID_PAIR_TOKEN, PAIR_TOKEN_EXPIRED, INVALID_CODE, RATE_LIMITED, DEVICE_NOT_FOUND, DEVICE_OFFLINE, REQUEST_NOT_FOUND, UNKNOWN_TYPE.

3. Core messages — UI ↔ Relay ​

DirectionTypePayload
UI→Rsession.create{}
UI→Rsession.resume{ sessionId, uiSecret }
R→UIsession{ sessionId, uiSecret, deviceWsUrl: string | null, pairing: PairingInfo, devices: DeviceSnapshot[], pendingRequests: ConnectRequest[], trustedDevices: TrustedDevice[] }
UI→Rpairing.refresh{} → new QR token and 6-digit code
R→UIpairingPairingInfo — when refreshed and after every pairing (QR/code are single-use)
R→UIdevice.status{ deviceId, state: 'connected' | 'disconnected' | 'unpaired', device: DeviceSnapshot | null }
R→UIconnect.requestConnectRequest (device awaiting approval)
UI→Rconnect.respond{ requestId, approve: boolean, trust: boolean }
R→UIconnect.resolved{ requestId, outcome: 'approved' | 'rejected' | 'expired' | 'cancelled' } — the request leaves the list
UI→Rdevice.unpair{ deviceId }
UI→Rtrusted.remove{ deviceId }
R→UItrusted{ devices: TrustedDevice[] } — when the list changes
UI→Dmodule.*target deviceId in the envelope is required; DEVICE_NOT_FOUND if there is no such device, DEVICE_OFFLINE if it is offline
ts
type PairingInfo = { qrToken: string; code: string; expiresAt: number };
type ConnectRequest = { requestId: string; deviceId: string; hello: Hello; via: 'local' | 'remote'; at: number; expiresAt: number };
type DeviceSnapshot = { deviceId: string; connected: boolean; pairedAt: number; expiresAt: number; lastSeenAt: number | null; hello: Hello | null; lifecycle: Lifecycle | null };
type TrustedDevice = { deviceId: string; appId: string; name: string; trustedAt: number };
type Lifecycle = 'active' | 'background' | 'inactive';

deviceWsUrl: the device address to put into the QR code when the relay has PUBLIC_URL set; otherwise null and the UI derives it from its own origin.

via: the relay cannot tell mDNS from localhost; it only knows the class of the address (loopback/private network = local).

With trust: true in connect.respond, the device is added to the trusted devices and is not asked for approval on later requests.

4. Core messages — Device ↔ Relay ​

DirectionTypePayload
D→Rpair.qr{ sessionId, qrToken, deviceId }
D→Rpair.code{ code, deviceId }
D→Rconnect{ deviceId, hello } (only isDevelopment; depends on the relay's CONNECT_REQUESTS setting)
R→Dconnect.pending{ requestId, expiresAt }
R→Dpaired{ sessionId, deviceSecret, expiresAt } (after QR, code or an approved connect)
R→Dconnect.rejected{ requestId | null, reason: 'rejected' | 'expired' | 'disabled' | 'noSession' | 'deviceLimit' }
D→Rresume{ sessionId, deviceId, deviceSecret }
R→Dresumed{ sessionId, expiresAt }
D→Runpair{}
R→Dunpaired{ reason: 'byUi' | 'byDevice' | 'expired' | 'sessionNotFound' | 'invalidSecret' | 'replaced' } → the SDK deletes its stored connection info
  • deviceId: a stable identifier the SDK generates once per install, [A-Za-z0-9_-]{8,128}.
  • The QR token and the code are generated together and are single-use; once a device pairs, both are regenerated.
  • Pairing again with the same deviceId replaces the old record; the old secret becomes invalid and the old socket is closed with 4000.
  • A pairing expires after a period of inactivity (DEVICE_IDLE_TTL_MIN, default 60 min): it does not expire while the device is connected; every message and every disconnect restarts the timer. expiresAt is the moment the pairing would be forgotten "if it disconnected now". A device that sends resume with an expired pairing receives unpaired{reason:'expired'}.
  • A connect request is shown to every session that has a UI open; the first answer wins. A new request from the same device cancels the previous one.
  • If a trusted device sends connect with the same hello.app.id, the relay returns paired without asking for approval. Trust is based on the deviceId + app.id match; for this reason CONNECT_REQUESTS should only be enabled on trusted networks.

QR content:

json
{ "v": 2, "url": "wss://host/ws/device", "sessionId": "…", "qrToken": "…" }

Close codes ​

CodeMeaning
4000A new connection from the same device replaced the old one
4001Authentication/pairing failed
4002Pairing removed (unpaired)
4003Not authenticated in time
4004Session expired
4005Unsupported protocol version
4006Too many failed pairing attempts
4007connect request rejected

5. Device → UI core messages (forwarded by the relay) ​

TypePayload
helloHello — the first message after every connect/resume
capabilities.updatePartial<Capabilities> — when command/flag/panel definitions change
lifecycle{ state: 'active' | 'background' | 'inactive' }
log{ level: 'debug' | 'info' | 'warn' | 'error', message: string, data?: unknown, tag?: string, at: number }
history{ entries: { type, payload, ts }[], final: boolean } — if history.enabled, the pre-connection buffer, sent in chunks right after hello once connected, in order. The UI handles each entry like a live message of the same type
ts
type Hello = {
    app: { id: string; name: string; version: string; build?: string };
    device: { platform: 'ios' | 'android' | 'web' | 'macos' | 'windows' | 'linux' | string; os: string; model?: string; name?: string };
    sdk: { name: string; version: string };      // e.g. "@himmetkaan/remote-devtools", "remote-devtools-swift"
    isDevelopment: boolean;
    meta: Record<string, string>;                // free-form developer fields (userId, apiEndpoint, env…)
    capabilities: Capabilities;
};

type Capabilities = {
    network?: { autoCapture: boolean };
    functions?: { commands: CommandDef[] };
    storage?: { adapters: { id: string; title: string; editable: boolean; masked?: boolean }[] };
    state?: { stores: { id: string; title: string }[] };
    flags?: { flags: FlagDef[] };
    panels?: PanelDef[];
    [custom: string]: unknown;                   // modules added later; the UI ignores keys it does not recognize
};

6. Field DSL ​

Command arguments, flags and form blocks share the same field definition. See Field DSL for a guide with examples.

ts
type Field =
    | { type: 'text'; label?: string; placeholder?: string; multiline?: boolean; default?: string; required?: boolean }
    | { type: 'number'; label?: string; min?: number; max?: number; step?: number; default?: number; required?: boolean }
    | { type: 'boolean'; label?: string; default?: boolean }
    | { type: 'select'; label?: string; options: (string | { label: string; value: string | number })[]; default?: string | number; required?: boolean }
    | { type: 'json'; label?: string; default?: unknown };

type Fields = Record<string, Field>;   // key = argument name; order is preserved

When an invoke arrives, SDKs validate the arguments against this definition. If they are invalid, ok:false is returned without calling the handler.

7. Module messages ​

All module messages have the form module.<module>.<event>. The relay does not look inside them. UI→Device requests carry an id; the reply comes back with type module.<module>.result and replyTo (in the tables, the value shown after → is the reply's result field). Common reply shape: { ok: true, result } | { ok: false, error: { code, message } }.

network ​

ts
type NetworkRequest = { uuid: string; method: string; url: string; headers: Record<string, string>; body: unknown; startedAt: number };
type NetworkResponse = {
    uuid: string;
    status: number | null;
    headers: Record<string, string> | null;
    body: unknown;
    durationMs: number | null;
    result: 'success' | 'error';
    errorMessage: string | null;
    bodyTruncated?: { originalSize: number };
};
DirectionTypePayload
D→UImodule.network.request{ uuid, method, url, headers, body, startedAt }
D→UImodule.network.response{ uuid, status, headers, body, durationMs, result: 'success' | 'error', errorMessage, bodyTruncated?: { originalSize } }

functions ​

ts
type CommandDef = { id: string; title: string; description?: string; group?: string; confirm?: string; danger?: boolean; args?: Fields };
DirectionTypePayload
UI→Dmodule.functions.invoke{ commandId, args }
D→UImodule.functions.resultcommon reply shape (with replyTo)

confirm: if set, the UI asks for confirmation with this text before running the command.

storage ​

Values are strings. For masked adapters (e.g. SecureStore) no preview is sent in the list; the UI hides the value until it is requested. Adapters with editable: false reply to set / remove / clear requests with a READ_ONLY error.

ts
type StorageEntry = { key: string; size: number; preview: string };   // size: character count, preview: first 120 characters
DirectionTypePayload
UI→Dmodule.storage.list{ adapterId } → { entries: StorageEntry[] } (sorted by key, keys without a value excluded)
UI→Dmodule.storage.get{ adapterId, key } → { value: string | null }
UI→Dmodule.storage.set{ adapterId, key, value: string }
UI→Dmodule.storage.remove{ adapterId, key }
UI→Dmodule.storage.clear{ adapterId }
D→UImodule.storage.changed{ adapterId, key: string | null } — null: multiple keys changed. If the adapter reports changes (MMKV, localStorage in another tab), it sends this; otherwise the SDK sends it after writes coming from the UI

state ​

The UI subscribes only while the State tab is open. If the subscription is not renewed with renew: true within STATE_SUBSCRIPTION_TTL_MS (60 s), the device stops the stream; this way the stream does not keep running even if a closed UI could not send unsubscribe. The diff is computed against the state the device last sent and is sent at most every throttleMs (default 100 ms). If the diff is too large (maxPatchOps, default 500), the full state is sent instead of patch. Functions (e.g. Zustand actions) are not sent.

ts
type JsonPatchOp = { op: 'add' | 'remove' | 'replace'; path: string; value?: unknown };   // RFC 6902 subset, path: JSON pointer
type StateChange = { storeId: string; at: number; actions: string[]; patch?: JsonPatchOp[]; state?: unknown };
DirectionTypePayload
UI→Dmodule.state.snapshot{ storeId } → { state }
UI→Dmodule.state.subscribe{ storeId, renew?: boolean } → { state }; {} if renew: true and the subscription is still active
UI→Dmodule.state.unsubscribe{ storeId }
D→UImodule.state.changeStateChange

actions: action names reported by the adapter during the interval (in Redux, those sent via store.dispatch), empty if unknown. A UI that cannot apply a patch (e.g. because it missed a message) subscribes again from scratch. diffJson and applyPatch are provided by the protocol package.

flags ​

ts
type FlagDef = {
    key: string; title?: string; description?: string; group?: string;
    field: Field;            // value type and UI editor
    value: unknown;          // effective value: the override if any, otherwise baseValue
    baseValue: unknown;      // the app's value, otherwise field.default
    overridden: boolean;
};
DirectionTypePayload
UI→Dmodule.flags.set{ key, value } → FlagDef (the value is validated against field; INVALID_VALUE if invalid)
UI→Dmodule.flags.reset{ key? } (if omitted, all overrides are removed)

When a value, override or definition changes, the device sends the current flags list with capabilities.update; there is no separate event. Overrides are stored on the device (the SDK's storage) and still apply after the app is restarted.

panels ​

Each panel becomes its own tab in the UI (in hello.capabilities.panels order, after the built-in modules). When a panel is added or removed, or its blocks change, the device sends the whole list with capabilities.update; the data of a single block changes via module.panels.update.

ts
type PanelDef = { id: string; title: string; icon?: string; blocks: Block[] };   // icon: emoji

type BlockTone = 'info' | 'success' | 'warn' | 'error';
type ActionDef = { id: string; label: string; confirm?: string; danger?: boolean };

type Block =
    | { id: string; type: 'text'; title?: string; text: string; tone?: BlockTone }
    | { id: string; type: 'stats'; title?: string; items: { label: string; value: string | number; hint?: string; tone?: BlockTone }[] }
    | { id: string; type: 'keyValue'; title?: string; data: Record<string, string | number | boolean | null> }
    | { id: string; type: 'table'; title?: string; columns: { key: string; title: string }[]; rows: Record<string, unknown>[]; rowActions?: ActionDef[] }
    | { id: string; type: 'json'; title?: string; data: unknown }
    | { id: string; type: 'actions'; title?: string; actions: ActionDef[] }
    | { id: string; type: 'form'; title?: string; fields: Fields; submit: { actionId: string; label: string; danger?: boolean } };
DirectionTypePayload
D→UImodule.panels.update{ panelId, blockId, data }: data holds the changed fields of the block other than id / type (e.g. { rows })
UI→Dmodule.panels.action{ panelId, blockId, actionId, row?, values? } → the handler's return value

Block ids are unique within a panel; module.panels.update finds blocks by id. row: the clicked row for a table row action. values: the field values on form submit; the SDK validates them against fields (INVALID_ARGS). Error codes: UNKNOWN_PANEL, UNKNOWN_BLOCK, UNKNOWN_ACTION. The UI skips block types it does not recognize and shows a note.

8. Limits ​

  • Message size: default 5 MB (MAX_PAYLOAD_MB); the relay closes the socket on a message that exceeds it. The SDK truncates large bodies and adds bodyTruncated.
  • history and large lists are split into chunks with the final flag.
  • Rate limit: DEVICE_MAX_MSGS_PER_SEC (default 200) messages per second per device. Excess messages are dropped, and the UI receives log { level: 'warn', tag: 'relay' } once per one-second window.
  • Pairing attempts: after PAIR_FAILURE_LIMIT (default 10 / 10 min) failed attempts from the same IP within the window, RATE_LIMITED.
  • At most MAX_DEVICES_PER_SESSION (default 20) devices per session.
  • Heartbeat: 15 s ping/pong (HEARTBEAT_INTERVAL_SEC).

Released under the MIT License.