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 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":[]}]}}}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, 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. |
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. |
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.
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.
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.- 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-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.
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 |
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.