---
title: Real-Time Dashboard
description: Stream live data into a generated UI by declaring a streamSpec channel on the contract and pushing updates with ggui_emit. The iframe owns the live channel; your host app just mounts the render.
---

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.

:::note[Endpoint defaults]
Snippets default to **self-hosted ggui** — `http://127.0.0.1:6781/mcp` with `Authorization: Bearer dev`, pairing with `ggui serve --dev-allow-all` (or wherever `ggui serve` is bound). On the hosted platform (coming soon), point the transport at your per-app endpoint `https://mcp.ggui.ai/apps/<appId>` — no `/mcp` suffix. Only the URL + auth header change.
:::

## 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:

```typescript
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](#pull-channels-source). |

The example's `replay: "all"` and `complete: true` are both explicit opt-ins on top of the defaults.

## 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`).

```typescript
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 },
});
```

:::tip[Claude Agent SDK callers]
With `@anthropic-ai/claude-agent-sdk` you don't call these tools by hand — the model does. `GGUI_AGENT_SYSTEM_PROMPT` teaches it the `handshake → render → emit` sequence; you describe the dashboard in the prompt ("render a live ops dashboard and stream throughput every second") and let the agent loop drive the emits. The wire contract is identical.
:::

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

```tsx
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](/sdk/react/) for the complete `<AppRenderer>` host contract (sandbox-proxy origin + `onReadResource` / `onCallTool` relay + `onMessage`), and the [`ggui-basic-web`](https://github.com/ggui-ai/ggui/tree/main/samples/apps/ggui-basic-web) sample for a runnable host.

## 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:

```typescript
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

- [React SDK](/sdk/react/) — `<AppRenderer>` + `useMcpAppsChat`, the host render-hosting surface
- [MCP protocol reference](/api/mcp-protocol/) — `ggui_handshake`, `ggui_render`, `ggui_emit` request/response shapes
- [Data vs Behavior](https://github.com/ggui-ai/ggui/blob/main/docs/principles/data-vs-behavior.md) — why live data is a contract field and rendering behavior is component code
- [`ggui-basic-web` sample](https://github.com/ggui-ai/ggui/tree/main/samples/apps/ggui-basic-web) — a runnable MCP-Apps host