Status: implemented in Portals.Protocol, Portals.Codec.MessagePack,
Portals.TermExtensions, and Portals.Handshake. This document must stay in
sync with those modules; the modules are authoritative where they disagree.
This is a language-neutral specification: a new SDK can be implemented from
this document, protocol/vectors/golden.exs, and protocol/malformed/fixtures.exs
alone, without reading Elixir source.
1. Transport framing
Each frame is emitted as a MessagePack-encoded envelope, length-prefixed on
the wire as a 4-byte big-endian unsigned length followed by that many bytes
of envelope. The length prefix is transport framing, not part of the codec
payload measured against max_frame_size below (the codec measures the
encoded envelope's own byte size).
2. Envelope shape
Every frame is a MessagePack array whose first element is an integer frame tag:
[tag, field_1, field_2, ...]Frames have fixed minimum arity and an optional bounded number of trailing fields, given per frame below. Decoders must reject any envelope that is not an array, whose first element is not an integer, whose tag is unknown, or whose arity is out of range, without raising and without malformed input propagating past that connection.
3. Frame tags and layouts
| Tag | Frame | Direction | Fields (after tag) |
|---|---|---|---|
| 1 | HELLO | Worker → BEAM | protocol_version, runtime, max_concurrency, max_streams, features |
| 2 | READY | BEAM → Worker | protocol_version, limits |
| 3 | CALL | BEAM → Worker | request_id, module, function, args[, deadline_ms[, callback_depth]] |
| 4 | RETURN | Worker → BEAM | request_id, value |
| 5 | ERROR | Worker → BEAM | request_id, error_map |
| 6 | CANCEL | BEAM → Worker | request_id |
| 7 | CALLBACK | Worker → BEAM | callback_id, module, function, args[, depth] |
| 8 | CALLBACK_RETURN | BEAM → Worker | callback_id, value |
| 9 | CALLBACK_ERROR | BEAM → Worker | callback_id, error_map |
| 10 | MESSAGE | Worker → BEAM | target_pid, value |
| 11 | STREAM_DATA | Either | stream_id, chunk |
| 12 | CREDIT | Either | stream_id, byte_count[, frame_count] |
| 13 | HALF_CLOSE | Either | stream_id |
| 14 | PING | Either | nonce |
| 15 | PONG | Either | nonce |
| 16 | SHUTDOWN | BEAM → Worker | [reason] (optional) |
error_map is a string-keyed map with at least kind and message, and may
include details, remote (foreign language/exception metadata), and a
bounded, normalized stacktrace.
4. Handshake and version matching
The worker connects and sends HELLO first. protocol_version must equal
the BEAM's own protocol version (Portals.Protocol.version/0, currently 1)
exactly. On a match, the BEAM replies with READY carrying its
configured limits (§6) and the connection becomes usable. On any mismatch,
the BEAM never sends READY; it closes the connection and worker startup
fails deterministically. There is no negotiation of intermediate versions.
HELLO's max_concurrency and max_streams are the worker's own
advertised capacities and are independent of each other: max_concurrency
bounds concurrently executing unary CALLs, max_streams bounds
concurrently open streams (§9.5). A worker that does not implement
streaming advertises max_streams 0.
5. Value model
Core MessagePack types:
nil,true/false- signed/unsigned integers representable in 64 bits, using the smallest MessagePack integer encoding available
- IEEE 754 double-precision floats
- UTF-8 strings (MessagePack
str) - raw binaries (MessagePack
bin) — see §5.2 on how implementations pickstrvsbin - arrays
- string-keyed maps (non-string keys are rejected)
5.1 Erlang-value extensions
Layered on MessagePack ext (fixext1/2/4/8/16 or ext8/16/32), each tagged
with a Portals extension type code:
| Type code | Name | Payload |
|---|---|---|
| 0 | tuple | MessagePack array of the tuple's elements |
| 1 | atom | UTF-8 atom text |
| 2 | pid | :erlang.term_to_binary/1 of the PID (opaque to foreign SDKs) |
| 3 | reference | :erlang.term_to_binary/1 of the reference (opaque to foreign SDKs) |
| 4 | bigint | 1 sign byte (0 = positive/zero, 1 = negative) followed by the big-endian magnitude |
| 5 | improper_list | MessagePack array of exactly 2 elements: [proper_prefix, tail] |
PIDs and references are opaque byte tokens to foreign SDKs: a trusted worker
may store and echo them back (e.g. as a MESSAGE target) but must not
attempt to construct or interpret their bytes itself.
5.2 str vs bin
The reference Elixir codec treats plain host-language strings as
MessagePack str by default. Binary payloads that are not meant to be
interpreted as UTF-8 text (large blobs, images, arbitrary bytes) must be
wrapped by the sending SDK to force bin encoding (in Elixir:
%Portals.Codec.Bin{data: bytes}). Decoders MUST NOT assume bin payloads
are valid UTF-8, and MUST return bin payloads to the host language as a
distinct byte-buffer type rather than a text string.
5.3 Safe decoding rules
- Atoms decode only to an atom that already exists in the runtime.
Decoding text for which no existing atom exists is a decode error
(
{:unsafe_atom, text}); decoders must never call an unsafe atom-creation primitive. hosts without a persistent atom table (Ruby, Python, Node) MAY decode atoms as native symbols/strings, since the safety concern is specific to the BEAM. - PIDs and references decode only through a safe, non-code-executing deserialization primitive, and the result is type-checked to confirm it is actually a PID/reference (not an arbitrary term) before it is accepted. Both local and distributed PIDs are accepted.
- Decoders never evaluate,
eval, or otherwise execute anything from the wire.
6. Limits
Every connection enforces caller-configured limits (see
Portals.Protocol.default_limits/0 for the shipped defaults):
| Limit | Default | Applies to |
|---|---|---|
max_frame_size | 16 MiB | Total encoded bytes of one envelope |
max_nesting_depth | 32 | Recursion depth while encoding/decoding a term |
max_collection_length | 65536 | Element count of one array/map/tuple |
max_metadata_size | 65536 | Bytes of error details/stacktrace metadata |
max_callback_depth | 16 | Nested reentrant CALLBACK depth |
max_in_flight_callbacks | 256 | Concurrently outstanding CALLBACKs per connection |
max_in_flight_requests | 4096 | Concurrently outstanding CALLs per connection |
max_stream_byte_credit | 4 MiB | Outstanding CREDIT byte allowance per stream direction |
max_queued_stream_frames | 1024 | Queued, unacknowledged STREAM_DATA frames per stream direction |
max_frame_size bounds the complete encoded envelope, independent of
max_collection_length, which bounds element counts (not raw binary
payload sizes — a single large bin/str payload is bounded only by
max_frame_size).
Exceeding any limit on encode or decode is a codec error, not an exception; callers must close only the offending connection.
7. Request semantics
request_idis an unsigned 64-bit integer, monotonically increasing per connection. A connection must be restarted before ID space exhaustion rather than wrapping.- Exactly one terminal
RETURNorERRORis sent per acceptedCALL. Responses may arrive out of order relative toCALLs. CANCELis idempotent: sending it after the terminal response already arrived (or is in flight) has no effect and produces no additional response.- An unknown
request_idon a terminal frame is logged/reported and otherwise ignored, unless it recurs enough to exceed a configurable protocol-error threshold, at which point the connection is closed. CALLBACK/CALLBACK_RETURN/CALLBACK_ERRORuse their own independent ID space (callback_id) and must never be executed inline on the socket reader — nested calls and out-of-order responses must not deadlock the connection.
8. Reentrant callbacks and PID messaging
CALLBACK's optional trailingdepthis self-reported by the worker (the BEAM does not and cannot independently derive it, since nothing else in the protocol links aCALLBACKto whicheverCALLit is nested under). This is safe specifically because Portals workers are trusted (see the PRD's non-goals: hostile workers are out of scope); the BEAM still enforcesmax_callback_depthandmax_in_flight_callbacksagainst the reported value, it just does not attempt to verify it independently. ACALLBACKomittingdepthis treated as depth1.- To make depth accounting work across a fully reentrant chain (BEAM
calls worker → worker's handler triggers a
CALLBACK→ the BEAM callback handler makes a nestedCALLback into the same worker → that nested call's handler triggers anotherCALLBACK, etc.), the BEAM echoes the depth it observed on the inboundCALLBACKas thecallback_depthfield of anyCALLframe dispatched from within that callback's handler. A worker's dispatcher reads this field and uses it as the baseline when it next reports aCALLBACK's owndepth(baseline+ 1). A top-levelCALL(not originating inside a callback handler) always carriescallback_depth0. - The connection's socket reader must never execute the target
module.functioninline: everyCALLBACKis dispatched to an independent supervised task, so a slow, blocked, or reentrant handler can never stall reading further frames or delivering other in-flight responses. - Exceeding
max_callback_depthormax_in_flight_callbacksis reported back as a terminalCALLBACK_ERRORwithkind: "overload"; it never silently drops theCALLBACKand never blocks the connection. MESSAGE'starget_pidis decoded per §5.3 (safe decode, then type-checked as an actual PID) before delivery. Delivery uses ordinary BEAMsend/2semantics: fire-and-forget, no acknowledgement, and no guarantee the target process still exists. Implementations may wrap the delivered value in an envelope (e.g. Elixir's reference implementation defaults to{:portals_message, connection, value}) so a target process can distinguish Portals messages from its other traffic; this is a local delivery-side convention, not a wire-format concern.
9. Bidirectional streaming and backpressure
9.1 Opening a stream
There is no separate "open stream" frame. A stream is opened by the BEAM
as an ordinary CALL, and that call's request_id is the stream's
stream_id; the two share one ID space per connection, so a stream_id
is never ambiguous. The call's RETURN/ERROR is the stream's terminal
frame: exactly one arrives, after which both peers release all state for
that stream_id.
How a worker decides that a given CALL is a stream is an SDK-local
concern, not a wire concern (the reference Python SDK marks streaming
targets with @portals.stream_handler and passes the handler a stream
object as its first argument). A worker that receives STREAM_DATA,
CREDIT or HALF_CLOSE for a stream_id it does not know must ignore
the frame rather than fail the connection, so that frames still in flight
when a stream terminates are harmless.
Streams are always opened by the BEAM. Once open, both peers may send
STREAM_DATA on it; the worker never initiates a stream of its own.
9.2 Credit
Each direction has its own, completely independent encoded-byte credit
window. A peer may send STREAM_DATA only while it holds enough credit,
and every frame is charged the complete frame size on the wire: the
4-byte length prefix plus the encoded envelope. Credit is never implicit:
- immediately after the
CALLthat opens a stream, the BEAM sendsCREDITgranting the worker its whole inbound window; - when a worker accepts a stream, it sends
CREDITgranting the BEAM its whole inbound window; - thereafter, a peer sends
CREDITas its application consumes frames, replenishing exactly the bytes consumed.
A window never exceeds the connection's max_stream_byte_credit
(§6). A peer that grants more than that, or that sends more bytes than it
was granted, has committed a protocol violation; the receiver tears down
only that stream (sending CANCEL, or a terminal ERROR with
kind: "protocol") and leaves the connection and all other streams
running.
CREDIT's optional third field, frame_count, is the number of queued
frames the grant acknowledges. It is backward compatible: a CREDIT that
omits it is treated as acknowledging exactly one frame, which is what a
per-frame-consumption sender produces anyway. Grants that carry no frame
acknowledgement (such as the initial window grant) send 0.
9.3 Queued-frame limit
Byte credit alone cannot stop a flood of tiny frames, so each direction
additionally enforces a hard cap of max_queued_stream_frames (§6)
frames that have been sent but not yet acknowledged by a CREDIT
frame_count. A sender must block once it reaches the cap even when
byte credit remains; a receiver that observes the cap exceeded treats it
as a protocol violation scoped to that stream. The two limits are
enforced independently and both apply at all times.
9.4 Half-close and cancellation
HALF_CLOSE closes only the sending direction of the peer that sent
it. The other direction stays fully usable — a peer may half-close
immediately and keep receiving for the rest of the stream's life.
HALF_CLOSE is idempotent, and STREAM_DATA received after the peer's
own HALF_CLOSE is a protocol violation scoped to that stream.
CANCEL on a stream's ID cancels the stream in both directions at once.
It is idempotent, races safely with the terminal RETURN/ERROR, and
requires both peers to release all state for that stream_id: queued
inbound frames, outstanding credit, and any blocked producer (which is
unblocked with a cancelled error rather than left waiting).
9.5 Capacity
A worker advertises its per-worker stream capacity as max_streams in
HELLO (§4), independently of the max_concurrency it advertises for
unary calls. max_streams of 0 means the worker does not implement
streaming; the BEAM then rejects open_stream locally with an overload
error and never sends stream frames to it. Both peers refuse to exceed
the advertised count, reporting overload rather than queueing without
bound.
The two budgets must stay independent in both directions: long-lived streams must not consume the worker's unary execution capacity (the reference Python SDK runs stream handlers on a separate thread pool), and unary saturation must not prevent a stream from being opened or serviced.
10. Conformance
Portals.Conformance.Runner is a black-box runner: given any module
implementing the Portals.Codec behaviour (encode/2, decode/2), it
replays every entry in protocol/vectors/golden.exs (must round-trip
byte-for-byte semantically) and protocol/malformed/fixtures.exs (must be
rejected with the documented error class, never accepted, never raised).
A non-Elixir SDK satisfies the same conformance requirement by implementing
an equivalent runner against the same two fixture files.