Page actions

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.

graph.py — the dashboard state
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.

Warning: A bound field must exist on the state class

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.

graph.py — the render_spec tool
@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.

graph.py — locating the specification
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.

graph.py — rewriting the message content
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.

Note: How the client recognizes it

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.

graph.py — emit_state
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 updates

Returning 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.

graph.py — graph wiring
_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())
Warning: This graph compiles its own checkpointer

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.

server.py
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.

app.config.ts
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.

json-render.component.ts
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.

Note: Why an explicit store matters

<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.

Tip: The same registry serves tool views

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

Looking for something specific?