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. Not sent to a plugin that declares jig:stateless true |
loadAsset |
{ key, bytes } |
Replace a jig:asset the profile marked jig:userReplaceable, after init. bytes is an ArrayBuffer, transferred |
dispose |
{} |
Release everything. No further messages will be sent |
plugin |
{ payload } |
Opaque, relayed from the plugin's own user interface. Section 2.4 |
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.
state, when present, is whatever a state reply (section 1.3) most recently returned for
this node, restored from a saved project's jig:nodeState. Absent on a plugin's first ever
load, and on any load where nothing was saved. A processor that receives it applies it after
assets, overriding whichever of a jig:userReplaceable asset's bytes it would otherwise
default to, exactly as a value restored from a save overrides a fresh install's shipped one.
loadAsset replaces one jig:asset the profile marked jig:userReplaceable, after the
plugin is already running: a person choosing a different file from the generated panel
(contract section 9.1), not part of the load sequence. key is the same fragment name
assets in init uses. A processor MUST accept this at any time and MUST NOT require a
reload to take effect. Contract section 8.2's rule about state applies here too: the new
bytes belong in what a later stateRequest returns, not duplicated anywhere else, or a
restore disagrees with what is actually loaded.
A file that fails to parse is an error with phase: "asset" and fatal: false: the plugin
keeps running on whatever it had loaded before, unlike a failure during init, because
rejecting a bad file the person just chose is a smaller event than the plugin itself being
broken and contract section 10.2's "does not stop the music" applies here at the scale of one
asset rather than the whole node.
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, asset |
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 |
plugin |
{ payload } |
Opaque, for the plugin's own user interface. Section 2.4 |
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.
The frame therefore keeps a real origin of its own. A frame sandboxed without
allow-same-origin has the opaque origin "null", and a message to it can only be
addressed to "*", which this section forbids. So the host sandboxes with
allow-scripts allow-same-origin and loads the UI from an origin other than its own, and a
host MUST refuse to frame a UI on its own origin: allow-same-origin on a same-origin frame
would hand the plugin the host's document. Jiggy does both in src/ui/PluginFrame.js, and
offers no editor for a plugin served from the page's own origin.
The UI does not know the host's origin until init arrives, so it MAY address ready to
"*". ready carries nothing, and every later message from the UI goes to the origin the
init came from.
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.