WebSocket protocol

The live frame protocol — connection model, subscription lifecycle, fire-and-forget appends, and how failures are signalled.

Referencedocs/reference/20-websocket-protocol.mdx
On this page

The WebSocket transport is Bubble's live delivery channel. Use it when a client wants events pushed as they are appended; for request/response work — thread metadata, grant changes, history paging, or any append where you need the assigned seq — HTTP is the right tool.

This page assumes you have read Events (especially the fromSeq / throughSeq semantics) and Authorization (for the capability checks).

Connection Model

  • Endpoint. GET /v1/ws (BUBBLE_WS_PATH).
  • Authentication. Standard Authorization: Bearer <token> on the upgrade request — not a per-frame auth message. The dispatcher resolves the bearer through the same drivers as HTTP. An authentication failure on upgrade closes the socket immediately after sending one informational error frame.
  • Multiplexing. A single connection multiplexes any number of threads. Every frame in either direction carries an explicit threadId; clients demux on it instead of opening one socket per thread.
  • Frame encoding. JSON text frames. Binary frames are not part of the protocol.

Two frame families:

ts
type BubbleWsClientFrame =
  | WsThreadSubscribeFrame // { type: 'thread.subscribe',   threadId, fromSeq? }
  | WsThreadUnsubscribeFrame // { type: 'thread.unsubscribe', threadId }
  | WsThreadAppendFrame; // { type: 'thread.append',      threadId, event }

type BubbleWsServerFrame =
  | WsThreadEventFrame // { type: 'thread.event',                 threadId, event }
  | WsThreadSubscriptionOpenedFrame // { type: 'thread.subscription_opened',   threadId, throughSeq }
  | WsThreadSubscriptionClosedFrame // { type: 'thread.subscription_closed',   threadId, reason }
  | WsErrorFrame; // { type: 'error',                        threadId?, code, message }

Delivery Semantics

The server never acks an individual client frame. Client frames are fire-and-forget; outcomes are observed indirectly:

Client frameWhat you observe on success
thread.subscribeone thread.subscription_opened, then a stream of thread.event
thread.unsubscribesilence — no further thread.event for that threadId
thread.appendsilence (the appended event arrives via any subscription you hold)

Failures arrive as error frames scoped to a threadId (or unscoped for connection-level problems). An error frame is informational only: receiving one does not by itself end any subscription. A subscription ends only via an explicit client thread.unsubscribe or a server-emitted thread.subscription_closed (more on this below).

Subscription Lifecycle

A subscription is bracketed by a server-emitted pair of frames:

  • Exactly one thread.subscription_opened confirms registration and pins the catch-up boundary.
  • At most one thread.subscription_closed marks server-initiated teardown. Client-initiated teardown via thread.unsubscribe is silent.

Opening a Subscription

ts
// client → server
{ "type": "thread.subscribe", "threadId": "thread-1", "fromSeq": 18 }

Server response sequence:

ts
// server → client (immediately on registration)
{ "type": "thread.subscription_opened", "threadId": "thread-1", "throughSeq": 42 }

// server → client (backlog — events with seq <= throughSeq)
{ "type": "thread.event", "threadId": "thread-1", "event": { "seq": 18, ... } }
{ "type": "thread.event", "threadId": "thread-1", "event": { "seq": 19, ... } }
// … through seq 42 …

// server → client (live — events with seq > throughSeq)
{ "type": "thread.event", "threadId": "thread-1", "event": { "seq": 43, ... } }

Three properties to internalise:

  • fromSeq is the catch-up cursor. Inclusive lower bound; defaults to 1. Pass the highest seq the client has already processed + 1 to resume; pass a sentinel beyond the head to skip backlog and receive only live events.
  • throughSeq pins the backlog/live boundary. It is the head seq at the moment registration completed. Events with event.seq <= throughSeq come from backlog replay; events with event.seq > throughSeq are live. Clients that paged history over HTTP can use throughSeq to dedupe the overlap with their last HTTP page deterministically.
  • Subscribing does not require participant presence. Read-only subscriptions are allowed for anyone the policy says can subscribe (events.subscribe on the thread target), even if they cannot append.

One Subscription Per Thread Per Connection

A connection holds at most one subscription per threadId. Re-subscribing while already subscribed fails with conflict:

ts
// server → client
{ "type": "error", "threadId": "thread-1", "code": "conflict",
  "message": "Connection is already subscribed to thread \"thread-1\"." }

If you actually want to change fromSeq, send thread.unsubscribe first.

Closing a Subscription

Client-initiated:

ts
// client → server
{ "type": "thread.unsubscribe", "threadId": "thread-1" }

There is no ack. Events already in flight for threadId may still arrive after the unsubscribe is sent and must be ignored by the client (this is one of the reasons every frame carries threadId).

Server-initiated teardown sends thread.subscription_closed:

ts
{ "type": "thread.subscription_closed", "threadId": "thread-1", "reason": "not_found" }

reason reuses BubbleErrorCode; the typical values are:

reasonMeaning
not_foundThe thread no longer exists.
forbiddenThe caller is no longer authorized to read the thread.
unavailableThe server is draining or otherwise shedding subscriptions.
internalTeardown as a side effect of an unrecoverable error; preceding error frame has the detail.

