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
| Path | Who connects |
|---|---|
/ws/device | SDK (app) |
/ws/ui | UI (desktop or browser) |
GET /health | Monitoring: { ok, sessions, uptimeSec } |
GET /discovery | isDevelopment 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
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
vdoes not match, the relay sendserror{code:'UNSUPPORTED_VERSION'}and closes the connection with4005. - An unknown
module.*type is not an error for the relay; it is forwarded. An unknown core type gets anUNKNOWN_TYPEerror. - On messages coming from the device,
deviceIdis filled in by the relay. Any value sent by the device is overwritten. payloadmust be a JSON object (use{}for messages without a payload).- Errors:
error { code, message }, withreplyToif 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
| Direction | Type | Payload |
|---|---|---|
| UI→R | session.create | {} |
| UI→R | session.resume | { sessionId, uiSecret } |
| R→UI | session | { sessionId, uiSecret, deviceWsUrl: string | null, pairing: PairingInfo, devices: DeviceSnapshot[], pendingRequests: ConnectRequest[], trustedDevices: TrustedDevice[] } |
| UI→R | pairing.refresh | {} → new QR token and 6-digit code |
| R→UI | pairing | PairingInfo — when refreshed and after every pairing (QR/code are single-use) |
| R→UI | device.status | { deviceId, state: 'connected' | 'disconnected' | 'unpaired', device: DeviceSnapshot | null } |
| R→UI | connect.request | ConnectRequest (device awaiting approval) |
| UI→R | connect.respond | { requestId, approve: boolean, trust: boolean } |
| R→UI | connect.resolved | { requestId, outcome: 'approved' | 'rejected' | 'expired' | 'cancelled' } — the request leaves the list |
| UI→R | device.unpair | { deviceId } |
| UI→R | trusted.remove | { deviceId } |
| R→UI | trusted | { devices: TrustedDevice[] } — when the list changes |
| UI→D | module.* | target deviceId in the envelope is required; DEVICE_NOT_FOUND if there is no such device, DEVICE_OFFLINE if it is offline |
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
| Direction | Type | Payload |
|---|---|---|
| D→R | pair.qr | { sessionId, qrToken, deviceId } |
| D→R | pair.code | { code, deviceId } |
| D→R | connect | { deviceId, hello } (only isDevelopment; depends on the relay's CONNECT_REQUESTS setting) |
| R→D | connect.pending | { requestId, expiresAt } |
| R→D | paired | { sessionId, deviceSecret, expiresAt } (after QR, code or an approved connect) |
| R→D | connect.rejected | { requestId | null, reason: 'rejected' | 'expired' | 'disabled' | 'noSession' | 'deviceLimit' } |
| D→R | resume | { sessionId, deviceId, deviceSecret } |
| R→D | resumed | { sessionId, expiresAt } |
| D→R | unpair | {} |
| R→D | unpaired | { 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
deviceIdreplaces the old record; the old secret becomes invalid and the old socket is closed with4000. - 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.expiresAtis the moment the pairing would be forgotten "if it disconnected now". A device that sendsresumewith an expired pairing receivesunpaired{reason:'expired'}. - A
connectrequest 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
connectwith the samehello.app.id, the relay returnspairedwithout asking for approval. Trust is based on thedeviceId+app.idmatch; for this reasonCONNECT_REQUESTSshould only be enabled on trusted networks.
QR content:
{ "v": 2, "url": "wss://host/ws/device", "sessionId": "…", "qrToken": "…" }Close codes
| Code | Meaning |
|---|---|
4000 | A new connection from the same device replaced the old one |
4001 | Authentication/pairing failed |
4002 | Pairing removed (unpaired) |
4003 | Not authenticated in time |
4004 | Session expired |
4005 | Unsupported protocol version |
4006 | Too many failed pairing attempts |
4007 | connect request rejected |
5. Device → UI core messages (forwarded by the relay)
| Type | Payload |
|---|---|
hello | Hello — the first message after every connect/resume |
capabilities.update | Partial<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 |
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.
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 preservedWhen 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
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 };
};| Direction | Type | Payload |
|---|---|---|
| D→UI | module.network.request | { uuid, method, url, headers, body, startedAt } |
| D→UI | module.network.response | { uuid, status, headers, body, durationMs, result: 'success' | 'error', errorMessage, bodyTruncated?: { originalSize } } |
functions
type CommandDef = { id: string; title: string; description?: string; group?: string; confirm?: string; danger?: boolean; args?: Fields };| Direction | Type | Payload |
|---|---|---|
| UI→D | module.functions.invoke | { commandId, args } |
| D→UI | module.functions.result | common 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.
type StorageEntry = { key: string; size: number; preview: string }; // size: character count, preview: first 120 characters| Direction | Type | Payload |
|---|---|---|
| UI→D | module.storage.list | { adapterId } → { entries: StorageEntry[] } (sorted by key, keys without a value excluded) |
| UI→D | module.storage.get | { adapterId, key } → { value: string | null } |
| UI→D | module.storage.set | { adapterId, key, value: string } |
| UI→D | module.storage.remove | { adapterId, key } |
| UI→D | module.storage.clear | { adapterId } |
| D→UI | module.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.
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 };| Direction | Type | Payload |
|---|---|---|
| UI→D | module.state.snapshot | { storeId } → { state } |
| UI→D | module.state.subscribe | { storeId, renew?: boolean } → { state }; {} if renew: true and the subscription is still active |
| UI→D | module.state.unsubscribe | { storeId } |
| D→UI | module.state.change | StateChange |
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
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;
};| Direction | Type | Payload |
|---|---|---|
| UI→D | module.flags.set | { key, value } → FlagDef (the value is validated against field; INVALID_VALUE if invalid) |
| UI→D | module.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.
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 } };| Direction | Type | Payload |
|---|---|---|
| D→UI | module.panels.update | { panelId, blockId, data }: data holds the changed fields of the block other than id / type (e.g. { rows }) |
| UI→D | module.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 addsbodyTruncated. historyand large lists are split into chunks with thefinalflag.- Rate limit:
DEVICE_MAX_MSGS_PER_SEC(default 200) messages per second per device. Excess messages are dropped, and the UI receiveslog { 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).
