---
title: Bootstrap handshake
description: How a host mounts the ggui renderer iframe, the postMessage contract that crosses that boundary, and the canonical bootstrap failure modes.
---

A ggui render ships as an [MCP App](https://modelcontextprotocol.io/) — a `ResourceContents` blob whose `text` is a thin-shell HTML document. When a host mounts that blob in an iframe, the shell loads the `@ggui-ai/iframe-runtime` bundle, which opens a live-channel WebSocket and starts rendering. This page is the handshake spec across the iframe boundary.

> **Audience:** protocol implementers and third-party MCP host builders. If you're already on `@ggui-ai/react`, the web consumer surface ([`useMcpAppsChat`](/sdk/react/) from `@ggui-ai/react/chat-helpers` + `<AppRenderer>` from `@mcp-ui/client`) wraps everything below — React Native's equivalent host is `<McpAppIframe>`. The protocol underneath those wrappers is what's documented here.

The `ProtocolError` union and the full `ObservabilityEvent` catalog ship as exported types in [`@ggui-ai/iframe-runtime`](https://github.com/ggui-ai/ggui/tree/main/packages/iframe-runtime) (re-exported through `@ggui-ai/react` so host apps need no direct renderer import); version-handshake details live in the [WebSocket Protocol reference](/api/websocket-protocol/). This page is the focused handshake spec.

---

## The boot flow

```
host                                                 renderer (in iframe)
 │                                                              │
 │ 1. fetch ResourceContents from MCP server                    │
 │    (`{contents: [{uri, mimeType:'text/html', text}]}`)       │
 │                                                              │
 │ 2. mount <iframe srcdoc={text} />                            │
 │    (or <iframe src={uri} /> for http(s) URIs)                │
 │ ──────────────────────────────────────────────────────────▶  │
 │                                                              │ 3. <script src={runtimeUrl}> loads
 │                                                              │ 4. runtime evaluates bundle
 │ 5. iframe → host: postMessage 'ggui:renderer-ready'          │
 │ ◀──────────────────────────────────────────────────────────  │
 │ 6. iframe → host: jsonrpc 'ui/initialize'                    │
 │ ◀──────────────────────────────────────────────────────────  │
 │ 7. host → iframe: result w/ hostContext (capabilities only)  │
 │ ──────────────────────────────────────────────────────────▶  │
 │ 8. host → iframe: 'ui/notifications/tool-result' carrying    │
 │    params._meta["ai.ggui/render"] (the bootstrap slice)      │
 │ ──────────────────────────────────────────────────────────▶  │
 │                                                              │ 9. parse _meta["ai.ggui/render"]
 │                                                              │ 10. WS handshake (live channel)
 │                                                              │ 11. subscribe + ack
 │                                                              │
 │ ── steady state: tools/call JSON-RPC, ggui:observe stream ── │
```

Self-contained shells — per-render HTML documents that can inline JSON before the bundle loads — write the same slice synchronously on `globalThis.__GGUI_META__` instead; the runtime reads it directly and skips waiting for step 8.

Any step from 3–11 can fail terminally. Failures surface as `postMessage({type: 'ggui:bootstrap-failed', reason, message})` from the iframe; the renderer does not recover. After step 11 (the subscribe ack), failures shift to live-channel `error` frames (e.g. a typed `CONTRACT_VIOLATION` error frame) — see [Envelopes](/protocol/envelopes/).

---

## postMessage contract

Four event types flow from iframe → host. Every host MUST handle the first three; `ggui:lifecycle` handling MAY be a no-op, but hosts MUST tolerate it.

### `ggui:renderer-ready`

Emitted once per render, immediately after the runtime bundle evaluates and its status DOM mounts — before `ui/initialize`. It means the bundle loaded; it does NOT mean the live channel is up. Steady-state liveness is signaled by the WS subscribe ack (and lifecycle state `code-ready`).

```ts
{ type: 'ggui:renderer-ready', version: string }
```

`version` is the iframe-runtime bundle version, not the protocol version. Hosts typically log it for support and diagnostics.

### `ggui:bootstrap-failed`

Emitted at most once per render, when any boot step from 3–11 fails terminally. The renderer does NOT recover. The host MUST surface this as a user-visible error — naming the `reason` verbatim is the recommended UX (it gives operators a searchable string).

```ts
{
  type: 'ggui:bootstrap-failed',
  reason: BootstrapFailureReason,  // extensibly-closed union — see below
  message: string,                  // operator-readable detail
}
```

### `ggui:observe`

Emitted multiple times per render, on happy paths and failures alike. Carries telemetry the host MAY render in a RenderInspector-style view. Hosts SHOULD forward these to their own telemetry pipeline; dropping them is allowed but blinds your operators.

```ts
{ type: 'ggui:observe', event: ObservabilityEvent }
```

`ObservabilityEvent` is an extensibly-closed union including `schema-version-mismatch`, `subscribe-failed`, `auth-required`. See [the implementer guide](https://github.com/ggui-ai/ggui/blob/main/docs/guides/implementing-ggui-protocol.md#observability) for the full event catalog.

---

## JSON-RPC methods the host responds to

The iframe-runtime makes JSON-RPC calls on the host over postMessage. Every implementer MUST handle these methods:

| Method                          | When                                                                                                              | Host responds with                                                                                                                                                                         |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ui/initialize`                 | Once, before bootstrap (step 6 above). Params: `{ appInfo, appCapabilities, protocolVersion }` per MCP Apps spec. | `{ toolOutput: { _meta: { "ai.ggui/render": RenderMeta } }, hostContext? }`. The `hostContext` MAY carry `containerDimensions`, `availableDisplayModes`, `platform`, `deviceCapabilities`. |
| `tools/call`                    | When generated component code issues a direct `tools/call` (e.g. `ggui_runtime_submit_action`).                   | Forward to the MCP server's tool registry; return its response verbatim.                                                                                                                   |
| `ui/open-link`                  | When generated component code calls a navigation primitive.                                                       | `{}` after performing host-appropriate navigation (open in new tab, deep-link, etc.).                                                                                                      |
| `ui/notifications/size-changed` | Iframe content resized; carries the new height. **Notification** (no response required).                          | n/a — host SHOULD adjust iframe dimensions.                                                                                                                                                |
| `ui/message`                    | Component code surfaces a chat-bound natural-language message.                                                    | `{}` after delivering to the chat surface.                                                                                                                                                 |
| `ui/update-model-context`       | Component code mutated a context slot; host forwards to the MCP server (fire-and-forget).                         | `{}`.                                                                                                                                                                                      |
| `ui/request-display-mode`       | Component code requests an enum change (`inline` / `fullscreen` / `pip`).                                         | `{}` after honoring (or rejecting) the request.                                                                                                                                            |

**Bootstrap delivery.** After answering `ui/initialize`, the host sends a `ui/notifications/tool-result` notification (host → iframe, no response) whose `params._meta["ai.ggui/render"]` carries the bootstrap slice — `params` is a `CallToolResult` per the MCP Apps spec; `params.toolOutput._meta` is accepted as a back-compat alias. Self-contained shells inline the same slice synchronously on `globalThis.__GGUI_META__` and skip this round-trip.

Hosts MUST NOT intercept `tools/call` traffic — verbatim forwarding to the MCP server's tool registry is the only correct behavior. Modifying or filtering tool calls breaks the typed-channel contract enforced server-side.

Liveness on the live-channel WebSocket (post-bootstrap) is a separate `type: 'ping'` WebSocket frame, not a postMessage method — see [WebSocket Protocol reference](/api/websocket-protocol/).

---

## `BootstrapFailureReason`

Extensibly-closed union. Hosts MUST handle unknown values gracefully (render the raw string, don't switch-case-throw). The canonical first-party set, grouped by source:

### Parse-time (slice-meta extractor failed)

Failures observed when the slice-meta extractor runs — after the runtime bundle evaluates, before any live-channel attempt.

| Reason                                                   | Cause                                                                                                                   |
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `MISSING_TOOL_OUTPUT`                                    | The `ui/notifications/tool-result` notification's `params` was missing or not an object (no usable `CallToolResult` payload). A well-formed `params` that lacks both `_meta` and the back-compat `toolOutput._meta` fails as `MISSING_META_GGUI_BOOTSTRAP` instead |
| `MISSING_META_GGUI_BOOTSTRAP` / `BOOTSTRAP_META_MISSING` | `_meta["ai.ggui/render"]` slice absent (synonyms; the error names are legacy labels — the wire key is `ai.ggui/render`) |
| `MALFORMED_BOOTSTRAP`                                    | Bootstrap token failed structural parse                                                                                 |
| `EXPIRED_BOOTSTRAP`                                      | Bootstrap token's expiry is in the past                                                                                 |

### Post-parse orchestration

Failures observed after parse but before renderer steady state.

| Reason                 | Cause                                                                                                              |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `UI_INITIALIZE_FAILED` | `ui/initialize` round-trip failed before bootstrap was readable                                                    |
| `WS_HANDSHAKE_FAILED`  | WebSocket rejected the bootstrap credential                                                                        |
| `UPGRADE_REQUIRED`     | Server-version not in client's supported set (also surfaces as `kind: 'version'` ProtocolError for finer handling) |

### Transport-observable (pre-WebSocket)

Failures the host can sometimes diagnose from outside the iframe.

| Reason                | Cause                                                               |
| --------------------- | ------------------------------------------------------------------- |
| `BUNDLE_FETCH_FAILED` | `<script src={runtimeUrl}>` failed to load                          |
| `CSP_VIOLATION`       | Host's Content-Security-Policy blocked something the renderer needs |
| `SESSION_NOT_FOUND`   | Server rejected pre-handshake — render expired or never existed     |
| `AUTH_REJECTED`       | Server rejected pre-handshake — auth context invalid                |
| `(string & {})`       | First-party renderers MAY mint new reasons without a major bump.    |

For `CSP_VIOLATION` specifically, the host's own CSP is the usual root cause. Recommended UX: ask the user to check their browser console for the blocked directive.

---

## Bundle integrity (out of band)

The handshake itself carries no SRI hash for `runtimeUrl` — the runtime bundle's integrity is the server's responsibility (immutable cache-control + same-origin or trusted CDN). Integrity hashes DO appear on the bootstrap payload, but on adjacent fields, not on the handshake:

- **`_meta["ai.ggui/render"].codeHash`** — hex-encoded SHA-256 of the static-component bytes served at `codeUrl`. Paired with `codeUrl` (present together or absent together). Lets consumers verify content addressing without re-parsing the URL.
- **`_meta["ai.ggui/render"].gadgets[].bundleSri`** — SHA-384 SRI hash (`sha384-<base64>`) of each operator-registered gadget bundle. When present alongside `bundleUrl`, the iframe-runtime injects the gadget via `<script type="module" integrity>` so the browser refuses execution on mismatch. Absent → integrity-less dynamic `import()` (back-compat for in-tree wrappers).

A bootstrap MAY arrive without either field. The handshake's failure modes (`BUNDLE_FETCH_FAILED`, `CSP_VIOLATION`) do NOT include a hash-mismatch class — when SRI fires, the browser blocks the script and the runtime surfaces it as a downstream `BUNDLE_FETCH_FAILED`.

---

## Recovery posture

| Failure                                        | Recoverable?                                                                                                                                                                    |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Pre-render bootstrap-failed (`reason`-bearing) | Renderer will NOT auto-recover. Host MAY re-mount the iframe after fixing the root cause (refresh auth, clear CSP, etc.).                                                       |
| Post-render live-channel errors                | Surfaced as typed live-channel `error` frames (e.g. `CONTRACT_VIOLATION`); renderer continues running. See [Envelopes](/protocol/envelopes/).                                                                |
| `UPGRADE_REQUIRED` (version mismatch)          | Terminal under the default `versionPolicy: 'reject'` — the server closes the connection and the failure surfaces as `ggui:bootstrap-failed` with reason `UPGRADE_REQUIRED`. Servers running the legacy `'advisory'` opt-out keep the connection open; the mismatch surfaces as a `schema-version-mismatch` `ggui:observe` event instead, and the host MAY render an inline "update the client" prompt. |

---

## Vanilla quickstart

For non-React hosts, the protocol is plain `<iframe>` + manual postMessage. The React wrapper is ~180 LOC of convenience; everything below is the wire contract.

```html
<!doctype html>
<iframe id="ggui" style="width:100%;height:100vh;border:0"></iframe>
<script type="module">
  const sessionId = "…"; // from ggui_render's structuredContent.sessionId

  // 1. Fetch ResourceContents via MCP `resources/read` on the render's
  //    `ui://ggui/render/<sessionId>` URI. Shown as a direct POST to a
  //    local `ggui serve`; production hosts proxy this through their backend.
  const rpc = await fetch("http://127.0.0.1:6781/mcp", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json, text/event-stream",
      Authorization: "Bearer dev",
    },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "resources/read",
      params: { uri: `ui://ggui/render/${sessionId}` },
    }),
  }).then((r) => r.json());
  const { contents } = rpc.result;
  const resource = contents[0]; // { uri, mimeType: 'text/html', text }

  // 2. Mount via srcdoc (inline HTML) OR src (http(s) URI).
  const iframe = document.getElementById("ggui");
  if (resource.text) iframe.srcdoc = resource.text;
  else iframe.src = resource.uri;

  // 3. Subscribe to postMessage events from the iframe.
  window.addEventListener("message", (ev) => {
    if (ev.source !== iframe.contentWindow) return;
    const msg = ev.data;

    if (msg?.type === "ggui:renderer-ready") {
      console.log("renderer version", msg.version);
    } else if (msg?.type === "ggui:bootstrap-failed") {
      // Terminal — render error UI naming msg.reason.
      showError({ reason: msg.reason, message: msg.message });
    } else if (msg?.type === "ggui:observe") {
      record(msg.event);
    } else if (msg?.type === "ggui:lifecycle") {
      iframe.setAttribute("data-ggui-mcp-app-iframe-lifecycle", msg.event.state);
    } else if (msg?.jsonrpc === "2.0" && msg.method === "ui/initialize") {
      // 4. Answer ui/initialize with host capabilities only — the
      //    bootstrap slice does NOT ride this response.
      iframe.contentWindow.postMessage(
        {
          jsonrpc: "2.0",
          id: msg.id,
          result: {
            hostContext: {
              containerDimensions: { width: iframe.clientWidth, height: iframe.clientHeight },
              availableDisplayModes: ["inline", "fullscreen"],
            },
          },
        },
        "*"
      );
      // 5. Then deliver the bootstrap slice on a
      //    ui/notifications/tool-result notification.
      iframe.contentWindow.postMessage(
        {
          jsonrpc: "2.0",
          method: "ui/notifications/tool-result",
          params: {
            _meta: {
              "ai.ggui/render": {
                sessionId: "…",
                appId: "…",
                runtimeUrl: "/_ggui/iframe-runtime.js",
                // mode discriminator: live (wsUrl+wsToken) | static-component (codeUrl) | system-card (kind)
                wsUrl: "wss://…",
                wsToken: "…",
              },
            },
          },
        },
        "*"
      );
    }
    // tools/call, ui/open-link, ui/notifications/size-changed, ui/message,
    // ui/update-model-context, ui/request-display-mode — forward / handle
    // as in the table above.
  });
