Skip to content

MPP Protocol Reference

  • Version: MPP v2
  • Audience: Developers implementing a mobrule Pack Adapter in any language.

Breaking change from v1. MPP v2 introduces a mandatory pull{queue} inbound frame as the credit grant for dispatch, adds a queue routing tag to the outbound invocation frame, narrows the disconnect-requeue scope to in-flight rows only, and rejects legacy mpp:1 envelopes with unsupported_mpp_version. There is no fallback path — adapters that do not send pull cannot pump invocations; the bridge does not eagerly dispatch on row insert. MPP v1 is deprecated and unsupported.

The Mobrule Pack Protocol (MPP) is the wire format used between the mobrule bridge and a Pack’s Adapter process. The bridge is the platform-owned router that relays Invocations (Pack events that need to run inside the game). The Adapter is community-supplied: it speaks MPP and translates each Invocation into actual in-game effects.

The protocol is line-oriented JSON over TCP. The Adapter connects; the bridge listens. After a successful authentication handshake the Adapter must issue a pull{queue} for each queue it is ready to service; the bridge then dispatches at most one matching Invocation per outstanding pull, and the Adapter reports the outcome back. This document is the authoritative reference for the v2 wire format — Adapters written from this document alone are guaranteed to interoperate with any conforming bridge.

  • Protocol: TCP. The bridge binds a port (default 7777, configurable via --port); the Adapter is the initiator.
  • Framing: NDJSON — exactly one JSON object per line. Each frame is terminated by a single LF byte (\n, 0x0A). CRLF is not emitted by either side; receivers SHOULD tolerate a trailing \r before the LF as a safety net but MUST NOT depend on it.
  • Encoding: UTF-8.
  • No length prefix. Parsers buffer until LF and parse the preceding bytes as a single JSON value.
  • Max frame size: 64 KiB (65,536 bytes including the trailing LF). If either side observes a line exceeding this cap, it MUST close the connection. The bridge SHOULD send a protocol_error with code = "FRAME_TOO_LARGE" before closing if at all possible.
  • Forward compatibility: parsers MUST tolerate unknown keys. Future minor versions of v2 may add optional fields; receivers ignore unrecognised keys silently.

Every frame, in either direction, MUST include:

{"mpp": 2, "type": "<frame-type>"}
  • "mpp" (integer) is the protocol major version. v2 implementations emit and accept only 2.
  • "type" (string) is the frame discriminator and selects how the remaining fields are interpreted.

JSON objects have no guaranteed key ordering. Senders SHOULD emit "mpp" and "type" first by convention, but parsers MUST tolerate any key order.

The Adapter’s hello MUST carry "mpp": 2. If the value is anything other than 2, the bridge replies with hello_ack ok=false reason="unsupported_mpp_version" and closes the connection. In particular, legacy mpp:1 envelopes are rejected unconditionally — there is no compatibility shim.

Worked example — successful negotiation:

{"mpp": 2, "type": "hello", "pack_id": "com.example.mypack", "manifest_hash": "sha256:abc123...", "bridge_token": "<raw-token>"}
{"mpp": 2, "type": "hello_ack", "ok": true, "broadcaster_login": "streamer_login", "session_id": 42}

Worked example — rejected mpp:1:

{"mpp": 1, "type": "hello", "pack_id": "com.example.mypack", "manifest_hash": "sha256:abc123...", "bridge_token": "<raw-token>"}
{"mpp": 2, "type": "hello_ack", "ok": false, "reason": "unsupported_mpp_version"}

Future versions may introduce a counter-proposal field in hello_ack to advertise a supported downgrade; v2 does not.

A→B denotes Adapter-to-Bridge; B→A denotes Bridge-to-Adapter.

