Threads
The BubbleThread shape, subthreads, and the store operations that create and inspect threads.
docs/concepts/20-threads.mdxOn this page
A thread is the unit of conversation in Bubble. Concretely, a thread
is two things glued together by a shared threadId:
- An ordered, append-only stream of events.
- A small piece of metadata — when the thread was created, what its head sequence number is, and (optionally) its parent.
This page covers the metadata side and the store operations that manage it. Event append and subscribe are described in Events; participants and grants on a thread are described in Principals and Authorization.
The BubbleThread Shape
A thread snapshot is a plain object. Every field except
parentThreadId is always present:
interface BubbleThread {
threadId: string;
parentThreadId?: string;
title?: string;
headSeq: number;
createdAt: string;
participants: Participant[];
}| Field | Meaning |
|---|---|
threadId | Stable, opaque identifier. Chosen by the caller when the thread is created. |
parentThreadId | Set only on subthreads (see below). Omitted for top-level threads. |
title | Optional human-readable label. Omitted when no title is set. Mutable via Renaming a thread. |
headSeq | Latest committed seq in the thread; 0 when no events have been appended yet. |
createdAt | RFC 3339 timestamp recorded when the thread was created. |
participants | Current participant rows for the thread, derived from grants. Covered in Authorization. |
headSeq and Lifecycle
A freshly created thread has headSeq: 0. Each successful append bumps
headSeq by exactly one — sequence numbers are assigned by the store and
start at 1. There is no upper bound and no retention policy: the stream
is designed to grow indefinitely.
The headSeq field is the canonical "where is this thread up to?"
indicator. A subscriber can record the latest seq it has processed and
compare against headSeq later to know how much backlog it would need to
replay; an HTTP poller can pass the value to getEventPage to advance
through history.
Top-Level Threads vs. Subthreads
A thread can optionally be scoped under a parent thread. The child still
has its own event stream, its own sequence numbers, its own creation
timestamp, its own participants, and its own grants — parentThreadId
is metadata only:
const root = await store.createThread('thread-root');
// root.parentThreadId === undefined
const child = await store.createThread('thread-child', { parentThreadId: 'thread-root' });
// child.parentThreadId === 'thread-root'
// child.headSeq === 0 (events on root are not visible on child)Two consequences worth internalising up front:
- Events appended to the parent are not copied or mirrored to the child, and vice versa. Every thread has exactly one stream.
- Grants on the parent are not copied to the child. They may inherit selected read/manage capabilities to the child via the policy, but no child grant rows are materialized. The full inheritance rules live in Authorization.
Subthreads are most useful when you want a logically nested conversation (for example, a side discussion spawned off the main thread) but still need to reason about it as an independent stream with its own membership.
Creating and Inspecting Threads
The store contract exposes four operations that deal with thread
metadata. They all live on BubbleStore and are async:
interface BubbleStore {
createThread(threadId: string, options?: { parentThreadId?: string }): Promise<BubbleThread>;
getThread(threadId: string): Promise<BubbleThread>;
getHeadSeq(threadId: string): Promise<number>;
getCreatedAt(threadId: string): Promise<string>;
// ... event, principal, grant, and participant operations covered in later docs
}createThread
Explicitly creates an empty thread stream and returns its snapshot.
const thread = await store.createThread('thread-42');
// thread.headSeq === 0
// thread.participants === []The operation is idempotent when the existing parent relationship matches
the request. Calling createThread('thread-42') twice returns the same
snapshot the second time. Calling it with a different parentThreadId
than the existing row throws — implementations reject:
- turning an existing top-level thread into a subthread,
- moving a subthread to a different parent,
- creating a subthread whose
parentThreadIddoes not exist, - creating a subthread whose
parentThreadIdequals its ownthreadId.
getThread
Returns the current snapshot, including parentThreadId when the thread
is a subthread and the current participant set:
const thread = await store.getThread('thread-42');
// thread.headSeq => latest committed seq, or 0
// thread.participants => current participant rows
// thread.parentThreadId => set when this is a subthreadThis is the same shape that the HTTP GET /v1/threads/:threadId route
returns.
getHeadSeq and getCreatedAt
Narrow accessors for the two scalar fields. They exist as separate
operations so that callers (for example, a subscription opener that only
needs the head seq to pin a catch-up boundary) do not have to materialise
the full snapshot:
const head = await store.getHeadSeq('thread-42'); // number
const ts = await store.getCreatedAt('thread-42'); // RFC 3339 stringRenaming a Thread
Threads carry an optional human-readable title. Update it via
PATCH /v1/threads/:threadId with body { title: string | null }:
pass a string to set or replace the title, null to clear it, or omit
the key to leave it untouched.
await store.updateThread('thread-42', { title: 'Q3 planning' });
await store.updateThread('thread-42', { title: null }); // clearThe route requires the thread.manage capability on the thread target,
which is bundled into the owner and thread_manager roles. Plain
members and readers cannot rename. When the title actually changes,
the server appends a thread_title_updated event to the thread's stream
so live subscribers and history readers observe the rename:
{ type: 'thread_title_updated', byPrincipalId: 'bob', title: 'Q3 planning' }The same event is emitted with title: null when the title is cleared.
Storage Notes
The store contract assigns sequence numbers, persists events, and fans
them out to live subscribers. The default InMemoryBubbleStore keeps one
growing array per thread and notifies subscribers synchronously after each
append; production deployments swap in a durable backend that preserves
the same ordering and idempotency guarantees.
The stream has no TTL and no retention policy — once an event is
appended it stays at its assigned seq forever, and a thread's
headSeq only grows. Backends that need pruning have to implement it as
an explicit operation outside this contract.