Plugin protocol
The plugin protocol is a versioned JSON contract over HTTP and WebSocket; implement it directly only when building another runtime or language binding - .NET plugins use MacroDeck.Plugin.Hosting.
The machine-readable contracts are authoritative for exact fields: the OpenAPI spec
(HTTP bootstrap, registration, sessions, the upgrade) and the AsyncAPI spec
(envelopes and payloads). The public DTOs and constants live in MacroDeck.Plugin.Protocol, which is
independent of the host assemblies. The message catalogue is in the WebSocket reference.
At a glance
Section titled “At a glance”sequenceDiagram
participant P as Plugin
participant H as Host
P->>H: GET /api/plugins/protocol
P->>H: POST /api/plugins/sessions (requestedVersion 1..3)
H-->>P: 201 negotiatedVersion 3, sessionToken
P->>H: GET /plugins/ws + Bearer + macrodeck.plugin.v1
P->>H: session.hello
H-->>P: session.welcome
P->>H: capability.declare
H-->>P: capability.declare.ack
H->>P: capability.invoke
P-->>H: capability.result
P->>H: host.invoke
H-->>P: host.result
P->>H: session.goodbye
- Read the descriptor.
- Self-registering only: register once, through pairing or a Developer token. Managed plugins receive launch credentials instead. See Authentication and Plugin hosting.
- Exchange the credential for a short-lived session - this is where the version is negotiated.
- Upgrade to
/plugins/wswith the session token. - Send
session.hello, wait forsession.welcome. - Exchange capability and host-callback messages until the session ends.
The envelope
Section titled “The envelope”{ "type": "session.hello", "id": "01a09528-bb98-798a-bd96-e46f5388cd89", "sentAt": "2026-09-12T10:27:49.784Z", "protocolVersion": 3, "payload": { "protocolVersion": 3, "sessionId": "01a09528-bb8e-7eb4-a4f0-0aa018202b2b", "instanceId": "01a09528-bb9d-7057-8d59-ff9a1d93885a" }}| Field | Type | Required | Notes |
|---|---|---|---|
type |
string | yes | <domain>.<verb> |
id |
string | yes | UUIDv7, minted by the sender |
correlationId |
string | no | The id this message answers |
sentAt |
string | no | RFC 3339 UTC, informational only |
protocolVersion |
integer | no | |
deadlineMs |
integer | no | |
idempotencyKey |
string | no | At most 128 characters |
payload |
object | no | Shaped by type; mutually exclusive with error |
error |
ProtocolError |
no | See Errors |
Compatibility rules:
- Unknown optional fields are ignored on read and never echoed back.
- An unknown
typegetsUNKNOWN_MESSAGE_TYPEwith theidpreserved ascorrelationId, and does not terminate the session. - Malformed input (unparseable, oversize, too deep, missing
typeorid) getsMALFORMED_ENVELOPE, never an unhandled parser exception, and does not close the socket either. - Replies carry the request’s
idascorrelationId. Correlation, cancellation and idempotency rules are in the WebSocket reference.
Version negotiation
Section titled “Version negotiation”Request, in POST /api/plugins/sessions:
{ "requestedVersion": { "minimum": 1, "maximum": 3 } }Response:
{ "negotiatedVersion": 3, "capabilities": [{ "kind": "actions", "accepted": true, "negotiatedVersion": 1 }] }No overlap:
HTTP/1.1 422 Unprocessable EntityContent-Type: application/json
{ "code": "PROTOCOL_VERSION_UNSUPPORTED", "message": "The requested protocol version is not supported.", "details": { "supportedMinimum": "1", "supportedMaximum": "3" }, "retryable": false }The version is a single integer major, not semver. Additive changes stay inside the current major;
breaking wire changes need a new major, with earlier majors still served. The host picks the highest
mutually supported version, once. session.hello only confirms it.
| Major | Change | Older plugins |
|---|---|---|
1 |
Baseline | - |
2 |
widgets host-api payload only: a widget appearance change names the states it applies to by stable id, not the old fixed selector |
Translated by the host |
3 |
Descriptor text (action names, parameter labels, config-flow text) may be a {"$localized":…} reference instead of a plain string. Below 3 a plugin must send a plain string |
Translated by the host |
- The subprotocol
macrodeck.plugin.v1names the protocol family, not the major, and does not change between majors. - Capability versions negotiate independently per kind. An unsupported capability comes back
accepted: falsewith arejectionReason, without rejecting the whole session. - See the migration guide and the compatibility policy.
The handshake
Section titled “The handshake”Captured against the CLI’s stub host:
{"type":"session.welcome","id":"01a09528-bb9d-7b67-9659-b7ec73280280","correlationId":"01a09528-bb98-798a-bd96-e46f5388cd89","payload":{"sessionId":"01a09528-bb8e-7eb4-a4f0-0aa018202b2b","resumed":false}}The first message must be session.hello (see the envelope) within the handshake
timeout (10 s). It carries no credential.
session.hello field |
Required | Meaning |
|---|---|---|
protocolVersion |
yes | The negotiated version, asserted |
sessionId |
yes | The session from POST /api/plugins/sessions |
resumeSessionId |
no | Present when resuming a dropped session |
instanceId |
no | The connecting process instance, for logs |
| Outcome | Host response |
|---|---|
Timeout, not session.hello, or malformed payload |
protocol.error INVALID_PAYLOAD, normal close |
Session unknown, or sessionId does not match the token |
SESSION_EXPIRED, close 4002 |
protocolVersion differs from the negotiated one |
PROTOCOL_VERSION_UNSUPPORTED, close 4001 |
| Resume refused | SESSION_NOT_RESUMABLE, normal close |
| Success | session.welcome with resumed |
Capability operations
Section titled “Capability operations”{ "type": "capability.invoke", "id": "<id>", "deadlineMs": 30000, "payload": { "…": "…" } }{ "type": "capability.result", "id": "<id>", "correlationId": "<invoke-id>", "payload": { "…": "…" } }A plugin declares the capabilities it serves. The host invokes them with capability.invoke; the
plugin answers with capability.result, an error, or the cancellation path. The kinds a host supports
are listed in the descriptor’s capabilityKinds:
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.
- Do not invent operation names inside an existing kind. Additions to the operation vocabulary are compatibility-sensitive protocol changes. Exact operations and payloads are in the protocol package and AsyncAPI.
state.updateis an invalidation signal: the peer refreshes that capability’s state. It is not a second per-capability diff protocol.actionsgainedstate, additively in major1, for actions that supply an Action Button’s states. It is keyed by the action’s configured parameters, so the host polls it:state.updateis keyed by the declared capability id (the action type) and cannot name which configured instance changed.actionsalso gainediconandicon.content, the same way, for an action whose configured instance supplies a widget’s icon (ActionDescriptorDto.ProvidesIcon).iconis polled likestateand answers an identity, not bytes;icon.contentfetches the bytes only when that identity changes, uploaded over theasset.*pipeline, never inside the capability reply. Thewidgetshost API’sinvalidate-iconasks the host to re-read an action sooner than its next poll.
See capabilities and Capability parity.
Host callbacks
Section titled “Host callbacks”{ "type": "host.invoke", "id": "<id>", "payload": { "…": "…" } }{ "type": "host.result", "id": "<id>", "correlationId": "<host-invoke-id>", "payload": { "…": "…" } }Plugins call host-owned APIs with host.invoke and cancel with host.cancel. MacroDeck.Plugin.Hosting
maps these to IIntegrationContext. Some synchronous-looking SDK state is served from the last
host.state snapshot, so check Capability parity before assuming
in-process timing.
The deck snapshot carries folders, profiles, clients and revision. Each entry in clients is one
connected client with clientId, deviceId (absent for a client that is not a paired device), profileId
and folderId. revision increases with every deck push within one host run and starts again when the
host restarts; hosts that predate it omit both fields, which read as an empty list and 0. deck pushes can
arrive out of order, so apply one only when its revision is higher than the last one applied in this
session, always apply revision 0, and reset the last applied revision when a session is not resumed.
A callback can return bytes: an icon from the devices api’s icon operation, or a widget’s rendered
action icon from its widget-icon operation. These travel over host.asset.*, a host-to-plugin
pipeline kept separate from the plugin-to-host asset.* types so those keep their major-1 direction.
See the WebSocket reference.
Errors
Section titled “Errors”{ "type": "protocol.error", "id": "<id>", "correlationId": "<id-of-the-offending-message>", "error": { "code": "UNKNOWN_MESSAGE_TYPE", "message": "The message type is not recognised.", "retryable": false }}On HTTP the same ProtocolError object is the whole response body. Protocol failures (malformed input,
unsupported operations or capabilities, authentication, timeouts, cancellation, backpressure) are
protocol outcomes with stable codes, not transport exceptions. message is a default English string
keyed by code; localise from the code. details is a string-to-string map, at most 16 entries.
| Code | Default message |
|---|---|
PROTOCOL_VERSION_UNSUPPORTED |
The requested protocol version is not supported. |
UNKNOWN_MESSAGE_TYPE |
The message type is not recognised. |
MALFORMED_ENVELOPE |
The message envelope could not be parsed. |
INVALID_PAYLOAD |
The message payload does not match the expected shape. |
UNAUTHENTICATED |
Authentication failed. |
PLUGIN_ALREADY_REGISTERED |
A plugin is already registered with this identity. |
SESSION_EXPIRED |
The session has expired. |
SESSION_NOT_RESUMABLE |
The session can no longer be resumed. |
SESSION_REPLACED |
The session was replaced by a newer connection. |
SESSION_NOT_FOUND |
No session matches that id. |
CAPABILITY_UNSUPPORTED |
The capability kind is not supported. |
CAPABILITY_UNAVAILABLE |
The capability is not currently available. |
PAYLOAD_TOO_LARGE |
The message payload exceeds the allowed size. |
ASSET_TOO_LARGE |
The asset exceeds the allowed size. |
QUEUE_OVERFLOW |
The message queue overflowed. |
RATE_LIMITED |
Too many requests; retry after the given delay. |
TIMEOUT |
The operation timed out. |
CANCELLED |
The operation was cancelled. |
CORRELATION_UNKNOWN |
No in-flight message matches this correlation id. |
DUPLICATE_IDEMPOTENCY_KEY |
This idempotency key is already in flight. |
INTERNAL_ERROR |
An internal error occurred. |
The list is append-only within a major. A reason in details (today only developer_mode_disabled)
refines a deliberately generic code; a client that does not recognise it handles the code alone.
Closing the socket is reserved for seven conditions; everything else is a protocol.error on an open
socket:
| Close | Condition | .NET SDK |
|---|---|---|
1013 |
QUEUE_OVERFLOW (RFC 6455 “Try Again Later”) |
Reconnects |
4000 |
SESSION_REPLACED |
Stops |
4001 |
PROTOCOL_VERSION_UNSUPPORTED |
Stops |
4002 |
SESSION_EXPIRED |
Opens a new session |
4003 |
Authentication failed | New session, up to MaxAuthenticationFailures, then stops |
4004 |
Supervisor is stopping a managed plugin - nothing failed | Stops |
4005 |
Declared capabilities rejected (invalid or duplicated ids, colliding integration id). Terminal: fix the declaration | Stops |
Delivery and retries
Section titled “Delivery and retries”Delivery is at-most-once. There are no sequence numbers and no replay log for messages lost during
a disconnect; do not expect the host to replay an unacknowledged event after reconnecting. For a
retryable operation that must not run twice, send an idempotencyKey: a repeat while the original is in
flight gets DUPLICATE_IDEMPOTENCY_KEY, a repeat after completion gets the cached result. A cancelled
invocation caches nothing, so a retry with its key runs again; an invocation that finished despite the
cancel keeps its result, so a retry replays it instead of running twice.
Limits
Section titled “Limits”{ "maxMessageBytes": 262144, "maxInboundQueueDepth": 256, "queueHighWatermark": 192, "queueLowWatermark": 64, "maxConcurrentInvocations": 32, "maxSessionsPerPlugin": 1 }Message size, queue depth, concurrency, timeouts and asset sizes are published in the descriptor and
again in the session response (limits, timeouts). Read them at runtime; do not copy the numbers into
plugin logic. The full current table is in the WebSocket reference.
A message over maxMessageBytes is MALFORMED_ENVELOPE; a payload over a specific cap (UI tree, asset)
is PAYLOAD_TOO_LARGE or ASSET_TOO_LARGE.
Backpressure
Section titled “Backpressure”Sent by the host when its inbound queue reaches the high watermark (192), and lifted at the low watermark (64):
{ "type": "flow.pause", "id": "<id>", "payload": { "reason": "The host's inbound queue is filling." } }{ "type": "flow.resume", "id": "<id>", "payload": { "reason": "The host's inbound queue has drained." } }| Field | Required | Meaning |
|---|---|---|
reason |
yes | Diagnostic text |
resumeAfterMs |
no | Hint for when to try again |
While paused, send only the exempt types (replies, pings, cancels, host.* calls, errors - the full
list is in the WebSocket reference). A peer that keeps sending
past maxInboundQueueDepth gets one QUEUE_OVERFLOW and a 1013 close. Use bounded concurrency and
honour cancellation.
Reconnection and resume
Section titled “Reconnection and resume”{ "type": "session.hello", "id": "<id>", "payload": { "protocolVersion": 3, "sessionId": "<session-id>", "resumeSessionId": "<session-id>", "instanceId": "<instance-id>" } }{ "type": "session.welcome", "id": "<id>", "correlationId": "<hello-id>", "payload": { "sessionId": "<session-id>", "resumed": true } }| Situation | Result |
|---|---|
New connection with resumeSessionId, inside the 60 s window, session still exists |
Resume: resumed: true |
| Same, but outside the window or the session is gone | SESSION_NOT_RESUMABLE - open a new session |
New session for a plugin that already has one (maxSessionsPerPlugin is 1) |
The old connection is closed with 4000 |
session.goodbye or DELETE /api/plugins/sessions/{sessionId} |
Session non-resumable at once |
| No inbound traffic for 60 s (host pings every 20 s) | Host aborts the socket |
A resume keeps the session id, negotiated version, capability map, declared catalogue and the plugin-side
idempotency cache. It drops in-flight invocations (a retry of one with its idempotency key runs
again), event subscriptions and queued outbound messages. A fresh session after expiry is not a resume
and may need capability state and lifecycle re-initialisation. Reconnect with full-jitter exponential backoff: 1 s initial, 30 s maximum, factor 2.
MacroDeck.Plugin.Hosting does all of this for .NET plugins.
Security
Section titled “Security”Plugin endpoints need plugin-specific credentials and accept only local callers. Browser cookies are not a plugin WebSocket authentication mechanism. Never log plugin secrets, session tokens, enrolment credentials, OAuth credentials or authorisation headers. See Security and Authentication.
SDK compatibility metadata
Section titled “SDK compatibility metadata”{ "sdk": { "sdkVersion": "3.0.0", "deprecatedApis": [], "truncated": false } }Sent in POST /api/plugins/sessions. deprecatedApis: null means “not reported”; [] means “reported,
none used”. The host can return a compatibility report without making older plugins unable to
deserialise the session response. See Deprecations.
Implementing the protocol yourself
Section titled “Implementing the protocol yourself”The specs and MacroDeck.Plugin.Protocol are the source of truth; this page does not duplicate every
field, timeout, operation or message type. Test at least negotiation, authentication, unknown-message
tolerance, cancellation, idempotency and retry, reconnect and resume, backpressure, and capability
payload compatibility. The conformance suite runs these against a plugin.
See also
Section titled “See also”- WebSocket reference - every message type and payload.
- Authentication - credentials and the session token.
- Plugin hosting
- Capability parity
- Manifest
- Conformance suite
- Compatibility policy