FrameDirectionLifecycle phasePurpose
helloA→BauthAdapter self-registers
hello_ackB→AauthBridge confirms or rejects
pullA→BdispatchAdapter grants one credit on a queue
invocationB→AdispatchBridge sends an Event to the Adapter
ackA→BdispatchAdapter confirms receipt of invocation
appliedA→BdispatchAdapter reports effect applied in-game
doneA→BdispatchAdapter signals completion (terminal)
failedA→BdispatchAdapter reports failure (terminal)
heartbeatA↔BkeepaliveLiveness ping; no response required
logA→BtelemetryAdapter emits a log line
state_writeA→BtelemetryAdapter writes an adapter-owned Pack-state key
protocol_errorB→AerrorBridge signals a fatal protocol violation

The twelve frame types listed above constitute the complete MPP v2 frame set.

In every example below the "mpp": 2 and "type" fields are mandatory; field tables document the additional payload.

{"mpp": 2, "type": "hello", "pack_id": "com.example.mypack", "manifest_hash": "sha256:abc123...", "bridge_token": "<raw-token>"}
FieldTypeRequiredNotes
pack_idstringyesReverse-DNS Pack identifier. MUST equal the bridge’s loaded Pack.
manifest_hashstringyesPack Manifest hash in the form "sha256:" + 64 lowercase hex.
bridge_tokenstringyesRaw bearer token issued out-of-band by the platform during pairing.

The Adapter MUST send hello as the first frame on a fresh connection. The bridge MUST receive hello within 10 seconds of accept; otherwise it closes the connection silently.

{"mpp": 2, "type": "hello_ack", "ok": true, "broadcaster_login": "streamer_login", "session_id": 42}
FieldTypeRequiredNotes
okbooleanyestrue on success.
broadcaster_loginstringyes (ok)The Twitch broadcaster login the bridge is authenticated as.
session_idu64yes (ok)Platform session identifier — opaque to the Adapter, useful in logs.
persistedarraynoReplay of durably stored persist Pack-state keys. Omitted when empty. See below.

On success the bridge server-pins pack_id and broadcaster_login from its loaded Manifest and session record. The Adapter is now considered the active Adapter for that session and MUST issue a pull for each queue it intends to service before any invocation will be sent.

persisted[] replay. For every Pack-state key declared persist = true in pack.toml that has a durably stored value, the bridge includes one { key, value, scope } row:

{"mpp": 2, "type": "hello_ack", "ok": true, "broadcaster_login": "streamer_login", "session_id": 42,
"persisted": [{"key": "resets", "value": 3, "scope": "trainer:42"}]}
FieldTypeRequiredNotes
keystringyesThe declared persist Pack-state key.
valueanyyesThe last durably stored value (schema-valid under the key’s current schema).
scopestringnoThe opaque run-scope tag the Adapter attached on the durable write. Omitted when none.

The bridge replays unconditionally. The bridge never interprets, compares, or parses scope — it stores it, echoes it here verbatim, and the Adapter alone matches each scope against its current run to decide resume-vs-reset. The Adapter MUST apply persisted[] before its first state_write, or it would clobber the seeded value with zeros under last-write-wins. A pack with no persist keys sees no persisted field at all.

{"mpp": 2, "type": "hello_ack", "ok": false, "reason": "manifest_hash_mismatch"}
FieldTypeRequiredNotes
okbooleanyesfalse on failure.
reasonstringyes (!ok)One of the reason codes below.

Reason codes:

  • invalid_tokenbridge_token does not match.
  • manifest_hash_mismatch — Adapter’s manifest_hash differs from bridge’s.
  • pack_id_mismatch — Adapter claims a different pack_id.
  • unsupported_mpp_version — Adapter’s mpp is not 2 (covers the legacy mpp:1 case explicitly).
  • unpaired — bridge has not completed its pairing handshake with the cloud.
  • already_connected — another Adapter is currently active.

After sending a failure hello_ack the bridge closes the TCP connection.

{"mpp": 2, "type": "invocation", "id": 123, "queue": "default", "event": "give_item", "params": {"item_id": 4, "count": 1}}
FieldTypeRequiredNotes
idu64yesUnique Invocation identifier.
queuestringyesRouting tag identifying which Pack-declared queue this Invocation belongs to. Matches the [events.<name>].queue value in the Pack Manifest. The Adapter MUST route this Invocation to its internal per-queue handler.
eventstringyesEvent name as declared in the Pack Manifest.
paramsobjectyesEvent parameters; already JSON-Schema-validated by the bridge before dispatch.

