Agent and connector protocol
Defines the messages exchanged over the WebSocket between the backend and agents/connectors. Go types live in entrosity-shared-go/proto and are the single source of truth; this document describes intent and state machines.
Transport
- URL:
wss://<server>/api/agent/v1/ws(agents) or/api/connector/v1/ws(connectors). - Header
Authorization: Bearer <agent_key>;X-RMM-Version: <semver>. NoOriginheader: browser-originated upgrades are refused. - Text frames, one JSON envelope per frame, max 1 MB. Larger payloads (full inventory) go over HTTPS
POST /inventory. - Server sends WebSocket pings every 30 s; connection considered dead after 2 missed pongs.
- Client reconnects with exponential backoff
1s → 2s → … → 5 min+ 20 % jitter. After 10 consecutive failures the agent switches to HTTP polling every 5 min and keeps retrying WS in the background.
Envelope
{
"id": "01J...", // uuidv7, unique per message
"type": "job.assign", // message type
"ts": "2026-09-23T10:00:00Z",
"reply_to": "01J...", // optional: id of the message this responds to
"payload": { ... }
}
Agent messages
Client → server
| type | payload | when |
|---|---|---|
hello | {agent_version, os_build, capabilities: ["winget","run_as_user","remote_desktop"], config_version, pending_job_ids[]} | first message after connect. Server replies hello.ack with {server_time, config, resend_job_ids[]}. pending_job_ids lets the server reconcile jobs that were sent before a disconnect. |
heartbeat | {cpu_pct, mem_pct, disks:[{drive,free_bytes,size_bytes}], logged_on_user, uptime_seconds, pending_reboot, cpu_temp_c?} (cpu_temp_c: hottest thermal zone in °C, -40..150, omitted without a sensor) | every 60 s |
inventory.report | {kind: "full"|"delta", collected_at, system?, software?, disks?, network?, services?, updates?, local_users?} | startup, every 24 h (full), every 4 h (delta), on request. Falls back to HTTP if >1 MB. |
job.ack | {job_id} | immediately after receiving job.assign |
job.progress | {job_id, pct?, message, phase: "downloading"|"installing"|"running", output?, output_offset?} | at most every 2 s; run_script also streams output chunks (≤16 KiB, UTF-8, output_offset = byte offset of the chunk) as they are produced |
job.result | {job_id, status: "succeeded"|"failed"|"timeout"|"cancelled", exit_code?, output_tail?, error_code?, error?, reboot_required?, detection_passed?, duration_ms} | when done |
event | {kind, data} — kinds: reboot_started, user_logon, user_logoff, agent_updated, update_failed ({from, to, detail}: the new version did not start and was rolled back), winget_missing | as they happen; agent_updated/update_failed are sent after the first hello following a self-update |
log | {level, message, fields} | rate-limited diagnostic forwarding (off by default) |
session.report | {at, open:[{user, session_id, type: console|remote, client?, logon_at}], ended?:[{…, logoff_at}]} | on connect, on every sign-in or sign-out, and at least every 15 min. open is every current sign-in; the server records new ones, sets the sign-out time of ended ones and closes stored open sign-ins missing from open at at (estimated). WebSocket only. |
Server → client
| type | payload | notes |
|---|---|---|
hello.ack | {server_time, config, resend_job_ids[]} | |
config.update | {config_version, heartbeat_seconds, full_inventory_hours, delta_inventory_hours, log_level, features} | agent persists and applies |
inventory.request | {kind} | |
job.assign | {job: {id, type, payload, timeout_seconds, expires_at, priority}} | agent must job.ack within 60 s or the server re-sends |
job.cancel | {job_id, force?} | a queued job is dropped and reported cancelled; a running job is only stopped with force (the installer's process tree is killed), otherwise it runs to completion and reports its real result |
ping | {} | application-level liveness (in addition to WS ping) |
Inventory sections
inventory.report carries sections: {system: true, software: true, ...} listing what was collected: JSON omits empty arrays, so a collected-but-empty section (no services) is distinguished from one that was not collected. The server replaces exactly the listed sections; a collector that failed is reported in errors[] and its section is left untouched. The software section is only listed when the registry collector succeeded (winget output only annotates registry entries with winget_id).
Job types (agent)
| type | payload | behaviour |
|---|---|---|
inventory | {kind} | run collectors, send report |
install_package | {package_id, kind: msi|exe|powershell|winget, download:{url?,sha256,size_bytes}?, file_name?, script_content?, install_args?, success_exit_codes[], detection?, winget:{id,version?,source?}?, reboot_policy, requires_reboot?, run_as: system|logged_on_user, timeout_seconds} | see execution flow below. download.url is filled in at delivery (a fresh one-hour presigned link) and may be empty in a resent job; the agent then asks for one. PowerShell packages carry either a file or inline script_content. |
uninstall_package | {package_id?, kind, uninstall_args?, uninstall_string?, product_code?, winget:{id}?, download?, file_name?, script_content?, success_exit_codes[], detection?, reboot_policy, run_as, timeout_seconds} | exactly one method is used, in this order: winget id → MSI product code (msiexec /x {GUID} /qn /norestart) → the device's inventory uninstall string (+ uninstall_args) → the package file with uninstall_args (exe/powershell). The server picks the method when building the job; no method → the target fails with no_uninstall_method without dispatching. Detection passing after uninstall → detection_failed. |
run_script | {script_run_id, language: powershell|pwsh|cmd, content, params, run_as, timeout_seconds} | see Scripts below |
reboot / shutdown | {delay_seconds, message?, force} | shutdown.exe /r|/s /t N /c <msg> [/f] /d p:0:0 (N at least 5 s so the result reaches the server; the message is one argument, never passed through a shell, and may not contain quotes or newlines); sends event reboot_started |
update_agent | {release_id, component: agent|connector, target: {version, url, sha256, size_bytes, signature}, rollback?: {…}} | see Self-update below; the same job type is used for connectors |
winget_search | {query} | used only when no cached index (rare) |
remote_desktop | {session_id, mode: control|view, require_consent, requested_by, hide_banner?, profile?} (hide_banner: no session bar for the user; set only for administrators' and teachers' screen wall sessions. profile: wall: a screen wall tile, view only, the picture scaled down to at most 640×400 at 1 frame per second and JPEG quality 40, 720p while enlarged (large); agents announcing the capability remote_wall honour it) | Windows only. Ask the signed-in user when require_consent (declined or no answer in 60 s → remote_declined), start the capture helper, open the session stream (see Remote desktop streams below). succeeded once the stream is up (remote session started); the stream outlives the job. Other failures: remote_unavailable. No secret: the stream is authenticated with the agent key. |
install_package execution flow
- If
detectionpresent and passes → resultsucceededwithdetection_passed=true, skipped=true(the target becomesskipped). run_as=logged_on_user(agents ≥ 0.5, capabilityrun_as_user): the installer runs in the console user's session with their token and environment; files are staged in the user's%TEMP%, winget uses--scope user. No one logged on →failedwithno_logged_on_user. Older agents answerrun_as_unsupported.- Download (phase
downloading, progress withpct) to%ProgramData%\RMM\cache\<sha256>\<file_name>unless already cached and verified. Downloads resume withRangeafter an interruption; an expired link (403/401/400) or a missingurlis refreshed once viaGET /api/agent/v1/packages/{id}/download-url. Free space is checked first (disk_full), then SHA-256 is verified (mismatch →hash_mismatch, file discarded). - Execute (phase
installing):- msi:
msiexec.exe /i "<file>" /qn /norestart /l*v "<log>" <install_args> - exe:
"<file>" <install_args>(the command line is passed verbatim, not re-quoted) - powershell:
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "<file>" <args> - winget:
winget install --id <id> [--version v] [--source s] --silent --scope machine --accept-package-agreements --accept-source-agreements --disable-interactivity; if winget missing → bootstrap App Installer from the server-hosted files (GET /api/agent/v1/releases/winget, installed withAdd-AppxProvisionedPackage), elseerror_code=winget_missing. winget's "no package found" exit code maps towinget_not_found. - msiexec exit
1618(another installation in progress) is retried locally 3× one minute apart, then reported asinstall_in_progress.
- msi:
- Exit code checked against
success_exit_codes(default0, 3010, 1641).3010/1641or pending-reboot registry keys →reboot_required=true. - Run
detectionagain if present; failure →failedwitherror_code=detection_failed. - Apply
reboot_policy:never→ nothing;if_required→ reboot whenreboot_required;always→ reboot. - Report
job.resultwith the last 64 KB of output (for MSI, the tail of the verbose log, UTF-16 decoded).
Cancellation: a job.cancel without force for a running install is ignored (half-finished installers do more damage than a late success); with force the process tree is killed (taskkill /T /F) and the job reports cancelled.
Crash recovery: before executing, the agent stores the job id with {action, detection} metadata in state.json. On restart every leftover job is reported over HTTP (POST /jobs/{id}/result): if the detection rule shows the intended state (installed for an install, absent for an uninstall) the job succeeded, otherwise it failed with agent_restarted (the deployment retry policy then applies).
Error codes: download_failed, hash_mismatch, exec_failed, exit_code, timeout, detection_failed, winget_missing, winget_not_found, disk_full, cancelled, agent_restarted, run_as_unsupported (agents before 0.5), no_logged_on_user, signature_invalid, update_unsigned, no_uninstall_method, install_in_progress, unsupported, invalid_payload.
Detection rule types (detection): msi_product_code {product_code}, registry {key, value?, op: exists\|==\|!=\|>=\|>\|<=\|<, expected?} (HKLM/HKCU, checked in both registry views), file {path, min_version?} (environment variables expanded, file version resource compared), winget {id}.
Scripts (run_script)
- The content is written to a temp file (
.ps1with a UTF-8 BOM, or.cmd) and run withpowershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File(5.1),pwsh.exe(7,exec_failedwhen missing) orcmd.exe /d /c. - Parameters (typed values already validated by the server against the script's schema) arrive both as environment variables
RMM_PARAM_<NAME>(booleanstrue/false) and, for PowerShell, splatted as named parameters from a JSON file by a small wrapper, so a script can declareparam([string]$Name, [int]$Days, [switch]$Force). - stdout and stderr are merged in order and streamed as
job.progresschunks (output,output_offset); the server appends them to the run and republishes them as SSEscript_run.output. - The result carries the last 64 KiB (
output_tail) andresult: {output_bytes, uploaded, truncated}. Larger output is uploaded first withPUT /api/agent/v1/jobs/{id}/output(gzip text, at most 10 MiB, anything beyond is dropped and the run is marked truncated) so the portal can show all of it. run_as=logged_on_userruns in the console session with the user's token (WTSQueryUserToken, environment fromCreateEnvironmentBlock), the script staged in their%TEMP%; no user →no_logged_on_user.- Timeout kills the process tree (
timeout);job.cancelwithforcedoes the same (cancelled).
Self-update (update_agent)
- The payload's
target(and optionalrollback: the MSI of the running version) carry an Ed25519 signature over the manifest"rmm-release-v1\n<component>\n<version>\n<sha256 lower hex>\n<size>\n". The public key is compiled in (-X …/agentkit/update.PublicKey=); a build without it answersupdate_unsigned, a bad signaturesignature_invalid. Nothing is downloaded before the signature checks out. - The MSI is downloaded (fresh presigned URL, filled at delivery), its size and SHA-256 verified, and stored as
%ProgramData%\RMM\update\<version>.msi(the rollback MSI asprevious.msi). - The agent writes
update.ps1and registers a one-shot scheduled taskRMMAgentUpdate/RMMConnectorUpdate(SYSTEM, starts in a minute), reportssucceededwith{scheduled, from_version, to_version, rollback}and keeps running. - The task runs
msiexec /i … /qn /norestart(WiXMajorUpgrade, which stops and replaces the service), then the watchdog: within five minutes the service must be running and still be running 60 s later. Otherwiseprevious.msiis installed again (the MSIs allow downgrades) and the service started. - The outcome goes to
update\result.json; the next agent start reports it asevent agent_updatedorupdate_failed.
The server offers an update on hello and from a rollout job every five minutes: to agents (and connectors) of active tenants that run an older release version than the newest published release of the tenant's channel (settings.update_channel, stable by default; beta also sees pre-releases), whose bucket (FNV-1a of the id, mod 100) is below the release's rollout_pct, and that have no update job in flight. The same version is not offered again for an hour. Only installations whose version is a semantic version (1.2.3, or with a pre-release suffix such as 0.0.42-dev.abc1234 from the CI dev builds) take part; local builds (dev, or versions with build metadata such as 1.0.0-dev+abc) are never updated.
Package HTTP endpoints (/api/agent/v1, agent key)
| method & path | response |
|---|---|
GET /packages/{id}/download-url | {url, sha256, size_bytes, expires_at} — a fresh one-hour presigned link for a ready package of the agent's tenant or a global package; 404 otherwise |
PUT /jobs/{id}/output | 204 — the full output of a run_script job of this device (Content-Encoding: gzip, text, ≤ 10 MiB) |
GET /releases/winget | {files:[{name, url, size_bytes, role: bundle|dependency}]} — the App Installer bundle and its dependencies stored under releases/winget/ in the bucket; 404 when none are hosted |
Agent-side state
%ProgramData%\RMM\
agent.dat DPAPI-encrypted {agent_id, agent_key, server_url}
config.json last config from server
state.json pending/in-progress job ids (+ detection metadata) for crash recovery
cache\ downloaded packages by sha256 (LRU, 2 GiB cap, `.verified` marker)
update\ self-update MSIs, update.ps1, result.json
logs\ rotating logs
Connector messages
The site connector (rmm-connector, Windows service RMMConnector) speaks
the same envelope, job lifecycle (job.assign → job.ack → job.progress
→ job.result), reconnect backoff and HTTP fallbacks as the agent; the code
is shared in entrosity-shared-go/agentkit (transport, executor, DPAPI secret store,
logging, service wrapper). Types: entrosity-shared-go/proto/connector.go.
Enrollment and endpoints (/api/connector/v1)
| method + path | purpose |
|---|---|
POST /enroll | {enrollment_token, hostname, domain, version} → {connector_id, connector_key, ws_url}. The token must be of kind connector; the same machine (tenant, hostname, domain) re-enrolling rotates its key and closes the old connection. Rate-limited per IP. |
GET /ws | the WebSocket (Authorization: Bearer <connector_key>) |
POST /heartbeat | HTTP fallback of heartbeat |
GET /jobs/pending, `POST /jobs/{id}/ack | progress |
POST /adsync/runs/{run_id}/chunk | large adsync.chunk bodies (above the 1 MiB frame limit; gzip accepted, 16 MiB max) |
POST /push/targets | HTTP fallback of push.target |
POST /vertex/jobs/{job_id}/secrets, POST /vertex/jobs/{job_id}/chunks | Entrosity Vertex jobs: the job's secrets (once) and result chunks, relayed to Vertex for live Vertex jobs of this connector; only while Vertex is configured (Vertex protocol) |
A connector is online from its hello until its socket closes (connectors run on servers, so a drop is shown at once) or it is silent for 3 min. Deleting a connector in the portal revokes its key, cancels its open jobs, deletes its AD sync configurations and closes its connection.
Client → server
| type | payload |
|---|---|
hello | as for agents; capabilities ⊆ ["ldap","winrm","smb","wol","vertex"] (smb and vertex only on Windows builds) |
heartbeat | {cpu_pct, mem_pct, active_jobs} every 60 s |
job.ack / job.progress / job.result | same shape as agent; job.result.result carries structured results (ldap.test, ldap.ous, push_agent) |
adsync.chunk | {run_id, seq, computers:[{object_guid, object_sid, dn, name, dns_hostname, os, os_version, enabled, last_logon_at, when_created, ou, description}], complete, total} — at most 1000 computers; the final chunk has complete:true and total (computers sent in the run). Chunks are idempotent per seq and may arrive out of order (large ones go over HTTP); the run completes once total computers arrived. |
push.target | {job_id, target:{ad_computer_id, hostname, fqdn}, status: "connecting"|"copying"|"installing"|"success"|"failed", error_code?, error?} per-target progress |
Server → client (job.assign payloads by job type)
Secrets (bind password, push credential, enrollment token, MSI link) are
added at delivery time by job enrichers: the jobs table stores only
references or sealed ciphertext, and job APIs never return connector job
payloads.
| job type | payload | lane |
|---|---|---|
adsync.run | {run_id, config:{ldap_host, ldap_port, tls_mode:"ldaps"|"starttls"|"none", skip_tls_verify, ca_pem?, base_dn, bind_dn, bind_password, computer_filter, ou_include[], ou_exclude[]}} | LDAP (serial) |
ldap.test | {config} → result {ok, computers_found, server_info, default_naming_context}; a failed bind is job.result failed with the error code | LDAP |
ldap.ous | {config} → result {ous:[{dn, name, parent_dn}]} | LDAP |
push_agent | {targets:[{ad_computer_id, hostname, fqdn}], msi:{url, sha256?}, enrollment_token, server_url, credential:{username, password}, concurrency} → result {succeeded, failed} | push (2 jobs in parallel, each concurrency targets, default 5) |
wake | {mac_addresses[] (≤16), broadcast?[]} → result {packets, targets[]}: magic packets on every IPv4 interface to the limited and directed broadcast addresses, UDP 9 and 7 (SO_BROADCAST). The server picks an online connector of the device's site with the wol capability and the device's MACs from its network inventory (409 no_site, no_connector, no_mac_address). | wake |
update_agent | as for agents (component: connector, task RMMConnectorUpdate, service RMMConnector) | update |
vertex.op, vertex.read | OpJob {operation_id, op, managed_ous[], server?, params, expires_at}: an Entrosity Vertex Active Directory operation, run on a domain controller; no secrets in the payload (Vertex protocol) | vertex (serial) / vertex-read (2) |
LDAP error codes: ldap_connect, ldap_bind, ldap_search, tls.
Sync on the connector: connect (LDAPS or StartTLS with system roots plus the
optional CA; verification is skipped only with skip_tls_verify), simple
bind, paged subtree search (500 per page) with
(&(objectCategory=computer)<computer_filter>), OU include/exclude by DN
suffix, chunks of 1000, progress every 1000 computers.
Push on the connector (per target, 10 min timeout): resolve the name →
connecting; SMB first (Windows builds, port 445): \\host\ADMIN$ with the
push account and the remote service manager; an existing RMMAgent service →
success with already_installed; copy to ADMIN$\Temp → copying; a
temporary RMMPush service starts msiexec /i … /qn ENROLLMENT_TOKEN=… SERVER_URL=… → installing; the installer log's final status and the
RMMAgent service decide: exit 0/3010/1641 → success, else
msiexec_failed with the log tail (tokens redacted). WinRM (HTTPS 5986, else
HTTP 5985 with NTLM message encryption) is used when 445 is closed, or after
an SMB copy_failed/unreachable: one shell, sequential commands, the MSI
as base64 lines decoded by certutil, SHA-256 checked. push_agent.msi is
the newest published stable agent release (versioned object and its
recorded SHA-256).
Push error codes: dns_failed, unreachable, winrm_disabled, auth_failed, access_denied, copy_failed, msiexec_failed, already_installed, download_failed, timeout.
Remote desktop streams
A remote desktop session uses two extra WebSockets that the backend relays to each other:
| Side | Endpoint | Authentication |
|---|---|---|
| Agent | GET /api/agent/v1/remote/{session_id} | Agent key (Authorization: Bearer). The session must belong to the agent's device and still be pending: 404 otherwise, 410 remote_session_ended, 409 remote_session_taken when the agent already joined. Browser origins are refused. |
| Viewer | GET /api/remote/v1/sessions/{session_id}/viewer?ticket= | The one-time ticket from POST …/remote-sessions (60 s, single use; only its SHA-256 is stored). Origins: the server's own host and RMM_CORS_ORIGINS (others get HTTP 403). A bad, used or expired ticket closes the socket with 1008. |
| Replica to replica | GET /api/remote/v1/internal/sessions/{session_id} | X-RMM-Relay: <unix expiry>.<hex HMAC-SHA256> over <session_id>|<expiry>, keyed from RMM_JWT_SECRET (the previous secret is also accepted during a rotation); tokens live 30 s. Refused at the public edge by Caddy. |
Messages are relayed unchanged in both directions, with two exceptions:
the server sends {"t":"status","state":"waiting"} to a viewer that
arrives before the agent and {"t":"status","state":"connected"} once
both sides are paired, and it forwards only viewer messages the session
allows. Viewer binary messages, unknown types, anything sent before
connected and, in view-only sessions, all input (mouse, wheel,
key, type, cad) are dropped. Read limits: 8 MiB per agent message,
64 KiB per viewer message.
Text messages are JSON objects with a t field:
t | Direction | Fields |
|---|---|---|
hello | agent → viewer | v (1), mode, displays[{id, name, x, y, width, height, primary}], display, width, height, user (empty at the sign-in screen). Sent first, and again when the display or the resolution changes. |
error | agent → viewer | message; the agent then closes the stream. |
status | server → viewer | state: waiting | connected |
ack | viewer → agent | seq: the frame whose last tile was drawn |
mouse | viewer → agent | x, y (display pixels), a: move | down | up, b: 0 left, 1 middle, 2 right |
wheel | viewer → agent | x, y, dx, dy in wheel units (120 per notch, positive dy scrolls down) |
key | viewer → agent | code (DOM KeyboardEvent.code, mapped to a scan code), down |
type | viewer → agent | text (up to 4096 characters, sent as Unicode input) |
cad | viewer → agent | Ctrl+Alt+Del (SendSAS) |
display | viewer → agent | id |
quality | viewer → agent | q: JPEG quality 10–95 (default 60) |
refresh | viewer → agent | send the whole screen again |
lock | viewer → agent | locked: lock (or unlock) the device's own keyboard and mouse; the viewer's input keeps working. Relayed in control sessions and on screen wall tiles only; lifted when the session ends. |
locked | agent → viewer | locked, error?: the lock state after a lock (agents that predate it never answer) |
large | viewer → agent | large: a screen wall tile is enlarged (720p: at most 1280×720, 5 frames per second, quality 60) or back to the small picture; a new hello with the new size follows. Wall tiles only. |
Binary messages (agent → viewer) are screen tiles: a 14-byte header followed by a JPEG image.
| Bytes | Field |
|---|---|
| 0 | kind (1 = JPEG) |
| 1–4 | frame sequence number (uint32, big endian) |
| 5–6, 7–8 | x, y of the tile in display pixels (uint16) |
| 9–10, 11–12 | width, height (uint16) |
| 13 | flags: bit 0 = last tile of the frame |
The agent captures up to 15 frames per second, compares each frame with the previous one in 64×64 tiles and sends only changed tiles (adjacent ones in a row merged into a strip); an unchanged screen sends nothing. Flow control: the viewer acknowledges each frame after drawing its last tile, and the agent keeps at most two frames unacknowledged, so a slow link lowers the frame rate instead of building a backlog.
Close codes (these streams only; the agent's main connection uses the codes below):
| Code | Meaning |
|---|---|
4000 | Ended: by a technician (reason ended by <name>), by the viewer closing, by the user at the device (the user ended the session), by the device, or because access changed (decommissioned, agent revoked, tenant suspended, technician's access changed) |
4001 | Replaced by a newer session on the same device |
4003 | The user declined the consent prompt or did not answer |
4004 | The device could not start the session, or the connection to the device (or to the replica holding it) was lost |
4008 | The device or the viewer did not join within 2 minutes, the job timed out, or the session reached 8 hours |
1008 | Invalid, used or expired viewer ticket |
With several backend replicas the agent and the viewer may reach
different replicas: the agent's replica records its RMM_NODE_URL in the
session, and the viewer's replica connects to it over the internal
endpoint (Scaling). Session ends are
broadcast to all replicas on the remote_sessions notification channel.
Job state machine (server view)
created ──send──► sent ──ack──► acked ──progress──► running ──result──► succeeded | failed | timeout | cancelled
▲ │ no ack in 60 s / disconnect
└────────────────┘
expires_at passed while not finished → timeout
Versioning
- Envelope carries no version; the endpoint path (
/v1/) does. Additive payload fields are allowed withinv1; removing or renaming a field requires/v2/and a dual-support window in the backend. - Agents and connectors report their version in
hello. Self-update (update_agent, above) is offered to installations older than the newest release of their tenant's channel, within the release's rollout percentage. - Installations older than
RMM_MIN_AGENT_VERSIONare offered the newest release at once, ignoring the rollout percentage. They keep their connection, so they can receive the update; the server never refuses a version, because that would strand the agent without its update channel. - Server 1.x supports agents and connectors from 1.0.0 (protocol
/v1).
Connection close codes
| Code | Meaning | Agent reaction |
|---|---|---|
4003 | Key revoked: the device was decommissioned, the connector deleted, or the tenant suspended | The agent stops (so does a 401 on connect); reinstalling with a token re-enrolls it |
4009 | A newer connection of the same installation took over | Reconnect with backoff |
1008 | Policy violation (for example, a message before hello) | Reconnect with backoff |
Revocation reaches every backend replica: its connection is closed and cached keys are dropped. A WebSocket opened from a browser (with a foreign Origin header) is refused with HTTP 403.