The pad is feature-complete. In this final chapter we take it one step further: make it collaborative. Multiple people connect to the same pad over SSH and see each other's changes in real time.
Architecture
Plushie apps communicate with the renderer over a byte stream. By default that stream is stdio to a local process, but it can be any transport, including an SSH channel.
The key insight: each connected client gets its own Plushie.Runtime.
A shared GenServer holds the authoritative model and broadcasts changes
to all clients via Runtime.dispatch/2. This gives you the full SDK
pipeline per client (error isolation, tree diffing, subscriptions,
event coalescing) while keeping state centralized.
ssh client 1 ──SSH──┐ ┌── Runtime 1 (tree diff, patch)
├── iostream ──┤
ssh client 2 ──SSH──┘ └── Runtime 2 (tree diff, patch)
│
Shared GenServer
(authoritative model,
update/2, broadcast)Each SSH channel acts as an iostream adapter. Events from any client
are decoded, forwarded to the shared server, which runs update/2
centrally and broadcasts the result. Each client's Runtime receives
the broadcast as a custom event, replaces its local model, re-renders,
diffs, and sends only the patches to the renderer.
Broadcast event
Define a struct for the shared server to dispatch through each client's Runtime:
defmodule PlushiePad.Broadcast do
@enforce_keys [:model]
defstruct [:model]
endHandle it in your app's update/2 to replace the local model with
the authoritative state:
def update(_model, %PlushiePad.Broadcast{model: shared_model}) do
shared_model
endShared state server
The shared GenServer holds the authoritative model, runs update/2
with error isolation, and broadcasts via Runtime.dispatch/2:
defmodule PlushiePad.Shared do
use GenServer
require Logger
def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, :ok, opts)
def get_model(server), do: GenServer.call(server, :get_model)
def connect(server, id, runtime), do: GenServer.call(server, {:connect, id, runtime})
def disconnect(server, id), do: GenServer.cast(server, {:disconnect, id})
def event(server, event), do: GenServer.cast(server, {:event, event})
@impl true
def init(:ok) do
{:ok, %{model: PlushiePad.init([]), clients: %{}}}
end
@impl true
def handle_call(:get_model, _from, state) do
{:reply, state.model, state}
end
@impl true
def handle_call({:connect, id, runtime}, _from, state) do
Process.monitor(runtime)
clients = Map.put(state.clients, id, runtime)
{:reply, :ok, %{state | clients: clients}}
end
@impl true
def handle_cast({:event, event}, state) do
case safe_update(state.model, event) do
{:ok, model} ->
broadcast(state.clients, model)
{:noreply, %{state | model: model}}
:error ->
{:noreply, state}
end
end
@impl true
def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do
clients =
state.clients
|> Enum.reject(fn {_id, p} -> p == pid end)
|> Map.new()
{:noreply, %{state | clients: clients}}
end
defp safe_update(model, event) do
try do
result = PlushiePad.update(model, event)
model =
case result do
{m, _commands} -> m
m -> m
end
{:ok, model}
rescue
e ->
Logger.error("Shared: update/2 crashed: #{Exception.message(e)}")
:error
end
end
defp broadcast(clients, model) do
event = %PlushiePad.Broadcast{model: model}
Enum.each(clients, fn {_id, runtime} ->
Plushie.Runtime.dispatch(runtime, event)
end)
end
endWhen any client sends an event, the server runs update/2 inside a
try/rescue so one client's bad input cannot crash the shared state.
Process monitoring cleans up disconnected clients automatically.
The important detail: Runtime.dispatch/2 pushes the broadcast event
through the standard Runtime pipeline. The Runtime calls update/2
with your broadcast struct, renders the new model via view/1, diffs
against the previous tree, and sends only the changed patches to the
renderer. No full snapshots, no manual tree normalization.
SSH channel as iostream adapter
Each SSH connection gets a channel that acts as an iostream adapter
for a dedicated Plushie.Runtime. The channel translates between
SSH framing and the iostream protocol that the Bridge speaks:
defmodule PlushiePad.SshChannel do
@behaviour :ssh_server_channel
alias Plushie.Transport.Framing
defstruct [:shared, :client_id, :conn, :channel, :bridge, :plushie_sup, buffer: <<>>]
@impl true
def init([shared]) do
{:ok, %__MODULE__{shared: shared, client_id: "ssh-#{:erlang.unique_integer([:positive])}"}}
end
@impl true
def handle_msg({:ssh_channel_up, channel, conn}, state) do
state = %{state | conn: conn, channel: channel}
# Seed the client with the current authoritative model
model = PlushiePad.Shared.get_model(state.shared)
plushie_name = :"plushie_pad_#{state.client_id}"
{:ok, sup} =
Plushie.start_link(PlushiePad,
name: plushie_name,
transport: {:iostream, self()},
format: :msgpack,
daemon: true,
app_opts: [shared_model: model]
)
runtime = Plushie.runtime_for(plushie_name)
PlushiePad.Shared.connect(state.shared, state.client_id, runtime)
{:ok, %{state | plushie_sup: sup}}
end
# iostream protocol: Bridge registers itself
def handle_msg({:iostream_bridge, bridge_pid}, state) do
{:ok, %{state | bridge: bridge_pid}}
end
# iostream protocol: Bridge sends data to the renderer
def handle_msg({:iostream_send, data}, state) do
packet = Framing.encode_packet(data) |> IO.iodata_to_binary()
:ssh_connection.send(state.conn, state.channel, packet)
{:ok, state}
end
def handle_msg(_msg, state), do: {:ok, state}
@impl true
def handle_ssh_msg({:ssh_cm, _conn, {:data, _channel, 0, data}}, state) do
combined = state.buffer <> data
{frames, buffer} = Framing.decode_packets(combined)
state = Enum.reduce(frames, state, &handle_frame/2)
{:ok, %{state | buffer: buffer}}
end
def handle_ssh_msg({:ssh_cm, _conn, {:closed, _channel}}, state) do
cleanup(state)
{:stop, state.channel, state}
end
def handle_ssh_msg(_msg, state), do: {:ok, state}
@impl true
def terminate(_reason, state) do
cleanup(state)
:ok
end
defp handle_frame(frame, state) do
case Plushie.Protocol.Decode.decode_message(frame, :msgpack) do
{:hello, _} ->
# Handshake: forward to Bridge
if state.bridge, do: send(state.bridge, {:iostream_data, frame})
state
%_{} = event ->
# User events go to shared server for centralized update
PlushiePad.Shared.event(state.shared, event)
state
_ ->
if state.bridge, do: send(state.bridge, {:iostream_data, frame})
state
end
end
defp cleanup(state) do
PlushiePad.Shared.disconnect(state.shared, state.client_id)
if state.bridge, do: send(state.bridge, {:iostream_closed, :ssh_closed})
if state.plushie_sup do
try do
Plushie.stop(state.plushie_sup)
catch
:exit, _ -> :ok
end
end
end
endThe channel decodes incoming frames to separate handshake messages (forwarded to Bridge) from user events (forwarded to Shared). Outgoing data from the Bridge gets SSH framing added before transmission.
On channel open, the channel fetches the current model, starts a
Plushie supervisor with iostream transport, and registers the runtime
with the shared server. From then on, the shared server broadcasts
model changes to the runtime via dispatch/2, and the runtime handles
tree diffing and patching automatically.
SSH server with key authentication
The server uses Erlang's built-in :ssh daemon. On first start, it
generates an Ed25519 host key (using ssh-keygen if available,
falling back to Erlang's :public_key module). Client authentication
uses the user's existing SSH keys via ~/.ssh/authorized_keys:
defmodule PlushiePad.SshServer do
def start(shared, port \\ 2222) do
:ok = Application.ensure_started(:crypto)
:ok = Application.ensure_started(:asn1)
:ok = Application.ensure_started(:public_key)
:ok = Application.ensure_started(:ssh)
system_dir = ensure_host_key()
user_dir = Path.expand("~/.ssh")
{:ok, _} =
:ssh.daemon({127, 0, 0, 1}, port,
system_dir: String.to_charlist(system_dir),
user_dir: String.to_charlist(user_dir),
auth_methods: ~c"publickey",
subsystems: [{~c"plushie", {PlushiePad.SshChannel, [shared]}}]
)
IO.puts("SSH server listening on localhost:#{port}")
end
defp ensure_host_key do
dir = Path.join(["priv", "ssh"])
File.mkdir_p!(dir)
key_file = Path.join(dir, "ssh_host_ed25519_key")
unless File.exists?(key_file) do
case System.find_executable("ssh-keygen") do
nil -> generate_key_erlang(key_file)
_ -> System.cmd("ssh-keygen", ["-t", "ed25519", "-f", key_file, "-N", "", "-q"])
end
IO.puts("Generated SSH host key: #{key_file}")
end
dir
end
defp generate_key_erlang(path) do
key = :public_key.generate_key({:namedCurve, :ed25519})
pem = :public_key.pem_encode([:public_key.pem_entry_encode(:ECPrivateKey, key)])
File.write!(path, pem)
end
endThe host key persists in priv/ssh/ so the server identity is stable
across restarts. Clients authenticate with their existing SSH keys.
Starting the server
Add a mix task to start everything:
defmodule Mix.Tasks.PlushiePad.Server do
use Mix.Task
def run(_args) do
Mix.Task.run("app.start")
{:ok, shared} = PlushiePad.Shared.start_link()
PlushiePad.SshServer.start(shared)
Process.sleep(:infinity)
end
endConnecting
Start the server in one terminal:
mix plushie_pad.server
Connect from another terminal using the Plushie renderer over SSH:
plushie --exec "ssh -p 2222 localhost -s plushie"
Open a third terminal and connect again. Both windows show the same pad. Edit an experiment in one window and click Save. The other window updates instantly.
This is the same wire protocol, the same renderer binary, the same
view/1 function. The only difference is the transport: SSH instead
of stdio. Your Elixir code runs on the server; the renderer runs
wherever there is a screen.
Why Runtime.dispatch?
The previous version of this demo sent full UI tree snapshots to each
client on every change. With Runtime.dispatch/2, you get:
- Tree diffing: only changed patches are sent, not the full tree
- Error isolation: if one client's event crashes
update/2, the shared server catches it without affecting other clients - Subscriptions: each client Runtime manages its own subscription lifecycle (timers, key events, etc.)
- Event coalescing: rapid events (typing, scrolling) are coalesced by the Runtime before rendering
The shared server stays simple: hold model, run update, broadcast. The SDK pipeline handles everything else.
Per-client state
When some state is per-client (like a dark mode toggle), define it
in your model and preserve it across broadcasts. The collab demo in
the plushie-demos repository shows this pattern:
def update(model, %Collab.Broadcast{model: shared_model, originator_id: originator_id}) do
if originator_id == model.client_id do
# Originator: local model is already up to date, just sync status
%{model | status: shared_model.status}
else
# Other clients: replace shared fields, keep per-client state
%{shared_model | dark_mode: model.dark_mode, client_id: model.client_id}
end
endThe originator_id pattern prevents double-applying state changes on the client that originated the event. Other clients replace their shared fields with the broadcast state while preserving local preferences.
Verify it
The shared GenServer is a plain GenServer that can be tested without SSH:
test "shared server broadcasts model changes" do
{:ok, shared} = PlushiePad.Shared.start_link()
# Start a real Runtime as the client
{:ok, sup} =
Plushie.start_link(PlushiePad,
name: :test_client,
transport: {:iostream, self()},
format: :msgpack,
daemon: true,
app_opts: [shared_model: PlushiePad.Shared.get_model(shared)]
)
runtime = Plushie.runtime_for(:test_client)
PlushiePad.Shared.connect(shared, "test", runtime)
PlushiePad.Shared.event(shared, %WidgetEvent{type: :click, id: "save"})
# The runtime received a Broadcast event via dispatch, re-rendered,
# and sent a patch to us (the iostream adapter)
assert_receive {:iostream_send, _data}, 1000
Plushie.stop(sup)
endWebSocket is another viable transport for shared state, particularly
for browser-based collaboration where SSH is not available. The wire
protocol is the same; only the transport layer changes. See the collab
demo in the plushie-demos repository for a WebSocket-based example
that includes origin checking, rate limiting, and per-client state.
You now have a collaborative editor with file management, styling, animation, subscriptions, effects, canvas drawing, custom widgets, tests, and shared state over SSH. The reference docs cover each topic in depth when you need it.