When the bridge sends an invocation it has already transitioned the Invocation from pending to dispatched, and a matching pull{queue} credit has been consumed (see §5.5 and §6). The Adapter SHOULD respond promptly with at least one of ack, applied, done, or failed, and MUST wait until the queue’s configured ready_after state is reached before sending the next pull for the same queue.

{"mpp": 2, "type": "pull", "queue": "default"}
FieldTypeRequiredNotes
queuestringyesName of the queue the Adapter is ready to service. MUST be a queue declared in the active Pack Manifest.

Credit-of-1 semantics. Each pull{queue} grants the bridge permission to dispatch at most one matching Invocation on that queue. The bridge does not pre-credit and does not multi-pull. After the Adapter sends pull{queue} and the bridge dispatches a matching pending Invocation, the Adapter MUST wait until that Invocation reaches the queue’s ready_after state (applied by default, or done if [queues.<name>].ready_after = "done") before sending the next pull for that same queue. Sending an additional pull for a queue that already has an outstanding (parked) credit or an in-flight Invocation is a protocol violation; the bridge MAY emit protocol_error code=INVALID_FRAME and close.

Per-queue independence. Pulls on different queues are independent: the Adapter MAY have one outstanding pull per declared queue simultaneously. This is how an Adapter pumps multiple queues concurrently while preserving strict serial ordering inside each queue.

Unknown queue. If queue is not declared in the active Pack Manifest, the bridge replies with protocol_error code="UNKNOWN_QUEUE" and closes the connection (see §5.12).

No fallback. If the Adapter never sends pull{queue}, the bridge will never dispatch on that queue. Pending Invocations accumulate indefinitely. This is by design — there is no eager-dispatch escape hatch.

{"mpp": 2, "type": "ack", "id": 123}

The Adapter confirms it received the invocation frame. The bridge logs receipt; the Invocation’s state does not change (it remains dispatched). ack is informational and does NOT release the queue’s in-flight slot (see §6).

{"mpp": 2, "type": "applied", "id": 123, "result": {"resolved_item_id": 4, "resolved_count": 1}}
FieldTypeRequiredNotes
idu64yesThe Invocation id this frame relates to.
resultobject or nullyesEffect-specific result payload. May be null if there is nothing to report.

The bridge transitions the Invocation from dispatched to applied. If the queue’s ready_after = "applied" (the default), this also clears the queue’s in-flight slot, freeing it to dispatch the next parked or future pull for the queue (see §6).

{"mpp": 2, "type": "done", "id": 123}

The Adapter signals completion. The bridge transitions the Invocation from applied to done. done is terminal; no further frames for this id are expected or honoured. If the queue’s ready_after = "done", done clears the queue’s in-flight slot.

{"mpp": 2, "type": "failed", "id": 123, "reason": "item not found in inventory"}
{"mpp": 2, "type": "failed", "id": 124, "reason": "target already fainted", "refund": true}
FieldTypeRequiredNotes
idu64yesThe Invocation id this frame relates to.
reasonstringyesHuman-readable failure reason; recorded verbatim.
refundboolnoIf true, signals the originating Cue should be refunded to the viewer. Default false.

When refund is omitted or false, the bridge transitions the Invocation to terminal state failed. When refund=true, the bridge transitions it to terminal state refunded and records a refund request; the platform subsequently calls the upstream provider’s refund API (e.g. Twitch Helix UpdateRedemptionStatus set to CANCELED for channel-point redemptions).

failed (regardless of refund) is terminal and always clears the queue’s in-flight slot.

Refundability depends on the upstream provider:

  • Channel-point redemptions: refundable via Helix; viewer’s points are returned.
  • Bits cheers: not programmatically refundable by Twitch; the refund request is recorded as skipped and the streamer is notified out-of-band.
  • Other Cue sources: handled case-by-case by the platform’s refund worker.

