Skip to content

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.

GET /plugins/ws HTTP/1.1
Upgrade: websocket
Sec-WebSocket-Protocol: macrodeck.plugin.v1
Authorization: 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.

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.

{
"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.
  • payload and error are 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 is maxJsonDepth (32).
{
"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.

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
{"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.hello asserts the version and session id already negotiated over REST; it never re-negotiates. A mismatch is PROTOCOL_VERSION_UNSUPPORTED and closes with 4001.
  • resumeSessionId distinguishes a resume from a replacement: presented inside the resume window, the session resumes; absent, the new connection replaces the prior session, which is closed with 4000.
  • session.goodbye is 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 a 4004 close.
  • Either side may send session.ping; the reply is a session.pong correlated to it. Interval 20s, timeout 60s.
{"type":"session.pong","id":"01a09529-55ae-752b-98d2-983ca3e8d859",
"correlationId":"01a09528-c000-7000-8000-000000000001","protocolVersion":3}
{"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 seventeen 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 seventeen 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. 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":[]}]}}}

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.

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.

{"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, 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; bounded per session by maxUiUpdatesPerSecond / maxUiUpdateBurst.
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.
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.

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.

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.

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":"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.

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
  • totalBytes is checked against maxAssetBytes before a byte is buffered; each chunk’s pre-encoding size is bounded by maxAssetChunkBytes.
  • One unacknowledged step in flight at a time, never a burst.
  • index must equal the next expected index: no reordering, gaps or duplicates.
  • asset.commit verifies the final byte count and a recomputed content hash against asset.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, not asset.* with the direction flipped: the four asset.* types stay fixed at plugin → host (asset.ack the host → plugin reply). All the rules above apply mirrored.
{"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}}

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.cancel against an unknown correlation is a no-op, never an error (no reply at all).
  • The receiver of a cancel still emits exactly one capability.result with a cancelled outcome, unless it had already replied.
  • Delivery is at-most-once: no sequence numbers, no replay buffer. Retry safety comes from idempotencyKey only.
  • 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.

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.

A protocol-level failure sets error instead of payload. Default messages are in the protocol page. The twenty-one 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.

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
{"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 type produces UNKNOWN_MESSAGE_TYPE, with the parsed id as its correlationId (see the example under Flow control and errors).
  • A malformed envelope - oversize input, depth violation, missing type, a string where a number belongs - produces MALFORMED_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.

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