Message protocol

Version: 0.1.0-draft Status: normative. Read with host-plugin-contract.md, which states the rules this document gives the wire format for.

Requirement keywords (MUST, MUST NOT, SHOULD, MAY) are used in the RFC 2119 sense.

Three parties exchange messages, over two channels that never meet.

   sandboxed UI  <--- iframe postMessage --->  HOST  <--- MessagePort --->  processor
       (frame)                             (main thread)              (audio thread)

The host is always in the middle. A user interface MUST NOT be given the AudioWorkletNode, its port, or any part of the project model, for the reasons in contract section 9. Everything a UI wants from a processor passes through the host, which is what makes the sandbox meaningful rather than decorative.

Every message is a plain object with a type field. Unknown message types MUST be ignored rather than treated as errors, so that a newer host and an older plugin interoperate as far as they are able.


1. Host and processor

The channel is the AudioWorkletNode's port. Both ends MUST treat it as lossy in one specific sense: a message posted to the processor is delivered, but not at a predictable time relative to any particular render quantum. Nothing may depend on a message arriving before a given process() call, which is why every timed thing carries a frame.

1.1 The clock

All positions are absolute frames since the AudioContext started, as currentFrame reports inside the worklet. A frame is a JavaScript number, which is exact to 2^53 and therefore exact for any session length that will ever occur.

There is no other clock. A message MUST NOT carry an offset within a block, a block index, or a wall-clock time.

1.2 Host to processor

type Payload Notes
init { module, assets, capabilities, sampleRate, quantum, state? } module is an ArrayBuffer of WebAssembly bytes, transferred. Sent once
events { events: [{ frame, bytes }] } bytes is a Uint8Array of one MIDI message
transport { playing, frame, beat, beatsPerFrame, tempo, timeSignature, loop? } Sent when anything in it changes, and at least once before playback
stateRequest { token } The processor replies with state carrying the same token
dispose {} Release everything. No further messages will be sent

init carries bytes rather than a compiled WebAssembly.Module or a URL.

Bytes because a WebAssembly.Module posted to an AudioWorklet is silently never delivered: postMessage does not throw, nothing arrives, and the load fails on a timeout. A Module is serializable only within an agent cluster and a worklet is outside the page's. Measured in Chrome, 2026-09-17.

Not a URL because AudioWorkletGlobalScope has no fetch, and because the host has already verified these exact bytes against the profile's digest. Fetching again would verify one response and execute another.

The processor compiles them synchronously with new WebAssembly.Module(bytes). The 4 KB limit on synchronous compilation applies to the main thread, not to a worklet.

capabilities is the resolved set from contract section 2, as an array of capability IRIs. A plugin that declared jig:prefers reads its fallback decision from here rather than probing for features.

assets is an object keyed by the fragment of each declared jig:asset resource's own IRI (<#script> becomes "script"), each value an ArrayBuffer, transferred, of that asset's verified bytes. Present and empty when the profile declares none. Delivered the same way as module and for the same reason: AudioWorkletGlobalScope has no fetch, so a resource the host has already fetched and integrity-checked has no other path to the processor that needs it. The worked case is plugins/_jsfx-runtime/, whose processor writes a converted JSFX effect's compiled script into the module's memory from assets.script before running it; a future plugin wanting a wavetable or an impulse response uses the same field.

1.3 Processor to host

type Payload Notes
ready { latencyFrames, tailFrames? } The plugin is instantiated and every buffer exists
error { phase, message, fatal } phase is one of instantiate, process, state
events { events: [{ frame, bytes }] } Outgoing MIDI
state { token, state } In reply to stateRequest
latency { latencyFrames, fromFrame } Latency changed. See latency.md
dropped { count, since } Events discarded on queue overflow

The host MUST NOT connect the node into the audio graph until ready arrives. Before then the processor MUST output silence.

error with fatal: true means the plugin cannot continue. The host MUST mute and disconnect it, MUST report it, and MUST leave the rest of the graph running, per contract section 10.2.

1.4 Events

Events are batched into an array rather than posted one at a time, because each postMessage costs a structured clone and a task, and a dense MIDI passage produces hundreds of events per second.

