Skip to content
Sneak peek — you found hosted ggui early · official launch soon

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/mcp-apps-react, the web consumer surface (useMcpAppsChat from @ggui-ai/mcp-apps-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/mcp-apps-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.


There are two ways the bootstrap slice reaches the renderer, and which one you implement depends on the server you are talking to. Get this wrong and you get a MISSING_META_GGUI_BOOTSTRAP with no obvious cause.

Posture Who runs it How the slice arrives
Read-plane-only Hosted ggui (https://mcp.ggui.ai) The host resources/reads the per-render locator off _meta.ui.resourceUri. The response is a self-contained shell that inlines the slice on globalThis.__GGUI_META__. Steps 8–9 below do not happen.
Inlined bootstrap Self-hosted ggui serve default The tool result carries the slice; the host relays it to the iframe. The full 1–11 flow below.

Under read-plane-only the tool result carries no ai.ggui/render slice and no wsToken — there is nothing for the host to relay, so the read plane is the only path, not a variant of one. See Tool result shape for both result shapes side by side.

A host that handles both is a host that reads _meta.ui.resourceUri first and only falls back to relaying a slice it actually found on the result.

Below is the inlined-bootstrap flow in full. Under read-plane-only, step 1 fetches the per-render locator rather than the declaration-level shell, and steps 8–9 are replaced by the shell’s own synchronous globalThis.__GGUI_META__ read.

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 inline JSON before the bundle loads — write the same slice synchronously on globalThis.__GGUI_META__; the runtime reads it directly and never waits for step 8. That is what a resources/read of a per-render locator returns, and it is the only path on hosted ggui.

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, and the channel-transport-* family. 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:

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. Under the inlined-bootstrap posture, 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. Under read-plane-only there is no slice on the result to relay: the self-contained shell inlines it on globalThis.__GGUI_META__ and this round-trip does not happen.

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.

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). Against a read-plane-only server such as hosted ggui, the usual cause is a host that mounted the declaration-level static shell instead of the per-render locator on _meta.ui.resourceUri — the static shell has no slice to inline and no host relay is coming.
MALFORMED_BOOTSTRAP Bootstrap token failed structural parse
EXPIRED_BOOTSTRAP Bootstrap token’s expiry is in the past

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)

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.

A srcdoc iframe inherits the embedding page’s Content-Security-Policy, and a ggui render is not self-contained. A page with a strict script-src gets a blank view that reports only “never negotiated with the host” — the runtime bundle was blocked by script-src-elem before the network log ever saw it, so nothing inside the frame runs to explain itself. Every strict-CSP embedder hits this class; the allowances below are the whole ladder, parameterized by the deployment’s MCP host (<mcp-host> is the origin your ggui_render results point at, e.g. mcp.ggui.ai; the runtime and its chunks are served from assets.<mcp-host> — the platform’s CDN naming):

Directive Entry What it unblocks
script-src https://assets.<mcp-host> everything the frame executes: the runtime bundle (/_ggui/iframe-runtime.<hash>.js), the component module (/code/<hash>.m<rt>.js), its import shims (/_ggui/shims/<rt>/…), and the precompiled validator bundle (/contract/<hash>.js)
connect-src https://<mcp-host> wss://<mcp-host> https://assets.<mcp-host> the render session’s live channel (/ws, /api/sessions/:id/events, /stream) and code fetches
style-src / font-src whatever your hostContext.styles imports (e.g. https://fonts.googleapis.com / https://fonts.gstatic.com) only if you hand the view a font @import through host context

That’s the whole recipe: two host allowances (plus the optional font row). No blob:, no data:, no 'unsafe-eval' — everything the frame executes is a fetchable, immutable asset on the asset origin, including the outbound-action validators (precompiled server-side and shipped as functions, so client-side validation runs at full strength under the strictest policy). One exception remains on the legacy ladder: a render that imports an operator-registered 3rd-party gadget package falls back to scheme-URL loading (its shims are derived per render and cannot be static assets) — such deployments keep script-src blob: data: until that path gets its own asset story.

If the view talks to a hosted agent platform as well (a chat kit relaying gestures through the platform’s tools/call proxy), that platform’s API origin belongs in connect-src too — the gesture never leaves the frame otherwise.

Diagnose from the embedder, not the frame. The violation fires as a securitypolicyviolation event on the embedding document — listen for it (filter blockedURI on assets.<mcp-host> / the session endpoints) and you can turn “never negotiated with the host” into “this page’s CSP blocks <url> — add <directive> <entry>” for your users. The frame cannot report what it was never allowed to load.

Keep that channel clean on your side, too. The frame under this recipe produces zero violations, so anything you see there is yours — and one common false alarm is worth naming: zod v4 feature-probes codegen with Function("") the first time an object schema is built and falls back correctly, but the attempt fires one script-src eval report from your page at boot (many chat kits and MCP client libraries build zod schemas at module init). Set z.config({ jitless: true }) in a side-effect module that is your entry’s first import — a call placed later runs after the probe. ggui’s own reference embedder (the ggui.ai landing) and the runtime inside the frame both do exactly this.


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.


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.
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: "…",
// this mount's history epoch — absent reads as 0.
// The runtime freezes when a later props_update
// frame carries a HIGHER epoch (the freeze latch).
epoch: 0,
},
},
},
},
"*"
);
}
// 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.