Events

Every event shape Bubble defines, how sequence numbers and timestamps are assigned, and how clients append and subscribe.

Conceptsdocs/concepts/30-events.mdx
On this page

A Bubble thread is an ordered, append-only stream of events. Every action that should be visible to subscribers — a model producing tokens, a tool call asking for approval, a user joining the thread — is encoded as one event written through BubbleStore.append.

This page covers three things:

  1. The sequencing envelope that wraps every event (seq, createdAt).
  2. The closed set of event shapes Bubble defines, with the fields each one carries and what they mean.
  3. The store operations that put events on a thread and read them back.

The Sequencing Envelope

Every event that has been written through the store is a BubbleSequencedEvent: the event payload plus the two metadata fields the store assigns at append time.

ts
type BubbleSequencedEvent = BubbleEvent & {
  seq: number; // monotonically increasing within the thread, starts at 1
  createdAt: string; // RFC 3339 timestamp recorded at append
};

Two guarantees worth committing to memory:

  • seq is monotonic per thread, starting at 1. The first append to a thread receives seq: 1; each subsequent append receives the previous seq + 1. There are no gaps and no reordering. seq resets per thread — events on a different threadId have an independent counter.
  • createdAt is the append time. It reflects when the store committed the event, not when the producer started building it. The format is RFC 3339 (e.g. "2026-05-06T12:34:56.789Z").

seq is the canonical cursor: every replay-from-here, dedupe-against-here, and "where is this thread up to" question is answered by it. Timestamps are for display and observability.

Event Shapes

BubbleEvent is a discriminated union — every member carries a literal type field that selects the variant:

ts
type BubbleEvent =
  | RunStartedEvent
  | RunFinishedEvent
  | MessagePartEvent
  | ToolCallApprovalEvent
  | ToolCallResultEvent
  | RunInterruptRequestEvent
  | ParticipantJoinedEvent
  | ParticipantLeftEvent;

Most events also carry a principalId (the actor that produced them) and, for run-scoped events, a runId that ties them back to one bounded principal-authored activity. The subsections below walk through every variant.

Run Lifecycle: run_started / run_finished

A run is one bounded activity authored by a principal on a thread. It is not limited to agent execution: a user submitting input can be a short run, an assistant response can be a streamed run, and a system process can use a run for background activity. A run begins with exactly one run_started event and ends with exactly one run_finished event. Every other run-scoped event in between carries the same runId.

ts
type BubbleRunKind = 'user_input' | 'agent_work' | 'system_work';

interface RunStartedEvent {
  type: 'run_started';
  principalId: string; // who started the run
  runId: string; // shared by every event in this run
  kind?: BubbleRunKind; // optional broad activity classification
  snapshotSeq?: number; // the seq at which the run was initiated, when known
}

interface RunFinishedEvent {
  type: 'run_finished';
  principalId: string; // responsible for ending the run
  runId: string;
}

kind is optional producer metadata; consumers that need stronger domain semantics can still inspect the authoring principal or higher-level thread state. run_finished is terminal regardless of outcome — success, failure, or interruption. snapshotSeq is optional: an agent or wrapper that wants to record the thread state it started from sets it to a specific seq (not necessarily the latest), but it can be omitted entirely.

ts
await store.append('thread-42', {
  type: 'run_started',
  principalId: 'user-alice',
  runId: 'run-user-001',
  kind: 'user_input',
});
await store.append('thread-42', {
  type: 'message_part',
  principalId: 'user-alice',
  runId: 'run-user-001',
  part: chunk,
  version: 'ai-sdk-v6',
});
await store.append('thread-42', {
  type: 'run_finished',
  principalId: 'user-alice',
  runId: 'run-user-001',
});

