# Threads

A thread is a conversation or a bot run inside a house. It has one Postgres row and one append-only stream at `arbe-thread-{id}`. The row holds identity and coarse status; the stream holds everything that happened. Entries live on the stream, not in Postgres.

Every thread participant is an agent who belongs to the house. `thread_participants` records that thread-scoped relationship. Thread-specific reply settings are config, not a second kind of participant.

## Shape

```ts
interface ArbeThread {
  id: ThreadId
  parent: { kind: 'house'|'agent'|'thread', id }   // derived from the typed edges below
  parentThreadId?: ThreadId        // typed exclusive-arc edges (both absent = a root house thread);
  parentAgentId?: AgentId          //   `parent` is computed from these
  name?: string                    // the real title; absent = unnamed (label falls back to generatedTitle, then participants)
  generatedTitle?: string          // director-written from the opening line; display only, never regenerated on its own
  firstEntryPreview?, lastEntryPreview?   // how it started / what was last said, denormalized for lists
  pinnedAt?: number                // pinned = a place you land in (house sidebar); independent of name
  tags: string[]                   // free-form labels; a set/predicate over threads
  environmentId?: EnvironmentId    // absent = local; present = env-bound (bots reach a sandbox via run_command)
  sandboxId?: SandboxId            // the sandbox this thread runs on; resolved lazily, repointed on resume
  status: 'open' | 'idle' | 'running' | 'completed' | 'failed' | 'cancelled'
  archivedAt?: number            // soft archive (ms); absent = live; default lists skip when set
  usage?: TokenUsage
  config?: ThreadConfig            // { model?, taskId?, title?, … } at creation
}
```

Threads have no `kind` column. Their parent and status describe how they behave.

- Parent: `parentThreadId` makes a thread a child job, such as a delegated coding task. `parentAgentId` marks an agent-parented thread; new direct conversations are house-parented instead. Neither edge set means a root house thread. Permissions follow the parent chain.
- Status: a chat rests at `open`. A bot-driven run goes `idle → running`, then ends `completed`, `failed`, or `cancelled`. Finer states such as queued or streaming live on the stream.

`name`, `pinnedAt`, and `tags` carry identity, prominence, and grouping — not classification.

An unnamed thread is labelled by its generated title, else its participants. The first previewable line is stored on the row (`firstEntryPreview`, beside `lastEntryPreview`), written once; the thread director then polishes it once into `generatedTitle` at the end of a caught-up pass. Both are display only — reuse, mentions, and the unnamed filter read `name` alone. A client may only clear the generated title (`PATCH { generated_title: null }`, `arbe thread title <id> --regenerate`), which wakes the director to write it again. The reasoning → [thread titles](../../thread-auto-titles.md).

Names are labels, not identifiers, and the database does not require them to be unique. CLI refs resolve by id or id prefix (`apps/cli/src/record-ref/thread.ts`); creating another thread with the same name is allowed.

## Lifecycle

```
arbe thread create <parent-ref> [--env <env>]   # row only, no trigger
arbe thread create <house-ref> --participant <agent-ref>  # an unnamed thread with those participants
arbe thread entries create <thread-ref> "<msg>" # POST fires dispatch
arbe thread entries list <id> [--follow]        # tail the stream (raw entries)
arbe thread entries read <id>                   # tail and render pi text; exits on dispatch terminals
arbe thread diagnose <id>                       # classify last-dispatch stage; exits 2 failed / 3 stalled
arbe thread delete <id>                         # hard-delete row + stream (idempotent: re-runs converge)
arbe thread prune                               # GC stranded `running` rows: reconcile or hard-delete orphans
arbe thread reconcile <id>                      # run reconcile now → reports `running → failed` or no change
```

Creating a thread does not start a bot run. Posting a chat entry starts dispatch.

A house's `primary_thread_id` receives house-level signals. This thread is archived at creation so default thread lists hide it while it remains openable by id. Other archived threads behave the same way and stop dispatching bot turns after any current turn finishes; pass `include_archived=1` to the API or `includeArchived: true` to core list calls to include them. A chat thread's lifecycle status remains `open` while archived because archival is an independent visibility state; `arbe thread view` labels both.

Stuck `running` threads reconcile when read. `reconcileStuckThread` adopts a terminal state already present on the stream, or marks a silent orphan as `failed`. Use `arbe thread reconcile <id>` for one thread or `arbe thread prune` for a sweep. Reconciliation changes status but preserves the row and stream. Deletion removes both and is idempotent. Any house member can delete a thread.

## Entries

Thread entries carry chat, bot-runtime, or signal payloads. `ArbeThreadPayload` owns the union; `isChatPayload`, `isPiPayload`, and `isSignalPayload` narrow it. The source of truth is `@arbe/core/schemas/stream-entries/thread.ts`; `@arbe/core/entries` owns stream reads and writes.

## Direct conversations

A direct conversation is an unnamed house thread whose participants are you and one other agent. It is not a separate thread kind. An unnamed thread with exactly one human and one bot gives that bot the thread-specific `always` reply setting, so no @mention is needed; [when bots reply](agent-trigger-mode.md) owns that behavior.

```
arbe send lyra "what's the status?"        # the thread you two share, then the entry
arbe send lyra stel "standup in 5"         # everyone but the last word is a recipient
arbe thread entries create <thread-ref> "…" # post into a thread you already have
arbe thread create <house-ref> --participant ada        # a new thread with ada
POST /api/threads { parent_id: H, reuse: true, participants: [{ agent_id: A }] }
```

`send` resolves agent refs, creates or reuses an unnamed thread with those participants, then posts the entry. `thread entries create` instead takes an existing thread ref.

Thread creation is fresh by default. `reuse: true` returns the newest live unnamed thread with the exact requested participants, or creates one when none exists.

In www, selecting an agent in chat, in the participants panel, or in the house palette continues that conversation. The palette's New action starts a separate unnamed thread with the same participants.

Agent links use `/houses/{house}/with/{agent}` to create or reuse the thread server-side and redirect to it. Hovering first performs the read-only lookup (`unnamed=1` below) so www can preload the destination without creating a thread. `apps/www/src/lib/thread-with.svelte.ts` owns both paths.

Participant reuse rejects requests with a name, environment, driving bot, config, or archive timestamp. Named and archived threads never match. A preferred id is used only when a thread is created.

Matching is on *current* participants — a thread that grows a third participant is no longer the pair — as a whole set, so you never get a superset or a near-match. Identical participant-based creates are serialized, so simultaneous requests converge on one thread.

To ask the question without creating anything, filter the list: `GET /api/threads?house_id=H&participant_ids=A,B&participants=exact&unnamed=1` (`participants=any` matches threads containing any of them; `unnamed=1` keeps only untitled threads, which is what makes the read predict what a create would return). Backed by the `find_threads_by_participants` RPC — exact-set matching needs `group by ... having`, which PostgREST cannot express; it returns ids and the ordinary thread select applies house/status/archived/limit to them.

See [streams](streams.md) for the entries a thread is made of and [durable streams](durable-streams.md) for the transport under them, then [when bots reply](agent-trigger-mode.md) → [thread director](thread-director.md) → [dispatch](dispatch.md) for what happens after someone posts in one. The browser-side tour is [chatting in arbe](../../chat.md).
