Skip to content

Event System

read as .md

User actions originate in the renderer (on the live channel), land in the server, and reach the agent through one of two read paths. This page covers the closed vocabulary of events, the canonical envelope, how subscriptions gate what gets delivered, and how an agent or React SDK consumer actually reads them.

For the full wire grammar of the envelope itself, see Envelopes. For the channel topology, see the Architecture overview.

The protocol recognizes exactly one event type. Every user gesture that drives a turn is a data:submit. The earlier data:change / lifecycle:* / interaction:* / error:* vocabulary was deleted (draft-2026-06-12) — those types never had a producer.

type EventType = "data:submit"; // the only member

A data:submit is schema-validated against the render’s actionSpec.

Every user gesture arrives as a flat ActionEnvelope:

interface ActionEnvelope<TPayload = JsonValue> {
sessionId: string; // bound at subscribe time; server rejects mismatches
type: EventType;
payload?: TPayload; // for `data:submit`: { action, data?, tool? }
clientSeq?: number; // client-monotonic dedup hint
schemaVersion?: string; // producer's PROTOCOL_SCHEMA_VERSION (advisory)
}

The envelope is intentionally flat — no nested event / context / meta blocks. Render-level diagnostics (device info, interface context, user identity) are captured once at subscribe time on the server, not per-delivery. See Envelopes — “Fields intentionally NOT on the envelope” for the rationale.

Delivery gating falls out of the contract’s actionSpec: every declared action emits a data:submit envelope the agent reads via ggui_consume. The old per-event EventSubscription filter (an allowlist of event types at render time) and the DEFAULT_SUBSCRIPTION constant were deleted from the protocol (no shims) — there is no wire-level subscribe object on render, and data:submit is now the only event type.

The current ggui_render input is {handshakeId, props, themeId?, infra?, override?}props is required (pass {} when the contract declares no propsSpec), and there is no subscribe field.

The same ActionEnvelope reaches consumers through two distinct seams. Don’t conflate them.

ConsumerPathShape returned
Agent (server-side, LLM-driven)Long-polls ggui_consume over MCPConsumeEventEntry
Renderer SDK (browser, e.g. React)Live tail on the WebSocket subscribe seamActionEnvelope

ggui_consume is the agent’s read path. It’s render-keyed, consume-once, and the row shape (ConsumeEventEntry) carries a tiny bit of extra context the LLM needs to route the gesture. The WebSocket subscribe seam is what the iframe-runtime uses to deliver interaction events into the rendered component. See MCP Protocol — Events for both shapes.

Host relay + the ai.ggui/userAction doorbell

Section titled “Host relay + the ai.ggui/userAction doorbell”

In MCP-Apps hosts the gesture reaches the server via the host’s ggui_runtime_submit_action tools/call relay instead of the WS. If the response reports consumerPresent: false (no ggui_consume long-poll is draining — e.g. after a page reload), the iframe emits a ui/message whose text directs the agent to call ggui_consume({sessionId}), with an optional structured mirror on content[0]._meta["ai.ggui/userAction"] — a pure pointer; the gesture itself is only ever drained via ggui_consume.

The agent never talks to the live channel directly — it renders UI, polls events, discovers gadgets, and browses the blueprint marketplace over MCP. The canonical agent-callable tool surface (lifecycle, capability discovery, stream emit, and blueprint marketplace) is enumerated in the MCP Protocol reference — that page is the single source of truth, with field-level shapes and return types. Linking out instead of duplicating here keeps this page from drifting as the tool surface evolves.

The ggui_consume long-poll in particular is the agent’s event read path — its return shape (ConsumeEventEntry) is covered in MCP Protocol — Events.

ActionEnvelope is the inbound half of the live channel. Server-to-renderer traffic on the same WebSocket arrives as StreamEnvelope (one delivery per named streamSpec channel). Contract violations are not a separate channel: an invalid inbound action is answered with a typed error frame (CONTRACT_VIOLATION) on the live channel, and a ggui_render / ggui_emit validation failure rejects the agent’s own tool call. The former _ggui:contract-error channel and its ContractErrorPayload vocabulary were removed (draft-2026-06-11). Both surviving shapes are covered in Envelopes.

While the server is generating a fresh UI (no blueprint hit), it emits progress on the reserved _ggui:lifecycle channel (a {type:'data'} frame) that the renderer surfaces as a loading state. The canonical GguiLifecyclePayload vocabulary lives in packages/protocol/src/types/lifecycle.ts:

handshake_started → handshake_completed → render_started → consume_polling

These feed the built-in progress UI inside the rendered iframe (the iframe-runtime) — your end-user sees real-time feedback instead of a frozen iframe.

In the web consumer path the live-channel WebSocket — and the progress events above — are owned by the iframe-runtime inside the sandboxed <AppRenderer>, not by host code. The host doesn’t open the socket, set a wsEndpoint, or handle ActionEnvelopes directly: the wsUrl is server-stamped on the render’s ai.ggui/render slice, the iframe connects + subscribes + resumes on its own, and the progress UI animates inside the iframe.

import { AppRenderer } from "@mcp-ui/client";
import { useMcpAppsChat } from "@ggui-ai/react/chat-helpers";
// Mount the render; the iframe-runtime drives the live channel + progress UI.
const { sessions, handleAppMessage } = useMcpAppsChat({ chatEndpoint });
// <AppRenderer ... onMessage={handleAppMessage} />

Structured signals the host may want (dispatch telemetry, subscribe failures, version mismatches, auth-required) surface on the ggui:observe postMessage channel. See React SDK for the host surface and Error Handling → renderer-side faults for the observability events.