Skip to main content

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>. No Origin header: 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​

typepayloadwhen
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_missingas 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​

typepayloadnotes
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)​

typepayloadbehaviour
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​

  1. If detection present and passes → result succeeded with detection_passed=true, skipped=true (the target becomes skipped).
  2. run_as=logged_on_user (agents ≥ 0.5, capability run_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 → failed with no_logged_on_user. Older agents answer run_as_unsupported.
  3. Download (phase downloading, progress with pct) to %ProgramData%\RMM\cache\<sha256>\<file_name> unless already cached and verified. Downloads resume with Range after an interruption; an expired link (403/401/400) or a missing url is refreshed once via GET /api/agent/v1/packages/{id}/download-url. Free space is checked first (disk_full), then SHA-256 is verified (mismatch → hash_mismatch, file discarded).
  4. 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 with Add-AppxProvisionedPackage), else error_code=winget_missing. winget's "no package found" exit code maps to winget_not_found.
    • msiexec exit 1618 (another installation in progress) is retried locally 3× one minute apart, then reported as install_in_progress.
  5. Exit code checked against success_exit_codes (default 0, 3010, 1641). 3010/1641 or pending-reboot registry keys → reboot_required=true.
  6. Run detection again if present; failure → failed with error_code=detection_failed.
  7. Apply reboot_policy: never → nothing; if_required → reboot when reboot_required; always → reboot.
  8. Report job.result with 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 (.ps1 with a UTF-8 BOM, or .cmd) and run with powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File (5.1), pwsh.exe (7, exec_failed when missing) or cmd.exe /d /c.
  • Parameters (typed values already validated by the server against the script's schema) arrive both as environment variables RMM_PARAM_<NAME> (booleans true/false) and, for PowerShell, splatted as named parameters from a JSON file by a small wrapper, so a script can declare param([string]$Name, [int]$Days, [switch]$Force).
  • stdout and stderr are merged in order and streamed as job.progress chunks (output, output_offset); the server appends them to the run and republishes them as SSE script_run.output.
  • The result carries the last 64 KiB (output_tail) and result: {output_bytes, uploaded, truncated}. Larger output is uploaded first with PUT /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_user runs in the console session with the user's token (WTSQueryUserToken, environment from CreateEnvironmentBlock), the script staged in their %TEMP%; no user → no_logged_on_user.
  • Timeout kills the process tree (timeout); job.cancel with force does the same (cancelled).

Self-update (update_agent)​

  1. The payload's target (and optional rollback: 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 answers update_unsigned, a bad signature signature_invalid. Nothing is downloaded before the signature checks out.
  2. 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 as previous.msi).
  3. The agent writes update.ps1 and registers a one-shot scheduled task RMMAgentUpdate / RMMConnectorUpdate (SYSTEM, starts in a minute), reports succeeded with {scheduled, from_version, to_version, rollback} and keeps running.
  4. The task runs msiexec /i … /qn /norestart (WiX MajorUpgrade, which stops and replaces the service), then the watchdog: within five minutes the service must be running and still be running 60 s later. Otherwise previous.msi is installed again (the MSIs allow downgrades) and the service started.
  5. The outcome goes to update\result.json; the next agent start reports it as event agent_updated or update_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 & pathresponse
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}/output204 — 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 + pathpurpose
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 /wsthe WebSocket (Authorization: Bearer <connector_key>)
POST /heartbeatHTTP fallback of heartbeat
GET /jobs/pending, `POST /jobs/{id}/ackprogress
POST /adsync/runs/{run_id}/chunklarge adsync.chunk bodies (above the 1 MiB frame limit; gzip accepted, 16 MiB max)
POST /push/targetsHTTP fallback of push.target
POST /vertex/jobs/{job_id}/secrets, POST /vertex/jobs/{job_id}/chunksEntrosity 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​

typepayload
helloas 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.resultsame 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 typepayloadlane
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 codeLDAP
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_agentas for agents (component: connector, task RMMConnectorUpdate, service RMMConnector)update
vertex.op, vertex.readOpJob {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:

SideEndpointAuthentication
AgentGET /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.
ViewerGET /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 replicaGET /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:

tDirectionFields
helloagent → viewerv (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.
erroragent → viewermessage; the agent then closes the stream.
statusserver → viewerstate: waiting | connected
ackviewer → agentseq: the frame whose last tile was drawn
mouseviewer → agentx, y (display pixels), a: move | down | up, b: 0 left, 1 middle, 2 right
wheelviewer → agentx, y, dx, dy in wheel units (120 per notch, positive dy scrolls down)
keyviewer → agentcode (DOM KeyboardEvent.code, mapped to a scan code), down
typeviewer → agenttext (up to 4096 characters, sent as Unicode input)
cadviewer → agentCtrl+Alt+Del (SendSAS)
displayviewer → agentid
qualityviewer → agentq: JPEG quality 10–95 (default 60)
refreshviewer → agentsend the whole screen again
lockviewer → agentlocked: 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.
lockedagent → viewerlocked, error?: the lock state after a lock (agents that predate it never answer)
largeviewer → agentlarge: 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.

BytesField
0kind (1 = JPEG)
1–4frame sequence number (uint32, big endian)
5–6, 7–8x, y of the tile in display pixels (uint16)
9–10, 11–12width, height (uint16)
13flags: 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):

CodeMeaning
4000Ended: 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)
4001Replaced by a newer session on the same device
4003The user declined the consent prompt or did not answer
4004The device could not start the session, or the connection to the device (or to the replica holding it) was lost
4008The device or the viewer did not join within 2 minutes, the job timed out, or the session reached 8 hours
1008Invalid, 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 within v1; 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_VERSION are 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​

CodeMeaningAgent reaction
4003Key revoked: the device was decommissioned, the connector deleted, or the tenant suspendedThe agent stops (so does a 401 on connect); reinstalling with a token re-enrolls it
4009A newer connection of the same installation took overReconnect with backoff
1008Policy 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.