Real-Time Dashboard
read as.md Live data in ggui is a contract concern, not a host-app concern. You don’t open a WebSocket in your React app and feed events into the render. Instead:
- The agent declares a
streamSpecchannel on the contract — a typed, named live channel. - The agent renders the contract once, then pushes updates with
ggui_emit. - The generated component inside the iframe subscribes to that channel and repaints as deliveries arrive. The iframe-runtime owns the channel transport (it picks WebSocket or polling per channel).
Your host app does nothing dashboard-specific: it mounts the render with <AppRenderer> (via useMcpAppsChat) like any other ggui UI. The live updates flow inside the sandboxed iframe — they never pass through your host code.
1. Declare a streamSpec channel
Section titled “1. Declare a streamSpec channel”A streamSpec is a flat Record<channelName, StreamChannelEntry> on the contract. Each channel declares a JSON Schema its payloads must satisfy, plus optional accumulation + replay behavior:
const contract = { // What the component shows initially (validated like any props). propsSpec: { properties: { title: { schema: { type: "string" }, required: true }, }, }, // Live channels. Payloads on `metrics` must match its schema. streamSpec: { metrics: { description: "Live throughput + error-rate samples for the dashboard.", schema: { type: "object", properties: { ts: { type: "string" }, rps: { type: "number" }, errorRate: { type: "number" }, }, required: ["ts", "rps"], }, mode: "append", // accumulate each delivery into a series replay: "all", // a late-subscribing iframe gets the full backlog complete: true, // allow a terminal ggui_emit({ complete: true }) }, },};| Channel field | Meaning |
|---|---|
schema | Required. JSON Schema every payload on this channel must satisfy. ggui_emit validates against it. |
mode | "append" accumulates deliveries into a series (a feed / chart); "replace" keeps only the latest (a single live value). Optional (default "append"). |
replay | What a freshly-mounted iframe receives: "all" (full backlog), "latest" (last delivery only), "none". Optional (default "none"). |
complete | Declare complete: true to allow a terminal ggui_emit({ complete: true }) that closes the channel; undeclared channels reject it. Optional. |
description | Natural-language hint that steers how the generator wires the component to the channel. Optional but recommended. |
source | { tool, args? } — declare a pull channel the runtime polls instead of you pushing. See Pull channels. |
The example’s replay: "all" and complete: true are both explicit opt-ins on top of the defaults.
2. Render, then emit
Section titled “2. Render, then emit”Negotiate + materialize the contract through the normal ggui_handshake → ggui_render flow, then push deliveries with ggui_emit. Payloads validate against streamSpec[channel].schema; a final complete: true closes the channel (allowed because the declaration above opted in with complete: true).
import { Client } from "@modelcontextprotocol/sdk/client/index.js";import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const client = new Client({ name: "dashboard-agent", version: "1.0.0" });await client.connect( new StreamableHTTPClientTransport(new URL("http://127.0.0.1:6781/mcp"), { requestInit: { headers: { Authorization: "Bearer dev" } }, }));
// Negotiate a blueprint for the dashboard contract.const handshake = await client.callTool({ name: "ggui_handshake", arguments: { intent: "Live ops dashboard — throughput + error rate", blueprintDraft: { contract }, },});const { handshakeId } = handshake.structuredContent as { handshakeId: string };
// Materialize it. `props` is REQUIRED — pass the initial values.const render = await client.callTool({ name: "ggui_render", arguments: { handshakeId, props: { title: "Ops — us-east-1" } },});const { sessionId } = render.structuredContent as { sessionId: string };
// Stubs — swap in your real metric source.const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));const sample = () => 100 + Math.random() * 50;const sampleErr = () => Math.random() * 0.05;
// Push live deliveries on the declared channel.for (let i = 0; i < 100; i++) { await client.callTool({ name: "ggui_emit", arguments: { sessionId, channel: "metrics", payload: { ts: new Date().toISOString(), rps: sample(), errorRate: sampleErr() }, }, }); await sleep(1000);}
// Close the channel — subsequent emits on it reject.await client.callTool({ name: "ggui_emit", arguments: { sessionId, channel: "metrics", payload: {}, complete: true },});3. Host side: just mount the render
Section titled “3. Host side: just mount the render”There is no dashboard-specific host code. The render arrives as an MCP-Apps resource on the agent’s tool result; your web app drives the conversation with useMcpAppsChat and mounts each render with <AppRenderer>. The generated component subscribes to the metrics channel itself, and the iframe-runtime delivers every ggui_emit into it — your host never sees the channel frames.
import { AppRenderer } from "@mcp-ui/client";import { useMcpAppsChat } from "@ggui-ai/react/chat-helpers";
function Dashboard({ agentUrl }: { agentUrl: string }) { const { sessions, handleAppMessage } = useMcpAppsChat({ chatEndpoint: `${agentUrl}/agent`, }); // Mount the latest render with <AppRenderer>; live `metrics` deliveries // repaint the chart INSIDE the iframe. See the React SDK page for the // full sandbox + relay wiring.}See React SDK for the complete <AppRenderer> host contract (sandbox-proxy origin + onReadResource / onCallTool relay + onMessage), and the ggui-basic-web sample for a runnable host.
Pull channels (source)
Section titled “Pull channels (source)”The example above is a push channel — the agent actively emits. For data the runtime should fetch on its own cadence, declare a source instead and skip ggui_emit entirely:
streamSpec: { orders: { description: "Open orders, refreshed from the orders tool.", schema: { type: "object", properties: { id: { type: "string" }, total: { type: "number" } } }, source: { tool: "list_open_orders", args: { region: "us-east-1" } }, },}source.tool MUST resolve to a tool the contract declares in its agentCapabilities.tools (a cross-reference the contract linter enforces). The iframe-runtime polls that tool and feeds results into the channel; the agent declares the channel and renders, but never pushes.
See also
Section titled “See also”- React SDK —
<AppRenderer>+useMcpAppsChat, the host render-hosting surface - MCP protocol reference —
ggui_handshake,ggui_render,ggui_emitrequest/response shapes - Data vs Behavior — why live data is a contract field and rendering behavior is component code
ggui-basic-websample — a runnable MCP-Apps host