Generative UI
An agent can return an interface rather than describe it. In this example, the model proposes a declarative json-render layout and the backend validates the operation before publishing the complete specification as assistant message content. The <chat> composition classifies that content and mounts it through the views registry you supply. The running example is an airline operations dashboard.
Angular is the default Example UI. Its render surface can update progressively as message content and state events arrive. Select React for the experimental native preview, which publishes confirmed layouts and data together only after verifying the exact saved checkpoint and tool evidence. Graph-state publication does not guarantee that browser painting happens atomically.
The detection and rendering are adapter-neutral. This page uses @threadplane/langgraph; the same registry and store work over the AG-UI adapter, where the backend delivers data differently.
What the demo does
The Run tab shows the prebuilt <chat> composition. Choose "Airline operations dashboard" or write your own request. The model authors a layout from six supported view types: dashboard_grid, container, stat_card, line_chart, bar_chart, and data_grid. A layout may use any valid subset of this catalog; it does not need six elements.
Try "Filter to cancelled flights" after creating a dashboard. A data update executes one query and replaces its validated dashboard slice while keeping the existing layout and its render owner. A request to change the arrangement instead requires a new layout. Interpretive questions execute no dashboard tools and answer from confirmed data. Unsupported requests make no changes.
The model still interprets freeform language, so the selected category and interpretive prose are probabilistic. The backend's independent validation bounds what can execute; it does not guarantee that the model understood every request correctly.
How it is built
The Code tab includes four backend modules. graph.py owns the turn, messages, publication, and response. dashboard_tools.py supplies the airline queries. operations.py admits bounded operations against the accepted prior layout, and dashboard_contract.py validates layouts, data, and bindings. The prompt is a separate asset. The frontend config registers the agent, and its component supplies the view registry and store.
Choosing a bounded operation
Every turn requires one named plan_dashboard_operation decision: create, data_update, restructure, interpret, or unsupported. The decision itself executes nothing. Independent admission checks the category, tool arguments, prior layout, and bindings before the real tool node runs a single batch.
A data update requires an existing accepted layout and exactly one query. Create and restructure require one render_spec call plus only the queries needed for bindings missing from the applicable data, with at most five unique calls in total. Interpret and unsupported decisions have no calls. There is no agent retry loop or iteration cap.
The tool that authors the layout
render_spec returns a flat element dictionary and root id as serialized JSON. The model proposes the layout; the graph controls whether it becomes a published dashboard.
@tool
async def render_spec(elements: dict, root: str) -> str:
"""Render a validated native dashboard layout. The graph owns publication."""
return json.dumps({"elements": elements, "root": root})Moving the accepted specification into the message
The graph assesses the entire current tool batch before wrapping a layout or emitting data. Every result must belong to the admitted call and graph-owned action parent, report success, and satisfy the dashboard contract. A layout must match the admitted arguments and resolve its bindings against the candidate data. A failed or malformed result prevents partial dashboard publication, retaining the prior board and genuine raw tool results.
Only an accepted, correlated render result can move into its assistant parent. wrap_spec_into_ai preserves message ids, transfers the complete JSON text into the empty parent, and changes that render tool result to rendered. Data-only updates leave the prior render pair and owner intact.
async def wrap_spec_into_ai(state: DashboardState) -> dict:
assessment = state.get("_assessment") or {}
if assessment.get("outcome") != "applied" or assessment.get("layout") is None:
return {}
parent = next((m for m in state["messages"] if m.id == state["_parent_id"]), None)
result = next((m for m in current_results(state) if m.id == assessment["render_result_id"]), None)
if (not submitted_human_matches(state)
or not isinstance(parent, AIMessage) or parent.content != "" or result is None
or parent.tool_calls != state["_operation"]["calls"]
or result_projection(current_results(state)) != assessment["observed_results"]
or [m.id for m in current_results(state)] != state["_result_ids"]):
return {"_assessment": {"outcome": "unfulfilled", "slots": {}, "layout": None, "reason": "Render parent could not be applied"}}
return {"messages": [result.model_copy(deep=True, update={"content": "rendered"}), parent.model_copy(deep=True, update={"content": assessment["render_text"]})]}The assistant message contains the JSON that the client classifies and renders. The graph also persists an owned layout, its exact render owner, dashboard data, and an internal operation receipt. That receipt records the submitted human message, admitted calls, observed results, validated slices, and outcome; it is completion evidence rather than a separate public receipt API.
Sending accepted data as state updates
emit_state rechecks the accepted messages and results before recording the dashboard, layout ownership, and operation receipt together in graph state. Only validated slices from an applied batch produce a custom state_update event. The newest validated result for a slice defines its value; admission forbids duplicate query names within an executable batch.
async def emit_state(state: DashboardState) -> dict:
from langgraph.config import get_stream_writer
assessment = deepcopy(state["_assessment"])
layout, owner = deepcopy(state["_prior_layout"]), deepcopy(state["_prior_owner"])
slots = {}
if assessment["outcome"] == "applied":
try:
require(submitted_human_matches(state), "Submitted human changed before publication")
operation = state["_operation"]
parent = next(m for m in state["messages"] if m.id == state["_parent_id"])
require(isinstance(parent, AIMessage) and parent.tool_calls == operation["calls"], "Changed action ownership")
observed = deepcopy(assessment["observed_results"])
if assessment["layout"] is not None:
next(item for item in observed if item["id"] == assessment["render_result_id"])["content"] = "rendered"
current = result_projection(current_results(state))
require(current == observed and [item["id"] for item in current] == state["_result_ids"], "Results changed after assessment")
if assessment["layout"] is not None:
result = next(m for m in current_results(state) if m.id == assessment["render_result_id"])
render = next(c for c in operation["calls"] if c["name"] == "render_spec")
require(result.content == "rendered" and result.status == "success" and result.name == "render_spec" and result.tool_call_id == render["id"] and parent.content == assessment["render_text"] and parse_data(parent.content) == assessment["layout"], "Render wrapper was not applied")
layout = deepcopy(assessment["layout"])
owner = {"parent_id": parent.id, "call_id": render["id"], "result_id": result.id}
else:
require(parent.content == "", "Unexpected data action content")
slots = deepcopy(assessment["slots"])
except (ValueError, StopIteration, KeyError) as error:
assessment = {"outcome": "unfulfilled", "slots": {}, "layout": None, "reason": str(error)}
layout, owner = deepcopy(state["_prior_layout"]), deepcopy(state["_prior_owner"])
dashboard = deepcopy(state["_prior_dashboard"])
dashboard.update(deepcopy(slots))
receipt = make_receipt(state, assessment, owner if assessment["outcome"] == "applied" and assessment.get("layout") is not None else None)
if slots:
patches = {}
for key, value in slots.items():
if type(value) is dict:
patches.update({f"/{key}/{field}": deepcopy(item) for field, item in value.items()})
else:
patches[f"/{key}"] = deepcopy(value)
get_stream_writer()({"name": "state_update", "data": deepcopy(patches)})
return {"dashboard": dashboard, "_owned_layout": layout, "_render_owner": owner, "operation_receipt": deepcopy(receipt), "_assessment": assessment}The event contains JSON Pointer keys such as /on_time/value and /recent_disruptions, matching the specification's $state bindings. The Angular store applies these events as they arrive. React additionally verifies the saved checkpoint and exact tool evidence before publishing a confirmed board.
Outcomes and graph wiring
The flow is prepare, required decision, admission, bounded execution when needed, whole-batch assessment, wrapping, state publication, response, and best-effort title generation. The graph owns the action, call, result, and answer identities. Its input schema accepts messages; the LangGraph API server supplies persistence, so the graph calls compile() without an explicit checkpointer.
_builder = StateGraph(DashboardState, input_schema=MessagesState)
for name, node in (("prepare_turn", prepare_turn), ("decide", decide), ("admit", admit_operation), ("tools", execute_tools), ("assess_results", assess_results), ("wrap_spec_into_ai", wrap_spec_into_ai), ("emit_state", emit_state), ("respond", respond), ("generate_title", generate_title)):
_builder.add_node(name, node)
_builder.set_entry_point("prepare_turn")
_builder.add_edge("prepare_turn", "decide")
_builder.add_edge("decide", "admit")
_builder.add_conditional_edges("admit", route_operation)
_builder.add_edge("tools", "assess_results")
_builder.add_edge("assess_results", "wrap_spec_into_ai")
_builder.add_edge("wrap_spec_into_ai", "emit_state")
_builder.add_edge("emit_state", "respond")
_builder.add_edge("respond", "generate_title")
_builder.add_edge("generate_title", END)
graph = _builder.compile()Action responses are derived from the executed receipt: an applied operation reports the actual layout or stored data, while rejected decisions, unsupported requests, and unfulfilled operations report no dashboard changes. Read-only interpretation uses model prose followed by an explicit no-change footer. Title generation is best effort and does not stream into the action response.
A malformed decision with no executable calls can be saved as an honest no-op and the conversation can continue. A real failed or malformed tool batch does not become a confirmed native turn merely because the backend retained its prior state or recorded an outcome. The React preview fails closed, keeps its prior confirmed board, and requires New before further submissions when saved evidence cannot be confirmed.
Registering the agent
provideAgent() from @threadplane/langgraph needs the API URL and the assistant id. The example passes a factory because it resolves both at runtime from the host that serves the demo; an application of your own passes the values directly.
import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';
import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/langgraph';
export const appConfig: ApplicationConfig = {
providers: [
provideAgent(() => {
const connection = injectCockpitRuntimeConnection();
if (connection.adapter !== 'langgraph') {
throw new Error('incompatible runtime');
}
return {
apiUrl: connection.apiUrl,
assistantId: connection.assistantId,
clientOptions: connection.clientOptions,
};
}),
],
};The view registry and the shared store
The component supplies the two things the specification needs. views() maps each type a specification can name to an Angular component, and signalStateStore({}) is the store the $state bindings resolve against.
import { Component } from '@angular/core';
import { ChatComponent, ChatWelcomeSuggestionComponent, views } from '@threadplane/chat';
import { injectAgent } from '@threadplane/langgraph';
import { signalStateStore } from '@threadplane/render';
import { ExampleChatLayoutComponent } from '@threadplane/example-layouts';
import { StatCardComponent } from './views/stat-card.component';
import { ContainerComponent } from './views/container.component';
import { DashboardGridComponent } from './views/dashboard-grid.component';
import { LineChartComponent } from './views/line-chart.component';
import { BarChartComponent } from './views/bar-chart.component';
import { DataGridComponent } from './views/data-grid.component';
const dashboardViews = views({
stat_card: StatCardComponent,
container: ContainerComponent,
dashboard_grid: DashboardGridComponent,
line_chart: LineChartComponent,
bar_chart: BarChartComponent,
data_grid: DataGridComponent,
});
const WELCOME_SUGGESTIONS = [
{
label: 'Airline operations dashboard',
value: 'Show me a dashboard of airline operations.',
description: 'Agent emits a render spec; charts and KPI cards appear inline in the chat.',
},
{
label: 'Filter to cancelled flights',
value: 'Filter to only the cancelled flights.',
description: 'Follow-up that updates the dashboard state — shows GenUI mutation in action.',
},
] as const;
@Component({
selector: 'app-generative-ui',
standalone: true,
imports: [ChatComponent, ChatWelcomeSuggestionComponent, ExampleChatLayoutComponent],
template: `
<example-chat-layout>
<chat main [agent]="agent" [views]="dashboardViews" [store]="dashStore" class="flex-1 min-w-0">
<div chatWelcomeSuggestions>
@for (s of suggestions; track s.value) {
<chat-welcome-suggestion
[label]="s.label"
[value]="s.value"
[description]="s.description"
(selected)="send($event)"
/>
}
</div>
</chat>
</example-chat-layout>
`,
})
export class GenerativeUiComponent {
protected readonly agent = injectAgent();
protected readonly dashboardViews = dashboardViews;
protected readonly suggestions = WELCOME_SUGGESTIONS;
/**
* Explicit shared store: backend graph state syncs into it via the chat
* composition, so every dashboard surface reads live values.
*/
protected readonly dashStore = signalStateStore({});
protected send(text: string): void {
void this.agent.submit({ message: text });
}
}Both travel as inputs on <chat>: [views] for the registry and [store] for the store.
<chat> forwards only an explicit [store] to the generative-UI surface. Without one, the surface falls back to a private store seeded from the specification itself, so the state_update payloads never reach the bound props and every card stays a skeleton.
How a specification reaches the screen
The pieces meet in the browser in a fixed order.
The content classifier in @threadplane/chat inspects each assistant message as it streams and decides from the start of the content: a { is a json-render specification, the ---a2ui_JSON--- sentinel is A2UI, and anything else is markdown. This backend supplies complete validated JSON through the correlated wrapper, rather than streaming raw model-authored JSON into the render surface.
For general streaming specifications, the content goes through @cacheplane/partial-json, and a ParseTreeStore materializes a best-effort Spec from the parse tree on every chunk, so a signal holds a usable specification long before the JSON closes. The composition mounts that specification as <chat-generative-ui>, which renders it through <render-spec> from @threadplane/render using the registry you passed. The surface also receives a loading input, true while the agent is still streaming, which a view component can read to draw a placeholder.
The data takes the other path. The LangGraph adapter surfaces a custom event named state_update on the agent's event stream, and the composition writes its payload straight into the render store, keys included. Because emit_state already emits JSON Pointer keys, a binding of { "$state": "/on_time/value" } resolves the moment the event lands.
This is the one place the LangGraph path differs from AG-UI. An AG-UI agent puts its data in graph state, and the composition maps each top-level key k of any adapter's state() to the pointer /k. Here the graph names the pointers itself in its custom state_update event. A nested value such as /on_time/value updates the Angular store directly, while the graph also persists that data in its owned dashboard field for saved-state confirmation.
Writing a view component
Each registered component receives the element's resolved props as Angular inputs, keyed by prop name. The example's stat_card shows the shape, including the skeleton it draws before its value exists:
@Component({
selector: 'app-stat-card',
standalone: true,
template: `
<div class="stat-card">
<div class="stat-card__label">{{ label() }}</div>
@if (isSkeleton()) {
<div class="skeleton skeleton-value"></div>
} @else {
<div class="stat-card__value">{{ formattedValue() }}</div>
}
</div>
`,
})
export class StatCardComponent {
readonly label = input<string>('');
readonly value = input<string | number | null>(null);
readonly delta = input<string | null>(null);
readonly isSkeleton = computed(() => this.value() == null);
readonly formattedValue = computed(() => {
const v = this.value();
if (v == null) return '';
if (typeof v === 'number') return v.toLocaleString();
return String(v);
});
}Give every input a default, including when using the renderer with other backends that stream partial specifications. A prop bound with $state is undefined until its event arrives, and a prop that is still streaming can be a partial string, so a component that requires its inputs will fail on the first frame.
The specification that drives it is flat. One root id and one elements map, each element naming a type, optional props, and optional children as an array of ids:
{
"root": "stats_row",
"elements": {
"stats_row": {
"type": "container",
"props": { "direction": "row" },
"children": ["on_time_card"]
},
"on_time_card": {
"type": "stat_card",
"props": {
"label": "On-time %",
"value": { "$state": "/on_time/value" },
"delta": { "$state": "/on_time/delta" }
}
}
}
}The general render format, including $bindState, $computed and visibility conditions, is covered in Specs and elements.
Event handlers
An element can also fire actions. A spec's on map binds an event name to an action binding, and the named action is looked up in the [handlers] map you pass to <chat>:
protected readonly handlers = {
submitForm: (params: Record<string, unknown>) => {
console.log('form submitted', params);
},
};An element wires it as "on": { "click": { "action": "submitForm", "params": {} } }. Handlers may return a value or a Promise, which lets an action await a network call before the surface advances. The general render library supports actions and computed expressions. This bounded dashboard demo registers no handlers and rejects action bindings and computed expressions in model-authored layouts. See Events and actions for the dispatch rules.
A2UI protocol
For agents that emit A2UI payloads, the same classifier detects content prefixed with ---a2ui_JSON--- and renders it as A2UI surfaces instead. Pass a2uiBasicCatalog() from @threadplane/chat as the registry when you want those surfaces drawn with the built-in components.
What's Next
Content classification, partial JSON parsing, and the rest of the streaming pipeline.
The full reference for the spec format, prop expressions, and element types.
How JSON Pointer paths resolve, and how components write back to the store.
Render A2UI surfaces with the built-in component catalog.