await store.append('thread-42', {
  type: 'run_started',
  principalId: 'bot-research',
  runId: 'run-001',
  kind: 'agent_work',
  snapshotSeq: 17,
});
// ... message_part, tool_call_*, etc. all referencing runId 'run-001' ...
await store.append('thread-42', {
  type: 'run_finished',
  principalId: 'bot-research',
  runId: 'run-001',
});

Streamed Output: message_part

message_part carries one chunk of model output, tool state, or user-authored message content. The chunk itself is a UIMessageChunk from the ai SDK, aliased here as BubbleMessageChunk:

ts
type BubbleMessageChunk = UIMessageChunk;

interface MessagePartEvent {
  type: 'message_part';
  principalId: string;
  runId: string;
  part: BubbleMessageChunk;
  version: 'ai-sdk-v6'; // schema version of `part` only
}

Each event carries exactly one chunk. To reconstruct a complete UI message from the stream, collect every message_part for a given runId in seq order and feed them through the ai SDK's public reducer.

Why UIMessageChunk rather than TextStreamPart?

  • The SDK exposes a public reducer for UI chunks, so consumers can fold a stream back into a full message without ad hoc assembly logic.
  • UI chunks can carry text, reasoning, tool activity, approvals, step markers, metadata, and file/image-bearing message content. TextStreamPart cannot represent files or images, which user input routinely needs.

version: 'ai-sdk-v6' describes the serialized chunk shape only — not the surrounding event envelope, which evolves through BUBBLE_PROTOCOL_VERSION.

Tool-Call Pauses: tool_call_approval

Some runs pause for manual review of a tool call. The reviewer's decision is recorded as a tool_call_approval event, which a paused run watches for before resuming:

ts
type ToolCallApprovalEvent = ({ approved: true; reason?: never } | { approved: false; reason?: string }) & {
  type: 'tool_call_approval';
  principalId: string; // who reviewed
  runId: string; // run that was waiting
  toolCallId: string; // which tool call was reviewed
};

reason is only meaningful on a rejection. The approval variant intentionally forbids reason so that callers cannot silently attach a justification a consumer would ignore.

A run that is waiting on approval should resume only after observing this event with a matching toolCallId and approved: true.

ts
await store.append('thread-42', {
  type: 'tool_call_approval',
  principalId: 'user-alice',
  runId: 'run-001',
  toolCallId: 'call-7',
  approved: false,
  reason: 'wrong customer id',
});

Out-of-Loop Tool Results: tool_call_result

When a tool runs outside the main run loop — for example, a human-in-the-loop tool or a delegated background worker — its result is folded back into the run as a tool_call_result event:

ts
interface ToolCallResultEvent<TInput = unknown, TOutput = unknown> {
  type: 'tool_call_result';
  principalId: string;
  runId: string;
  toolCallId: string;
  input: TInput; // what was passed to the tool
  output: TOutput; // what the tool returned
}

TInput and TOutput let callers narrow the payload when the contract is known; they default to unknown for generic stream handling.

Cooperative Stop: run_interrupt_requested

A subscriber can ask an in-flight run to stop at the next safe point by appending a run_interrupt_requested:

ts
interface RunInterruptRequestEvent {
  type: 'run_interrupt_requested';
  principalId: string;
  runId: string;
}

This is a signal, not a guarantee. The run may emit additional events before honouring the request, and may complete normally if the request arrives after the run has already finished its work. Honouring an interrupt still requires the producer to emit a run_finished.

Participant Lifecycle: participant_joined / participant_left

Participant changes are mutated through HTTP grant calls (covered in Authorization), but they show up on the thread's event stream so live subscribers see them through the same channel they already replay — there is no separate notification frame.

ts
interface ParticipantJoinedEvent {
  type: 'participant_joined';
  byPrincipalId: string; // who performed the join
  principalId: string; // who was added
}

interface ParticipantLeftEvent {
  type: 'participant_left';
  byPrincipalId: string; // who performed the removal
  principalId: string; // who was removed
}

byPrincipalId equals principalId for self-join / self-leave; it differs when an authorised caller added or removed someone else.

