Skip to content

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:

  1. The agent declares a streamSpec channel on the contract — a typed, named live channel.
  2. The agent renders the contract once, then pushes updates with ggui_emit.
  3. 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.

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 fieldMeaning
schemaRequired. 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").
replayWhat a freshly-mounted iframe receives: "all" (full backlog), "latest" (last delivery only), "none". Optional (default "none").
completeDeclare complete: true to allow a terminal ggui_emit({ complete: true }) that closes the channel; undeclared channels reject it. Optional.
descriptionNatural-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.

Negotiate + materialize the contract through the normal ggui_handshakeggui_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 },
});

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.

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.