Skip to content
Public preview · the console is open

WebSocket Protocol

read as .md

The live channel — the live session plane — runs over a WebSocket between a GGUI server and a connected client. It carries agent render notifications, outbound StreamEnvelope deliveries, and canonical inbound ActionEnvelope user actions.

Deployment URL
Self-hosted (ggui serve) ws://127.0.0.1:6781/ws (default; configurable)
Hosted GGUI wss://mcp.ggui.ai/ws
Direction Type Payload Purpose
Client → Server subscribe SubscribePayload Bind the connection to a GguiSession. MUST be first.
Client → Server action ActionEnvelope Canonical inbound user action.
Client → Server ping — Heartbeat; server answers pong.
Client → Server channel_subscribe channel name Subscribe to a streamSpec[*].source.tool channel; server polls the tool.
Client → Server channel_unsubscribe channel name Cancel a channel_subscribe (idempotent).
Client → Server host_context_observed host-context projection Iframe echoes the MCP-Apps host context.
Server → Client ack AckPayload Acknowledges subscribe; seeds resume cursors.
Server → Client pong — Heartbeat response.
Server → Client error ErrorPayload Transport / auth / subscribe-level error.
Server → Client render RenderPayload {session, matchType?} The agent committed a new GguiSession.
Server → Client props_update {sessionId, props, epoch?} ggui_update / ggui_amend fan-out — full props replacement.
Server → Client data StreamEnvelope Outbound delivery on a declared streamSpec channel. Generation-pipeline progress also flows here as a {type:'data'} delivery on the reserved _ggui:lifecycle channel — there is no dedicated progress frame.
Server → Client render_event GguiSessionEvent Event-ledger replay when subscribe.sinceSequence is set.
Server → Client drain_ack DrainAckPayload ggui_consume drained an action; iframe cancels its claim timer.
Server → Client channel_payload channel frame source.tool result for a subscribed channel.
Server → Client channel_error channel error frame Channel subscribe rejected / poll failed / tool errored.

Bind the connection to a GguiSession. MUST be the first message.

{
"type": "subscribe",
"payload": {
"sessionId": "ses_abc123",
"appId": "app_myapp",
"wsToken": "btkn_…"
}
}
Field Required Description
sessionId Yes The GguiSession to bind.
appId No Tenancy scope — when present MUST match the session’s bound app; when omitted the server resolves the identity-default appId.
wsToken No Short-TTL auth credential from the _meta["ai.ggui/render"] slice. Required unless the connection authenticated by bearer (see below).
fromSeq No Per-channel stream cursor — replay outbound StreamEnvelopes with seq > N before the live tail begins (needs a GguiSessionStreamBuffer).
fromEpoch No The streamEpoch your fromSeq was counted in. Send it with fromSeq; if it is not the session’s current epoch, the server replays all.
sinceSequence No Event-ledger replay cursor — replays GguiSessionEvents with sequence > N as render_event frames. Independent of fromSeq.
role No 'user' or 'agent'.
supportedVersions No Protocol-version handshake — see below.

fromSeq and sinceSequence are two independent replay cursors over two ledgers: per-channel stream replay vs the render-level event ledger. When both are set, sinceSequence events replay first.

The load-bearing credential is the wsToken minted at ggui_render and delivered on the _meta["ai.ggui/render"] slice. Clients thread it twice: as ?wsToken=<encoded> on the WebSocket upgrade URL AND inside SubscribePayload.wsToken. It is opaque, validated server-side against sessionId + appId, short-TTL, and reusable within its TTL for reconnects. A reconnect presents a live wsToken again; the server mints no reconnect credential into the ack.

A canonical ActionEnvelope — flat, no nested blocks.

{
"type": "action",
"payload": {
"sessionId": "ses_abc123",
"type": "data:submit",
"payload": { "action": "submit", "data": { "rating": 5 } },
"clientSeq": 1
}
}

The sessionId identifies the render the action originated from; the server rejects envelopes whose sessionId doesn’t match the subscriber’s bound render.


Acknowledges the subscribe and seeds resume cursors.

{
"type": "ack",
"payload": { "sequence": 42, "timestamp": 1716130000000, "streamSeq": 12, "session": null }
}
Field Description
sequence Inbound event-ledger position.
timestamp Epoch milliseconds.
session Current GguiSession snapshot when one is already committed; null / absent otherwise.
streamSeq The outbound cursor at the ack: the live tail begins after it. Not a dedupe floor (see below).
streamEpoch The generation of the counter streamSeq counts in. A different one from the epoch you hold means the counter restarted.
replayTruncated true when a requested fromSeq predates the server’s buffer window — the client got the live tail but missed history.
sessionToken Deprecated: sent by no server since this release, and removed in the next. Don’t depend on it.
serverVersion Server’s PROTOCOL_SCHEMA_VERSION stamp — see the protocol-version handshake below.