A ParticipantLeftEvent does not implicitly tear down active subscriptions held by the removed principal — the server's authorization check on each subsequent frame decides whether delivery still happens.

Appending Events

BubbleStore.append writes one event and returns the stored BubbleSequencedEvent:

ts
interface BubbleStore {
  append(threadId: string, event: BubbleEvent): Promise<BubbleSequencedEvent>;
  // ...
}
ts
const stored = await store.append('thread-42', {
  type: 'message_part',
  principalId: 'bot-research',
  runId: 'run-001',
  part: chunk,
  version: 'ai-sdk-v6',
});
// stored.seq         => server-assigned, e.g. 18
// stored.createdAt   => "2026-05-06T12:34:56.789Z"

One append corresponds to one event. The store assigns seq by bumping the thread's head, and the same value is what subscribers see on delivery.

Both transports exposed by Bubble call this method:

  • HTTP POST /v1/threads/:threadId/events returns the BubbleSequencedEvent synchronously to the caller — use it when you need the assigned seq before continuing.
  • The WebSocket thread.append frame is fire-and-forget — use it when the caller does not need an immediate response body.

The full route and frame contracts live in HTTP Protocol and WebSocket Protocol.

Subscribing to a Thread

BubbleStore.subscribe opens a live subscription that first replays the backlog and then forwards live events, both through the same callback:

ts
interface BubbleThreadSubscriptionOptions {
  fromSeq?: number; // inclusive lower bound; defaults to 1
  filter?: (event: BubbleSequencedEvent) => boolean; // skip events that return false
}

interface BubbleThreadSubscription {
  throughSeq: number; // head seq at registration time
  unsubscribe: () => Promise<void>; // idempotent
}

interface BubbleStore {
  subscribe(
    threadId: string,
    options: BubbleThreadSubscriptionOptions,
    onEvent: (event: BubbleSequencedEvent) => void,
  ): Promise<BubbleThreadSubscription>;
  // ...
}
ts
const sub = await store.subscribe('thread-42', { fromSeq: 18 }, (event) => {
  if (event.seq <= sub.throughSeq) {
    // backlog replay (event existed when the subscription was registered)
  } else {
    // live delivery
  }
});
// ... later:
await sub.unsubscribe();

Two contracts to internalise:

  • fromSeq is the catch-up cursor. Pass the highest seq the client has already processed + 1 to resume; pass 1 (or omit it) to replay from the beginning; pass a sentinel beyond the head to skip the backlog and receive only live events.
  • throughSeq pins the backlog/live boundary. It is the head seq at the moment the subscription was registered. Events with seq <= throughSeq are backlog; events with seq > throughSeq are live. Clients that paged history over HTTP can use it to dedupe the overlap with their last HTTP page deterministically.

The optional filter is applied to every event during both backlog replay and live delivery. Returning false skips the event silently; returning true (or omitting the predicate) delivers it.

Reading History over HTTP

When a client only wants a bounded slice of the past — for example, a UI rendering the most recent N events without opening a socket — BubbleStore.getEventPage returns one contiguous page:

ts
interface GetEventPageQuery {
  fromSeq?: number; // inclusive lower bound; defaults to 1
  toSeq?: number; // inclusive upper bound; defaults to the latest event
  limit?: number; // server may also impose its own ceiling
}

interface GetEventPageResponse {
  events: BubbleSequencedEvent[]; // ascending seq order
  nextSeq: number | null; // null when the slice reached toSeq/head with no more pages
}
ts
const page = await store.getEventPage('thread-42', { fromSeq: 1, limit: 50 });
// page.events    => first 50 events
// page.nextSeq   => 51 if the thread has more, otherwise null

This is not an HTTP subscription primitive: a client that wants to stay current over HTTP polls the endpoint with fromSeq set to the previous response's nextSeq. For a live push stream, use the WebSocket thread.subscribe frame.