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.