The agent committed a new GguiSession for this sessionId. The payload is { session: GguiSession, matchType? } — the frame discriminator type: "render" stays verb-named; the object inside is the GguiSession.

{
"type": "render",
"payload": {
"session": {
"id": "ses_xyz",
"componentCode": "/* compiled JS */",
"propsSpec": {/* … */},
"actionSpec": {/* … */},
"streamSpec": {/* … */},
"contextSpec": {/* … */}
}
}
}

The ggui_update / ggui_amend fan-out — the agent mutated props on this GguiSession. props is the FULL replacement state (post-merge for kind: "merge" updates), not a patch. Persistence is the source of truth; this frame is the latency optimization. epoch is the commit-time epoch — ggui_update fans its just-advanced epoch, ggui_amend fans the unchanged head epoch. It is also the freeze-latch signal: a mount whose own epoch is lower than the frame’s epoch freezes (superseded).

epoch is OPTIONAL on the wire. Absent reads as 0 — pre-epoch producers and amends against an epoch-0 head omit it. Consumers MUST tolerate the absence rather than reject the frame; a client that treats epoch as required will throw on a legitimate amend.

{
"type": "props_update",
"payload": { "sessionId": "ses_abc123", "props": { "rating": 5 }, "epoch": 3 }
}

One GguiSessionEvent from the per-session event ledger, replayed when subscribe.payload.sinceSequence is set. Same ledger as GET /api/sessions/:sessionId/events — two transports, one cursor.

The frame body is { seq, type, timestamp, data }. seq is monotonic and gap-free per session, starting at 1 (0 is the “no events yet” sentinel). type discriminates; data is structurally identical to the matching live-channel frame’s payload.

GguiSessionEventType is the canonical taxonomy. Implementations MUST emit events for the core types; custom types MAY be added, and MUST carry an x- or ext: prefix so they can never collide with a future core name. Consumers MUST tolerate a type they don’t recognize.

Type Meaning data
ui.created A render was minted for this session. Implementation-defined.
ui.committed The render’s component + contract were committed and are mountable. Implementation-defined.
ui.updated Props changed — the ledger twin of the props_update frame. { sessionId, props, epoch? }
ui.reminted The session head advanced to a new epoch-numbered record. ggui_update only. { epoch } — the epoch the head advanced TO.
tool.called A tool call was issued against this session. Implementation-defined.
tool.result A tool call returned. Implementation-defined.
user.submitted The user fired a gesture — the retained ActionEnvelope. The ActionEnvelope.

Stated honestly: the reference ggui serve appends ui.updated, ui.reminted, and user.submitted to the replayable ledger today. ui.created and ui.committed are the names ggui_render emits on its telemetry sink; tool.called / tool.result are reserved taxonomy names. A conformant implementation may write more than the reference does, so consume defensively and never key behavior on a type’s absence.

There is no epoch counter field on the session row. The count of ui.reminted events at a seq IS the epoch at that seq. A replaying client folds the ledger forward and increments its epoch on every ui.reminted; slicing at a cursor yields the epoch as of that cursor, which is exactly how a pinned #N read reconstructs its snapshot (props replayed up to and including the N-th remint boundary).

That derivation is what makes the freeze latch work: a ui.reminted whose epoch exceeds the epoch in the mount’s own resource URI means this mount has been superseded, and it freezes rather than applying newer state. The props_update frame carries the same signal on the live path.

An outbound StreamEnvelope. channel names the streamSpec[name] it belongs to; mode is append or replace.

{
"type": "data",
"payload": {
"sessionId": "ses_abc123",
"channel": "message",
"mode": "append",
"payload": { "text": "Found 3 flights.", "sender": "agent" },
"seq": 7
}
}

A server-side error — transport, auth, subscribe rejection, or an inbound action that failed contract validation. An inbound action that fails contract validation surfaces here as a typed error frame with code CONTRACT_VIOLATION; nothing reaches the consume buffer. The earlier _ggui:contract-error reserved channel, its ContractErrorPayload, and the ContractErrorCode union were deleted in draft-2026-06-11 — contract failures now surface on the call that caused them, not on a side channel.