Adapters SHOULD use refund=true only when the Invocation could not be meaningfully applied (target already in requested state, prerequisite resource missing, gating impossible). Adapters MUST NOT use refund=true to express “applied, but partially” — that is applied followed by done or failed without refund.

{"mpp": 2, "type": "heartbeat"}

Either side MAY emit heartbeat at any time after the handshake. No response is required. A connection that goes more than 30s without traffic in either direction MAY be considered stale; the bridge SHOULD log a warning but is not required to close the connection on that basis alone.

{"mpp": 2, "type": "log", "level": "info", "message": "loaded ROM in 12 ms"}
FieldTypeRequiredNotes
levelstringyesOne of trace, debug, info, warn, error.
messagestringyesFree-form log line.

The bridge captures log frames into an in-memory ring buffer (capacity 1000 entries, FIFO eviction).

{"mpp": 2, "type": "state_write", "key": "resets", "value": 3, "scope": "trainer:42"}
FieldTypeRequiredNotes
keystringyesA [state.<key>] declared in the active Pack Manifest.
valueobject/anyyesArbitrary JSON; validated against the key’s declared schema (if any).
scopestringnoOpaque run-scope tag. Stored + replayed for a persist = true key; ignored for non-persist keys. The bridge never interprets it.

The Adapter is the writer for a Pack-state key only when that key’s [state.<key>] writer = "adapter". On receipt the bridge enforces, in order:

  1. key is declared in the active Manifest’s [state.*] table — else protocol_error code=INVALID_FRAME and close.
  2. The key’s declared writer is adapter — else protocol_error code=INVALID_FRAME and close (the frame is structurally valid but the Adapter is not the authorised writer).
  3. value validates against the key’s compiled JSON Schema (when the key declares a schema; keys with no schema accept any JSON) — else protocol_error code=SCHEMA_VIOLATION and close.

On success the bridge inserts the value into the Pack-state store keyed by (pack_id, key). There is no outbound acknowledgement frame: an accepted write is silent, and the snapshot/diff fan-out to overlay subscribers is the store’s concern, not this frame’s.

When the key is declared persist = true, the accepted value + scope are additionally durably stored (eventually durable — an async write, not commit-before-ack) keyed by (pack_id, key), last-write-wins, and replayed to the Adapter in the next hello_ack persisted[] leg (§5.2). The durable store survives both Adapter and bridge restarts.

{"mpp": 2, "type": "protocol_error", "code": "INVALID_FRAME", "message": "unknown type: 'foo'"}
FieldTypeRequiredNotes
codestringyesMachine-readable error code (see list below).
messagestringyesHuman-readable detail.

Codes:

  • INVALID_FRAME — JSON parse failed, missing mpp/type, or unknown type. Also covers a state_write to an undeclared key or to a key the Adapter does not own.
  • UNKNOWN_INVOCATION_ID — Adapter referenced an id the bridge did not dispatch.
  • UNKNOWN_QUEUE — Adapter sent pull{queue} for a queue not declared in the active Pack Manifest.
  • SCHEMA_VIOLATION — params for an Invocation, or a state_write value, failed schema validation.
  • UNEXPECTED_STATE_TRANSITION — Adapter sent a frame inconsistent with the Invocation’s state.
  • FRAME_TOO_LARGE — line exceeded 64 KiB.

protocol_error from the bridge is always fatal: the bridge sends the frame and immediately closes the TCP connection. An Adapter that receives protocol_error MUST treat it as terminal and exit or reconnect rather than continue.

An Adapter-originated protocol_error (sent A→B in unusual recovery scenarios) is similarly treated as fatal by the bridge: it is logged into the ring buffer and the connection is closed.

MPP v2 is pull-driven. Dispatch occurs on a given queue if and only if both conditions hold:

  1. The Adapter has sent a pull{queue} whose credit is still outstanding (parked) for that queue.
  2. A pending Invocation exists whose queue equals that queue and whose pack_id + manifest_hash match the bridge’s server-pinned identity.

