Public API

Remote-control channel · /signal.ashx · v1

Consent-gated screen control on top of a CodeB meeting. A helper (browser tab, native tray app, embedded SDK) can drive the target's pointer and keyboard once the target explicitly allows it, and only then. The protocol lives on top of the existing WebSocket signaling and a labelled RTCDataChannel. No admin rights, no unattended access, no third-party servers.

Non-goals. This spec does not describe a service the operator runs unattended in the background. Every session begins with a user click on the target's screen and can be ended by that user at any moment. See the target-side watchdog contract in the Safety guarantees section.

Overview

Both peers are already joined to the same room over /signal.ashx and have an established RTCPeerConnection to each other (mesh mode; see the mode note below). One of them (the helper) wants to control the other's screen; the other (the target) hosts the local application and the consent UI.

The flow is:

  1. Helper obtains a signed identity attestation for its own peer (optional but strongly recommended before UI is shown).
  2. Helper sends control-request to the target through the signaling channel.
  3. Target's application renders the consent dialog (with the attested helper identity).
  4. On Allow, target sends control-grant. On Deny, target sends control-revoke with reason user-stopped and the flow ends.
  5. Both sides open (or reuse) an RTCDataChannel labelled codeb-control-v1 on their existing PeerConnection and start exchanging control events.
  6. Either side ends the session with control-revoke. The target's local watchdog also auto-revokes on lock, disconnect, meeting close, and 10 minutes of no input.
The protocol is transport-agnostic: control events flow on an RTCDataChannel in mesh mode, and reliability-tolerant events (a single pointer position, a heartbeat) can also travel as JSON payloads through the existing signal WS relay when a data channel is not available. See Fallback path below.

Roles

  • Helper — the peer that ends up sending pointer and keyboard events. Typically identified by an OIDC-verified verifiedIdentity. Anonymous helpers can still ask, but the target's dialog is expected to warn about that.
  • Target — the peer whose screen is under control. Owns the consent dialog, the visible "you are being controlled — Stop" banner, and the hardware event injection.
  • Moderator — any peer with room-level lock rights. May issue control-lock to block new sessions in the room.

Lifecycle

States on the target:

  • idle — no request outstanding.
  • pending — request received; consent dialog visible.
  • granted — grant sent; data channel open.
  • ended — revoke sent or received; local watchdog cleared.

Transitions are single-writer: only the target can move pending → granted. Either side can move any state to ended.

WS control-request

Helper → server → target. The server routes to to, then overwrites the frame's from, name, verified and verifiedIdentity with its own view. A client that sets these fields itself is ignored.

Send

{
  "type":              "control-request",
  "to":                "<targetPeerId>",
  "purpose":           "You asked me for help with the printer setup",
  "requestedFeatures": [ "pointer", "keyboard", "clipboard-read", "clipboard-write" ],
  "requestId":         "req-1a2b3c"
}

Receive (target)

{
  "type":              "control-request",
  "from":              "<helperPeerId>",
  "name":              "Alice Walker",
  "verified":          true,
  "verifiedIdentity":  "alice@aloaha.com",
  "purpose":           "You asked me for help with the printer setup",
  "requestedFeatures": [ "pointer", "keyboard", "clipboard-read", "clipboard-write" ],
  "requestId":         "req-1a2b3c",
  "attest":            "eyJhbGciOi...",           // compact JWS, server-signed
  "attestExpiresUtc":  "2026-09-28T15:24:00Z"
}

Server-embedded attestation. The server signs an identity-attest JWS for the helper's peer id at delivery time and inserts it into the frame. The target's consent dialog SHOULD verify locally (HMAC-SHA256 with WebPhone:IdentityAttestSecret or, when unset, WebPhone:AdminSharedSecret) or via GET /signal.ashx?attest-verify&t=<jws>, and MUST treat the helper as not verified if attest is null even when the verified flag is true. Anonymous helpers show up in the dialog as "name as entered — not verified".