Canonical code values exported from @ggui-ai/protocol:

Code Meaning
UPGRADE_REQUIRED Protocol-version handshake mismatch (see below). Servers default to versionPolicy: 'reject' and close the connection after emitting this frame.
CONTRACT_VIOLATION An inbound action failed validation against the render’s actionSpec (undeclared name or schema-rejected payload); nothing reaches the consume buffer. A dispatch through the relay (ggui_runtime_submit_action) gets the same code as an ok: false tool result instead of a frame.

Other codes are free-form strings. Server-emitted today: SESSION_MISMATCH, BOOTSTRAP_SESSION_MISMATCH, SESSION_CREATE_FAILED, REPLAY_HORIZON_PASSED; the channel_error frame uses CHANNEL_UNKNOWN, CHANNEL_NOT_LOCAL, SESSION_NOT_FOUND, SUBSCRIBE_UNAUTHORIZED, POLL_FAILED. See Envelopes.


Both peers advertise their schema version on the wire so version mismatch surfaces explicitly instead of silently corrupting state.

  • subscribe.payload.supportedVersions?: string[] — client declares the versions it accepts (first-party clients populate from CLIENT_SUPPORTED_VERSIONS).
  • ack.payload.serverVersion?: string — server stamps its PROTOCOL_SCHEMA_VERSION on every ack.

Mismatch policy:

  • Server-side — if subscribe.supportedVersions is present and the server’s PROTOCOL_SCHEMA_VERSION isn’t a member, the server replies with error { code: 'UPGRADE_REQUIRED' }. Default versionPolicy: 'reject' also closes the socket; versionPolicy: 'advisory' keeps it open (controlled-migration opt-out only).
  • Client-side — if ack.serverVersion is absent from the client’s CLIENT_SUPPORTED_VERSIONS, the client surfaces UPGRADE_REQUIRED on its error channel.

Absent declarations on either side are legacy-pass-through (version-agnostic) — preserves pre-handshake behavior for older peers.


1. Connect to ws[s]://<host>/ws?wsToken=<token>
2. Send "subscribe" (sessionId + appId + wsToken, optional fromSeq / sinceSequence)
3. Receive "ack" (carries streamSeq + initial session)
4. Loop: receive "render" / "props_update" / "data" / "error"
send "action"
(generation-pipeline progress arrives as a {type:'data'} delivery on the reserved _ggui:lifecycle channel)
5. Close on session end or client disconnect

A subscribe without fromSeq replays no channel the render declares: the ack carries the initial state. The server may still send the retained envelopes of the reserved _ggui:* channels, such as the _ggui:preview skeleton, right after the ack, with seq at or below the ack’s streamSeq. Apply them. Dedupe against the highest seq you have applied, never against streamSeq, which only marks where the live tail begins (SPEC §12.2.1).

@ggui-ai/live-channel’s WSTransport — reached through @ggui-ai/iframe-runtime inside the render iframe — reconnects automatically with exponential backoff (1s → 2s → 4s → … capped at 60s, max 10 attempts). One exception shortens the ladder: on close code 1012 (service restart) the first retry skips backoff, so a server roll doesn’t show the user a multi-second blink. A subscribe the server acknowledged but could not complete, such as a failed event-ledger read, closes with 1011, which takes the ordinary backoff.

If the socket never opens on two consecutive attempts, the ladder bails immediately instead of burning the full schedule. That is the signature of a structurally unreachable destination — a host CSP or iframe sandbox that blocks wss:// — not a transient drop, and the runtime pivots to its polling transport instead.

To resume the outbound stream without gaps, track the last observed StreamEnvelope.seq and its streamEpoch, and pass them as fromSeq and fromEpoch on the next subscribe. The server replays buffered envelopes (where supported) before re-entering the live tail.

A seq counts within one counter, and a counter can restart: after a long idle period, or when a server restarts over a persistent session store. Each counter carries a generation, streamEpoch, on every envelope with a seq and on the ack. If you dedupe by seq, keep the epoch beside your highest applied seq. An envelope or ack of a different epoch means the counter restarted, so reset to nothing applied and apply it, whatever its seq. That covers a restart while the socket stays open, which no ack announces. A resume whose fromEpoch is not the session’s current one is replayed in full, with replayTruncated: true. A server or envelope without an epoch is unknown, never a mismatch (SPEC §12.2.1, invariant 4).

Status Meaning
connecting Initial socket handshake in flight
connected Subscribed; ack received
disconnected Socket closed, no retry pending
reconnecting Backoff in progress after a drop