---
title: WebSocket Protocol
description: Live-channel wire format — the live session plane between a ggui client and your self-hosted ggui serve (hosted ggui coming soon).
---

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.ai`) — coming soon | `wss://mcp.ggui.ai/ws`                           |

:::note[You probably don't write this yourself]
You normally don't write this wire: `@ggui-ai/iframe-runtime` (booted inside the MCP-Apps iframe, mounted by `<AppRenderer>` from `@mcp-ui/client`) handles connect, subscribe, resume, and reconnect; `@ggui-ai/react`'s `GguiRender` path does the same for first-party hosts. Agents talk to ggui over the [MCP HTTP API](/api/mcp-protocol/), not the WebSocket. Read on only if you're building a custom client or debugging traffic.
:::

## Frame catalog

| 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}`                 | `ggui_update` 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.                    |
| Server → Client | `system`                | system payload                       | System-level events (auth, credentials).                                    |

## Client → Server

### `subscribe`

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

```json
{
  "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 `StreamEnvelope`s with `seq > N` before the live tail begins (needs a `GguiSessionStreamBuffer`).    |
| `sinceSequence`     | No       | Event-ledger replay cursor — replays `GguiSessionEvent`s 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.

#### Authentication

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.

### `action`

A canonical [`ActionEnvelope`](/protocol/envelopes/#actionenvelope) — flat, no nested blocks.

```json
{
  "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.

---

## Server → Client

### `ack`

Acknowledges the `subscribe` and seeds resume cursors.

```json
{
  "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`       | Highest outbound `StreamEnvelope.seq` sent — seeds `fromSeq` on the next subscribe.                                       |
| `replayTruncated` | `true` when a requested `fromSeq` predates the server's buffer window — the client got the live tail but missed history.  |
| `sessionToken`    | Longer-lived reconnect credential, minted on the first wsToken-authed subscribe (see Authentication above).               |
| `serverVersion`   | Server's `PROTOCOL_SCHEMA_VERSION` stamp — see the protocol-version handshake below.                                      |

### `render`

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

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

### `props_update`

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.

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

### `render_event`

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.

### `data`

An outbound [`StreamEnvelope`](/protocol/envelopes/#streamenvelope). `channel` names the `streamSpec[name]` it belongs to; `mode` is `append` or `replace`.

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

### `error`

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`:

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

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](/protocol/envelopes/).

---

## Protocol-version handshake

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.

---

## Lifecycle

```
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
```

### Reconnection

`@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.

### Status (SDK)

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