|
| 1 | +# Backend-Driven Generative UI with AG-UI Shared State |
| 2 | + |
| 3 | +<Summary> |
| 4 | +Let the agent author a UI layout AND stream its data over AG-UI. The agent |
| 5 | +sends a json-render spec as the assistant message — the chat lib mounts it |
| 6 | +against your `views` registry — while the dashboard's numbers arrive as |
| 7 | +**agent shared state** (AG-UI `STATE_SNAPSHOT` / `STATE_DELTA`). The spec's |
| 8 | +`$state` bindings resolve against that state, so the same layout re-renders |
| 9 | +as the backend updates the data. |
| 10 | +</Summary> |
| 11 | + |
| 12 | +<Prompt> |
| 13 | +Render a backend-authored dashboard with `@threadplane/chat` over the AG-UI adapter. Register your view components in the `views` map and pass it to `<chat>`. Have the agent emit a json-render spec (with `$state` bindings) as the assistant message content, and put the data the spec binds to in the LangGraph graph state so `ag-ui-langgraph` emits it as a `STATE_SNAPSHOT`. The chat composition resolves the bindings automatically. |
| 14 | +</Prompt> |
| 15 | + |
| 16 | +<Steps> |
| 17 | +<Step title="Register the view components"> |
| 18 | + |
| 19 | +Build a `views` registry keyed by the component types your spec will reference, and pass it to `<chat>`: |
| 20 | + |
| 21 | +```typescript |
| 22 | +// json-render.component.ts |
| 23 | +import { ChatComponent, views } from '@threadplane/chat'; |
| 24 | +import { injectAgent } from '@threadplane/ag-ui'; |
| 25 | +import { StatCardComponent } from './views/stat-card.component'; |
| 26 | +import { DashboardGridComponent } from './views/dashboard-grid.component'; |
| 27 | +// …line-chart, bar-chart, data-grid, container |
| 28 | + |
| 29 | +const dashboardViews = views({ |
| 30 | + stat_card: StatCardComponent, |
| 31 | + dashboard_grid: DashboardGridComponent, |
| 32 | + // … |
| 33 | +}); |
| 34 | +``` |
| 35 | + |
| 36 | +```html |
| 37 | +<chat main [agent]="agent" [views]="dashboardViews" /> |
| 38 | +``` |
| 39 | + |
| 40 | +</Step> |
| 41 | +<Step title="Configure the AG-UI provider"> |
| 42 | + |
| 43 | +```typescript |
| 44 | +// app.config.ts |
| 45 | +import { provideAgent } from '@threadplane/ag-ui'; |
| 46 | +import { provideChat } from '@threadplane/chat'; |
| 47 | + |
| 48 | +export const appConfig: ApplicationConfig = { |
| 49 | + providers: [provideAgent({ url: '/agent' }), provideChat({})], |
| 50 | +}; |
| 51 | +``` |
| 52 | + |
| 53 | +</Step> |
| 54 | +<Step title="Emit the layout spec from the agent"> |
| 55 | + |
| 56 | +The agent authors the layout once and returns it as JSON. A post-process node |
| 57 | +moves that payload into the assistant message content, where the chat lib's |
| 58 | +content classifier detects the leading `{` and mounts the render surface. Each |
| 59 | +data prop uses a `$state` binding rather than a literal: |
| 60 | + |
| 61 | +```json |
| 62 | +{ |
| 63 | + "elements": { |
| 64 | + "on_time_card": { |
| 65 | + "type": "stat_card", |
| 66 | + "props": { "label": "On-time %", "value": { "$state": "/on_time/value" } } |
| 67 | + } |
| 68 | + }, |
| 69 | + "root": "..." |
| 70 | +} |
| 71 | +``` |
| 72 | + |
| 73 | +</Step> |
| 74 | +<Step title="Deliver the data as agent shared state"> |
| 75 | + |
| 76 | +This is the AG-UI-native part. Instead of pushing data through a side channel, |
| 77 | +put it in the **graph state** — `ag-ui-langgraph` emits the state object as a |
| 78 | +`STATE_SNAPSHOT`, the adapter writes it to the agent's `state` signal, and the |
| 79 | +chat composition syncs it into the render store where the `$state` bindings |
| 80 | +resolve: |
| 81 | + |
| 82 | +```python |
| 83 | +# graph.py — emit_state returns the accumulated tool data into state |
| 84 | +async def emit_state(state: DashboardState) -> dict: |
| 85 | + updates: dict = {} |
| 86 | + for msg in reversed(state["messages"]): |
| 87 | + if getattr(msg, "type", None) == "tool" and msg.name == "query_airline_kpis": |
| 88 | + updates.update(json.loads(msg.content)) # {on_time: {value, delta}, …} |
| 89 | + # …other data tools |
| 90 | + return updates # becomes top-level state fields → STATE_SNAPSHOT |
| 91 | +``` |
| 92 | + |
| 93 | +The spec binding `/on_time/value` resolves to `state.on_time.value`. Run the |
| 94 | +backend with: |
| 95 | + |
| 96 | +```bash |
| 97 | +uv run uvicorn src.server:app --port 5323 |
| 98 | +``` |
| 99 | + |
| 100 | +<Warning> |
| 101 | +A field is only visible to the frontend if it is in the graph's **output |
| 102 | +schema** — `ag-ui-langgraph` filters the snapshot to output-schema keys. |
| 103 | +Declare every bound field on `DashboardState` (a plain `StateGraph(State)` uses |
| 104 | +its state schema as the output schema). Also: `ag-ui-langgraph` requires a |
| 105 | +checkpointer — the graph uses `MemorySaver` for development. |
| 106 | +</Warning> |
| 107 | + |
| 108 | +</Step> |
| 109 | +</Steps> |
| 110 | + |
| 111 | +<Tip> |
| 112 | +The same `views` registry powers tool-driven rendering too — a component you |
| 113 | +register here is reusable for the tool-views pattern with no changes. The only |
| 114 | +difference is where the layout and data come from: a backend spec + shared |
| 115 | +state here, versus a tool call's args/result there. |
| 116 | +</Tip> |
| 117 | + |
| 118 | +<Related> |
| 119 | +- [AG-UI Tool Views](/ag-ui/core-capabilities/tool-views/overview/python) — Frontend component keyed by tool name (no spec on the wire) |
| 120 | +- [AG-UI A2UI](/ag-ui/core-capabilities/a2ui/overview/python) — Backend-authored A2UI surfaces in message content |
| 121 | +- [AG-UI Streaming](/ag-ui/core-capabilities/streaming/overview/python) — Real-time token streaming with the AG-UI adapter |
| 122 | +</Related> |
0 commit comments