Skip to content

Bootstrap handshake

read as .md

A ggui render ships as an MCP App — 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 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 (re-exported through @ggui-ai/react so host apps need no direct renderer import); version-handshake details live in the WebSocket Protocol reference. This page is the focused handshake spec.


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.


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.

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

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

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

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

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.

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

ObservabilityEvent is an extensibly-closed union including schema-version-mismatch, subscribe-failed, auth-required. See the implementer guide for the full event catalog.


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

MethodWhenHost responds with
ui/initializeOnce, 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/callWhen 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-linkWhen generated component code calls a navigation primitive.{} after performing host-appropriate navigation (open in new tab, deep-link, etc.).
ui/notifications/size-changedIframe content resized; carries the new height. Notification (no response required).n/a — host SHOULD adjust iframe dimensions.
ui/messageComponent code surfaces a chat-bound natural-language message.{} after delivering to the chat surface.
ui/update-model-contextComponent code mutated a context slot; host forwards to the MCP server (fire-and-forget).{}.
ui/request-display-modeComponent 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.


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:

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

ReasonCause
MISSING_TOOL_OUTPUTThe 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_BOOTSTRAPBootstrap token failed structural parse
EXPIRED_BOOTSTRAPBootstrap token’s expiry is in the past

Failures observed after parse but before renderer steady state.

ReasonCause
UI_INITIALIZE_FAILEDui/initialize round-trip failed before bootstrap was readable
WS_HANDSHAKE_FAILEDWebSocket rejected the bootstrap credential
UPGRADE_REQUIREDServer-version not in client’s supported set (also surfaces as kind: 'version' ProtocolError for finer handling)

Failures the host can sometimes diagnose from outside the iframe.

ReasonCause
BUNDLE_FETCH_FAILED<script src={runtimeUrl}> failed to load
CSP_VIOLATIONHost’s Content-Security-Policy blocked something the renderer needs
SESSION_NOT_FOUNDServer rejected pre-handshake — render expired or never existed
AUTH_REJECTEDServer 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.


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.


FailureRecoverable?
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 errorsSurfaced as typed live-channel error frames (e.g. CONTRACT_VIOLATION); renderer continues running. See 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.

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.

<!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 before mirroring state into trusted UI.


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 — a third-party host that satisfies them passes the host-implementer fixtures.