ADR 0054: Communication Protocols

Status

Implemented (2026-03-05)

Context

The Beamtalk system comprises several components written in different languages (Rust CLI, Erlang/OTP runtime, browser frontend) that must communicate across process and language boundaries. Over the course of development, three distinct communication protocols emerged, each chosen to fit a specific boundary:

BoundaryComponentsTransport
Client ↔ WorkspaceCLI, browser, IDE ↔ REPL backendWebSocket + JSON
Workspace ↔ CompilerErlang runtime ↔ Rust compilerOTP Port + ETF
Actor ↔ ActorBeamtalk actor processesBEAM gen_server calls/casts

The architectural choice to use different protocols for different channels — rather than a single universal protocol — has never been formally recorded. This ADR documents the rationale.

Why Not a Single Protocol?

Each boundary has fundamentally different constraints:

A single protocol (e.g., JSON-RPC everywhere) would impose unnecessary overhead at the tighter boundaries and lose native guarantees at the loosest one.

Decision

Use three protocols, each fit-for-purpose at its boundary.

1. Client ↔ Workspace: WebSocket + JSON

Transport: WebSocket (RFC 6455) over TCP, ws://127.0.0.1:{port}/ws Format: JSON, one message per WebSocket text frame Security: Cookie-based authentication handshake (ADR 0020) Reference: docs/repl-protocol.md (authoritative)

The REPL protocol is inspired by nREPL and Jupyter. It uses an operation-based message format:

Request:

{"op": "eval", "id": "msg-001", "code": "1 + 2"}

Response:

{"id": "msg-001", "value": 3, "status": ["done"]}

Why WebSocket + JSON:

Key features:

Evolution: The REPL protocol originally used raw TCP with newline-delimited JSON. ADR 0020 migrated to WebSocket for browser compatibility and cookie authentication. The operation semantics are unchanged.

2. Workspace ↔ Compiler: OTP Port + ETF

Transport: OTP Port (stdin/stdout), 4-byte length-prefixed frames ({packet, 4}) Format: Erlang External Term Format (ETF) Reference: ADR 0022 (authoritative)

The Rust compiler runs as a standalone executable managed by an OTP supervisor via open_port/2. The Erlang side uses term_to_binary/1 and binary_to_term/1; the Rust side uses an ETF library for encode/decode.

%% Compile a REPL expression — direct function call
KnownVars = [atom_to_binary(V) || V <- maps:keys(Bindings)],
beamtalk_compiler:compile_expression(Expression, ModuleName, KnownVars).

Why OTP Port + ETF:

Evolution: Originally the compiler ran as a separate daemon process communicating via JSON-RPC over Unix domain sockets. ADR 0022 replaced this with an embedded OTP Port, eliminating daemon lifecycle management, stale socket files, and JSON serialization overhead. The daemon code was fully removed in Phase 5.

3. Actor ↔ Actor: BEAM Process Messages

Transport: BEAM process messages via gen_server Format: Erlang terms Reference: docs/internal/beamtalk-protocols.md

Every Beamtalk actor is a BEAM process running a gen_server. Message sends compile to synchronous calls or asynchronous casts:

%% Sync: blocks until result
Result = gen_server:call(ActorPid, {increment, []}).

%% Async: returns a future immediately
FuturePid = beamtalk_future:new(),
gen_server:cast(ActorPid, {increment, [], FuturePid}).

Message format:

Why BEAM process messages:

Future resolution is a sub-protocol within this boundary. Futures are lightweight BEAM processes that transition through pending → resolved | rejected states. Waiters register via {await, Pid} messages and receive {future_resolved, FuturePid, Value} or {future_rejected, FuturePid, Reason} notifications. Futures self-terminate after 5 minutes of inactivity.

Prior Art

Smalltalk (Pharo, Squeak)

Everything runs in a single image — there are no protocol boundaries between the compiler, runtime, and UI. Message passing between objects is the only "protocol." Beamtalk differs because the compiler is Rust (not Beamtalk) and clients can be external processes (CLI, browser).

Erlang/OTP

OTP uses multiple protocols by design: distribution protocol for cross-node messaging, Ports for native code integration, and gen_server calls/casts for intra-node communication. Beamtalk follows this convention directly. The OTP Port pattern for the compiler aligns with how Erlang itself handles native code (heart, epmd, inet_gethost).

Elixir + Phoenix

Phoenix uses WebSocket channels for browser communication and native BEAM messaging for backend processes — the same split as Beamtalk. LiveView's server-rendered approach influenced the Workspace UI design (ADR 0017).

