bb.threads
The headless bb.threads plugin owns thread data, execution, events, subscriptions, drafts, and selection.
Purpose
bb.threads provides the complete thread domain as an ordinary first-party plugin. Other plugins consume its replaceable service.
This plugin declares no surfaces. Its data model carries no presentation assumptions.
The service does not define side panels, layout, rendering, React components, icons, or colors.
Surfaces
| ID | kind | replaceable | props contract sketch | notes |
|---|---|---|---|---|
| — | — | — | — | This plugin declares no surfaces. |
bb.thread-ui owns thread presentation. A data-service replacement does not need to implement any UI contract.
Services
| ID | kind | replaceable | default provider | dependency use | purpose |
|---|---|---|---|---|---|
bb.threads |
single |
yes | bb.threads |
required, optional, or watched |
Provides records, execution, timeline, events, interactions, queues, drafts, and selection. |
The server resolves bb.threads to one live in-process object. A required consumer restarts after a winner change.
An optional or watched consumer stays active. It handles an unavailable service through its declared edge.
The service returns data only. It does not return React nodes, icons, colors, layout values, or renderer choices.
Service token and exported data types
import { defineService } from "@get-bb/plugin";
export const threadsService = defineService<ThreadsService>("bb.threads", "1.0");
export type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
export type ThreadId = string;
export type ProjectId = string;
export type TimelineItemId = string;
export interface ThreadSummary {
id: ThreadId;
projectId: ProjectId;
title: string;
parentThreadId: ThreadId | null;
sectionId: string | null;
providerId: string | null;
status: "idle" | "active" | "failed";
isUnread: boolean;
isPinned: boolean;
isArchived: boolean;
pendingInteractionCount: number;
createdAt: string;
updatedAt: string;
}
export type ComposerScope =
| { kind: "thread"; threadId: ThreadId }
| { kind: "queued-message"; threadId: ThreadId; queuedMessageId: string }
| { kind: "new-thread"; projectId: ProjectId | null }
| {
kind: "extension";
key: string;
threadId: ThreadId | null;
projectId: ProjectId | null;
data: JsonValue | null;
};
export interface ComposerDraft {
text: string;
mentions: readonly ComposerMention[];
attachments: readonly ComposerAttachment[];
revision: number;
}
export interface ComposerMention {
provider: string;
id: string;
label: string;
from: number;
to: number;
}
export interface ComposerAttachment {
id: string;
name: string;
mediaType: string;
size: number;
}
export interface ExecutionSelection {
providerId: string;
model: string;
reasoningLevel?: string;
serviceTier?: string;
permissionMode: string;
}
export type TimelineItemType =
| "message"
| "reasoning"
| "command"
| "file-change"
| "tool"
| "web-search"
| "web-fetch"
| "image-view"
| "plan"
| "compaction"
| "task"
| "interaction"
| "turn"
| "notice"
| `${string}/${string}`;
export interface TimelineItem {
id: TimelineItemId;
threadId: ThreadId;
turnId: string | null;
parentItemId: TimelineItemId | null;
type: TimelineItemType;
status: "pending" | "completed" | "error" | "interrupted";
seqStart: number;
seqEnd: number;
payload: JsonValue;
createdAt: string;
completedAt: string | null;
}
export interface ThreadRecord extends ThreadSummary {
goal: { text: string; status: "active" | "complete" } | null;
planMode: boolean;
execution: ExecutionSelection | null;
latestAssistantText: string | null;
error: string | null;
storageLocation: string | null;
revision: number;
}
export interface Page<T> {
items: readonly T[];
cursor: string | null;
}
export interface ThreadSubscription {
close(): void;
}
export interface ThreadChangedEvent {
threadId: ThreadId | null;
reason:
| "created"
| "updated"
| "status"
| "timeline"
| "interaction"
| "queue"
| "deleted";
revision: number | null;
}Complete service interface
export interface ThreadsService {
readonly records: ThreadRecordsService;
readonly sections: ThreadSectionsService;
readonly execution: ThreadExecutionService;
readonly timeline: ThreadTimelineService;
readonly events: ThreadEventsService;
readonly interactions: ThreadInteractionsService;
readonly queue: ThreadQueueService;
readonly drafts: ThreadDraftsService;
readonly selection: ThreadSelectionService;
readonly attention: ThreadAttentionService;
readonly storage: ThreadStorageService;
}
export interface ThreadRecordsService {
get(args: { threadId: ThreadId }): Promise<ThreadRecord>;
list(args?: {
projectId?: ProjectId;
archived?: boolean;
pinned?: boolean;
cursor?: string;
limit?: number;
}): Promise<Page<ThreadSummary>>;
search(args: {
query: string;
projectId?: ProjectId;
cursor?: string;
limit?: number;
}): Promise<Page<ThreadSummary>>;
update(args: {
threadId: ThreadId;
title?: string;
sectionId?: string | null;
expectedRevision?: number;
}): Promise<ThreadRecord>;
archive(args: { threadId: ThreadId }): Promise<ThreadRecord>;
archiveTree(args: { threadId: ThreadId }): Promise<{ threads: readonly ThreadId[] }>;
unarchive(args: { threadId: ThreadId }): Promise<ThreadRecord>;
delete(args: { threadId: ThreadId; recursive?: boolean }): Promise<{ deleted: readonly ThreadId[] }>;
pin(args: { threadId: ThreadId }): Promise<ThreadRecord>;
unpin(args: { threadId: ThreadId }): Promise<ThreadRecord>;
reorderPinned(args: { projectId: ProjectId; threadIds: readonly ThreadId[] }): Promise<void>;
markRead(args: { threadId: ThreadId }): Promise<ThreadRecord>;
markUnread(args: { threadId: ThreadId }): Promise<ThreadRecord>;
fork(args: { threadId: ThreadId; fromItemId?: TimelineItemId }): Promise<ThreadRecord>;
childSummary(args: { threadId: ThreadId }): Promise<{
total: number;
active: number;
failed: number;
}>;
conversationOutline(args: { threadId: ThreadId }): Promise<readonly OutlineEntry[]>;
subscribe(
args: { threadId?: ThreadId; projectId?: ProjectId },
listener: (event: ThreadChangedEvent) => void,
): ThreadSubscription;
}
export interface OutlineEntry {
itemId: TimelineItemId;
title: string;
level: number;
}
export interface ThreadSection {
id: string;
projectId: ProjectId;
title: string;
order: number;
}
export interface ThreadSectionsService {
create(args: { projectId: ProjectId; title: string }): Promise<ThreadSection>;
delete(args: { sectionId: string }): Promise<void>;
list(args?: { projectId?: ProjectId }): Promise<readonly ThreadSection[]>;
update(args: { sectionId: string; title?: string; order?: number }): Promise<ThreadSection>;
}
export type PromptInput =
| { kind: "text"; text: string }
| { kind: "mention"; provider: string; id: string; label: string }
| { kind: "attachment"; attachmentId: string };
export interface ThreadExecutionService {
spawn(args: {
projectId: ProjectId;
input: readonly PromptInput[];
selection: ExecutionSelection;
environment?: JsonValue;
parentThreadId?: ThreadId;
}): Promise<{ thread: ThreadRecord; executionId: string }>;
send(args: {
threadId: ThreadId;
input: readonly PromptInput[];
mode: "steer" | "queue" | "new-turn";
clientRequestId?: string;
}): Promise<{ executionId: string; queuedMessageId?: string }>;
stop(args: { threadId: ThreadId; interrupt?: boolean }): Promise<{ stopped: boolean }>;
wait(args: {
threadId: ThreadId;
until?: "idle" | "failed" | "event";
eventType?: string;
afterSeq?: number;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<{ thread: ThreadRecord; event?: ThreadEvent }>;
compact(args: { threadId: ThreadId }): Promise<{ executionId: string }>;
cancelPlan(args: { threadId: ThreadId }): Promise<ThreadRecord>;
clearGoal(args: { threadId: ThreadId }): Promise<ThreadRecord>;
defaultOptions(args: { threadId: ThreadId }): Promise<ExecutionSelection>;
output(args: { threadId: ThreadId; afterItemId?: TimelineItemId }): Promise<{
text: string;
sourceSeqEnd: number;
}>;
promptHistory(args: { threadId?: ThreadId; projectId?: ProjectId; limit?: number }): Promise<readonly string[]>;
resolveMentions(args: {
threadId: ThreadId;
input: readonly PromptInput[];
}): Promise<{ input: readonly PromptInput[]; context: readonly string[] }>;
}
export interface ThreadTimelineService {
list(args: {
threadId: ThreadId;
afterSeq?: number;
beforeSeq?: number;
limit?: number;
}): Promise<{ items: readonly TimelineItem[]; frontier: number }>;
turnSummaryDetails(args: { threadId: ThreadId; turnId: string }): Promise<{
title: string;
summary: string;
itemIds: readonly TimelineItemId[];
}>;
editMessage(args: {
threadId: ThreadId;
itemId: TimelineItemId;
text: string;
}): Promise<TimelineItem>;
}
export type ThreadEventName =
| "thread.created"
| "thread.active"
| "thread.idle"
| "thread.failed"
| "thread.archived"
| "thread.deleted";
export interface ThreadEventMap {
"thread.created": { thread: ThreadRecord };
"thread.active": { thread: ThreadRecord };
"thread.idle": { thread: ThreadRecord; lastAssistantText: string | null };
"thread.failed": { thread: ThreadRecord; error: string | null };
"thread.archived": { thread: ThreadRecord };
"thread.deleted": { thread: ThreadRecord };
}
export interface ThreadEvent<E extends ThreadEventName = ThreadEventName> {
id: string;
seq: number;
name: E;
threadId: ThreadId;
time: string;
payload: ThreadEventMap[E];
}
export interface ThreadEventsService {
list(args: {
threadId: ThreadId;
afterSeq?: number;
names?: readonly ThreadEventName[];
limit?: number;
}): Promise<readonly ThreadEvent[]>;
wait<E extends ThreadEventName>(args: {
threadId: ThreadId;
name: E;
afterSeq?: number;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<ThreadEvent<E>>;
subscribe<E extends ThreadEventName>(
name: E,
handler: (event: ThreadEvent<E>) => void | Promise<void>,
): ThreadSubscription;
}
export interface ThreadInteraction {
id: string;
threadId: ThreadId;
type: string;
title: string;
payload: JsonValue;
status: "pending" | "resolving" | "resolved" | "cancelled" | "expired";
createdAt: string;
expiresAt: string | null;
resolution: JsonValue | null;
}
export interface ThreadInteractionRequest {
threadId: ThreadId;
type: string;
title: string;
payload: JsonValue;
timeoutMs?: number;
}
export interface ThreadInteractionsService {
request(args: ThreadInteractionRequest, options?: { signal?: AbortSignal }): Promise<JsonValue>;
get(args: { threadId: ThreadId; interactionId: string }): Promise<ThreadInteraction>;
list(args: { threadId: ThreadId; status?: ThreadInteraction["status"] }): Promise<readonly ThreadInteraction[]>;
respond(args: {
threadId: ThreadId;
interactionId: string;
response: JsonValue;
}): Promise<ThreadInteraction>;
resolve(args: {
threadId: ThreadId;
interactionId: string;
resolution: JsonValue;
}): Promise<ThreadInteraction>;
cancel(args: { threadId: ThreadId; interactionId: string }): Promise<ThreadInteraction>;
}
export interface QueuedMessage {
id: string;
threadId: ThreadId;
input: readonly PromptInput[];
order: number;
startsGroup: boolean;
createdAt: string;
updatedAt: string;
}
export interface ThreadQueueService {
create(args: { threadId: ThreadId; input: readonly PromptInput[] }): Promise<QueuedMessage>;
delete(args: { threadId: ThreadId; queuedMessageId: string }): Promise<void>;
list(args: { threadId: ThreadId }): Promise<readonly QueuedMessage[]>;
reorder(args: { threadId: ThreadId; queuedMessageIds: readonly string[] }): Promise<readonly QueuedMessage[]>;
send(args: { threadId: ThreadId; queuedMessageId: string }): Promise<{ executionId: string }>;
setGroupBoundary(args: {
threadId: ThreadId;
queuedMessageId: string;
startsGroup: boolean;
}): Promise<QueuedMessage>;
update(args: {
threadId: ThreadId;
queuedMessageId: string;
input: readonly PromptInput[];
}): Promise<QueuedMessage>;
}
export interface ThreadDraftsService {
get(args: { scope: ComposerScope }): Promise<ComposerDraft | null>;
set(args: {
scope: ComposerScope;
draft: ComposerDraft;
expectedRevision?: number;
}): Promise<ComposerDraft>;
clear(args: { scope: ComposerScope }): Promise<void>;
subscribe(
args: { scope: ComposerScope },
listener: (draft: ComposerDraft | null) => void,
): ThreadSubscription;
}
export interface ThreadSelectionState {
activeThreadId: ThreadId | null;
activeProjectId: ProjectId | null;
composerScope: ComposerScope | null;
execution: ExecutionSelection | null;
contexts: readonly ThreadSelectionContext[];
}
export interface ThreadSelectionContext {
id: string;
owner: string;
kind: string;
data: JsonValue | null;
}
export interface ThreadSelectionService {
get(args?: { clientId?: string }): Promise<ThreadSelectionState>;
set(args: {
clientId?: string;
activeThreadId?: ThreadId | null;
activeProjectId?: ProjectId | null;
composerScope?: ComposerScope | null;
execution?: ExecutionSelection | null;
}): Promise<ThreadSelectionState>;
open(args: {
clientId?: string;
threadId: ThreadId;
contextId?: string;
}): Promise<ThreadSelectionState>;
updateContext(args: {
clientId?: string;
context: ThreadSelectionContext;
}): Promise<ThreadSelectionContext>;
getContexts(args: { threadId: ThreadId }): Promise<readonly ThreadSelectionContext[]>;
setContexts(args: {
threadId: ThreadId;
contexts: readonly ThreadSelectionContext[];
}): Promise<readonly ThreadSelectionContext[]>;
subscribe(
args: { clientId?: string },
listener: (selection: ThreadSelectionState) => void,
): ThreadSubscription;
}
export interface ThreadAttentionState {
unread: number;
active: number;
failed: number;
needsInput: number;
}
export interface ThreadAttentionService {
get(): Promise<ThreadAttentionState>;
subscribe(listener: (state: ThreadAttentionState) => void): ThreadSubscription;
}
export interface ThreadStorageService {
files(args: { threadId: ThreadId }): Promise<readonly { path: string; size: number }[]>;
location(args: { threadId: ThreadId }): Promise<{ path: string | null }>;
paths(args: { threadId: ThreadId }): Promise<readonly string[]>;
}Thread subscriptions
records.subscribe() replaces the old thread:changed realtime channel. It reports an invalidation reason and a revision.
events.subscribe() replaces observe-only lifecycle listeners. It does not block or change the thread transition.
events.list() and events.wait() read durable events. Realtime delivery does not replace durable history.
Interactions
interactions.request() creates a durable pending interaction and waits for one result. The caller can cancel its wait with an abort signal.
The interaction remains in timeline data after a client disconnects. Its record contains no renderer component or layout value.
The bb.thread-ui.timeline.item surface shows pending and final states. The UI plugin exports built-in renderer schemas.
Selection
The selection service stores active IDs, execution choices, and opaque extension contexts.
It does not define tabs, panes, titles, split rules, or frame layout. A presentation plugin interprets each owned context.
RPC boundary
Generic app.rpc.* types belong to the kernel bb.rpc port. No inventory app.rpc.* item declares a thread scope.
Thread code uses the typed bb.threads service. The bb.thread-ui plugin adapts that service for the app.
Exports
The plugin exports its service token and its data contracts. It exports no UI component.
// @bb/threads/contracts
export { threadsService } from "./service";
export type {
ThreadsService,
ThreadRecordsService,
ThreadSectionsService,
ThreadExecutionService,
ThreadTimelineService,
ThreadEventsService,
ThreadInteractionsService,
ThreadQueueService,
ThreadDraftsService,
ThreadSelectionService,
ThreadAttentionService,
ThreadStorageService,
ThreadRecord,
ThreadSummary,
ThreadEvent,
ThreadEventMap,
ThreadInteraction,
ThreadInteractionRequest,
ThreadSubscription,
TimelineItem,
TimelineItemType,
ComposerDraft,
ComposerMention,
ComposerAttachment,
ComposerScope,
ExecutionSelection,
PromptInput,
QueuedMessage,
ThreadSelectionState,
ThreadSelectionContext,
JsonValue,
};Module imports select this exact data contract. Service resolution follows the user's current winner.
Host roles
bb.threads declares no public host role. It uses bb.workspace and bb.files for host-backed work.
Example
This server plugin consumes thread events and durable drafts.
// bb.plugin.jsonc
{
"id": "acme.thread-audit",
"version": "2.0.0",
"requires": [{ "service": "bb.threads", "range": "^1" }],
"artifacts": { "server": "./dist/server.js" }
}// src/server.ts
import { defineServerPlugin } from "@get-bb/plugin/server";
import { threadsService } from "@bb/threads/contracts";
export default defineServerPlugin(async (api) => {
const threads = await api.services.use(threadsService);
threads.events.subscribe("thread.idle", async ({ payload }) => {
const scope = { kind: "thread", threadId: payload.thread.id } as const;
const draft = await threads.drafts.get({ scope });
api.log.info("Thread became idle.", {
threadId: payload.thread.id,
hasDraft: draft !== null,
});
});
});The required edge restarts this plugin after a bb.threads service winner change.
Covers
| old item ID | new contract/verb | note |
|---|---|---|
server.ui.requestInput |
bb.threads service: interactions.request() |
The thread interaction service replaces the server UI request. |
server.events |
bb.threads service: events |
The domain service owns thread lifecycle events. |
server.events.on |
bb.threads service: events.subscribe() |
The subscription stays observe-only. |
server.events.thread.created |
bb.threads service: ThreadEventMap["thread.created"] |
The lifecycle event keeps its payload. |
server.events.thread.active |
bb.threads service: ThreadEventMap["thread.active"] |
The lifecycle event keeps its payload. |
server.events.thread.idle |
bb.threads service: ThreadEventMap["thread.idle"] |
The lifecycle event keeps its payload. |
server.events.thread.failed |
bb.threads service: ThreadEventMap["thread.failed"] |
The lifecycle event keeps its payload. |
server.events.thread.archived |
bb.threads service: ThreadEventMap["thread.archived"] |
The lifecycle event keeps its payload. |
server.events.thread.deleted |
bb.threads service: ThreadEventMap["thread.deleted"] |
The lifecycle event keeps its payload. |
server.ui.requestInput.request |
bb.threads service: ThreadInteractionRequest |
The request keeps the thread, interaction type, title, payload, and timeout. |
server.ui.requestInput.options.signal |
bb.threads service: interactions.request() options.signal |
The abort signal cancels the wait. |
server.events.payloads |
bb.threads service: ThreadEventMap |
The event map keeps typed payloads. |
server.sdk.threadSections |
bb.threads service: sections |
The thread service owns section records. |
server.sdk.threadSections.create |
bb.threads service: sections.create() |
|
server.sdk.threadSections.delete |
bb.threads service: sections.delete() |
|
server.sdk.threadSections.list |
bb.threads service: sections.list() |
|
server.sdk.threadSections.update |
bb.threads service: sections.update() |
|
server.sdk.threads |
bb.threads service |
The compatibility facade resolves to this named service. |
server.sdk.threads.archive |
bb.threads service: records.archive() |
|
server.sdk.threads.archiveAll |
bb.threads service: records.archiveTree() |
|
server.sdk.threads.childSummary |
bb.threads service: records.childSummary() |
|
server.sdk.threads.compact |
bb.threads service: execution.compact() |
|
server.sdk.threads.cancelPlan |
bb.threads service: execution.cancelPlan() |
|
server.sdk.threads.clearGoal |
bb.threads service: execution.clearGoal() |
|
server.sdk.threads.conversationOutline |
bb.threads service: records.conversationOutline() |
|
server.sdk.threads.defaultExecutionOptions |
bb.threads service: execution.defaultOptions() |
|
server.sdk.threads.delete |
bb.threads service: records.delete() |
|
server.sdk.threads.editMessage |
bb.threads service: timeline.editMessage() |
|
server.sdk.threads.fork |
bb.threads service: records.fork() |
|
server.sdk.threads.get |
bb.threads service: records.get() |
|
server.sdk.threads.list |
bb.threads service: records.list() |
|
server.sdk.threads.markRead |
bb.threads service: records.markRead() |
|
server.sdk.threads.markUnread |
bb.threads service: records.markUnread() |
|
server.sdk.threads.open |
bb.threads service: selection.open() |
|
server.sdk.threads.paneAction |
bb.threads service: selection.updateContext() |
The service stores an opaque context and does not define pane layout. |
server.sdk.threads.output |
bb.threads service: execution.output() |
|
server.sdk.threads.pin |
bb.threads service: records.pin() |
|
server.sdk.threads.promptHistory |
bb.threads service: execution.promptHistory() |
|
server.sdk.threads.reorderPinned |
bb.threads service: records.reorderPinned() |
|
server.sdk.threads.resolveMentions |
bb.threads service: execution.resolveMentions() |
|
server.sdk.threads.search |
bb.threads service: records.search() |
|
server.sdk.threads.send |
bb.threads service: execution.send() |
|
server.sdk.threads.spawn |
bb.threads service: execution.spawn() |
|
server.sdk.threads.stop |
bb.threads service: execution.stop() |
|
server.sdk.threads.timeline |
bb.threads service: timeline.list() |
|
server.sdk.threads.timelineTurnSummaryDetails |
bb.threads service: timeline.turnSummaryDetails() |
|
server.sdk.threads.storageFiles |
bb.threads service: storage.files() |
|
server.sdk.threads.storageLocation |
bb.threads service: storage.location() |
|
server.sdk.threads.storagePaths |
bb.threads service: storage.paths() |
|
server.sdk.threads.unarchive |
bb.threads service: records.unarchive() |
|
server.sdk.threads.unpin |
bb.threads service: records.unpin() |
|
server.sdk.threads.update |
bb.threads service: records.update() |
|
server.sdk.threads.wait |
bb.threads service: execution.wait() |
|
server.sdk.threads.events |
bb.threads service: events |
The domain service exposes durable event history. |
server.sdk.threads.events.list |
bb.threads service: events.list() |
|
server.sdk.threads.events.wait |
bb.threads service: events.wait() |
|
server.sdk.threads.interactions |
bb.threads service: interactions |
The domain service owns pending interactions. |
server.sdk.threads.interactions.cancel |
bb.threads service: interactions.cancel() |
|
server.sdk.threads.interactions.get |
bb.threads service: interactions.get() |
|
server.sdk.threads.interactions.list |
bb.threads service: interactions.list() |
|
server.sdk.threads.interactions.resolve |
bb.threads service: interactions.resolve() |
|
server.sdk.threads.interactions.respond |
bb.threads service: interactions.respond() |
|
server.sdk.threads.queuedMessages |
bb.threads service: queue |
The queue service owns queued messages. |
server.sdk.threads.queuedMessages.create |
bb.threads service: queue.create() |
|
server.sdk.threads.queuedMessages.delete |
bb.threads service: queue.delete() |
|
server.sdk.threads.queuedMessages.list |
bb.threads service: queue.list() |
|
server.sdk.threads.queuedMessages.reorder |
bb.threads service: queue.reorder() |
|
server.sdk.threads.queuedMessages.send |
bb.threads service: queue.send() |
|
server.sdk.threads.queuedMessages.setGroupBoundary |
bb.threads service: queue.setGroupBoundary() |
|
server.sdk.threads.queuedMessages.update |
bb.threads service: queue.update() |
|
server.sdk.threads.tabs |
bb.threads service: selection.getContexts() and selection.setContexts() |
Opaque contexts replace tab-specific storage. |
server.sdk.threads.tabs.get |
bb.threads service: selection.getContexts() |
|
server.sdk.threads.tabs.update |
bb.threads service: selection.setContexts() |
|
server.sdk.subscribe.threadChanged |
bb.threads service: records.subscribe() |
The typed subscription replaces the realtime thread channel. |
app.contracts.PluginComposerScope |
@bb/threads/contracts: ComposerScope |
The durable draft and selection services use this scope. |
app.composer.PluginComposerMention |
@bb/threads/contracts: ComposerMention |
|
app.composer.ComposerStructuredDraft |
@bb/threads/contracts: ComposerDraft |
The draft also carries attachments and a revision. |
server.sdk.system.attention |
bb.threads service: attention.get() |
The thread owner computes unread and pending attention. |