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:
Helper obtains a signed identity attestation for its own peer (optional but strongly recommended before UI is shown).
Helper sends control-request to the target through the signaling channel.
Target's application renders the consent dialog (with the attested helper identity).
On Allow, target sends control-grant. On Deny, target sends control-revoke with reason user-stopped and the flow ends.
Both sides open (or reuse) an RTCDataChannel labelled codeb-control-v1 on their existing PeerConnection and start exchanging control events.
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.
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.
WScontrol-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",
"accessCode": "k7m2x9" // R17, optional; see below
}
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",
"accessCode": "k7m2x9" // present only when helper sent one
}
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".
Access code (R17). When the helper includes accessCode and the target host has an access code configured that matches, the host grants control directly without showing its consent dialog (TeamViewer-style one-touch help). See Remote help with an access code below for the end-to-end flow, format, and safety rules.
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 — helper hit R17's per-helper caps: 5/min per (helper, target) pair, or 20/min per (helper, room). Rejected requests don't consume budget.
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.
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.
Unsolicited grants MUST be ignored. A helper accepts a control-grant only if it (a) matches a requestId the helper actually sent, (b) has a from equal to the peer the request was sent to, and (c) arrives while the helper has no active session. Anything else is dropped silently — otherwise any participant could push a spoofed grant to overwrite a target's state, replace an idle peer's session-free state with a phantom "granted" state, or silently steal a session in flight.
Fires webhook control.granted.
WScontrol-revoke
Either side ends the session. Also emitted by the target's local watchdog on window-lock, disconnect, and no-input timeout.
Recognised reason values: user-stopped, timeout, target-locked, target-disconnected, helper-disconnected, policy, emergency, expired, cancelled, superseded. superseded is emitted by the TARGET to the OLD helper when a preempt with a valid access code took over the session (R19, see Preempt an active session).
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:
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.
WScontrol-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.
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:
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.
R17accessCode — Remote help with an access code
The target user turns on a short, single-use (or few-use) access code in their native host's UI (in Aloaha's RDPLauncher, the Join-Meeting dialog carries a "Remote help: allow control with an access code" switch), reads it out to the helper (typically by phone), and the helper types it into the tile's Request-control form. When the code matches, the host grants control directly — no Allow / Deny dialog. Like a TeamViewer session password.
Without a code, or with a wrong code, the request falls back to the normal consent dialog exactly as before. Nothing in this feature loosens the safety guarantees below: the target user still controls whether they share, still sees the banner while it is happening, still has the Stop button and the Ctrl+Alt+F12 emergency release, and still owns the automatic ends (window unshare, meeting close, no-input timeout).
Wire
A single optional field on control-request. Format: 1–32 characters, subset [A-Za-z0-9 -] (space and dash are allowed for readability; the host normalises them out before comparing). The server relays the field unchanged when it matches the pattern, silently drops it otherwise, and NEVER logs the value (not in server logs, not in the control.requested webhook, not in diagnostics).
The target-side page forwards the code to its native host only when the host advertised the access-code capability in its bridge hello. Older hosts and browsers-without-a-host just ignore the field and behave as they always did.
Where the code lives
On the target side: created and stored (as a salted hash, compared in constant time) inside the host application. Blocked after three wrong attempts. Valid only inside its own meeting window.
On the helper side: either typed into the Request-control form by hand, or pre-filled from the invite fragment (see next).
On the wire: only inside a control-request frame the helper sends. Never in join, never in signal, never in a URL query string, never in localStorage, never in the page's own "Copy link" output.
Invite link with the code — fragment form only
The target's host may optionally build an invite link that embeds the code as a URL fragment:
Why the fragment. Browsers never send the #… portion of a URL to the server, so the code never appears in access logs, proxy logs, or a Referer header. The query-string form (?rc=, ?code=) is explicitly NOT supported for exactly that reason. Any other form of the parameter is ignored.
What room.html does with it, in order:
Reads location.hash, matches rc=([A-Za-z0-9]{1,32}), ignores anything that doesn't match.
Immediately strips the fragment from the address bar with history.replaceState(null, '', location.pathname + location.search). So it does not stay visible, get bookmarked, or appear when the user copies the URL from the address bar or from the page's own Copy link button.
Holds the value in a closure — no localStorage, no sessionStorage, no logging, no sending anywhere.
Pre-fills the tile's Request-control form when the helper opens it. The helper still has to click Request; nothing goes to the target automatically.
Clears the stored value once it has been consumed, and on pagehide / beforeunload so a bfcache restore doesn't carry it back.
The invite-fragment feature is opt-in on the target host's side and comes with an explicit warning ("Anyone who gets this link can take control while the code is valid"). Distribution of the link is the helper's responsibility, not the protocol's.
Rate limits (server-side, additive to R17)
To keep a bad actor from brute-forcing a code and to keep consent dialogs from being spammed, the server rate-limits control-request per source peer on two sliding 60-second windows: 5 per (helper, target) pair and 20 per (helper, room). A request that fails one window doesn't consume budget on the other, and a rejected request doesn't consume budget itself. Requests over either cap get error with code rate-limited.
Verification
Server logs, control.requested webhook, and diagnostics MUST NOT contain any occurrence of the string the helper sent as accessCode.
A malformed accessCode (too long, wrong chars) MUST NOT stop the request from routing; the field is just dropped.
A control-request to a host that did not advertise access-code MUST NOT include the field in the bridge message the page forwards to the host, even if the wire frame carried one.
Opening /room.html?room=X#rc=abc123 and then reading location.href or the address bar MUST NOT show #rc=abc123.
Clicking the meeting's own Copy link button after opening the invite MUST NOT include the fragment in the copied URL.
Not a licence. An access code establishes that this helper is who the target expected right now; it says nothing about whether the target's host is entitled to any commercial feature. A future spec will layer a signed licence token on top for that.
Automatic request from an invite link (R18)
When the helper opens a link that carries #rc=, the page requests control automatically, once per page load, as soon as every trigger is true:
The #rc= code was captured on this page load (still in window.__CodebInviteAccessCode).
The page has completed join; no session or pending request is currently held.
Exactly one peer in the room is control-capable (per the R10 client-prefix gate). With several such peers, the page shows the pre-filled form on each of their tiles instead and lets the helper pick.
That peer's shared video is live — the track is unmuted and the video element has decoded a first frame (videoWidth > 0). So the page waits if the helper opens the link before the target starts sharing.
When the request fires, the tile enters the normal pending state with the extra hint "Requesting control with the access code from the invite link…" and the usual Cancel button.
On success control starts without a dialog on the target — end of flow.
On refused or expired (wrong / used / expired code, target shares only a window, target denied) the page:
shows the R9-3 refusal hint on the tile;
puts the code back into __CodebInviteAccessCode via putBackAccessCode();
opens the R17 access-code form pre-filled with the same code so the helper can adjust it and retry manually.
No automatic retries. Only one auto-request per page load, however it lands. Manual retries via the form use the same rate-limits and code-storage rules as R17.
Give-up. If none of the triggers ever holds — for example the target never shares — the watch stops after 30 minutes without firing. The code stays in memory until then; after 30 minutes the helper can still open the form manually and paste it.
Unchanged: the code stays fragment-only (never a URL query parameter), memory-only (no localStorage, no history, no logs), and never appears in the page's own Copy-link output. Server-side rate limits (5/min per pair, 20/min per room) count the single automatic request like any other control-request.
Preempt an active session — last valid code wins (R19)
Under R2..R18 the target page refused any second control-request while a session was already active (reason: "policy"), so the host never saw it. The owner decision on 2026-09-28 made this too strict: a helper who correctly types the access code should be able to take over from whoever is controlling right now. R19 implements that.
Rule. While a control session is active on the target, the page forwards a new control-request to the host ONLY when both hold:
The host advertised the access-code capability in its bridge hello.
The request carries a non-empty accessCode field.
Everything else — a request without a code, or a request to a host that does not implement access-code — still receives the R2-1 refusal (control-revoke with reason: "policy") and never touches the host. So a peer that guesses cannot displace the current helper without the code the target-user chose to share.
Host decides. The host (e.g. RDPLauncher) compares the code against its salted-hash and:
Right code → answers control-decision {allow: true, requestId, grantedFeatures, expiresUtc, hbIntervalMs}. The page tears down the OLD session first (see below), then sets up the new one exactly as for a first-time grant.
Wrong code → answers control-decision {allow: false, requestId}. The page tells the new requester no via control-revoke {reason: "user-stopped"}. The OLD helper keeps control — a deny on a preempt never tears down the running session.
The 3-strikes lockout on the host still applies. And the target-user can always Stop or press Ctrl+Alt+F12 regardless of what the wire says.
Old-session teardown (only on a preempt approve). Before the page mints a session id for the new helper it:
Sends the OLD helper a WS control-revoke {sessionId: <old>, reason: "superseded"}.
Sends an end {reason: "superseded"} event on the OLD helper's codeb-control-v1 data channel so the DC-side teardown fires with the same reason.
Clears the local _controlState (releases any keys / buttons the target-side watchdog was tracking).
Then the standard grant flow runs: new sessionId, WS control-grant to the new helper, host is told control-granted, DC + heartbeat + expiry timer start.
Old helper's UI. The ended event carries reason: "superseded" and the helper's tile shows the hint "Someone else took over with the access code." for a few seconds before returning to idle. The rest of the UI teardown is identical to any other ended transition.
Reason enum.superseded is added to control-revoke.reason and to the DC end.reason. Server relays it unchanged; webhook consumers see it in control.revoked.
Preconditions on the code. A takeover only makes sense when the code can be used more than once — a single-use code that the current helper already consumed is expired and the host answers deny. Rate limits (5/min per pair, 20/min per room) still count preempt attempts like any other request.
DCcodeb-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.
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.
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.
Cursor (target → helper)
// Standard cursor:
{ "s":"…", "seq":2, "t":"cursor", "kind":"text" }
// Program-specific cursor (first occurrence in the session -- carries the PNG):
{ "s":"…", "seq":3, "t":"cursor", "kind":"custom",
"id":"c1", "hx":0, "hy":0, "w":32, "h":32, "fallback":"default",
"png":"iVBORw0KGgoAAAANSUhEUgAA..." }
// Same custom cursor selected again later (no PNG -- receiver looks it up in the session map):
{ "s":"…", "seq":42, "t":"cursor", "kind":"custom", "id":"c1", "hx":0, "hy":0, "w":32, "h":32 }
Display-only metadata. Requires the target host to have advertised the cursor-shape capability in its bridge hello. Sent whenever the target's cursor changes shape; repeats are suppressed by the host. The helper applies it to the controlled video's inline cursor: style; nothing is forwarded back to any host.
kind is one of the standard CSS cursor names default, text, pointer, wait, progress, crosshair, move, not-allowed, help, ew-resize, ns-resize, nwse-resize, nesw-resize, none (hidden) — or custom for a program-specific cursor delivered as PNG. id matches ^c\d{1,6}$; hx/hy is the hot spot; w/h ≤ 128; PNG ≤ 64 KiB. Ids are session-scoped and reset each session.
Same direction rule as screen-info: on the target, a cursor event arriving from the helper direction is silently dropped (forgery guard).
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.
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.
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.
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).
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
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.)