Error codes

  • join-required — the sending socket has not completed join.
  • peer-not-found — to is not a peer in this room.
  • control-locked — a moderator has locked control in this room (see control-lock).
  • rate-limited — more than three requests per minute per source peer.
Fires webhook control.requested. See Webhooks.

WS control-grant

Target → server → helper. The server relays after stamping from. The sessionId is target-picked and both sides echo it on every subsequent frame in this session.

Send (target after Allow)

{
  "type":             "control-grant",
  "to":               "<helperPeerId>",
  "requestId":        "req-1a2b3c",
  "sessionId":        "s7v8h1kw2q3r4p5m",
  "grantedFeatures":  [ "pointer", "keyboard", "wheel" ],
  "expiresUtc":       "2026-09-28T16:20:00Z",
  "dataChannelLabel": "codeb-control-v1",
  "hbIntervalMs":     5000
}

hbIntervalMs is the target's chosen heartbeat cadence for the helper's hb messages on the data channel. Clamped server-side to [500, 30000] ms. Missing three consecutive heartbeats at this cadence auto-revokes with reason timeout.

grantedFeatures may be a subset of what was requested (e.g. the target's UI granted view without keyboard). Helpers must respect the intersection.

Fires webhook control.granted.

WS control-revoke

Either side ends the session. Also emitted by the target's local watchdog on window-lock, disconnect, and no-input timeout.

{
  "type":       "control-revoke",
  "to":         "<otherPeerId>",
  "sessionId":  "s7v8h1kw2q3r4p5m",
  "reason":     "user-stopped"
}

Recognised reason values: user-stopped, timeout, target-locked, target-disconnected, helper-disconnected, policy, emergency.

Pre-grant denial (deny in the consent dialog, refusal because a session is already active, absent host bridge) uses requestId in place of sessionId, because no session id exists yet:

{
  "type":       "control-revoke",
  "to":         "<helperPeerId>",
  "requestId":  "req-1a2b3c",
  "reason":     "user-stopped"
}

The AsyncAPI schema requires exactly one of sessionId or requestId. Older clients that only understand sessionId can still ignore an unknown field.

Fires webhook control.revoked.

WS control-lock

Moderator toggles a room-wide block on new control sessions. Existing sessions keep running; new control-request frames are answered by the server with an error, code control-locked, and never delivered.

{ "type": "control-lock", "locked": true }

Broadcast to all peers as:

{ "type": "control-lock-changed", "locked": true, "by": "Moderator" }

WS identity-attest-req → identity-attest

Consent dialog binding. When the target's user is about to see a helper's name in the Allow / Deny prompt, they need to be sure the peer id shown is the one that will actually control them, and that the display name has not been forged. The client asks the server to sign a short-lived attestation:

{ "type": "identity-attest-req", "peerId": "<helperPeerId>" }
{
  "type":             "identity-attest",
  "peerId":           "<helperPeerId>",
  "name":             "Alice Walker",
  "verified":         true,
  "verifiedIdentity": "alice@aloaha.com",
  "attest":           "<compact JWS, HS256>",
  "issuedUtc":        "2026-09-28T15:19:00Z",
  "expiresUtc":       "2026-09-28T15:24:00Z"
}

attest is a compact JWS with header {alg:HS256,typ:JWT} and payload:

{
  "iss": "signal.ashx",
  "aud": "codeb-consent",
  "sub": "<peerId>",
  "name": "Alice Walker",
  "verified": true,
  "vsub": "alice@aloaha.com",
  "room": "<roomCode>",
  "tenant": "phone.aloaha.com",
  "iat": 1790000000,
  "exp": 1790000300
}

The signing secret is the per-tenant WebPhone:IdentityAttestSecret (falls back to WebPhone:AdminSharedSecret). Consumers can either treat the token as opaque and verify with GET /signal.ashx?attest-verify&t=<compact> (returns {ok, payload} on success), or verify locally by HMAC-SHA256 over the standard JWS b64url(header) + "." + b64url(payload) byte-string.

Attestations are short-lived (five minutes by default) so a stale grant screen cannot be re-used later. The token binds to peerId, not display name, so renaming does not carry the attestation over.

DC codeb-control-v1 — RTCDataChannel wire

Once a grant is in place, both sides open (or reuse) an RTCDataChannel with label codeb-control-v1 on the existing PeerConnection. Data channel messages are UTF-8 JSON, one event per SCTP message, ordered and reliable (ordered: true, maxRetransmits: null).

Every event carries the session id, so a receiver that has just accepted a fresh grant does not act on late frames from a superseded session.

Common envelope

{ "s": "<sessionId>", "seq": 42, "t": "<eventKind>", ... }

Ordering rule. seq starts at 0 and is strictly increasing per session, per direction. Receivers MUST drop any event with seq <= last from the same direction, and MUST NOT act on events whose s does not match the current session. The two directions do not share a counter.

Screen info (target → helper)

{ "s":"…", "seq":0, "t":"screen-info",
  "surface":     "monitor",
  "surfaceRect": { "x":-1920, "y":0, "w":1920, "h":1080 },
  "virtual":     { "x":-1920, "y":0, "w":4480, "h":1440 },
  "monitors":    [
    { "id":"\\\\.\\DISPLAY1", "x":0,     "y":0, "w":2560, "h":1440, "scale":1.25, "primary":true },
    { "id":"\\\\.\\DISPLAY2", "x":-1920, "y":0, "w":1920, "h":1080, "scale":1.0,  "primary":false }
  ] }

Sent immediately after the data channel opens, and again whenever the shared surface changes (share swapped, monitor reconfiguration, DPI change, window drag between monitors). The helper uses surfaceRect as the reference frame for the normalised pointer coordinates below.

When surface is "window" or "browser", the helper only ever sees a subset of the target's desktop; the target MAY refuse control entirely for those surfaces, or restrict input to inside surfaceRect.

Pointer (helper → target)

{ "s":"…", "seq":1, "t":"pointer", "nx":0.4531, "ny":0.1093 }

Position only. A pointer event never changes button state. nx and ny are normalised to the current shared surface, both in [0, 1] with (0, 0) at the surface's top-left. The target maps to desktop coordinates using the most recent screen-info. This design survives resolution changes, DPI scaling, video downscale, and multi-monitor negative offsets without renegotiation.

Button (helper → target)

{ "s":"…", "seq":2, "t":"button", "code":0, "down":true }

Authoritative for button state. code: 0 left, 1 middle, 2 right, 3 back, 4 forward.

Wheel (helper → target)

{ "s":"…", "seq":3, "t":"wheel", "dx":0, "dy":-120, "deltaMode":0 }

Authoritative for scroll. dx/dy carry the deltas; deltaMode names the unit and must be one of the DOM WheelEvent.deltaMode constants: 0 = DOM_DELTA_PIXEL, 1 = DOM_DELTA_LINE, 2 = DOM_DELTA_PAGE. When deltaMode is absent the receiver assumes pixels. Because Chromium and Firefox disagree about the raw magnitudes (Chromium reports pixels typically scaled by DPI, Firefox may report lines, and touchpad devices vary), targets normalise to their platform's native scroll units: RDPLauncher treats 120 pixels as one Windows WHEEL_DELTA notch. Positive dy is scroll-down.

Keyboard (helper → target)

{ "s":"…", "seq":4, "t":"key", "code":"KeyA", "keyCode":65, "down":true, "repeat":false,
  "mods":{"ctrl":true,"shift":false,"alt":false,"meta":false} }

code uses W3C UI Events KeyboardEvent.code values (physical key layout, not the shifted glyph). Native targets translate to platform virtual-key codes. Some keys and combinations are always refused silently — see Safety guarantees below.

Clipboard (opt-in, feature-gated)

{ "s":"…", "seq":5, "t":"clip-read"  }
{ "s":"…", "seq":6, "t":"clip-write", "text":"…" }
{ "s":"…", "seq":7, "t":"clip",       "text":"…" }

clip-read is a request from the helper; clip is the target's response.

File-transfer (opt-in, feature-gated)

{ "s":"…", "seq":8, "t":"file-offer", "name":"driver.zip", "size":184320, "sha256":"…" }
{ "s":"…", "seq":9, "t":"file-chunk", "id":"file-1", "n":0, "b":"<base64>" }

Chunk size ≤ 16 KiB. Chunks are also allowed on a dedicated codeb-file-v1 channel to avoid head-of-line blocking with pointer/keyboard traffic.

Heartbeat and end

{ "s":"…", "seq":10, "t":"hb", "monoMs":18342 }
{ "s":"…", "seq":11, "t":"end", "reason":"user-stopped" }

Missing three consecutive hb at the target-picked interval (default 5 s) triggers an auto-revoke with reason: "timeout".

Fallback path

If the data channel cannot open (SFU mode without SCTP forwarding, blocked DTLS, misbehaving NATs), the same event envelopes may be sent as signal frames with a payload that is the raw event object. The server forwards opaquely (see the signal frame in signal_api.html). Latency is higher and the server sees the events in cleartext; use this only when a data channel is unavailable, and never for continuous pointer streams.

Safety guarantees

  • Explicit consent. Nothing happens until the target's user clicks Allow. Deny defaults; the consent dialog auto-denies after 60 s.
  • Always visible. While a session is active the target displays a top-most banner naming the helper's attested identity and a Stop button.
  • Panic hotkey. The target's application binds Ctrl+Alt+F12 to an immediate control-revoke with reason emergency.
  • Auto-revoke. Window lock, workstation lock, meeting close, WebSocket disconnect, PeerConnection close, and 10 minutes of no client input all trigger an automatic revoke.
  • No admin rights. The target's process runs asInvoker; input into elevated windows (UAC prompts, secure desktop) is a documented limitation, not a bug.
  • Identity binding. The consent dialog displays the attested identity; peer id is stable for the lifetime of the WebSocket and is what the data channel is bound to.
  • Data channel authenticity. In mesh mode the SCTP channel is P2P DTLS-encrypted and only carries messages from the specific paired peer. In SFU mode data channels are not forwarded today; the protocol falls back to signal and each delivered frame carries a server-stamped from.
  • Room-wide lock. Moderators can shut off new sessions with control-lock.
  • Release-all on end. On end, control-revoke, WebSocket close, PeerConnection close, or data-channel close, the target MUST synthesise up events for every key and mouse button that has an outstanding down without a matching up. No key or button may remain "stuck" after the session ends.
  • Refused keys are not errors. A target MAY silently refuse individual keys and combinations. The helper receives no notification and MUST NOT retry. Fixed refusals include: the target's own emergency hotkey Ctrl+Alt+F12 (the helper must never be able to trigger or block the target's panic-stop), Win+L (locking the target's workstation would break the always-visible banner contract), and — on Windows — the entire Secure Attention Sequence Ctrl+Alt+Del (which no user-mode process can inject regardless). Targets are free to add more refusals.
  • Share-required. A target SHOULD grant control only while sharing a full monitor: otherwise the helper's pointer coordinates map to a surface the helper cannot see. Native targets (RDPLauncher) enforce this at grant time; when the target stops sharing, the browser client auto-revokes with reason user-stopped.
  • Ordering. seq is per-session, per-direction, strictly increasing. Receivers drop events with seq <= last from the same direction. Events whose s does not equal the current session are ignored (guards against late frames from a superseded grant).

Webhooks

Three tenant-scoped events fire through the same bridge webhook channel as the SFU and room events. See api-docs.html#webhooks for the transport (HMAC-SHA256 signed POSTs, retry semantics).

control.requested

{
  "event":            "control.requested",
  "room":             "hostinghelp",
  "helperPeerId":     "<peerId>",
  "helperName":       "Alice Walker",
  "helperVerified":   true,
  "helperIdentity":   "alice@aloaha.com",
  "targetPeerId":     "<peerId>",
  "targetName":       "Bob Bloggs",
  "requestedFeatures":[ "pointer", "keyboard", "clipboard-read", "clipboard-write" ],
  "requestId":        "req-1a2b3c",
  "ts":               "2026-09-28T15:19:04Z"
}

control.granted

{
  "event":            "control.granted",
  "room":             "hostinghelp",
  "sessionId":        "s7v8h1kw2q3r4p5m",
  "helperPeerId":     "<peerId>",
  "targetPeerId":     "<peerId>",
  "grantedFeatures":  [ "pointer", "keyboard" ],
  "expiresUtc":       "2026-09-28T16:20:00Z",
  "ts":               "2026-09-28T15:19:22Z"
}

control.revoked

{
  "event":            "control.revoked",
  "room":             "hostinghelp",
  "sessionId":        "s7v8h1kw2q3r4p5m",
  "byPeerId":         "<peerId>",
  "reason":           "user-stopped",
  "durationSec":      182,
  "ts":               "2026-09-28T15:22:24Z"
}

Helper JS API · window.CodebControl

Helper-side page-JS API for driving a granted session. Available on window.CodebControl in room.html. Installed via Object.defineProperty as non-writable + non-configurable; the object itself is frozen. A native host (RDPLauncher tray) calls into it via chrome.webview.executeScriptAsync; a scripted second meeting window calls it directly for automated end-to-end testing.

Every helper-side send silently drops when there is no active session, when the caller's role is target, when the data channel is not open, or when the requested feature is not in grantedFeatures. Return value is true on send, false on drop — no exception is thrown.

Session management (either role)

CodebControl.requestControl(peerId, { purpose, features })
  → requestId string, or false if peerId is not in the room
CodebControl.revokeControl(reason?)  → true if a session was ended, false otherwise
CodebControl.status()                → { active, role, sessionId, peerId, features } | null

Helper-side input (helper role only)

CodebControl.sendPointer(nx, ny)                   // pointer
CodebControl.sendButton(code, down)                // pointer     (0..4)
CodebControl.sendWheel(dx, dy, deltaMode)          // wheel       (deltaMode 0=pixel|1=line|2=page)
CodebControl.sendKey(code, down, { ctrl, shift, alt, meta })   // keyboard
CodebControl.sendClipRead()                        // clipboard-read
CodebControl.sendClipWrite(text)                   // clipboard-write

All coordinates and semantics are the same as the underlying DC events documented in RTCDataChannel wire above. seq is stamped by the API; callers do not manage it.

This API is the answer to "no helper can send control events yet" in the RDPLauncher R3 review. The full per-tile "Request control" UI is a follow-up patch; until then a helper drives the session from JavaScript, either from an embedding host or from a scripted browser window.

Reference transcript

Full happy-path flow for a helper attaching to a target in room hostinghelp. Both peers are already joined. Frames are shown with the direction (→ server or ← server) and stripped of formatting whitespace.

helper → server  { "type":"identity-attest-req", "peerId":"aa11bb22cc33" }
helper ← server  { "type":"identity-attest", "peerId":"aa11bb22cc33", "name":"Alice Walker", "verified":true,
                   "verifiedIdentity":"alice@aloaha.com", "attest":"eyJhbGciOi...", "issuedUtc":"...", "expiresUtc":"..." }

helper → server  { "type":"control-request", "to":"dd44ee55ff66",
                   "purpose":"Printer setup", "requestedFeatures":["pointer","keyboard"], "requestId":"req-1a2b3c" }

target ← server  { "type":"control-request", "from":"aa11bb22cc33", "name":"Alice Walker",
                   "verified":true, "verifiedIdentity":"alice@aloaha.com",
                   "purpose":"Printer setup", "requestedFeatures":["pointer","keyboard"], "requestId":"req-1a2b3c" }

// target user clicks Allow in the consent dialog

target → server  { "type":"control-grant", "to":"aa11bb22cc33", "requestId":"req-1a2b3c",
                   "sessionId":"s7v8h1kw2q3r4p5m", "grantedFeatures":["pointer","keyboard"],
                   "expiresUtc":"2026-09-28T16:20:00Z", "dataChannelLabel":"codeb-control-v1",
                   "hbIntervalMs":5000 }

helper ← server  { "type":"control-grant", "from":"dd44ee55ff66", "requestId":"req-1a2b3c",
                   "sessionId":"s7v8h1kw2q3r4p5m", "grantedFeatures":["pointer","keyboard"],
                   "expiresUtc":"2026-09-28T16:20:00Z", "dataChannelLabel":"codeb-control-v1",
                   "hbIntervalMs":5000 }

// both sides open RTCDataChannel "codeb-control-v1" on the existing PC.
// target sends screen-info immediately after the channel opens:

DC target→helper { "s":"s7v8h1kw2q3r4p5m", "seq":0, "t":"screen-info",
                   "surface":"monitor",
                   "surfaceRect":{"x":0,"y":0,"w":2560,"h":1440},
                   "virtual":{"x":-1920,"y":0,"w":4480,"h":1440},
                   "monitors":[{"id":"\\\\.\\DISPLAY1","x":0,"y":0,"w":2560,"h":1440,"scale":1.25,"primary":true},
                               {"id":"\\\\.\\DISPLAY2","x":-1920,"y":0,"w":1920,"h":1080,"scale":1.0,"primary":false}] }

// helper starts sending events on the DC. Note pointer is position-only;
// buttons are their own events:

DC helper→target { "s":"s7v8h1kw2q3r4p5m", "seq":1, "t":"pointer", "nx":0.2043, "ny":0.0819 }
DC helper→target { "s":"s7v8h1kw2q3r4p5m", "seq":2, "t":"button",  "code":0, "down":true }
DC helper→target { "s":"s7v8h1kw2q3r4p5m", "seq":3, "t":"button",  "code":0, "down":false }
DC helper→target { "s":"s7v8h1kw2q3r4p5m", "seq":4, "t":"pointer", "nx":0.2101, "ny":0.0819 }

// heartbeat, every 5 s per hbIntervalMs on the grant:
DC helper→target { "s":"s7v8h1kw2q3r4p5m", "seq":10, "t":"hb", "monoMs":5023 }
DC target→helper { "s":"s7v8h1kw2q3r4p5m", "seq":1,  "t":"hb", "monoMs":5028 }

// ... 3 minutes later, target clicks Stop:

target → server  { "type":"control-revoke", "to":"aa11bb22cc33",
                   "sessionId":"s7v8h1kw2q3r4p5m", "reason":"user-stopped" }
helper ← server  { "type":"control-revoke", "from":"dd44ee55ff66",
                   "sessionId":"s7v8h1kw2q3r4p5m", "reason":"user-stopped" }
DC helper→target { "s":"s7v8h1kw2q3r4p5m", "seq":181, "t":"end", "reason":"user-stopped" }

// target releases every key and mouse button with an outstanding down.
// (In this transcript, nothing is stuck. In one where the helper had
// pressed Ctrl+Shift then dropped, the target would synthesise
// ControlLeft up and ShiftLeft up before closing the DC.)
Machine-readable schema: /asyncapi.json · Signaling frame catalogue: /signal_api.html · REST + webhooks: /api-docs.html