Page actions

Generative UI

An agent can return the interface rather than describe it. It writes a declarative json-render specification as the assistant message content, and the <chat> composition detects that content, parses it while it is still streaming, and mounts it through the views registry you supply. The running example is an airline operations dashboard, and this guide walks the three files that build it.

The detection and the rendering are adapter-neutral. This page uses @threadplane/langgraph; the same registry and the same store work over the AG-UI adapter, where only the way the backend delivers the data differs.

What the demo does

The Run tab shows the prebuilt <chat> composition. Choose the welcome suggestion "Airline operations dashboard" and the agent calls one tool to author the layout — four KPI cards, a line chart, a bar chart, and a table — and, in the same turn, the data tools that back it. The cards mount as skeletons and take their values when the data 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 result replaces that slice of the store, and the dashboard already on screen updates in place.

How it is built

Three files carry the feature: a LangGraph graph that authors the layout and streams the data, 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 tool that authors the layout

The layout arrives as a tool call. render_spec takes the flat 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) -> str:
    """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:
        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 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 specification into the 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 {}

The node returns early when this turn produced no render_spec result at all; the guard that makes it safe on every pass of the loop is the content check in the next region.

Rewriting the message in place

The rest of wrap_spec_into_ai replaces both messages, keeping their ids so LangGraph's add_messages reducer matches and replaces rather than appends. The early return on an already-filled parent message is what stops a second pass from wrapping the same payload twice.

graph.py — rewriting the message content
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 {}
 
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, stripped of a code fence if the model wrapped one around it.

Note: Why the message and not the state

The specification travels as message content because that is what the client classifies. DashboardState in this example adds no fields of its own to MessagesState: the layout lives in the message, and the numbers arrive as the state updates below.

Sending the tool data as state updates

The data tools return their results as tool messages, which the browser would otherwise only see as tool output. emit_state walks this turn's messages back to the most recent user message, turns each known tool result into JSON Pointer paths, and writes them to the stream as one custom event named state_update.

graph.py — emit_state
async def emit_state(state: DashboardState) -> DashboardState:
    """Emit state_update custom events from data tool results. Walks
    state["messages"] in reverse, accumulates state patches from
    ToolMessages produced this turn (until the most recent human message
    — NOT ai, since the loop produces multiple AI messages per turn).
 
    Ignores tool names not in the known set (e.g. render_spec, whose
    payload was already wrapped into AI content by wrap_spec_into_ai
    and whose ToolMessage is now the "rendered" stub).
    """
    from langgraph.config import get_stream_writer
 
    tool_results: dict = {}
    for msg in reversed(state["messages"]):
        if msg.type == "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":
                for section_key, section_val in data.items():
                    if isinstance(section_val, dict):
                        for k, v in section_val.items():
                            tool_results[f"/{section_key}/{k}"] = v
            elif msg.name == "query_on_time_trend":
                tool_results["/on_time_trend"] = data
            elif msg.name == "query_flights_by_airline":
                tool_results["/flights_by_airline"] = data
            elif msg.name == "query_recent_disruptions":
                tool_results["/recent_disruptions"] = data
        elif msg.type == "human":
            break
 
    if tool_results:
        writer = get_stream_writer()
        writer({"name": "state_update", "data": tool_results})
 
    return state

The keys it builds, such as /on_time/value and /recent_disruptions, are exactly the pointers the specification binds to with $state.

The graph wiring

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; the turn ends through emit_state, a short conversational summary, and background title generation. should_continue routes to finalize instead of tools when the six-iteration cap is hit, so orphaned tool_calls are stripped from the last AI message before the turn ends. See the persistence guide for the no-checkpointer rule the last line follows and its one exception.

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)
 
graph = _builder.compile()

The last line calls compile() with no checkpointer, because this graph is served by the LangGraph API server, which supplies persistence itself.

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.

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

generative-ui.component.ts
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.

Warning: Pass an explicit store or the data never lands

<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. That is why the system prompt insists on raw JSON with no prose and no code fence around it.

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

Note: Pointers, not top-level keys

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, so a nested value like /on_time/value needs no state field to hold it.

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. 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 full 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 dashboard example registers no handlers, because its dashboard is read-only. 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

Looking for something specific?