A processor MUST maintain a bounded, preallocated event queue. On overflow it MUST discard events and report the count in a dropped message on its next outgoing send. It MUST NOT grow the queue, allocate, or block.

The host MUST post events in non-decreasing frame order. It SHOULD post them at least one quantum ahead of the frame they apply to, and MUST accept that a late event is applied in the quantum it arrives in rather than dropped, because dropping it is worse than moving it. A processor MUST apply an event in the quantum containing its frame, or immediately if that frame has already passed.

The bytes array is transferred, not copied, where the host has no further use for it. A transferred Uint8Array is detached at the sender, so the host MUST NOT retain a reference to anything it transfers.

1.5 Parameters are not messages

Parameter values travel as AudioParams and reach the processor through the parameters argument of process(). They MUST NOT be sent as messages.

Sending them as messages would give two paths for one value, arriving at different times, with no defined precedence, and would discard sample-accurate automation. This is the same reasoning as contract section 8.2: a parameter is not state.


2. Host and user interface

The channel is postMessage between the host document and the plugin's sandboxed frame.

2.1 Origin checking

The host MUST verify event.origin against the UI's expected origin on every message received, and MUST pass an explicit targetOrigin on every message sent. It MUST NOT use "*" for either.

The UI frame is cross-origin by construction, so event.source comparison is available and SHOULD also be used. A host that accepts a message from any origin has a sandbox that any page can reach into.

2.2 Host to UI

type Payload Notes
init { profile, parameters, capabilities, state? } profile is the plugin's own profile as JSON-LD. Sent once, after the frame reports ready
parameter { symbol, value } A parameter changed, from automation, a generated panel, or another surface
state { state } The processor's state, after a restore or on request
plugin { payload } Opaque, relayed from the processor

2.3 UI to host

type Payload Notes
ready {} The frame has loaded and will accept init
parameter { symbol, value } Set a parameter
gesture { symbol, phase } phase is begin or end. Brackets a drag so automation records one gesture
resize { width, height } Requested size in CSS pixels. The host MAY refuse
plugin { payload } Opaque, relayed to the processor

A parameter message from the UI is a request, not an assignment. It goes through the host's operation dispatcher like any other edit, per contract section 9.3, so that undo, automation recording and the WebMCP surface all see it. The UI MUST NOT assume its requested value took effect and MUST render from the parameter message it receives back.

That round trip is deliberate. A UI that renders optimistically from its own input disagrees with the host the first time a value is clamped, rejected or overridden by automation.

2.4 The opaque relay

plugin messages carry a payload the host does not interpret, relayed between the UI and the processor in both directions. This is how a plugin sends a spectrum to its own display, or a waveform to its own editor.

The host MUST NOT parse, validate or act on the payload, and MUST NOT allow it to reach any other plugin. It MUST apply the same bounds as any other message: a payload arriving from the processor is subject to the same rate limiting as events, and a UI that floods the relay MUST be throttled rather than allowed to starve the main thread.

The payload is data. It is never markup and never code, and a host that inserts any part of it into a document has made the sandbox pointless.


3. Sequence: loading a plugin

host                      processor                 UI frame
 |                            |                        |
 | addModule(), construct     |                        |
 |--------- init ------------>|                        |
 |                            | instantiate wasm       |
 |                            | allocate buffers       |
 |<-------- ready ------------|                        |
 | connect into graph         |                        |
 |                            |                        |
 | create sandboxed frame     |                        |
 |<-------------------- ready -------------------------|
 |--------------------- init ------------------------->|
 |                            |                        |
 |--------- transport ------->|                        |

The processor is ready and connected before the frame is created. A plugin makes sound whether or not its editor is open, and a UI that fails to load MUST NOT prevent the plugin from running.

4. What is deliberately absent

No request and response framing beyond stateRequest. Adding a general correlation-identifier mechanism invites the message layer to become a remote procedure call layer, and every operation that belongs in the dispatcher would migrate into it.

No versioning field. Unknown types are ignored, which covers forward compatibility without a negotiation nobody would exercise. If that proves insufficient, a version belongs in init, where it can be answered once.

No direct UI to processor channel. It would be faster and it would remove the host from the middle, which is exactly the property that makes running someone else's code reasonable.