Events
Every event shape Bubble defines, how sequence numbers and timestamps are assigned, and how clients append and subscribe.
docs/concepts/30-events.mdxOn 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:
- The sequencing envelope that wraps every event (
seq,createdAt). - The closed set of event shapes Bubble defines, with the fields each one carries and what they mean.
- 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.
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:
seqis monotonic per thread, starting at1. The first append to a thread receivesseq: 1; each subsequent append receives the previousseq + 1. There are no gaps and no reordering.seqresets per thread — events on a differentthreadIdhave an independent counter.createdAtis 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:
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.
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.
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:
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.
TextStreamPartcannot 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:
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.
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:
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:
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.
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:
interface BubbleStore {
append(threadId: string, event: BubbleEvent): Promise<BubbleSequencedEvent>;
// ...
}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/eventsreturns theBubbleSequencedEventsynchronously to the caller — use it when you need the assignedseqbefore continuing. - The WebSocket
thread.appendframe 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:
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>;
// ...
}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:
fromSeqis the catch-up cursor. Pass the highestseqthe client has already processed + 1 to resume; pass1(or omit it) to replay from the beginning; pass a sentinel beyond the head to skip the backlog and receive only live events.throughSeqpins the backlog/live boundary. It is the headseqat the moment the subscription was registered. Events withseq <= throughSeqare backlog; events withseq > throughSeqare 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:
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
}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 nullThis 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.