A backend-agnostic agent canvas SDK. The transcript, tool timeline, approval gates, and workspace panel of a coding-agent UI, packaged so they can sit on top of any orchestrator — LangGraph, Mastra, a raw WebSocket, or a fixture replayer.
The visual and interaction target is the OpenHands
agent canvas (MIT — see NOTICE). The architecture is not: OpenHands couples its
UI directly to its own Python event stream, and the point of this package set is
that nothing above the transport knows what is on the other end.
| Package | What it is |
|---|---|
@agent-ui/protocol |
The normalized event model. Types only, zero runtime deps. |
@agent-ui/core |
Timeline reducer, stores, and every piece of logic that must not belong to a framework. |
@agent-ui/tokens |
Tailwind v4 design tokens and base styles. |
@agent-ui/react |
React 19 bindings and components. |
@agent-ui/angular |
Angular 22 standalone components and signal bindings. |
@agent-ui/transport-mock |
Scripted fixture replayer — the development backend. |
apps/react-playground and apps/angular-playground drive the whole thing off
the same fixtures, with identical toolbars. Any visible difference between the
two is a parity bug rather than a design choice.
core contains all logic and imports no framework. React and Angular are
thin subscribers. This is enforced by lint (@nx/enforce-module-boundaries over
scope:* tags), not by discipline.
It is also what makes maintaining two framework packages tractable. Event reduction, action↔observation pairing, streaming-delta merging, panel resize maths, status derivation, and event→title formatting are written once and bound twice. When a behaviour cannot be pushed into core, that is a signal to redesign it rather than fork it.
transport → normalizer → core store (vanilla) → framework binding → components
Observations link to actions explicitly. ObservationEvent.actionId is
required rather than inferred from adjacency. Under async multi-agent
orchestration an action and its result are routinely separated by unrelated
events from other agents, so adjacency is not a correlation strategy.
Titles are data, not markup. getEventContent emits typed segments
(text / code / path / emphasis) plus an i18n key and params. React maps
them to spans; Angular maps them to spans. The decision about what is a path
lives in core, so the two renderers cannot drift.
pnpm install
pnpm nx serve @org/react-playground # the canvas, driven by fixtures
pnpm nx serve @org/angular-playground # the same canvas, same fixtures (:4300)
pnpm check # lint + test + build + typecheck
pnpm nx sync # regenerate TS project referencesFixtures live in packages/transport-mock/src/lib/scripts.ts and cover a clean
run, long streaming, a failing tool, an approval gate, a hung tool, a rate limit,
artifacts, and a 60-step transcript. They are the primary development loop:
every state that is awkward to provoke against a live agent is one dropdown away.
packages/react/src/lib/canvas/agent-canvas.spec.tsx and
packages/angular/src/lib/agent-canvas.component.spec.ts drive the same
fixtures through both implementations and assert the same observable behaviour.
That pairing is the parity gate — a divergence there means one of the thin
framework layers has drifted.
The React tests render under <StrictMode> deliberately. StrictMode
double-invokes effects (mount → cleanup → mount) to expose components that
cannot survive being torn down and rebuilt, and every real app runs under it.
Rendering without it once hid a controller whose transport subscription could
only be established once: after StrictMode's second pass it received no events
at all, leaving an empty transcript and a permanently disabled composer. Keep
the wrapper.
- Angular and TypeScript project references do not mix. This workspace uses
the TS-solution setup (
composite,emitDeclarationOnly), which the Angular compiler rejects outright.packages/angular/tsconfig*.jsonoverride those flags locally. It works, but it is a seam to be aware of when upgrading Nx or Angular. - The Angular app needs three workspace-level accommodations, all in service
of the same root cause as above. Its
typechecktarget is overridden because the inferred one runstsc --build --emitDeclarationOnly, which an application cannot satisfy. Itstsconfig.app.jsonsets norootDir, becausecustomConditions: ["@org/source"]makes it compile SDK sources that legitimately sit outsidesrc. Andpnpm-workspace.yamlpublic-hoists@agent-ui/*,zustandandmarked, because Angular's dev server serves from a synthetic.angular/vite-root/at the workspace root where pnpm's strict layout exposes nothing — this affectsnx serveonly; the production build resolves correctly without it. - ESLint is pinned to 9.x.
eslint-plugin-reactdeclares peer support only through^9.7; on ESLint 10 it crashes on removed APIs. - Markdown rendering differs by necessity: React uses
react-markdown(never produces HTML), Angular compiles to HTML and relies on Angular's sanitizer for[innerHTML]. The Angular path must never be paired withbypassSecurityTrustHtml.
- Workspace tab content beyond the artifact viewer: Monaco diff, xterm terminal, embedded browser.
- Real transport adapters (WebSocket / SSE / ACP / Mastra). The mock transport
proves the
AgentTransportseam; a real adapter is mechanical from there. - Storybook, and virtualization for very long transcripts.
- A Playwright smoke test driving each fixture to completion in both playgrounds. Parity is currently asserted at the component level; asserting it against the two running apps would close the loop.