WebSocket reference
Every message on the plugin WebSocket, with a real frame for each; the narrative lives on the protocol page, the machine-readable contract in asyncapi.yaml (AsyncAPI 3.0.0), and the C# types in protocol/src/MacroDeck.Plugin.Protocol/, kept in step by drift tests.
At a glance
Section titled “At a glance”GET /plugins/ws HTTP/1.1Upgrade: websocketSec-WebSocket-Protocol: macrodeck.plugin.v1Authorization: Bearer <session-token>sequenceDiagram
participant P as Plugin
participant H as Host
Note over P,H: POST /api/plugins/sessions (REST) negotiates version and capabilities
P->>H: WebSocket upgrade (macrodeck.plugin.v1, Bearer token)
P->>H: session.hello
H->>P: session.welcome
opt re-declare
P->>H: capability.declare
H->>P: capability.declare.ack
end
H->>P: capability.invoke
P->>H: host.invoke (optional, while handling)
H->>P: host.result
P->>H: capability.result
P-->>H: event.publish / log.publish / state.update (fire-and-forget)
P->>H: session.ping (every 20s, either side)
H->>P: session.pong
P->>H: session.goodbye, then close
The frames on this page were captured from the .NET SDK talking to the MacroDeck.Plugin.Testing stub host, except where a section says the example is built from the schema.
Connecting
Section titled “Connecting”| Path | /plugins/ws, loopback remote addresses only |
| Subprotocol | macrodeck.plugin.v1, offered through Sec-WebSocket-Protocol - required |
| Authentication | Authorization: Bearer <sessionToken>, scope plugin, checked before the upgrade is accepted |
| Encoding | JSON, camelCase, application/json |
See authentication for how the session token is obtained.
The envelope
Section titled “The envelope”{ "type": "host.invoke", "id": "01a09528-bc61-7dea-be36-6c143daa1395", "sentAt": "2026-09-12T10:27:49.98595+00:00", "protocolVersion": 3, "deadlineMs": 30000, "payload": { "api": "variables", "operation": "list" }}Every message, in either direction, is one ProtocolEnvelope.
| Field | Type | Required | Meaning |
|---|---|---|---|
type |
string | yes | <domain>.<verb>, e.g. capability.invoke. |
id |
string | yes | UUIDv7, minted by the sender. |
correlationId |
string | no | The id of the message this one answers. |
sentAt |
string | no | RFC 3339 UTC; informational only, clocks differ between the processes. |
protocolVersion |
integer | no | The sender’s protocol version. |
deadlineMs |
integer | no | The request deadline. |
idempotencyKey |
string | no | At most 128 characters, scoped to (sessionId, key). |
payload |
object | no | Shaped by type. |
error |
ProtocolError |
no | Set instead of payload on failure. |
payloadanderrorare mutually exclusive.- Unknown optional fields are ignored on read and never echoed back - there is no extension-data slot.
- Deadline, idempotency key and correlation live on the envelope only; no payload repeats them.
- Strict serialisation: camelCase, case-sensitive, no comments, no trailing commas. A number sent as a string (
"deadlineMs": "30000") is a hard failure. Maximum nesting depth ismaxJsonDepth(32).
ProtocolError
Section titled “ProtocolError”{ "type": "capability.result", "id": "01a09528-bc76-73cb-9d15-b0dc9c05eada", "correlationId": "01a09528-bc76-758e-a190-263c3254b26f", "protocolVersion": 3, "error": { "code": "CAPABILITY_UNAVAILABLE", "message": "No action 'does-not-exist' is registered in this plugin.", "details": {}, "retryable": false }}| Field | Type | Required | Meaning |
|---|---|---|---|
code |
string | yes | Stable error code - see Error handling. |
message |
string | yes | Default English text keyed by code, not copy to render; localise from the code. |
details |
map of string to string | no | Extra diagnostic context. |
retryable |
boolean | yes | Whether retrying can succeed. |
Message catalogue
Section titled “Message catalogue”Twenty-eight message types. Direction is enforced by the transport: a message arriving from the wrong side is rejected.
| Family | Types |
|---|---|
| Session | session.hello, session.welcome, session.goodbye, session.ping, session.pong |
| Capabilities | capability.declare, capability.declare.ack, capability.invoke, capability.result, capability.cancel |
| Host callbacks | host.invoke, host.result, host.cancel, host.state |
| Events, logs and state | event.publish, log.publish, state.update |
| Assets | asset.begin, asset.chunk, asset.commit, asset.ack, host.asset.begin, host.asset.chunk, host.asset.commit, host.asset.ack |
| Flow control and errors | flow.pause, flow.resume, protocol.error |
Session
Section titled “Session”{"type":"session.hello","id":"01a09528-bc4b-7500-8673-1df7313e68c9","protocolVersion":3, "payload":{"protocolVersion":3,"sessionId":"01a09528-bc42-7dc7-bf63-3d18ff4880c9","instanceId":"54292"}}{"type":"session.welcome","id":"01a09528-bc53-7099-817f-88ae70b8c334", "correlationId":"01a09528-bc4b-7500-8673-1df7313e68c9", "payload":{"sessionId":"01a09528-bc42-7dc7-bf63-3d18ff4880c9","resumed":false}}| Type | Direction | Payload |
|---|---|---|
session.hello |
plugin → host | protocolVersion (int, required), sessionId (required), resumeSessionId, instanceId |
session.welcome |
host → plugin | sessionId (required), resumed (bool, required) |
session.goodbye |
both | reason |
session.ping |
both | none - payload is omitted; an empty object is also valid |
session.pong |
both | none - payload is omitted; an empty object is also valid |
session.helloasserts the version and session id already negotiated over REST; it never re-negotiates. A mismatch isPROTOCOL_VERSION_UNSUPPORTEDand closes with4001.resumeSessionIddistinguishes a resume from a replacement: presented inside the resume window, the session resumes; absent, the new connection replaces the prior session, which is closed with4000.session.goodbyeis voluntary teardown and makes the session non-resumable at once. A plugin sends it to end its own session; the host sends it to a managed plugin the supervisor is stopping, immediately before a4004close.- Either side may send
session.ping; the reply is asession.pongcorrelated to it. Interval 20s, timeout 60s.
{"type":"session.pong","id":"01a09529-55ae-752b-98d2-983ca3e8d859", "correlationId":"01a09528-c000-7000-8000-000000000001","protocolVersion":3}Capabilities
Section titled “Capabilities”{"type":"capability.invoke","id":"01a09528-bc6e-7610-94f6-f7a6d537ad16", "payload":{"kind":"variables","localId":"temperature","operation":"get"}}{"type":"capability.result","id":"01a09528-bc71-789e-bcff-8de629047430", "correlationId":"01a09528-bc6e-7610-94f6-f7a6d537ad16","protocolVersion":3, "payload":{"data":{"value":{"kind":"number","number":21.5}}}}| Type | Direction | Payload |
|---|---|---|
capability.declare |
plugin → host | capabilities (array of DeclaredCapability, required, ≤ 512 items) |
capability.declare.ack |
host → plugin | capabilities (array of CapabilityNegotiationResult, required) |
capability.invoke |
host → plugin | kind, localId, operation (all required), arguments |
capability.result |
plugin → host | data - the success value only; a failure sets the envelope’s error instead |
capability.cancel |
host → plugin | reason - best-effort, see Correlation |
Capabilities are first declared on the POST /api/plugins/sessions request; capability.declare re-declares on the open socket. It is complete, not a delta: it replaces what the session knows. Built from the schema:
{"type":"capability.declare","id":"<uuid-v7>", "payload":{"capabilities":[ {"kind":"actions","localId":"toggle-light","versionRange":{"minimum":1,"maximum":1},"displayName":"Toggle light"}, {"kind":"weather","localId":"provider","versionRange":{"minimum":1,"maximum":1}}]}}{"type":"capability.declare.ack","id":"<uuid-v7>","correlationId":"<declare id>", "payload":{"capabilities":[ {"kind":"actions","accepted":true,"negotiatedVersion":1}, {"kind":"weather","accepted":false,"rejectionReason":"<reason>"}]}}| Shape | Field | Type | Required | Meaning |
|---|---|---|---|---|
DeclaredCapability |
kind |
string | yes | One of the nineteen kinds below. |
localId |
string | yes | The capability’s id within the plugin. | |
versionRange |
{minimum, maximum} |
yes | Integer capability versions the plugin serves. | |
displayName |
string | no | Human-readable name. | |
CapabilityNegotiationResult |
kind |
string | yes | The kind negotiated. |
accepted |
boolean | yes | Whether the session will use it. | |
negotiatedVersion |
integer | no | The agreed capability version. | |
rejectionReason |
string | no | Why it was rejected. |
Negotiation fails non-fatally: an unsupported or unknown kind comes back rejected with a reason, and the session proceeds degraded.
The nineteen kind values: actions, events, variables, icons, config-flow, music-player, weather, virtual-profiles, issues, ui, localization, device-provider, layout-provider, folder-view-provider, migration, widget-type-provider, screensaver-provider, messaging, video-stream-provider. operation comes from a fixed vocabulary per kind - see capability operations. Two more captured invokes:
{"type":"capability.invoke","id":"01a09528-bc57-7b85-bed4-952327ffedcd", "payload":{"kind":"actions","localId":"toggle-light","operation":"execute","arguments":{"parameters":{}}}}{"type":"capability.result","id":"01a09528-bc76-7445-bb11-c953c477af93", "correlationId":"01a09528-bc72-7432-bc32-be05ca2bbbf3","protocolVersion":3, "payload":{"data":{"providerName":"com.example.lights","hasDynamicEventOptions":false, "events":[{"localId":"light-toggled","name":"Light toggled","deliveryKind":"Push", "configurationParameters":[],"payloadParameters":[]}]}}}localization
Section titled “localization”Hands the plugin’s own strings to the host instead of baking resolved text into its UI. Both operations are capability.invoke.
| Operation | Arguments | Result | Meaning |
|---|---|---|---|
describe |
none | scope, defaultCulture, cultures |
Declares the plugin’s scope (plugin:<plugin-id>) and shipped cultures, so the host can reject a plugin claiming a scope it does not own. |
catalog |
culture |
culture, entries (key to template) |
One culture’s strings; nothing for that culture is an empty map, not a failure - the host’s fallback chain decides. |
The host asks for one culture at a time (bounded per culture, not across them). Bounded by maxLocalizationCultures, maxLocalizationEntries, maxLocalizationKeyLength, maxLocalizationValueLength. See Localization and MacroDeck.Plugin.Protocol.Capabilities.Localization.
variables
Section titled “variables”The only kind that is item-shaped and provider-shaped at once. The eager half declares one capability per variable at that variable’s local id, as actions does; the catalog half declares nothing and carries resource ids in each operation’s arguments, keeping large providers under maxDeclaredCapabilities.
| Operation | Arguments | Result | Meaning |
|---|---|---|---|
describe |
none | variables, declaredVariables, variablesDependOnConfiguration, supportsCatalog, supportsPush, supportsSearch, catalogName |
Eager definitions and whether a catalog is also served. |
get |
none | value, min, max, step |
One variable’s value and volatile attributes, or unavailable. |
set |
value |
status, message |
Applies a value; only invoked for a definition that declared write. A refused write is a status, not a failed operation. |
discover |
parentId, search, continuationToken, pageSize |
items, continuationToken |
One page of browsable catalog resources (pageSize ≤ 200). |
resolve |
id |
definition (nullable) |
Resolves any catalog id, including one never returned by discover; null means invalid, not unavailable. |
subscribe |
ids (array) |
values (array) |
Replaces the catalog working set wholesale (empty is “watch nothing”, ≤ 1024 ids) and returns current values. |
get and set address the variable through the invoke’s own localId; describe and the catalog operations ignore it. A definition’s materialization (eager or on-demand) is validated against the operation it arrived on. See Variables.
Host callbacks
Section titled “Host callbacks”{"type":"host.invoke","id":"01a09528-bc61-7dea-be36-6c143daa1395","protocolVersion":3,"deadlineMs":30000, "payload":{"api":"variables","operation":"list"}}{"type":"host.result","id":"01a09528-bc69-7d06-a0dd-85ed3c5e4c69", "correlationId":"01a09528-bc61-7dea-be36-6c143daa1395","payload":{"data":[]}}| Type | Direction | Payload |
|---|---|---|
host.invoke |
plugin → host | api, operation (required), arguments - the reverse of capability.invoke |
host.result |
host → plugin | data - success value only |
host.cancel |
plugin → host | reason - best-effort cancellation of a host.invoke |
host.state |
host → plugin | api (required), data - the list a plugin’s synchronous members serve from |
APIs: variables, user-variables, config, deck, scripts, widgets, notifications, action-interactions, ui, devices, variable-values, layouts, folder-views, widget-types, screensavers, adb, messaging, icon-packs, video-streams, and the push-only event-bindings. There is no events api; use event.publish. A plugin ignores a host.state api it does not know.
host.state for config has no data: it means “your config changed, re-read it”. Built from the schema:
{"type":"host.state","id":"<uuid-v7>","payload":{"api":"config"}}| API | Special rule |
|---|---|
action-interactions |
show-modal answers with a modal id as soon as the modal opens; the user’s answer arrives later as a ui/modal.result capability.invoke naming that modal. Exactly one result per modal. |
widgets |
Payload differs by protocol major - see below. |
ui |
Not charged to the per-plugin callback throttle. snapshot, patch and fault are bounded per session by maxUiUpdatesPerSecond / maxUiUpdateBurst; reload ends its session, and one for a session that already ended is ignored; register-resource, remove-resource and register-music-player-artwork have their own per-plugin rate limit, and register-music-player-artwork a tighter one on top. |
variable-values |
Data-carrying push for the catalog half only; eager variables are always polled via variables/get. |
event-bindings |
Push-only host.state, no host.invoke operations. data lists the triggers bound to this plugin’s own events, each an eventId and parameters keyed by name (value, absent for a state operator, and operator). Sent on registration and whenever that list changes. |
adb |
Gated per plugin, runs off the session’s dispatch loop, at most 4 calls in flight per plugin - see adb. |
icon-packs |
get-icon-resource and sync-bundled reach the calling plugin’s own bundled icon packs only, get-icon any installed icon by id; sync-bundled only from a self-registered development session - see icon-packs. |
messaging |
Needs the messaging capability kind; own rate limit instead of the per-plugin callback throttle; send and request run off the session’s dispatch loop - see messaging. |
video-streams |
Only for sessions the host opened on the calling plugin’s own providers; own rate limit instead of the per-plugin callback throttle - see video-streams. |
widgets by major
Section titled “widgets by major”Major 1 |
Major 2 |
|
|---|---|---|
widgets/apply arguments |
state: integer selector (0 current, 1 on, 2 off, 3 both) |
stateIds: stable state ids or the $current / $all sentinels |
host.state for widgets |
each entry has hasOnOffStates |
each entry has states ({id, label}) and currentStateId |
For a major 1 session the host translates rather than refuses: off/on map to the states with those literal ids, falling back to the first and second state; both reaches every state, including a third and beyond. See the migration guide.
{"type":"host.invoke","id":"<uuid-v7>", "payload":{"api":"ui","operation":"patch","arguments":{"sessionId":"<ui-session-id>","patch":{"fromRevision":1,"toRevision":2,"operations":[{"op":"set-properties","nodeId":"title","properties":{"text":"Hello"}}]}}}}| Operation | Arguments | Meaning |
|---|---|---|
snapshot |
sessionId, tree |
The full current tree; send one for every session.snapshot the host invokes. |
patch |
sessionId, patch |
One patch; fromRevision / toRevision are read from the patch itself. |
fault |
sessionId, code, message |
The session can no longer be served; the host ends it and tells clients, without relaying your text. |
reload |
sessionId |
Your code for this session changed during development. The host ends the session without reporting a fault, and every client showing it opens it again, which asks your provider for a new session. The SDK sends it when .NET Hot Reload updates the plugin. A host that predates it answers CAPABILITY_UNSUPPORTED; the SDK then sends fault instead, which also makes clients reopen. |
register-resource |
name, contentHash, mediaType |
Registers bytes as a UI resource under a plugin-chosen name and answers a resource (resourceId, contentHash, mediaType, byteLength), or uploadRequired: true when the host does not hold those bytes for this plugin yet. |
remove-resource |
name |
Removes the named resource. An unknown name is not an error. |
register-music-player-artwork |
name, instanceId, artworkId |
Registers the artwork of any music player, named by its qualified instance id (integrationId::instanceId) and artwork id, as a UI resource under a plugin-chosen name. Needs no upload. Answers a resource like register-resource, or an empty result with no resource when the host knows no such player or artwork. |
tree and patch are opaque JSON: bounded, then forwarded byte-for-byte (unknown members, member order, number formatting survive). A refused snapshot or patch is an error on its own host.result: PAYLOAD_TOO_LARGE, RATE_LIMITED, INVALID_PAYLOAD, SESSION_NOT_FOUND. See Serving a view.
To register a resource, call register-resource first. On uploadRequired, send the bytes as an asset.* upload of kind ui-resource with the same media type, then call register-resource once more; a second uploadRequired means the upload was lost, not that you should loop. Registering a name again replaces its bytes: the resourceId stays, the contentHash changes. Registering unchanged bytes under the same name answers the existing handle without an upload. Resources belong to the plugin session: they survive a resumed session, are released when the session ends or is replaced, and are gone after the host restarts. A refused registration is INVALID_PAYLOAD (name, media type, or a type that differs from the upload’s), UI_RESOURCE_QUOTA_EXCEEDED (maxUiResourceBytesPerPlugin or maxUiResourcesPerPlugin; what the name held is unchanged), RATE_LIMITED, or SESSION_NOT_FOUND from a session that has been replaced. A host that predates the operations answers CAPABILITY_UNSUPPORTED before any bytes are sent. See Resources.
register-music-player-artwork registers the artwork with the same rules as register-resource: the resource belongs to the plugin session, counts against maxUiResourceBytesPerPlugin and maxUiResourcesPerPlugin, and registering a name again replaces it. The host may re-encode the image, so mediaType can differ from the player’s. A refusal is INVALID_PAYLOAD (name, an instanceId or artworkId empty or over 512 characters, or artwork of a media type a UI resource cannot have; not retryable), ASSET_TOO_LARGE (artwork over maxUiResourceBytes), UI_RESOURCE_QUOTA_EXCEEDED, RATE_LIMITED (also when the plugin already waits on four lookups), TIMEOUT (the player did not answer within 20 seconds; retryable) or SESSION_NOT_FOUND. The host answers it off the session’s dispatch loop, so the plugin’s other callbacks are served while a slow player is asked. A host that predates the operation answers CAPABILITY_UNSUPPORTED.
icon-packs
Section titled “icon-packs”| Operation | Arguments | Meaning |
|---|---|---|
get-icon-resource |
key, name |
Answers a UI resource handle (resourceId, contentHash, mediaType, byteLength) for the icon name of the calling plugin’s bundled pack key. |
get-icon |
iconId |
Answers the same UI resource handle for the icon with that id in any installed icon pack. |
sync-bundled |
packs: [{key, contentHash, byteLength}] |
Replaces the development session’s bundled packs with the complete declared set: adds, replaces and removes packs by key. Answers uploadRequired (content hashes) and changed. |
get-icon-resource needs no upload and uses no UI resource quota: the handle is served from the host’s icon store and stays valid across host restarts. Its contentHash changes when the icon is replaced, so clients refetch. Only the calling plugin’s packs are searched. An unknown key or name is PLUGIN_ICON_NOT_FOUND; an icon that cannot be served within maxUiResourceBytes is ASSET_TOO_LARGE.
get-icon behaves the same way but names the icon by its id, the GUID an icon input returns as an icon-pack reference and the one Macro Deck copies from the icon packs page. Every installed pack is searched: the user’s own, imported, Store and plugin-bundled packs. An id no installed pack holds is ICON_NOT_FOUND; an icon that cannot be served within maxUiResourceBytes is ASSET_TOO_LARGE. A host that predates the operation answers CAPABILITY_UNSUPPORTED.
sync-bundled is how macrodeck-plugin run brings a project’s bundled packs into a running host without restarting the plugin. Send every pack the manifest declares, each identified by the content hash of its .macroDeckIconPack archive (the same hash asset.begin carries); a key left out is removed. When uploadRequired is not empty nothing was synced: upload each of those archives as an asset.* upload of kind icon-pack with media type application/zip, then call sync-bundled again with the same set. Otherwise changed says whether any pack was added, replaced or removed. The archive is bounded by maxAssetBytes; a larger pack reaches the host only by installing the built plugin. A session that is not self-registered is refused with ICON_PACK_SYNC_NOT_ALLOWED, since an installed plugin’s packs come from its artifact, and an archive that is not a usable icon pack is ICON_PACK_INVALID, leaving what that key held unchanged. A host that predates the api answers CAPABILITY_UNSUPPORTED to both operations.
devices
Section titled “devices”| Operation | Arguments | Meaning |
|---|---|---|
register |
a DeviceDescriptor |
Registers a device, or re-registers a known provider-local id as the same device. |
update |
a DeviceDescriptor |
Refreshes a registered device’s metadata. |
presence |
deviceId, presence |
Reports whether the device is reachable. |
unregister |
deviceId |
Withdraws the device from this session; the device is retained. |
interaction |
sessionId, kind, widgetId, controlIndex, value, surfaceRevision, data |
Reports a hardware interaction; returns a DeviceInteractionResult. |
icon |
sessionId, iconId, size, knownETag |
Fetches icon bytes for the current surface; the bytes arrive over host.asset.*. |
close |
sessionId |
Closes an open device session at the provider’s request. |
register, update, presence, unregister need no session, are keyed by provider-local deviceId, and are available to every device-provider plugin. interaction, icon, close are keyed by the sessionId from session.open, apply only to an opened session (capability version 2), and are the reverse of the capability’s session.open / session.surface / session.close invokes. See Device providers.
variable-values
Section titled “variable-values”| Operation | Arguments | Meaning |
|---|---|---|
value |
values (array of {id, reading}) |
Publishes values for ids in the most recently subscribed set; other ids are dropped, not faulted. ≤ maxVariableValuesPerBatch per call. |
invalidate |
none | The resource set changed. Accepted and recorded but not acted on today - nothing re-queries discover. |
See Push instead of poll.
{"type":"host.invoke","id":"<uuid-v7>","deadlineMs":90000, "payload":{"api":"adb","operation":"shell","arguments":{"serial":"R58M123","command":"getprop ro.build.version.release"}}}Operations on the Android devices the host’s own adb server sees. Every operation names the device by
serial. Argument and result shapes are in
AdbInvokeArguments.cs.
| Operation | Arguments | Result data |
|---|---|---|
shell |
serial, command |
exitCode, standardOutput, standardError, truncated |
battery |
serial |
level, isCharging, status, health |
push |
serial, localPath, remotePath |
none |
pull |
serial, remotePath, localPath |
none |
install |
serial, apkPath |
none |
uninstall |
serial, packageName |
none |
package-installed |
serial, packageName |
installed |
connect |
address (host:port) |
serial |
- Access is per plugin. An installed plugin needs
host:adbin its manifest; a self-registered session does not. ADB must be enabled and the user must allow plugins to use it. A refused call fails withADB_NOT_ENABLEDorADB_NOT_ALLOWED. See Android devices. - A non-zero shell exit code is a result.
commandmust not start with-and gets no standard input.truncatedistruewhen the host cut the output to fit one message. - Local paths (
localPath,apkPath) are absolute paths on the host’s machine, read and written with the host’s identity. Device paths are absolute. - At most 4 calls in flight per plugin. A fifth is refused at once with a retryable
RATE_LIMITED. Calls also count toward the per-plugin callback throttle. Other host APIs keep flowing while anadbcall runs. host.cancelends an in-flight call. It then gets exactly onehost.result, withCANCELLED.connectrunsadb connectfor a host name or IPv4 address with a port and answers with the serial the device has from then on, which is its address. The device reaches every plugin through theadbhost.statepush. adb reporting that it could not connect isADB_FAILEDwithadb_command_failed.- While the host is locked,
shell,push,pull,install,uninstallandconnectfail with a retryableCAPABILITY_UNAVAILABLEanddetails.reason: "host_locked".batteryandpackage-installedstill answer. - A failure of adb or the device is
ADB_FAILED, refined bydetails.reason:adb_executable_not_found,adb_server_unreachable,adb_device_not_found,adb_device_offline,adb_device_unauthorized,adb_timeout,adb_command_failed,adb_invalid_argument,adb_unsupported. Treat an unknown reason asADB_FAILEDalone. - The host’s own time limit is ten seconds for
batteryandpackage-installed, twenty seconds forconnect, one minute forshellanduninstall, and five minutes forpush,pullandinstall. It ends inADB_FAILEDwithadb_timeout. Wait longer than that before giving up on thehost.result, as the .NET SDK does.
The adb host.state push is per plugin, sent on registration and whenever this plugin’s access or the
device list changes. Its shape is
AdbStateDto:
{"type":"host.state","id":"<uuid-v7>","payload":{"api":"adb","data":{"access":"available","revision":7, "devices":[{"serial":"R58M123","state":"online","model":"SM-G991B","manufacturer":"samsung","product":"o1s"}]}}}access is available, adb-not-enabled or adb-not-allowed; devices is empty unless it is
available, and a device that left is simply absent. A device’s state is online, connecting,
offline or unauthorized. revision follows the same rule as the deck push: apply a push only when
its revision is higher than the last one applied in this session. A host without this api never pushes it
and answers every adb invoke with CAPABILITY_UNSUPPORTED.
messaging
Section titled “messaging”{"type":"host.invoke","id":"<uuid-v7>","deadlineMs":10000, "payload":{"api":"messaging","operation":"request","arguments":{"topic":"obs.scene.current","payload":{"format":"short"}}}}Plugins and integrations talking to each other by topic, brokered by the host. A plugin states what it
listens to with subscriptions; the host delivers to it as capability.invoke of the
messaging capability kind, which it declares at local id provider. Argument
and result shapes are in
MessagingInvokeArguments.cs.
The SDK side is Messaging between plugins.
| Operation | Arguments | Result data |
Meaning |
|---|---|---|---|
publish |
topic, payload |
none | An event for every subscription whose pattern matches, including the publisher’s own. Answers once accepted, before delivery. |
send |
topic, payload |
none | A command for the topic’s one handler. Answers once the handler finished. |
request |
topic, payload |
payload |
A request for the topic’s one handler; payload is its reply. |
subscriptions |
events, commands, requests (arrays of strings) |
rejected (array) |
Replaces everything this plugin listens to. Complete, not a delta. |
- Topics are two or more dot-separated segments of
[a-z0-9],-and_, starting and ending with a letter or digit, at most 128 characters.eventsentries may instead be a prefix followed by.*, which matches every topic below the prefix. A malformed topic isINVALID_PAYLOADwith reasonmessaging_invalid_topic, or arejectedentry with that reason. - Payloads are any JSON value, at most 64 KiB serialized (
PAYLOAD_TOO_LARGE). The host stamps the sender; a plugin cannot name one. - One handler per command or request topic. A
subscriptionsentry for a topic another participant already handles comes back inrejectedwithkind(event,commandorrequest),topic,reasonmessaging_topic_handledandowner, that participant’s integration id. Each list holds at most 256 entries; more isINVALID_PAYLOAD. deadlineMsbounds the handler. Forsendandrequestthe host waits for the handler fordeadlineMs, at most and by default 30 seconds, and then answersTIMEOUTitself. Wait a little longer than that for thehost.result.- Failures of
sendandrequestareCAPABILITY_UNAVAILABLErefined bydetails.reason:messaging_no_handler,messaging_handler_unavailable(retryable: the handler’s plugin is reconnecting) ormessaging_handler_failed. Treat an unknown reason as a failed handler. - Declare the kind first. A session that did not declare the
messagingcapability kind getsCAPABILITY_UNAVAILABLEwith reasonmessaging_not_declaredfor every operation. Asubscriptionscall from a session that has since been replaced isSESSION_NOT_FOUND: send it again from the new session. - Limits.
publish,sendandrequestshare a per-plugin budget of 100 calls, refilled at 50 a second, separate from the callback throttle;subscriptionsis not rate-limited. At most 16sendandrequestcalls per plugin wait at once, and a handler receives at most 8 at once; beyond either is a retryableRATE_LIMITED, never queued.host.cancelends a waitingsendorrequest. - Lifetime. A plugin’s
subscriptionssurvive a resume and are removed when its session is replaced or pruned. Send them again after every new session.
A host without this api answers every messaging invoke with CAPABILITY_UNSUPPORTED, and does not list
messaging in the descriptor’s capabilityKinds.
The messaging capability kind
Section titled “The messaging capability kind”The host delivers through it:
{"type":"capability.invoke","id":"<uuid-v7>","deadlineMs":10000, "payload":{"kind":"messaging","localId":"provider","operation":"request", "arguments":{"topic":"obs.scene.current","sender":"com.example.deck","messageId":"<id>","sentAt":"2026-09-22T10:00:00+00:00","payload":{"format":"short"}}}}| Operation | Arguments | Result data |
|---|---|---|
event |
topic, sender, messageId, sentAt, payload |
none |
command |
same | none |
request |
same | payload, the reply |
A plugin answers a topic it does not handle with CAPABILITY_UNAVAILABLE and reason
messaging_no_handler, and a handler that failed with reason messaging_handler_failed. The kind has no
describe.
video-streams
Section titled “video-streams”{"type":"host.invoke","id":"<uuid-v7>","deadlineMs":10000, "payload":{"api":"video-streams","operation":"session-update", "arguments":{"sessionId":"<session-id>","state":"Reconnecting","reason":"ProviderReconnecting"}}}A video stream provider tells the host what changed and reports on the sessions the host opened on it. The
host drives the providers and sessions through the
video-stream-provider capability kind. Argument shapes are
in
VideoStreamsInvokeArguments.cs.
The SDK side is Video streams.
| Operation | Arguments | Result data |
Meaning |
|---|---|---|---|
providers-changed |
none | none | The plugin registered or withdrew a provider. The host calls describe. |
streams-changed |
providerId |
none | A provider’s streams, their metadata or their state changed. The host calls streams. |
session-update |
sessionId, state, description, reason, message |
none | The session’s state, optionally with a replacement description. |
session-close |
sessionId, reason, message |
none | The provider ended the session. The host sends no session.close for it. |
- The host is the only writer of the catalog.
providers-changedandstreams-changedcarry no data; they make the host read the catalog again. Several in quick succession are coalesced into one read, and a read is applied only while the plugin session it came from is still current. - Session operations are scoped. They are accepted only for a session the host opened on one of the
calling plugin’s own providers; anything else is
CAPABILITY_UNAVAILABLEwith reasonvideo_stream_unknown_session. An update sent while itssession.openis still in flight is held, at most 64 per session, and applied in order after the open’s result. - Enums travel as the SDK’s member names:
stateis aVideoStreamSessionState,reasonaVideoStreamSessionReason. A reader maps a name it does not know toReconnectingandNone. - Limits. Values past a
VideoStreamLimitsbound areINVALID_PAYLOAD. The api has its own per-plugin budget of 64 calls, refilled at 32 a second, separate from the callback throttle; beyond it isRATE_LIMITED.
A host without this api answers every video-streams invoke with CAPABILITY_UNSUPPORTED, and does not
list video-stream-provider in the descriptor’s capabilityKinds.
The video-stream-provider capability kind
Section titled “The video-stream-provider capability kind”Version 1, declared at local id provider. DTOs are in
VideoStreamProviderDtos.cs.
{"type":"capability.invoke","id":"<uuid-v7>","deadlineMs":10000, "payload":{"kind":"video-stream-provider","localId":"provider","operation":"session.open", "arguments":{"sessionId":"<session-id>","providerId":"door-cameras","streamId":"front", "acceptedTransports":["hls","mjpeg"]}}}| Operation | Arguments | Result data |
|---|---|---|
describe |
none | providers: id, name, description, registrationId |
streams |
providerId |
streams: id, name, description, width, height, hasAudio, state, metadata |
session.open |
sessionId, providerId, streamId, acceptedTransports |
description (transport, url), registrationId |
session.suspend |
sessionId, providerId |
none |
session.resume |
sessionId, providerId |
description, absent when the previous one stays valid |
session.close |
sessionId, providerId, reason |
none |
- The description is where the host fetches the media.
transportishlsormjpeg, one ofacceptedTransports, andurlan absolutehttporhttpsURL without user info and without an encoded slash in its path;urlis required for both. The host relays the media to its clients and never passes the URL on, so a provider can listen on loopback only. A reader ignores description members it does not know, so the description can gain members additively. A description the host refuses, onsession.open,session.resumeorsession-update, fails the session: the consumer is told, withTransportNotAcceptedfor a transport it did not accept, and the provider gets its onesession.closewith the reasonFailed. The video streams guide lists what the relay checks. registrationIdis fresh for every registration. When the host sees it change or vanish for the same provider id, it treats every session it opened on the earlier registration as closed.- The host mints
sessionId. Asession.closecan overtake itssession.open: answer the close with success, and the open that follows withvideo_stream_unknown_sessionwithout opening anything. Closing an unknown or already closed session succeeds. - Exactly one close. Every successful
session.openis followed by onesession.closefor it, unless the plugin sentsession-closefirst. An open result that arrives after its consumer went away is closed at once, with the reason the session ended with; one for an earlier registration of the provider is closed withProviderRemoved. - Concurrency. The host sends at most 8
describe,streams,session.open,session.suspend,session.resumecalls at once per plugin, queues up to 256 more for at most the capability invoke timeout each, and refuses the rest as busy;session.closehas 4 slots of its own and is never dropped, and one refused withRATE_LIMITEDorTIMEOUTis retried up to 10 times in all, backing off from 250 ms to 5 s, while the plugin session is current. After that the host gives up; the plugin closes its side of the session when its connection ends. - Sessions end with the plugin session. When a plugin session ends or detaches, the host closes every
video session opened under it and removes the plugin’s providers until the next
describe. A plugin closes its own side of those sessions withHostDisconnectedand never sees asession.closefor them. - Enums travel as the SDK’s member names. An unknown
statereads asUnavailableand an unknownreasonasNone.
A provider refuses an operation with CAPABILITY_UNSUPPORTED when it does not support it, and otherwise
with CAPABILITY_UNAVAILABLE and one of these reasons in details.reason; a failure without a reason is
Failed:
| Reason | SDK error code | Retryable |
|---|---|---|
video_stream_unknown_provider |
UnknownProvider |
no |
video_stream_unknown_stream |
UnknownStream |
no |
video_stream_unknown_session |
UnknownSession |
no |
video_stream_stream_unavailable |
StreamUnavailable |
yes |
video_stream_transport_not_accepted |
TransportNotAccepted |
no |
video_stream_capacity_reached |
CapacityReached |
yes |
video_stream_busy |
Busy |
yes |
Events, logs and state
Section titled “Events, logs and state”{"type":"event.publish","id":"01a09528-bc5f-72ca-90a1-15f8c2ff9d17","protocolVersion":3, "payload":{"eventId":"light-toggled","parameters":{"on":true}}}All three are plugin → host and fire-and-forget - no reply message.
| Type | Field | Type | Required | Meaning |
|---|---|---|---|---|
event.publish |
eventId |
string | yes | Unqualified id; the host qualifies it with the authenticated plugin id. |
parameters |
object | no | Parameter values; a nested object or array reaches triggers and templates as its JSON text. | |
log.publish |
events |
array of LogEventDto |
yes | A batch of structured log events (≤ 64). |
dropped |
integer | no | Events the plugin’s sink dropped since the previous batch. | |
state.update |
kind |
string | yes | The kind whose snapshot is stale - re-describe it. Not data-carrying. |
localId |
string | no | The capability that changed. | |
reason |
string | no | Diagnostic reason. |
Built from the schema:
{"type":"log.publish","id":"<uuid-v7>","payload":{"dropped":0,"events":[ {"timestamp":"2026-09-12T10:27:50.1+00:00","level":"Warning","messageTemplate":"Bridge {Host} slow", "renderedMessage":"Bridge 10.0.0.2 slow","sourceContext":"Lights.Bridge","properties":{"Host":"10.0.0.2"}}]}}LogEventDto field |
Type | Required | Meaning |
|---|---|---|---|
timestamp |
string | yes | RFC 3339. |
level |
string | yes | A LogLevels value. |
messageTemplate |
string | yes | The unrendered template. |
renderedMessage |
string | yes | The rendered text. |
sourceContext |
string | no | Logger category. |
properties |
map of string to string | no | Flat, pre-rendered - not a nested tree. |
exception |
{type, message, stackTrace?, inner?} |
no | Structured exception; inner nests. |
LogEventDto carries nothing identifying (no plugin id, integration id, version or process id); the host has all four from the session. Under load the host drops log traffic rather than reporting QUEUE_OVERFLOW. See logging.
Assets
Section titled “Assets”Built from the schema:
{"type":"asset.begin","id":"<uuid-v7>","payload":{"assetId":"icon-42","kind":"<asset-kind>","mimeType":"image/png","totalBytes":90112,"contentHash":"<content-hash>"}}{"type":"asset.ack","id":"<uuid-v7>","correlationId":"<begin id>","payload":{"assetId":"icon-42","accepted":true}}{"type":"asset.chunk","id":"<uuid-v7>","payload":{"assetId":"icon-42","index":0,"data":"iVBORw0KGgo..."}}{"type":"asset.ack","id":"<uuid-v7>","correlationId":"<chunk id>","payload":{"assetId":"icon-42","index":0,"accepted":true}}{"type":"asset.commit","id":"<uuid-v7>","payload":{"assetId":"icon-42"}}| Type | Direction | Payload |
|---|---|---|
asset.begin |
plugin → host | assetId, kind, mimeType, totalBytes, contentHash (all required) |
asset.chunk |
plugin → host | assetId, index, data (all required); data is base64 |
asset.commit |
plugin → host | assetId (required) |
asset.ack |
host → plugin | assetId, accepted (required), index - acknowledges any of the three |
host.asset.begin |
host → plugin | same as asset.begin; the host’s answer to a devices/icon call |
host.asset.chunk |
host → plugin | same as asset.chunk |
host.asset.commit |
host → plugin | same as asset.commit |
host.asset.ack |
plugin → host | same as asset.ack |
totalBytesis checked againstmaxAssetBytesbefore a byte is buffered; each chunk’s pre-encoding size is bounded bymaxAssetChunkBytes.- The
kindvalues areicon,artwork,action-icon,ui-resourceandicon-pack. Aui-resourceupload is also refused atasset.beginwhen it is empty, larger thanmaxUiResourceBytes(ASSET_TOO_LARGE), or notimage/png,image/jpeg,image/webporimage/gif(INVALID_PAYLOAD). Its bytes are held in memory for theui/register-resourcecall that follows and are never written to the host’s on-disk asset cache. Anicon-packupload is held the same way, for theicon-packs/sync-bundledcall that follows. - One unacknowledged step in flight at a time, never a burst.
indexmust equal the next expected index: no reordering, gaps or duplicates.asset.commitverifies the final byte count and a recomputed content hash againstasset.begin.- A resumed session never resumes an in-flight transfer; it restarts from
asset.begin/host.asset.begin. host.asset.*is a separate type set, notasset.*with the direction flipped: the fourasset.*types stay fixed at plugin → host (asset.ackthe host → plugin reply). All the rules above apply mirrored.
Flow control and errors
Section titled “Flow control and errors”{"type":"flow.pause","id":"<uuid-v7>","payload":{"reason":"<reason>","resumeAfterMs":500}}| Type | Direction | Payload |
|---|---|---|
flow.pause |
both | reason (required), resumeAfterMs - asks the peer to stop sending non-exempt types |
flow.resume |
both | reason (required), resumeAfterMs - lifts a prior pause |
protocol.error |
both | none; the envelope’s error carries the ProtocolError |
{"type":"protocol.error","id":"01a09529-55ae-7b9e-add8-25515c13d6ac", "correlationId":"01a09528-c000-7000-8000-000000000002","protocolVersion":3, "error":{"code":"UNKNOWN_MESSAGE_TYPE","message":"The message type is not recognised.","retryable":false}}Correlation and response pairing
Section titled “Correlation and response pairing”A reply sets correlationId to the id it answers. Five types require one: capability.result, capability.declare.ack, asset.ack, host.result, host.asset.ack. protocol.error is excluded: it correlates only when it answers a specific message.
| Situation | Outcome |
|---|---|
A reply type with no correlationId |
MALFORMED_ENVELOPE |
A non-reply type with no correlationId |
Accepted |
| A correlation that already timed out | Dropped silently - never reported |
| A correlation the receiver does not recognise | Dropped and logged as CORRELATION_UNKNOWN |
| A known, live correlation | Accepted |
- Cancellation is best-effort both ways.
capability.cancel/host.cancelagainst an unknown correlation is a no-op, never an error (no reply at all). - The receiver of a cancel still emits exactly one
capability.resultwith a cancelled outcome, unless it had already replied. - Delivery is at-most-once: no sequence numbers, no replay buffer. Retry safety comes from
idempotencyKeyonly. - A repeat key while the original is in flight fails with
DUPLICATE_IDEMPOTENCY_KEY; after completion it returns the cached result. A cancelled invocation caches nothing, so a retry runs again; one that finished despite the cancel keeps its result. The cache is plugin-side and survives a resume; a restarted plugin process re-executes.
Backpressure
Section titled “Backpressure”flow.pause asks the peer to stop sending; flow.resume lifts it. Fourteen types stay exempt during a pause because they drain the peer’s queue:
capability.result, capability.declare.ack, asset.ack, host.asset.ack, host.result, session.ping, session.pong, session.goodbye, flow.pause, flow.resume, capability.cancel, protocol.error, host.invoke, host.cancel.
host.invoke and host.cancel are exempt because a capability handler blocked on host.result would otherwise live-lock. Ignoring a pause past maxInboundQueueDepth earns one QUEUE_OVERFLOW and a 1013 close.
Error handling
Section titled “Error handling”A protocol-level failure sets error instead of payload. Default messages are in the protocol page. The twenty-nine codes, append-only within a protocol major (removing or renaming one requires a version advance):
PROTOCOL_VERSION_UNSUPPORTED, UNKNOWN_MESSAGE_TYPE, MALFORMED_ENVELOPE, INVALID_PAYLOAD, UNAUTHENTICATED, PLUGIN_ALREADY_REGISTERED, SESSION_EXPIRED, SESSION_NOT_RESUMABLE, SESSION_REPLACED, SESSION_NOT_FOUND, CAPABILITY_UNSUPPORTED, CAPABILITY_UNAVAILABLE, PAYLOAD_TOO_LARGE, ASSET_TOO_LARGE, QUEUE_OVERFLOW, RATE_LIMITED, TIMEOUT, CANCELLED, CORRELATION_UNKNOWN, DUPLICATE_IDEMPOTENCY_KEY, INTERNAL_ERROR, ADB_NOT_ENABLED, ADB_NOT_ALLOWED, ADB_FAILED, UI_RESOURCE_QUOTA_EXCEEDED, PLUGIN_ICON_NOT_FOUND, ICON_PACK_INVALID, ICON_PACK_SYNC_NOT_ALLOWED, ICON_NOT_FOUND.
Close codes
Section titled “Close codes”Closing the socket is reserved for exactly seven conditions:
| Code | Condition |
|---|---|
1013 |
QUEUE_OVERFLOW - RFC 6455 “Try Again Later”, not a Macro Deck code |
4000 |
SESSION_REPLACED |
4001 |
PROTOCOL_VERSION_UNSUPPORTED |
4002 |
SESSION_EXPIRED |
4003 |
Authentication failed |
4004 |
SupervisorShutdown - the supervisor is stopping a managed plugin; not an error, nothing failed |
4005 |
RegistrationRejected - invalid or duplicated declared ids, or a colliding integration id; terminal, not a resumable drop |
The unknown-message-type rule
Section titled “The unknown-message-type rule”{"type":"protocol.error","id":"01a09529-55af-781e-b95f-246ba3c16d1e","protocolVersion":3, "error":{"code":"MALFORMED_ENVELOPE","message":"The message envelope could not be parsed.","retryable":false}}- An unrecognised
typeproducesUNKNOWN_MESSAGE_TYPE, with the parsedidas itscorrelationId(see the example under Flow control and errors). - A malformed envelope - oversize input, depth violation, missing
type, a string where a number belongs - producesMALFORMED_ENVELOPE. - Neither closes the connection. Together with “unknown fields are ignored”, this makes every additive change backward-compatible, so a protocol version bump only ever means breaking.
Limits and timeouts
Section titled “Limits and timeouts”Advertised at runtime in the protocol descriptor and again in the session response - read them rather than hard-coding. Values today:
| Limit | Value |
|---|---|
maxMessageBytes |
256 KiB |
maxAssetBytes |
8 MiB |
maxAssetChunkBytes |
64 KiB |
maxInboundQueueDepth / maxOutboundQueueDepth |
256 |
| Queue high / low watermark | 192 / 64 |
maxConcurrentInvocations |
32 |
maxDeclaredCapabilities |
512 |
maxIdempotencyKeyLength |
128 |
maxJsonDepth |
32 |
maxSessionsPerPlugin |
1 |
maxLocalizationCultures |
64 |
maxLocalizationEntries |
2000 |
maxLocalizationKeyLength |
128 |
maxLocalizationValueLength |
4096 |
maxVariableValuesPerBatch |
128 |
maxUiTreeBytes |
192 KiB |
maxUiPatchBytes |
64 KiB |
maxUiNodesPerTree |
2000 |
maxUiUpdatesPerSecond / maxUiUpdateBurst |
30 / 90 |
maxUiResourceBytes |
2 MiB |
maxUiResourceBytesPerPlugin / maxUiResourcesPerPlugin |
16 MiB / 256 |
maxUiAttachmentsPerSession |
16 |
maxUiSessionsPerProvider |
8 |
The maxUi* limits are optional in the descriptor; a host predating the ui capability omits them.
| Timeout | Value |
|---|---|
| Handshake | 10s |
| Default request | 30s |
| Capability invoke | 30s |
| Asset upload | 60s |
| Keep-alive interval | 20s |
| Keep-alive timeout | 60s |
| Session resume window | 60s |
| Graceful close | 5s |
See also
Section titled “See also”- Plugin protocol - negotiation, host callbacks, the asset pipeline, reconnection and resume.
- asyncapi.yaml - this page, machine-readable.
- openapi.yaml - the REST surface that precedes the upgrade.
- Authentication - how the upgrade’s session token is obtained.
- Plugin hosting - the .NET client that implements all of this.