MINARA

Streaming over the multiplexed WebSocket

GET /v1/stream. One WebSocket carrying every server-push channel (chat, notifications, preferences, deposit/withdraw watches, workflow, data-studio previews), with per-channel sequence numbers and reconnect replay.

GET /v1/stream (WebSocket upgrade)

Every long-lived server-push stream the gateway emits (chat turns, notifications, preference saves, deposit/withdraw watches, workflow runs, Data Studio previews) rides one multiplexed WebSocket. Folding them onto a single socket keeps them out of the browser's ~6-connection-per-origin HTTP/1.1 pool, so a busy or stuck stream in one tab can't starve other tabs' plain requests.

MethodGET (HTTP Upgrade: websocket)
Path/v1/stream
Auth?token=<GATEWAY_AUTH_TOKEN> query param when auth is enabled (a browser WebSocket can't set an Authorization header)

Reference client: createMultiplexClient in @minara/gateway-client. It owns reconnect, resume, and the connection timeout, so callers only write subscribe(channel, key, { onEvent }).

Connecting

Open a WebSocket to /v1/stream, deriving ws:// / wss:// from the gateway origin. When GATEWAY_AUTH_TOKEN is set, append ?token=<token>; a bad or missing token is rejected at the handshake with 401.

wss://<gateway-host>/v1/stream?token=<GATEWAY_AUTH_TOKEN>

The connection multiplexes many logical streams. A channel is the stream type; a key scopes one subscription within a channel (a chat session id, a withdraw operation id, a workflow instance id, …). Singleton channels (notifications, prefs) use an empty-string key.

Channels

ChannelKeyReplaySource
chatsession_idyes (per-turn buffer)a turn started by POST /v1/chat/stream
notifications""no (live-only)global notification bus
prefs""no (live-only)runtime-preference saves
depositchain:asset:addressyeson-demand deposit watch
withdrawoperationIdyeson-demand withdraw watch
workflowinstanceIdyesworkflow run bus
data-studiosession_idyesData Studio live preview

Live-only channels (notifications, prefs) deliver only events that occur after a subscription attaches. Replaying the backlog to a freshly opened tab would re-toast old notifications. Reconnect gaps on those are covered by each feature's REST reload.

On-demand channels (deposit, withdraw, workflow, data-studio) start their upstream when the first subscriber for a (channel, key) attaches and stop it when the last one leaves (the hub ref-counts), so a deposit watch's polling runs only while a tab is watching it.

Client → server frames

Send JSON control frames to subscribe and unsubscribe. Re-subscribing the same (channel, key) replaces the previous attachment.

{ "op": "subscribe", "channel": "chat", "key": "chat_abc", "fromSeq": 0 }
{ "op": "unsubscribe", "channel": "chat", "key": "chat_abc" }

fromSeq is the reconnect cursor: the server replays every buffered event with seq strictly greater than it. Omit it (or send 0) for a fresh subscription that wants the full backlog.

Server → client frames

Each event is one JSON frame. type and data are carried verbatim from the channel's own event union (e.g. text_delta for chat), so a client that already parsed the pre-WebSocket streams reuses its reducers unchanged.

{ "channel": "chat", "key": "chat_abc", "seq": 12, "type": "text_delta", "data": { "text": "…" } }
FieldMeaning
channel / keywhich subscription the frame belongs to
seqmonotonic per-(channel, key) sequence from 1; the reconnect cursor
typethe channel's own event discriminator
datathe channel's own payload

Control frames

Two out-of-band frames the multiplex layer itself emits carry seq: 0 and never participate in replay:

typeMeaning
__errorthe subscription was rejected or has no live stream (e.g. a chat session with no in-flight turn). data.message is human-readable; the client falls back to a REST reload.
__truncatedthe replay buffer overflowed and dropped events older than the delivered backlog, so the client can warn that some history was lost.

Reconnect & resume

On disconnect the reference client reconnects with exponential backoff and re-subscribes every live channel from the highest seq it saw, so the server replays only what was missed. Resume relies on the per-(channel, key) buffer: it is process-local and does not survive a gateway restart, after which a chat re-subscribe returns __error ("no active stream") and the client reloads from REST.

Failure is bounded so a client never hangs: a socket that never reaches the open state is force-closed and retried, and once the reconnect budget is exhausted every live subscription's error handler fires once (rather than spinning silently) while the client keeps retrying at the capped backoff.

Keep-alive

The gateway pings every open socket on a fixed interval. A WebSocket ping is real traffic the browser auto-pongs, so it stops an idle proxy in front of the gateway from reaping a quiet connection, and it reaches sockets with no active subscription, which a channel event never would.

On this page