Generative UI
Choose React in the frontend selector for the experimental native React dashboard preview. Docs, Code and Run follow the selected frontend. The React preview uses the same server tools and fixed airline data with six authored read-only views; confirmed layouts bind to the latest native shared state.
An AG-UI agent can author the interface, not only the text. It returns a declarative json-render specification as the assistant message content, and it puts the numbers that specification binds to in graph state, which ag-ui-langgraph emits as STATE_SNAPSHOT and STATE_DELTA events. The <chat> composition mounts the specification through your views registry, syncs the agent state into a render store, and @threadplane/render resolves the $state bindings against it.
The running example is an airline operations dashboard, and this guide walks the four files that build it.
What the demo does
The Run tab shows the prebuilt <chat> composition. Choose the welcome suggestion "Airline operations dashboard" and the agent authors a layout of KPI cards, a line chart, a bar chart, and a table, then calls the data tools that fill it. The cards render as skeletons first and take their values when the state arrives.
The second suggestion, "Filter to cancelled flights", is the follow-up worth trying. The agent does not author a new layout for it. It calls one data tool again with new arguments, the new results replace that slice of state, and the dashboard already on screen re-renders in place.
How it is built
Four files carry the feature: a LangGraph graph that authors the layout and returns the data as state, a FastAPI server that exposes it over AG-UI, an application config that registers the agent, and a component that supplies the view registry and the store. Open the Code tab to read them in full.
The state the dashboard binds to
Every value the layout can bind to is a field on the graph state class. The docstring in the example states the reason: fields declared here are the ones ag-ui-langgraph includes in the snapshot it sends to the client.
class DashboardState(TypedDict):
"""Graph state for the airline KPI dashboard.
Data fields are declared here so ag-ui-langgraph includes them in the
STATE_SNAPSHOT emitted to the client. The Angular sync effect maps each
top-level key k → /k in the render store, so spec $state bindings like
/on_time/value resolve correctly.
"""
messages: Annotated[list, add_messages]
on_time: Optional[dict]
flights_today: Optional[dict]
avg_delay: Optional[dict]
load_factor: Optional[dict]
on_time_trend: Optional[list]
flights_by_airline: Optional[list]
recent_disruptions: Optional[list]The $state pointers the agent writes into the specification, such as /on_time/value, address these top-level keys.
A pointer that names a field the state class does not declare resolves to nothing, and the component bound to it stays in its skeleton state forever. Add the field to DashboardState whenever you teach the agent a new binding.
The tool that authors the layout
The layout arrives as a tool call. render_spec takes the element dictionary and the id of the root element, and returns them serialized as JSON.
@tool
async def render_spec(elements: dict, root: str, tool_call_id: Annotated[str, InjectedToolCallId]) -> ToolMessage:
"""Render an interactive dashboard layout.
Use this tool to author or update the dashboard layout. See the system
prompt for the full component catalog and state binding conventions.
Call this tool AT MOST ONCE per turn — only when the layout needs to
be created (first turn) or restructured (follow-up structural change).
Do NOT call it again to refresh data; the data tools handle that.
Args:
elements: Dict keyed by component id. Each value has `type`, optional
`props`, and optional `children` (list of component ids).
root: The id of the top-level component (must be a key in `elements`).
Returns:
A stable tool message containing the spec serialized as JSON.
A post-process node (wrap_spec_into_ai)
wraps this payload into the AI message content where the
chat-lib's content-classifier picks it up.
"""
return ToolMessage(
id=tool_call_id,
tool_call_id=tool_call_id,
name="render_spec",
content=json.dumps({"elements": elements, "root": root}),
)The docstring is the contract the model reads, which is why it says to call the tool at most once per turn: layout changes go through this tool, and data refreshes go through the data tools instead.
Moving the layout into the assistant message
A tool result is not message content, so a post-processing node moves it. wrap_spec_into_ai finds the most recent render_spec tool message and the assistant message whose tool call produced it.
async def wrap_spec_into_ai(state: DashboardState) -> dict:
"""Post-process that wraps the most recent render_spec ToolMessage
payload into the parent AI tool-call message's content (in place via
LangGraph's add_messages reducer matching by id). The chat-lib's
content-classifier then sees content starting with `{` and mounts
<chat-generative-ui>.
Idempotent: if the parent AI message already has non-empty content
(already wrapped on a prior iteration), no-op. Also no-op if there
is no render_spec ToolMessage to process.
Mirrors emit_generated_surface from examples/chat/python/src/graph.py,
adapted to loop back to `agent` instead of going to END.
"""
msgs = state["messages"]
render_tool_msg: ToolMessage | None = None
parent_ai: AIMessage | None = None
for m in reversed(msgs):
if isinstance(m, ToolMessage) and m.name == "render_spec":
render_tool_msg = m
for prior in reversed(msgs):
if isinstance(prior, AIMessage) and prior.tool_calls:
if any(tc.get("id") == render_tool_msg.tool_call_id for tc in prior.tool_calls):
parent_ai = prior
break
break
if render_tool_msg is None or parent_ai is None:
return {}
existing = parent_ai.content
if isinstance(existing, str) and existing.strip():
return {}
payload = render_tool_msg.content if isinstance(render_tool_msg.content, str) else ""
if not payload:
return {}The node is idempotent: it returns early when the parent message already carries content, so looping back through the agent does not wrap the same payload twice.
Rewriting the assistant message in place
The rest of the node replaces both messages, keeping their ids so LangGraph's add_messages reducer matches and replaces rather than appends.
stripped = payload.strip()
if stripped.startswith("```"):
lines = stripped.split("\n")
stripped = "\n".join(line for line in lines if not line.startswith("```")).strip()
out: list = []
placeholder_kwargs: dict = {
"content": "rendered",
"tool_call_id": render_tool_msg.tool_call_id,
"name": "render_spec",
}
if getattr(render_tool_msg, "id", None):
placeholder_kwargs["id"] = render_tool_msg.id
out.append(ToolMessage(**placeholder_kwargs))
replacement_kwargs: dict = {
"content": stripped,
"tool_calls": parent_ai.tool_calls,
"additional_kwargs": parent_ai.additional_kwargs or {},
"response_metadata": parent_ai.response_metadata or {},
}
if getattr(parent_ai, "id", None):
replacement_kwargs["id"] = parent_ai.id
out.append(AIMessage(**replacement_kwargs))
return {"messages": out}The tool message becomes a short placeholder and the assistant message takes the specification JSON.
On the client the content classifier in @threadplane/chat sees content that begins with {, classifies it as json-render, and mounts <chat-generative-ui>, which renders it through <render-spec> from @threadplane/render.
Returning the tool data as state
The data tools return their results as tool messages, which the client would otherwise never see as state. emit_state walks this turn's messages back to the most recent user message and returns the parsed tool results as top-level state fields.
async def emit_state(state: DashboardState) -> dict:
"""Accumulate this turn's tool results into graph state so ag-ui-langgraph
emits them as STATE_SNAPSHOT. Walk messages in reverse to the most recent
human turn; map each known data tool to its state field(s).
query_airline_kpis returns the four nested *_card sections; merge directly."""
updates: dict = {}
for msg in reversed(state["messages"]):
if getattr(msg, "type", None) == "tool":
try:
data = json.loads(msg.content) if isinstance(msg.content, str) else msg.content
except (json.JSONDecodeError, TypeError):
continue
if msg.name == "query_airline_kpis" and isinstance(data, dict):
updates.update(data) # {on_time:{...}, flights_today:{...}, avg_delay:{...}, load_factor:{...}}
elif msg.name == "query_on_time_trend":
updates["on_time_trend"] = data
elif msg.name == "query_flights_by_airline":
updates["flights_by_airline"] = data
elif msg.name == "query_recent_disruptions":
updates["recent_disruptions"] = data
elif getattr(msg, "type", None) == "human":
break
return updatesReturning a field from a node is all that is required: ag-ui-langgraph emits the updated state as a snapshot on the wire.
The graph and its checkpointer
The wiring shows the loop. agent calls the tools and the tools node runs them; wrap_spec_into_ai post-processes the result and returns to the agent, and the turn ends through emit_state, a short conversational summary, and background title generation.
_builder = StateGraph(DashboardState)
_builder.add_node("agent", agent)
_builder.add_node("tools", ToolNode(_ALL_TOOLS))
_builder.add_node("wrap_spec_into_ai", wrap_spec_into_ai)
_builder.add_node("finalize", finalize)
_builder.add_node("emit_state", emit_state)
_builder.add_node("respond", respond)
_builder.add_node("generate_title", generate_title)
_builder.set_entry_point("agent")
_builder.add_conditional_edges("agent", should_continue)
_builder.add_edge("tools", "wrap_spec_into_ai")
_builder.add_edge("wrap_spec_into_ai", "agent")
_builder.add_edge("finalize", "emit_state")
_builder.add_edge("emit_state", "respond")
_builder.add_edge("respond", "generate_title")
_builder.add_edge("generate_title", END)
# ag-ui-langgraph requires a checkpointer to manage thread state.
# The chat example omits it because LangGraph Cloud provides one at runtime,
# but the ag-ui-langgraph/uvicorn runtime needs it explicitly.
graph = _builder.compile(checkpointer=MemorySaver())ag-ui-langgraph reads thread state through the checkpointer, so a graph served this way must compile one, and the example uses MemorySaver for development. That is the opposite of a graph served by langgraph dev or LangGraph Platform, where the platform supplies persistence and compiling your own saver is an error. See the persistence guide for that case.
Serving the graph over AG-UI
The server is the standard ag-ui-langgraph mount: wrap the compiled graph in a LangGraphAgent and attach it to a FastAPI application at a path. The state events that carry the dashboard data are emitted by the adapter because the graph returns those fields.
from fastapi import FastAPI
from ag_ui_langgraph import LangGraphAgent, add_langgraph_fastapi_endpoint
from .graph import graph
agent = LangGraphAgent(name="json-render", graph=graph)
app = FastAPI(title="cockpit-ag-ui-json-render")
add_langgraph_fastapi_endpoint(app, agent, path="/agent")
@app.get("/ok")
def ok() -> dict:
return {"ok": True}Nothing in this file is specific to generative UI.
Registering the AG-UI agent
provideAgent() from @threadplane/ag-ui needs the URL of that endpoint. The example passes a factory because it resolves the URL at runtime from the host that serves the demo; an application of your own passes url directly.
import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';
import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/ag-ui';
export const appConfig: ApplicationConfig = {
providers: [
provideAgent(() => {
const connection = injectCockpitRuntimeConnection();
if (connection.adapter !== 'ag-ui') {
throw new Error('incompatible runtime');
}
return {
url: connection.url,
};
}),
],
};The view registry and the shared store
The component supplies the two things the specification needs: a views registry that maps each type in the specification to an Angular component, and an explicit store for the bindings to resolve against.
import { Component } from '@angular/core';
import { ChatComponent, ChatWelcomeSuggestionComponent, views } from '@threadplane/chat';
import { injectAgent } from '@threadplane/ag-ui';
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-json-render',
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 JsonRenderComponent {
protected readonly agent = injectAgent();
protected readonly dashboardViews = dashboardViews;
protected readonly suggestions = WELCOME_SUGGESTIONS;
/**
* Explicit shared store: backend state (STATE_SNAPSHOT) 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 }); }
}The [store] input is what makes the dashboard live.
<chat> forwards only an explicit store to the generative-UI surface; without one, the surface falls back to a private store of its own, and backend state never reaches it.
How a binding resolves
The pieces meet in the browser in a fixed order.
The adapter reducer in @threadplane/ag-ui handles STATE_SNAPSHOT by setting the agent's state signal to the snapshot, and STATE_DELTA by applying the JSON Patch operations to the current value. The <chat> composition watches that signal and writes each top-level key k into the render store under the JSON Pointer /k, skipping messages. @json-render/core then resolves a { "$state": "/on_time/value" } prop by reading that pointer out of the store.
So the state field on_time on the graph becomes the pointer /on_time in the store, and the binding /on_time/value reaches the value key inside it. Pointer syntax, including the ~0 and ~1 escapes, is covered in the state store guide.
A component registered in views is reusable with no changes for the tool-views pattern, where the frontend owns the layout and a tool call supplies the data. The difference is only where the layout comes from. See Tool Views.
What's Next
Render a frontend component keyed by tool name, with no specification on the wire.
The json-render specification format: elements, props, children, and bindings.
JSON Pointer paths, reads and writes, and the signal-backed store implementation.
How each AG-UI event maps onto the signals the chat composition reads.