The bridge does not dispatch eagerly when an Invocation is created. A new pending Invocation whose queue has no parked pull simply waits; it will be picked up the next time the Adapter sends pull{queue}. There is no fallback path: Adapters that do not pull do not pump.

For each queue the bridge maintains an in-memory record of:

  • in_flight — set when an Invocation has been dispatched on this queue and not yet cleared; empty otherwise.
  • pending_pull — set when a pull{queue} has arrived for this queue and not yet been consumed by a dispatch; cleared on dispatch.
  • ready_after — resolved from the active Manifest’s [queues.<name>].ready_after at the time of the first pull.

A queue is dispatch-eligible iff in_flight is empty and pending_pull is set. When both conditions hold and a matching pending Invocation exists, the bridge dispatches the oldest such Invocation, marks it in-flight, and clears pending_pull.

[queues.<name>].ready_after controls when the bridge clears in_flight for that queue:

  • "applied" (default) — in_flight clears as soon as the bridge receives applied for the in-flight Invocation. The queue immediately becomes dispatch-eligible if a new pull is parked, even though the Invocation may still progress to done or failed. Use this when the Adapter’s “applied” event represents the point at which the next Invocation can safely run.
  • "done"in_flight clears only when the bridge receives done (or failed) for the in-flight Invocation. The queue is held strictly until the terminal transition. Use this for queues whose effects must fully complete in-game before the next Invocation begins.

failed (terminal) always clears in_flight, regardless of ready_after.

Within a single queue, at most one Invocation is in flight at any time. The credit-of-1 + in-flight gate guarantees this without any extra coordination: while an Invocation is in flight, no subsequent pull on the same queue can be consumed.

Across queues, dispatches are independent. An Adapter with N declared queues can have up to N Invocations in flight simultaneously (one per queue).

Queue names are not validated when a Cue produces an Invocation; validation happens at bridge dispatch time:

  • If a pending Invocation names a queue that the bridge’s loaded Manifest does not declare, the bridge marks it failed with reason = "unknown_queue: <name>" and does not dispatch it.
  • If the Adapter sends pull{queue} for a queue the Manifest does not declare, the bridge replies with protocol_error code="UNKNOWN_QUEUE" and closes the connection.

After a successful hello_ack, the bridge records pack_id and broadcaster_login as the server-pinned identity for the connection. These values are taken from the bridge’s loaded Manifest and its platform session — not from anything the Adapter sends in subsequent frames.

The Adapter MUST NOT include pack_id or broadcaster_login in non-hello frames. If the bridge sees those keys in any non-hello frame, they are silently ignored. This rule prevents a compromised or buggy Adapter from claiming another Pack’s identity once connected.

Similarly, queue is a routing tag on invocation and pull only — the bridge does not honour queue on terminal frames (ack, applied, done, failed). The bridge resolves the queue for those frames from its own in-flight map keyed by the Invocation id.

When the Adapter’s TCP connection closes (clean FIN, RST, read error, or protocol_error), the bridge:

  1. Stops dispatching further Invocations to this Adapter.
  2. For every queue, requeues only the Invocation currently in flight (i.e. dispatched-but-not-yet-cleared) back to pending. Invocations that have already reached applied on a ready_after = "done" queue are left in applied — they are NOT requeued. This narrowed scope eliminates the double-application risk that the v1 “requeue everything dispatched” rule had for long-running ready_after = "done" workflows.
  3. Pending Invocations whose queue had a parked pull at disconnect time stay pending; the next Adapter connection’s first pull{queue} will pick them up.
  4. The bridge clears its in-memory per-queue state and resumes listening for a new Adapter connection. State is built fresh on the next hello.

Invocations belonging to other bridge sessions are untouched. There is no timeout-based retry. The bridge does not re-send or duplicate Invocations after dispatch. Resilience is provided exclusively by the narrowed re-queue-on-disconnect mechanism above.