When teardown is the consequence of a specific failure, the server sends an error frame with the same threadId first to explain why, then the thread.subscription_closed frame as the authoritative end-of-subscription signal. The client may resubscribe (typically with fromSeq set to the next unseen seq) if it still wants the stream.

| Authorization | events.subscribe on the thread target. Denial maps to not_found (same as GET /v1/threads/:threadId) so callers cannot probe thread existence. |

Fire-and-Forget Append

thread.append writes one event without waiting for a per-frame ack. The connection does not need to be subscribed to threadId — append and subscribe are independent. Human input uses the same frame shape as agent output: start a user-authored run, append one or more message_part events with that runId, then finish the run.

ts
// client → server
{ "type": "thread.append", "threadId": "thread-1",
  "event": { "type": "message_part", "principalId": "bot-research",
             "runId": "run-001", "part": { /* UIMessageChunk */ },
             "version": "ai-sdk-v6" } }

What happens:

  • The server picks the events.* capability that matches event.type (e.g. events.message_part for a message_part, events.tool_call_approval for a tool_call_approval) and authorizes the caller against it on the thread target, then calls BubbleStore.append. Subscribers — on this connection or any other — receive the event as a thread.event frame. Server-emitted shapes (participant_joined, participant_left, thread_title_updated) are not appendable from the client and are rejected as invalid_request.
  • There is no response body. The server does not echo back the assigned seq. If the connection holds a subscription on the same thread, the persisted event arrives back through that subscription (with its seq and createdAt); otherwise, the only way to learn the assigned seq after the fact is to page history over HTTP.

If you need the assigned seq synchronously — for example, to chain a follow-up that references the new event — use the HTTP POST /v1/threads/:threadId/events route instead.

| Authorization | The events.* capability matching event.type on the thread target |

Failures (validation, authorization, store error) come back as an error frame scoped to threadId:

ts
{ "type": "error", "threadId": "thread-1", "code": "forbidden",
  "message": "Principal \"alice\" lacks \"events.message_part\" on thread \"thread-1\"." }

The connection stays open and any other subscriptions or appends keep working.

Catch-Up Strategy

Long backlogs are easier on everyone if you mix HTTP and WebSocket:

  • HTTP first. Page history with GET /v1/threads/:threadId/events until you've consumed the last full page (the response's nextSeq becomes null).
  • Then subscribe. Open thread.subscribe with fromSeq set to the next unseen seq. Use the returned throughSeq to deterministically dedupe any overlap.

Why mix transports? Replaying a long backlog inline over WebSocket head-of-line blocks every other thread on the connection — because the connection multiplexes threads and frames are written in order, all subscriptions on the same socket wait while the backlog drains. HTTP paging avoids that; the eventual thread.subscribe only has to forward live frames.

Errors and Recovery

error frames carry BubbleErrorCode and a human-readable message, optionally scoped to a threadId:

ts
{ "type": "error", "threadId": "thread-1", "code": "invalid_request",
  "message": "Frame body must be a JSON object." }

The two patterns to remember:

  • An unscoped error frame is connection-level. Examples: malformed JSON in a frame, an unknown frame type. The connection stays open.
  • A scoped error frame followed by thread.subscription_closed is the only way a subscription ends from the server side. The error frame explains the cause; the closed frame is the authoritative signal that no further thread.event for that threadId will arrive on this connection. The client may resubscribe.

Client-initiated thread.append rejections (authorization, schema) arrive as a scoped error frame and do not tear down any subscription you might also be holding on the same thread.

Worked Example

A minimal browser client that prints every event for thread-1, seamlessly transitioning from backlog to live:

ts
const ws = new WebSocket(`ws://localhost:3000/v1/ws`, [], {
  // Browsers don't allow custom headers on WS; in production you
  // typically pass the bearer in the URL or use a session cookie
  // bridged by an upstream proxy. Server-side clients can set the
  // Authorization header directly.
});

let throughSeq: number | null = null;

ws.addEventListener('open', () => {
  ws.send(JSON.stringify({ type: 'thread.subscribe', threadId: 'thread-1', fromSeq: 1 }));
});

ws.addEventListener('message', (msg) => {
  const frame = JSON.parse(msg.data);
  switch (frame.type) {
    case 'thread.subscription_opened':
      throughSeq = frame.throughSeq;
      console.log(`subscribed; backlog ends at seq ${throughSeq}`);
      break;
    case 'thread.event': {
      const phase = frame.event.seq <= (throughSeq ?? -Infinity) ? 'backlog' : 'live';
      console.log(`[${phase}] seq=${frame.event.seq}`, frame.event);
      break;
    }
    case 'thread.subscription_closed':
      console.warn(`subscription ended: ${frame.reason}`);
      // Optionally: re-subscribe with fromSeq set to the next unseen seq.
      break;
    case 'error':
      console.error(`error on ${frame.threadId ?? 'connection'}: ${frame.code} ${frame.message}`);
      break;
  }
});

That is the entire surface. Subscribe, observe the boundary, append when you have something to write, and let participant_joined / participant_left events flow through the same channel as the rest of the conversation.