Skip to content

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.

DeploymentURL
Self-hosted (ggui serve)ws://127.0.0.1:6781/ws (default; configurable)
Hosted (ggui.ai) — coming soonwss://mcp.ggui.ai/ws
DirectionTypePayloadPurpose
Client → ServersubscribeSubscribePayloadBind the connection to a GguiSession. MUST be first.
Client → ServeractionActionEnvelopeCanonical inbound user action.
Client → ServerpingHeartbeat; server answers pong.
Client → Serverchannel_subscribechannel nameSubscribe to a streamSpec[*].source.tool channel; server polls the tool.
Client → Serverchannel_unsubscribechannel nameCancel a channel_subscribe (idempotent).
Client → Serverhost_context_observedhost-context projectionIframe echoes the MCP-Apps host context.
Server → ClientackAckPayloadAcknowledges subscribe; seeds resume cursors.
Server → ClientpongHeartbeat response.
Server → ClienterrorErrorPayloadTransport / auth / subscribe-level error.
Server → ClientrenderRenderPayload {session, matchType?}The agent committed a new GguiSession.
Server → Clientprops_update{sessionId, props}ggui_update fan-out — full props replacement.
Server → ClientdataStreamEnvelopeOutbound 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 → Clientrender_eventGguiSessionEventEvent-ledger replay when subscribe.sinceSequence is set.
Server → Clientdrain_ackDrainAckPayloadggui_consume drained an action; iframe cancels its claim timer.
Server → Clientchannel_payloadchannel framesource.tool result for a subscribed channel.
Server → Clientchannel_errorchannel error frameChannel subscribe rejected / poll failed / tool errored.
Server → Clientsystemsystem payloadSystem-level events (auth, credentials).

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

{
"type": "subscribe",
"payload": {
"sessionId": "ses_abc123",
"appId": "app_myapp",
"wsToken": "btkn_…"
}
}
FieldRequiredDescription
sessionIdYesThe GguiSession to bind.
appIdNoTenancy scope — when present MUST match the session’s bound app; when omitted the server resolves the identity-default appId.
wsTokenNoShort-TTL auth credential from the _meta["ai.ggui/render"] slice. Required unless the connection authenticated by bearer (see below).
fromSeqNoPer-channel stream cursor — replay outbound StreamEnvelopes with seq > N before the live tail begins (needs a GguiSessionStreamBuffer).
sinceSequenceNoEvent-ledger replay cursor — replays GguiSessionEvents with sequence > N as render_event frames. Independent of fromSeq.
roleNo'user' or 'agent'.
supportedVersionsNoProtocol-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. On a successful wsToken-authed subscribe the server mints AckPayload.sessionToken — a longer-lived reconnect credential passed on the standard bearer path (Authorization: Bearer <sessionToken> or ?token=) on later connections.

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 }
}
FieldDescription
sequenceInbound event-ledger position.
timestampEpoch milliseconds.
sessionCurrent GguiSession snapshot when one is already committed; null / absent otherwise.
streamSeqHighest outbound StreamEnvelope.seq sent — seeds fromSeq on the next subscribe.
replayTruncatedtrue when a requested fromSeq predates the server’s buffer window — the client got the live tail but missed history.
sessionTokenLonger-lived reconnect credential, minted on the first wsToken-authed subscribe (see Authentication above).
serverVersionServer’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 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.

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

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.

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 (numeric -32020); 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:

CodeMeaning
UPGRADE_REQUIREDProtocol-version handshake mismatch (see below). Servers default to versionPolicy: 'reject' and close the connection after emitting this frame.
CONTRACT_VIOLATIONAn inbound action failed validation against the render’s actionSpec (undeclared name or schema-rejected payload); nothing reaches the consume buffer.

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

@ggui-ai/react reconnects automatically with exponential backoff (1s → 30s, capped at 10 attempts). To resume the outbound stream without gaps, track the last observed StreamEnvelope.seq and pass it as fromSeq on the next subscribe. The server replays buffered envelopes (where supported) before re-entering the live tail.

StatusMeaning
connectingInitial socket handshake in flight
connectedSubscribed; ack received
disconnectedSocket closed, no retry pending
reconnectingBackoff in progress after a drop