The wire protocol defines the message format between the Elixir SDK and the Rust renderer. The protocol is language-agnostic and shared across all Plushie SDKs. This reference covers the Elixir perspective: how the SDK encodes, decodes, and manages the protocol lifecycle.

For the complete message format specification (every field, every patch operation, every event family), see the Renderer Protocol Spec.

Wire formats

Two formats carry the same message structures. Controlled by the :format option on Plushie.start_link/2 or the --json flag on mix plushie.gui.

MessagePack (default)

Each message is prefixed with a 4-byte big-endian unsigned integer indicating the payload size:

<<size::32-big, payload::binary-size(size)>>

The Bridge opens the Erlang Port with {:packet, 4}, which handles framing automatically in both directions. For custom transports (iostream adapters, SSH channels), use Plushie.Transport.Framing.encode_packet/1 and decode_packets/1.

MessagePack is more efficient for binary data (images, pixel buffers) and high update rates.

JSON (JSONL)

Each message is a single JSON object terminated by \n. Messages must not contain embedded newlines.

The Bridge opens the Port with {:line, 65_536}. For custom transports, use Plushie.Transport.Framing.encode_line/1 and decode_lines/1.

JSON is human-readable, making it useful for debugging. Combine with RUST_LOG=plushie=debug for full visibility:

RUST_LOG=plushie=debug mix plushie.gui MyApp --json 2>protocol.log

Format auto-detection

The renderer auto-detects the format from the first byte of stdin: 0x7B ({) means JSON, anything else means MessagePack. The --json and --msgpack CLI flags override auto-detection.

Maximum message size

64 MiB. Messages exceeding this are rejected by the renderer.

Protocol version

The current protocol version is 1. It is sent in the settings message as protocol_version and returned in the renderer's hello response. A version mismatch indicates an SDK/renderer version skew.

Read the version programmatically: Plushie.Protocol.protocol_version/0.

Startup handshake

The SDK and renderer follow a fixed startup sequence:

  1. SDK sends Settings - Plushie.Protocol.encode_settings/2 serialises the app's settings/0 callback result plus the protocol version. The Bridge writes this as the first message.
  2. Renderer auto-detects format from the first byte and reads the Settings.
  3. Renderer sends Hello - reports its version, mode (mock, headless, windowed), backend, transport, and registered extensions.
  4. SDK sends Snapshot - the Runtime calls view/1, normalises the tree via Plushie.Tree.normalize/1, and sends the full tree via Plushie.Protocol.encode_snapshot/2.
  5. Normal message exchange begins.

If the renderer restarts (crash recovery), the handshake repeats from step 1. The Runtime re-sends settings and a fresh snapshot.

Encoding (SDK -> renderer)

The Plushie.Protocol module delegates encoding to an internal Encode module for all outbound messages:

FunctionMessage typeWhen sent
encode_settings/2settingsStartup, renderer restart
encode_snapshot/2snapshotFirst render, renderer restart
encode_patch/2patchIncremental tree updates
encode_effect/4effectPlatform effect requests
encode_subscribe/5subscribeSubscription activation
encode_unsubscribe/3unsubscribeSubscription removal
encode_widget_op/3widget_opFocus, scroll, select, cursor, announce
encode_widget_command/4extension_commandNative widget commands
encode_window_op/4window_opWindow open, close, update
encode_image_op/3image_opIn-memory image lifecycle
encode_interact/5interactTest interactions
encode_advance_frame/2advance_frameManual frame step (test/headless)

Key stringification

The Elixir SDK works with atom keys internally (%{type: "text", props: %{content: "Hello"}}). During encoding, all map keys are converted to strings for the wire format. The stringify_tree/1 function in the encode module recursively walks the tree, converting atom keys to strings and encoding type-specific values (colours, lengths, paddings, borders, etc.) via the Plushie.Encode protocol.

This means you never need to think about string vs atom keys in your view code. The encoding layer handles it.

Decoding (renderer -> SDK)

The decode layer (Plushie.Protocol.decode_message/2) deserialises inbound messages and dispatches them to typed structs:

Wire typeDecoded toDelivered via
hello{:hello, map}Bridge stores and notifies Runtime
eventWidgetEvent, KeyEvent, WindowEvent, etc.update/2
effect_response{:effect_response, wire_id, result}Runtime maps to %EffectEvent{tag: tag}
interact_step{:interact_step, id, events}Test backend processes
interact_response{:interact_response, id, events}Test backend resolves
screenshot_response{:screenshot_response, data}update/2 as raw tuple
effect_stub_registered{:effect_stub_ack, kind}Runtime resolves pending stub call
session_error{:session_error, data}Session pool handles
session_closed{:session_closed, data}Session pool handles

Safe atomisation

Inbound maps from the renderer use string keys. The decode layer converts known keys to atoms via String.to_existing_atom/1 with a rescue fallback that preserves unknown keys as strings. This prevents atom table exhaustion from arbitrary renderer data while giving you atom keys for standard fields.

Effect results and system query data pass through safe_atomize_keys/1 recursively, so nested maps also get atom keys for known fields.

Snapshots vs patches

The Runtime decides whether to send a snapshot or a patch:

  • Snapshot - sent when there is no previous tree to diff against (startup, renderer restart, first render). Resets all renderer-side caches.
  • Patch - sent when the tree changes incrementally. Plushie.Tree.diff/2 compares the old and new normalised trees and produces a list of patch operations (replace_node, update_props, insert_child, remove_child). Child reordering is handled by replacing the parent node. If the diff is empty (identical trees), no message is sent.

Patches are more efficient for large trees with small changes. The renderer preserves widget caches (layer tessellation, text layout) for unchanged subtrees.

See the Renderer Protocol Spec for the complete patch operation format.

Session multiplexing

Every wire message carries a session field (string). In single-session mode (the default), this is "". In multiplexed mode (test session pool with --max-sessions N), each test session gets an isolated session ID. The renderer maintains per-session state (tree, subscriptions, effects, caches) keyed by this field.

Session lifecycle in multiplexed mode:

  • Sessions are created implicitly on first message
  • Reset tears down a session (thread exits, state freed)
  • The session ID can be reused after reset
  • --max-sessions limits concurrent sessions

See Plushie.Test.SessionPool for the Elixir-side multiplexing implementation.

The interact protocol

Test interactions (click, type_text, etc.) use a synchronous request-response protocol:

  1. SDK sends interact message with an action, selector, and payload.
  2. Renderer resolves the selector, simulates the interaction, and sends back one or more interact_step messages with intermediate events.
  3. Renderer sends a final interact_response with the last batch of events.
  4. The test backend processes all events through run_update and returns the final state to the test process.

This ensures test interactions are fully synchronous. click("#save") blocks until the full update cycle (event -> update -> view -> patch) completes.

iostream adapters

Custom transports use {:iostream, pid} and must implement a message protocol for bidirectional communication with the Bridge. See the Configuration reference for the full message contract.

The adapter is responsible for framing. For byte-stream transports (TCP sockets, SSH channels), use Plushie.Transport.Framing for 4-byte length-prefixed framing (MessagePack) or newline-delimited framing (JSON).

See also