Adapter Bridge
| ---- hello (mpp:2) -----> |
| <---- hello_ack (ok) ------- | bridge server-pins pack_id + broadcaster_login
| |
| ----- pull (queue=default) -> | bridge parks credit on "default"
| <--- invocation (id=42, | bridge: invocation 42 pending → dispatched
| queue=default) -------- | in_flight[default] = 42; pending_pull[default] = false
| ----- ack (id=42) ----------> |
| ---- applied (id=42) -------> | bridge: 42 dispatched → applied
| | ready_after=applied → in_flight[default] = None
| ------ done (id=42) --------> | bridge: 42 applied → done (terminal)
| ----- pull (queue=default) -> | bridge parks next credit
| |
| <--- heartbeat -------------- |
| ---- log (info, "...") -----> | bridge appends to ring buffer
| |
| [TCP close] | bridge requeues ONLY in-flight rows
| | per queue → pending

9.1 Invocation lifecycle (for cross-reference)

Section titled “9.1 Invocation lifecycle (for cross-reference)”
pending ──► dispatched ──► applied ──► done (terminal)
pending ──► dispatched ──► failed (terminal; refund=false)
pending ──► dispatched ──► refunded (terminal; refund=true)
pending ──► failed (validation-fail before dispatch, e.g. unknown_queue)

The bridge owns every transition. The Adapter never talks to the platform’s database directly. The refunded state is reached only via a failed frame carrying refund: true.

ScenarioAction
Inbound JSON parse errorBridge sends protocol_error code=INVALID_FRAME, closes connection.
Frame missing "mpp" or value ≠ 2Hello path: hello_ack ok=false reason=unsupported_mpp_version. Post-hello: protocol_error code=INVALID_FRAME. Both close the connection.
pull{queue} referencing a queue not in the active ManifestBridge sends protocol_error code=UNKNOWN_QUEUE, closes connection.
ack/applied/done/failed referencing an id this connection did not dispatchBridge sends protocol_error code=UNKNOWN_INVOCATION_ID, closes connection.
Bridge-side schema validation fails before dispatchBridge marks the Invocation failed with reason = "schema_validation: <details>". Adapter is never sent the frame.
Pending Invocation naming a queue not in the active ManifestBridge marks it failed with reason = "unknown_queue: <name>"; no invocation is emitted.
Adapter sends protocol_errorBridge logs the frame into the ring buffer and closes the connection.
Frame exceeds 64 KiBReceiver closes the connection; bridge SHOULD send protocol_error code=FRAME_TOO_LARGE first.
state_write to a key not declared in the active Manifest, or to a key the Adapter does not own (writeradapter)Bridge sends protocol_error code=INVALID_FRAME, closes connection; no store write.
state_write value fails the key’s compiled schemaBridge sends protocol_error code=SCHEMA_VIOLATION, closes connection; no store write.

MPP v2 is designed for localhost deployment: the bridge binds to a local TCP port and the Adapter runs on the same machine. The bridge_token is a shared secret of approximately 32+ bytes. The following are explicit choices, not oversights:

  • No TLS: the assumption is loopback transport. Operators who expose the bridge port off-host are responsible for tunnelling it themselves.
  • Token comparison is a plain hash equality (sha256(received) == stored_hash). Constant-time comparison is not specified because the threat model precludes a meaningful timing-attack adversary.
  • The bridge ignores pack_id/broadcaster_login outside hello precisely to limit damage from a hostile Adapter.
  • Queue names are capped at 64 bytes at ingress and validated against the loaded Manifest at bridge dispatch; a hostile or buggy caller cannot inflate storage by injecting arbitrary queue names.
VersionStatusNotes
v2CurrentAdds pull{queue} credit grant + invocation.queue routing tag; narrows disconnect requeue to in-flight per queue; rejects mpp:1. Breaking; no v1 fallback.
v1DeprecatedInitial release. Eager dispatch; single-slot serial dispatch; no queue concept. Unsupported as of v2 — mpp:1 envelopes are rejected with unsupported_mpp_version.