Generative UI
Agents that respond with your UI — via OpenUI — through a streaming output codec, a server-authoritative context layer, tool-authored fragments, and a transport.
Quick Example
Register a component library, hand it to callModel as a streaming output codec, and the model renders your UI instead of returning plain text:
import { AgentHarness, callModel, type ContextData } from '@noetic-tools/core';
import { createLibrary, defineComponent, openUi } from '@noetic-tools/openui';
import { z } from 'zod';
const library = createLibrary([
defineComponent({ name: 'Card', description: 'A titled container', props: z.object({ title: z.string(), children: z.array(z.unknown()).optional() }) }),
defineComponent({ name: 'Text', props: z.object({ value: z.string() }) }),
defineComponent({ name: 'Stack', props: z.object({ children: z.array(z.unknown()) }) }),
]);
const dashboard = callModel<ContextData, string, unknown>({
id: 'dashboard',
model: 'claude-sonnet-5',
output: openUi(library), // ← the model emits OpenUI Lang; the step returns a UiDocument
});
// A UI step returns a UiDocument, not a string, so it is run directly rather
// than being installed as the harness's `agentGraph` (which is string → string).
const harness = new AgentHarness({ name: 'ui-agent', params: {} });
const ctx = harness.createContext();
const doc = await harness.run(dashboard, 'Show a welcome card', ctx);openUi(library) generates a system prompt from the component signatures, streams the model's OpenUI Lang output as openui.* framework events, and returns the materialized document. Nothing about callModel changes — generative UI composes inside loop, inParallel, spawn, and the JSON runtime like any other step.
What is generative UI?
Generative UI lets an agent answer with a user interface built from components you registered, rather than a text blob. Noetic implements the OpenUI standard through the @noetic-tools/openui package, which depends only on @noetic-tools/context and @noetic-tools/types — never on core. Core sees two dialect-agnostic contracts (OutputCodec and UiFragment) and resolves everything else from the package, exactly the way the ACP client is decoupled.
There are three surfaces, adopted independently:
- A model-authored codec —
openUi(library)oncallModel.output. - A server-authoritative context layer —
openUiSurface()owns the UI state so it is durable, resumable, visible to the model, and conditionable by the step graph. - Tool-authored fragments — a tool declares
uirender functions and its calls carry their own UI.
A transport (@noetic-tools/openui/server) wires OpenUI's own React client to a Noetic harness.
The design center is server-authoritative UI state: the context layer is the single owner of the mounted document, the reactive variables, and the interaction record. The client renderer is a projection of that state — never the other way around.
OpenUI Lang
OpenUI Lang is a token-efficient, line-oriented format: one assignment statement per line.
$tab = "overview"
sales = Query("sales_tool", { region: $tab })
chart = Card("Sales", [Text("hi")])
root = Stack([chart])- Components —
ref = Component(arg1, arg2, …); positional args map to props in signature order. The statement assigned torootis the rendered root. - Reactive state —
$name = value; passing$nameto an input two-way binds it. - Data —
Query("tool", { args })fetches on load and re-fetches when a referenced$varchanges;Mutation("tool", { args })runs only when an action triggers it. Both resolve against the step's owntoolsarray, so data fetches run as ordinary tool calls with full cost/observability coverage. - Actions —
Action([@Run(ref), @Set($var, value), @ToAssistant("message")]); steps run in order.@ToAssistantsends the agent a message — the path a UI interaction takes back into the conversation.
The parser is tolerant: prose, code fences, and unparseable lines become diagnostics rather than throwing.
The surface layer
Install openUiSurface() as a context layer to make the server the owner of UI state. It is modeled on taskState (thread-scoped, durable, rendered into the view) and the steering layer (enforcement hooks).
import { AgentHarness } from '@noetic-tools/core';
import { openUiSurface, type UiLibrary } from '@noetic-tools/openui';
declare const library: UiLibrary;
const surface = openUiSurface({ library });
const harness = new AgentHarness({
name: 'ui-agent',
params: {},
contextLayers: [surface], // AgentHarness takes a ContextLayer[]
});What each hook buys you:
- Durable, resumable state (
init+store, thread scope) — the surface is loaded from and written through toScopedStorage, so a resumed run or a reconnecting client reconstructs the exact UI the user was looking at. - Client events flow back in (
onItemAppend) — a transport appends each client interaction as aui-eventitem; the layer reduces it intovars/interactions, drops keystroke noise from the item log, and requests an immediate re-render so the next turn already reflects it. - The model sees the UI (
recall) — a budget-trimmed<ui_surface>block renders the mounted components, currentvars, and fresh query results. - Cheap history (
projectHistory) — superseded OpenUI Lang renders in history collapse to a one-line placeholder; the current surface is already in the recall block. - Server-side validation (
afterModelCall) — each render is validated against the library (unknown component, prop mismatch) and repaired or guided before the client renderer ever sees it.
Waiting for a submit
ctx.getLayerState exposes the surface to loop predicates, so the interaction loop is plain composition — no new primitive:
import { callModel, loop, type Tool } from '@noetic-tools/core';
import { openUi, ui, type OpenUiSurfaceLayer, type UiLibrary } from '@noetic-tools/openui';
declare const library: UiLibrary;
declare const surface: OpenUiSurfaceLayer;
declare const quoteShipping: Tool;
const checkout = loop({
id: 'checkout',
steps: [
callModel({ id: 'render', model: 'claude-sonnet-5', tools: [quoteShipping], output: openUi(library) }),
],
until: ui.submitted(surface, 'checkout-form'),
});The package ships ui.submitted(surface, ref?), ui.interacted(surface, kind?), and ui.toAssistant(surface).
Tool-authored UI
A tool can define programmatic render functions so its calls and results carry their own UI — the way each Claude Code tool renders its own output. Fragments are built with a typed fragment(library) builder compiled from the library's Zod schemas, so a typo'd component or bad prop fails at typecheck:
import { toolWithGenerator } from '@noetic-tools/core';
import { createLibrary, defineComponent, fragment } from '@noetic-tools/openui';
import { z } from 'zod';
const library = createLibrary([
defineComponent({ name: 'Card', props: z.object({ title: z.string(), children: z.array(z.unknown()) }) }),
defineComponent({ name: 'Text', props: z.object({ value: z.string() }) }),
defineComponent({ name: 'Progress', props: z.object({ pct: z.number() }) }),
defineComponent({ name: 'Table', props: z.object({ rows: z.array(z.unknown()) }) }),
]);
const f = fragment(library);
const QuoteIn = z.object({ carrier: z.string().optional() });
const QuoteOut = z.object({ quotes: z.array(z.object({ carrier: z.string(), price: z.number() })) });
const QuoteProgress = z.object({ pct: z.number() });
// A generator tool: `toolWithGenerator` is the builder that takes `event` plus
// an `async *execute`; `tool()` is the non-streaming form.
const quoteShipping = toolWithGenerator({
name: 'quote_shipping',
description: 'Quote shipping rates for a carrier',
input: QuoteIn,
output: QuoteOut,
event: QuoteProgress,
ui: {
call: (args) => f.Card('Quoting…', [f.Text(args.carrier ?? '…')]),
progress: (events) => f.Progress(events.at(-1)?.pct ?? 0),
result: (out) => f.Table(out.quotes),
},
async *execute() {
yield { pct: 40 };
/* … */
return { quotes: [{ carrier: 'ups', price: 12.5 }] };
},
});The runtime runs call when the call streams in, progress on each yielded event, and result/error on completion, forwarding each fragment as an openui.fragment framework event. This works for both model-requested tool calls and direct invokeTool steps — and it works even without the generative-UI codec installed, so an agent whose model emits plain text still gets rich tool cards. Core never interprets the fragment source.
The transport
Point OpenUI's React client (@openuidev/react-ui) at a Noetic agent with serveOpenUi. It is runtime-neutral (built on Request/Response/ReadableStream, no node:* imports), so it runs on Node, Bun, and the edge.
import type { AgentHarness } from '@noetic-tools/core';
import type { OpenUiSurfaceLayer } from '@noetic-tools/openui';
import { serveOpenUi } from '@noetic-tools/openui/server';
declare const harness: AgentHarness;
declare const surface: OpenUiSurfaceLayer;
const handler = serveOpenUi(harness, { surface });
// POST { prompt } → runs a turn, streams the surface as SSE
// POST { event } → ingests a client UI event (202)
// GET → returns the current surface snapshot (reconnect rehydration)serveOpenUi translates the harness's response.* and openui.* events into the wire protocol, ingests client interactions as ui-event items (carrying the document version they were rendered against, so stale interactions are flagged), and answers a reconnect with a single snapshot instead of replaying the stream. The matching client descriptor is noeticStreamAdapter().
JSON workflow runtime
A callModel node in a JSON workflow opts into the codec by reference. Because a codec is a runtime object, the node names a library ref and the caller supplies the live codec through HydrationContext.uiLibraries — the same registry-resolution pattern acp-agent nodes use for adapters:
{
"kind": "callModel",
"id": "dashboard",
"model": "claude-sonnet-5",
"instructions": "Render a sales dashboard",
"output": { "codec": "openui", "library": "dashboard-lib" }
}import { hydrateWorkflow } from '@noetic-tools/core';
import type { ExecuteStepFn, Tool, WorkflowDocument } from '@noetic-tools/core';
import { openUi, type UiLibrary } from '@noetic-tools/openui';
declare const doc: WorkflowDocument;
declare const tools: ReadonlyMap<string, Tool>;
declare const executeStep: ExecuteStepFn;
declare const dashboardLibrary: UiLibrary;
const steps = hydrateWorkflow(doc, {
tools,
executeStep,
uiLibraries: new Map([['dashboard-lib', openUi(dashboardLibrary)]]),
});A node referencing an unregistered library fails hydration with UNKNOWN_UI_LIBRARY_REFERENCE.
Package layout
openui ─→ context ─→ types
core ──→ types (core never imports openui)@noetic-tools/types— the dialect-agnostic contracts:OutputCodec(wideningStepCallModel.output) andUiFragment/ToolUiDeclaration(onTool).@noetic-tools/openui— the OpenUI implementation:openUi(),openUiSurface(),fragment(),createLibrary/defineComponent, and theui.*predicates.@noetic-tools/openui/server—serveOpenUiand the client adapter.
Core resolves the codec from the step (output) or the JSON hydration registry and never imports the package, so the OpenUI Lang parser, surface layer, and transport stay out of core's dependency graph.