WebSocket protocol
The live frame protocol — connection model, subscription lifecycle, fire-and-forget appends, and how failures are signalled.
docs/reference/20-websocket-protocol.mdxOn 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 informationalerrorframe. - 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:
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 frame | What you observe on success |
|---|---|
thread.subscribe | one thread.subscription_opened, then a stream of thread.event |
thread.unsubscribe | silence — no further thread.event for that threadId |
thread.append | silence (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_openedconfirms registration and pins the catch-up boundary. - At most one
thread.subscription_closedmarks server-initiated teardown. Client-initiated teardown viathread.unsubscribeis silent.
Opening a Subscription
// client → server
{ "type": "thread.subscribe", "threadId": "thread-1", "fromSeq": 18 }Server response sequence:
// 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:
fromSeqis the catch-up cursor. Inclusive lower bound; defaults to1. Pass the highestseqthe client has already processed + 1 to resume; pass a sentinel beyond the head to skip backlog and receive only live events.throughSeqpins the backlog/live boundary. It is the headseqat the moment registration completed. Events withevent.seq <= throughSeqcome from backlog replay; events withevent.seq > throughSeqare live. Clients that paged history over HTTP can usethroughSeqto 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.subscribeon 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:
// 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:
// 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:
{ "type": "thread.subscription_closed", "threadId": "thread-1", "reason": "not_found" }reason reuses BubbleErrorCode;
the typical values are:
reason | Meaning |
|---|---|
not_found | The thread no longer exists. |
forbidden | The caller is no longer authorized to read the thread. |
unavailable | The server is draining or otherwise shedding subscriptions. |
internal | Teardown 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.
// 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 matchesevent.type(e.g.events.message_partfor amessage_part,events.tool_call_approvalfor atool_call_approval) and authorizes the caller against it on the thread target, then callsBubbleStore.append. Subscribers — on this connection or any other — receive the event as athread.eventframe. Server-emitted shapes (participant_joined,participant_left,thread_title_updated) are not appendable from the client and are rejected asinvalid_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 itsseqandcreatedAt); otherwise, the only way to learn the assignedseqafter 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:
{ "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/eventsuntil you've consumed the last full page (the response'snextSeqbecomesnull). - Then subscribe. Open
thread.subscribewithfromSeqset to the next unseenseq. Use the returnedthroughSeqto 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:
{ "type": "error", "threadId": "thread-1", "code": "invalid_request",
"message": "Frame body must be a JSON object." }The two patterns to remember:
- An unscoped
errorframe is connection-level. Examples: malformed JSON in a frame, an unknown frametype. The connection stays open. - A scoped
errorframe followed bythread.subscription_closedis the only way a subscription ends from the server side. Theerrorframe explains the cause; the closed frame is the authoritative signal that no furtherthread.eventfor thatthreadIdwill 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:
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.