</script>
```

The `event.source` check is non-optional — without it, any window can spoof renderer envelopes. Production hosts SHOULD also validate `msg` against [the lifecycle envelope schema](https://github.com/ggui-ai/ggui/blob/main/packages/protocol/src/integrations/mcp-apps.ts) before mirroring state into trusted UI.

---

## Host obligations summary

A host that implements the JSON-RPC methods above plus the four postMessage event handlers honors the protocol's bootstrap contract. The MUST / SHOULD breakdown:

1. **MUST** honor `_meta["ai.ggui/render"].runtimeUrl` from the resource — the iframe-runtime bundle URL.
2. **MUST** classify `ggui:bootstrap-failed` onto `BootstrapFailureReason` (or render the raw string for unknown values).
3. **MUST** surface `ggui:bootstrap-failed` as a user-visible error.
4. **SHOULD** surface `ggui:observe` events to host telemetry.
5. **MUST NOT** intercept `tools/call` JSON-RPC traffic — forward to the MCP server's tool registry verbatim.
6. **MUST** narrow `event.source` to the iframe's `contentWindow` before reading any postMessage data.

These obligations are encoded in the [Conformance kit](/protocol/conformance/) — a third-party host that satisfies them passes the host-implementer fixtures.

---

## See also

- [Protocol overview](/protocol/overview/) — three-channel topology; bootstrap is the path into the live channel.
- [Envelopes](/protocol/envelopes/) — live-channel wire shapes (post-bootstrap traffic).
- [Conformance](/protocol/conformance/) — the bar a host implementation must pass.
- [`@ggui-ai/iframe-runtime` on GitHub](https://github.com/ggui-ai/ggui/tree/main/packages/iframe-runtime) — `ProtocolError` union, full `ObservabilityEvent` catalog, boot-sequence source.
- [`useMcpAppsChat`](/sdk/react/) (from `@ggui-ai/react/chat-helpers`) + `<AppRenderer>` (from `@mcp-ui/client`) — the web React surface that boxes everything above (React Native's host is `<McpAppIframe>`).
- [`packages/console/src/routes/GguiSessions.tsx`](https://github.com/ggui-ai/ggui/blob/main/packages/console/src/routes/GguiSessions.tsx) — production reference implementation.