Skip to main content

One WebSocket for the ui channel

A conversation's ops go down, and what the person presses goes up, over one WebSocket: reactor.ui.ws on the Python host, @datalayer/reactor/ui/ws on the page, and useUiSocket in React. The model's own stream (AG-UI, Vercel AI) is the host's and carries the model's text and tool calls; the ui channel does not ride on it.

TierImportWhat it holds
Pythonreactor.ui.wsUiHub (a UiChannel: ops numbered and kept per scope), UiSocket (the endpoint a host mounts)
TypeScript@datalayer/reactor/ui/wsUiSocket: hello, ordering, resume, heartbeats, presses with results
React@datalayer/reactor/ui/reactuseUiSocket: the ops reduced into a UiModel, act, state

The frames​

Every frame is one JSON object told apart by t, declared in reactor.ui.wire and in the committed schema (UpFrame, DownFrame). A frame that is not one of them is refused and the connection closed with the reason — on either side.

WayFrameSays
up, first{t: "hello", v: 1, token, scope, since}the credential (never in the URL), the conversation, the last op the page applied
down, first{t: "welcome", v: 1, scope, seq}the hello accepted; seq the scope's last op now
down{t: "op", seq, op}one op of the envelope, numbered one more than the last
up{t: "action", ref, action}a press: a UiAction, with its token and idempotency id
up{t: "state", ref, body}RFC 6902 operations on the scope's shared state
down{t: "result", ref, ok, status, detail, value}what came of an action or a state frame, with the HTTP status a route would answer
both{t: "ping"}, {t: "pong"}heartbeats

Ordering and resume​

The hub numbers each scope's ops from 1, keeps the last keep of them, and hands each to every connection on the scope. A hello with no since gets every op kept; with since, the ops after it. The page applies an op once: one it has already applied is skipped, and a gap makes it reconnect from its last op. A resume the hub cannot serve — since older than what it keeps, or past its last op — is closed with 4410, and the page draws the conversation again from its host.

The page's onOp(op, seq, history) says which ops are the past: on a fresh hello, every op the host replays (up to the welcome's seq) is history — the page draws it again but does not act on it as news, so a window op of the past is not raised again (useUiSocket passes only new ones to onWindow). Ops replayed on a resume were missed live, and are news.

A press carries an idempotency id (the page makes one when the caller gives none). Waiting for its result when the connection drops, it is sent again after the welcome with the same id, and the host's ActionRegistry runs it once.

Close codes​

CodeWhyThe page
4400a frame that is not the schema's, or a first frame that is not a hellostops
4401the credential refusedstops
4403the scope is not the caller'sstops
4408no hello in time, or nothing heard for timeoutreconnects
4410the resume cannot be servedstops: draw again
4429the page's queue overflowed (backpressure)reconnects from its last op

Mounting it​

from fastapi import FastAPI
from reactor.ui import ActionRegistry
from reactor.ui.ws import UiHub, UiSocket, UiSocketRefused, CLOSE_FORBIDDEN

hub = UiHub() # producers call hub.emit(op)
actions = ActionRegistry(secret)

def authenticate(token: str, scope: str):
principal = check(token) # raise to refuse (4401)
if not owns(principal, scope):
raise UiSocketRefused(CLOSE_FORBIDDEN, f"{scope} is not yours")
return principal

async def on_action(principal, scope, action):
return await actions.dispatch(principal, scope, action)

app = FastAPI()
app.add_api_websocket_route(
"/ui", UiSocket(hub, authenticate=authenticate, on_action=on_action).endpoint
)

On the page:

import { useUiSocket, ReactorDisplay } from '@datalayer/reactor/ui/react';

function Conversation({ scope }: { scope: string }) {
const { model, status, act } = useUiSocket({ url: 'wss://host/ui', scope, token: getToken });
return <>{model.order.map((id) => <ReactorDisplay key={id} display={model.displays[id]} act={act} />)}</>;
}

Two tabs on one conversation are two connections on one scope: each applies every op, and each press is answered on the tab that pressed it.

Tested on both tiers​

tests/test_ui_ws.py drives the endpoint: ordering after the welcome, a resume with none lost and none twice, a resume refused (4410), every refusal of a first frame, a frame not the schema's closing the connection, heartbeats and timeouts, a page too slow closed with 4429, presses answered with the registry's status on their own tab while the other tab sees the op, state frames, and a real FastAPI WebSocket. It writes schema/reactor-ui.v1.ws-examples.json — two connections of one page — which src/ui/__tests__/ws.test.ts replays against the page's UiSocket, frame for frame, beside its own tests of gaps, drops, refusals, heartbeats and two tabs; src/react/ui/__tests__/useUiSocket.test.tsx covers the hook.