Skip to main content

UI channel

A Python host tells a page what to show; the page shows it and sends back what the person pressed. Without a contract, every feature invents that exchange again: a hand-named event per message, a tool call faked to draw a card, one renderer slot the host owns and every plugin fights over, and a button that posts whatever the page decides to post.

reactor.ui is that exchange, written down once — one typed, versioned wire between a Python host and the page, with a half on each tier:

TierImportWhat it holds
Pythonreactor.uithe envelope and its ops, typed displays with audiences, typed actions with minted tokens, the AG-UI encoder, the JSON Schema
TypeScript@datalayer/reactor/uithe wire types and guards, the renderer registry and tool bindings, a pure reducer
TypeScript@datalayer/reactor/ui/wsUiSocket, the page's end of the ui channel's WebSocket
React@datalayer/reactor/ui/react<ReactorDisplay>, defineRenderer, useUiChannel, useUiSocket

The Python half needs nothing beyond pydantic (the AG-UI encoder is an optional extra); the TypeScript half has no React in it.

Four ideas​

One envelope. Everything the host says is an op — { "v": 1, "op", "scope", "id", "body" } — and there are eight of them: show, patch, append, remove, state, ask, window, meta. The page keeps what they say with one pure reducer. See Channel ops and the reducer.

Displays with a text. A display is a typed payload: a MIME-like type, a version, its data, and a text every display must have. A page with no renderer for the type draws the text; a transcript keeps it; a model reads it. Each field says who it is for — the page and the model, the page only, or (a secret) neither — and that is applied once, where the display leaves Python. See Displays and audiences.

Renderers keyed by type. On the page, a plugin draws a type by contributing a renderer for it. Several may claim one type; rank and version range decide. A tool result becomes a display too, by carrying one or by a plugin binding the tool's name to a type. See Renderers and tool bindings.

Actions back, with tokens. Only what a display offered can be pressed: the server mints a token for the session, the display and the action when it offers it, and checks it when the press comes back — then asks every plugin, then validates the payload. See Actions.

What a model sees​

The same display has two views, and the host picks one by who it is talking to:

from typing import Annotated
from pydantic import SecretStr
from reactor.ui import Display, audience, display_type

@display_type("application/vnd.example.weather+json", version=1)
class Weather(Display):
city: str
celsius: float
note: Annotated[str, audience("page")] = "" # the person sees it, the model does not
api_key: SecretStr | None = None # neither does

def text(self) -> str:
return f"{self.city}: {self.celsius} °C"

weather = Weather(city="Ghent", celsius=9, note="Bring a coat", api_key="k-123")
weather.wire() # what the page receives: city, celsius, note
weather.model_view() # what a model is told: the text, city, celsius
>>> weather.model_view()
{'type': 'application/vnd.example.weather+json', 'text': 'Ghent: 9.0 °C',
'data': {'city': 'Ghent', 'celsius': 9.0}}

In this section​

  • Displays and audiences — @display_type, the wire form, model_view, the registry and the provide_display_types hook.
  • Renderers and tool bindings — Renderers, ToolBindings, ReactorDisplay, resolveToolResult.
  • Channel ops and the reducer — the eight ops, UiChannel, applyUiOp, useUiChannel, and the JSON Patch rules.
  • Actions — tokens, the authorize_ui_action hook, idempotency, and the errors a route answers with.
  • AG-UI — the ops as AG-UI events, for a page that already reads an AG-UI stream.
  • WebSocket — one connection per conversation: ops down numbered, presses and page state up, hello, resume, heartbeats, backpressure, refusal (reactor.ui.ws, UiSocket, useUiSocket).
  • Schema and types — the JSON Schema, and how the TypeScript types are kept in step.
  • Security — what never reaches a model, and what the page may send.

The approval card example runs the whole loop: a FastAPI host, a React page, a press with a veto, and tests on both tiers.