Jupyter

Jupyter's kernel protocol uses ZeroMQ with JSON messages over multiple channels (shell, IOPub, stdin). Beamtalk's REPL protocol is simpler (single WebSocket connection, multiplexed operations) but drew inspiration from Jupyter's operation semantics and streaming output model.

Language Server Protocol (LSP)

LSP uses JSON-RPC over stdio or TCP. Beamtalk's LSP integration uses standard LSP; the REPL protocol is separate because LSP's request/response model doesn't support streaming eval output, push notifications, or interactive stdin.

User Impact

Newcomer

The protocol boundaries are invisible. The CLI connects to the workspace, types expressions, gets results. The browser opens a URL and provides the same experience. No protocol knowledge is needed.

Smalltalk Developer

Actor messaging via gen_server preserves the message-passing semantics they expect. doesNotUnderstand:args: works as in Smalltalk. The fact that the compiler uses a different protocol (Port + ETF) is an implementation detail hidden behind beamtalk_compiler.

Erlang/BEAM Developer

All three protocols use standard BEAM patterns: WebSocket via Cowboy, OTP Port for native code, gen_server for process communication. They can inspect any layer with standard tools (observer, sys:get_state, dbg).

Production Operator

One BEAM node to monitor. The compiler port is supervised and auto-restarts on crash. WebSocket connections are authenticated via cookie handshake. Actor messaging stays within the VM — no network exposure. Standard BEAM monitoring tools work at every layer.

Tooling Developer (LSP, IDE)

The REPL protocol's describe operation enables dynamic capability discovery. The JSON format is easy to parse in any language. Push messages (Transcript, actor events) enable real-time UI updates. The show-codegen operation supports debugging and learning tools.

Steelman Analysis

For a Single Protocol (Rejected)

Response: The schema/versioning argument is the strongest case here, but it conflates protocol format with contract enforcement. Beamtalk actors are not public APIs — they're generated gen_server processes where message format is controlled entirely by the compiler. The implicit contract is maintained by the code generator, not by runtime validation; adding a serialization boundary would shift the enforcement mechanism without strengthening it. The distributed Erlang point is valid as a future concern, but Beamtalk v0.1 is explicitly single-node (ADR 0031) and premature generalization is a real cost. If actors span nodes in the future, the gen_server message format can be wrapped in a distribution layer at that point — and because the message format is compiler-generated rather than hand-written by users, adding schema validation or versioning is a codegen change, not a language change. For the compiler boundary, the response is similar: the Port boundary already provides crash isolation without needing JSON; ETF preserves Erlang term fidelity that JSON would lose. The complexity of three protocols is hidden behind clean APIs — callers use beamtalk_compiler:compile/2 and gen_server:call/2, not raw frames.

For Unix Sockets Instead of WebSocket (Rejected)

Response: Unix sockets don't work on Windows (ADR 0027), can't be accessed from browsers (ADR 0017), and are incompatible with standard HTTP reverse proxies for remote access (ADR 0020). The cookie handshake provides comparable access control for Beamtalk's local/remote access model, with different operational tradeoffs than filesystem permissions.

Tension Points

Alternatives Considered

Alternative A: JSON-RPC Everywhere

Use JSON-RPC 2.0 for all three boundaries: client ↔ workspace (WebSocket), workspace ↔ compiler (Unix socket or TCP), and a JSON-based actor messaging layer.

Rejected because:

Alternative B: ETF Everywhere

Use Erlang Term Format for all communication, including client ↔ workspace.

Rejected because:

Alternative C: gRPC / Protocol Buffers

Use gRPC for structured, typed communication across all boundaries.

Rejected because:

Consequences

Positive

Negative

Neutral

Implementation

This ADR records existing decisions. All protocols are implemented:

ProtocolImplementationADR
Client ↔ Workspace (WebSocket + JSON)beamtalk_repl_server.erl, beamtalk_repl_protocol.erl, beamtalk_ws_handler.erlADR 0020 (security), ADR 0017 (browser), ADR 0029 (streaming)
Workspace ↔ Compiler (OTP Port + ETF)beamtalk_compiler.erl, beamtalk_compiler_server.erl, beamtalk_compiler_port.erlADR 0022
Actor ↔ Actor (BEAM gen_server)beamtalk_actor.erl, beamtalk_future.erlADR 0005 (object model), ADR 0043 (sync-by-default)

No code changes are required.

References