# Threads

A thread is a conversation. Every surface — chat, env-bound dispatch — is a thread. One Postgres row + 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. A thread's agents come from its house; there is no per-thread agent table.

```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                    // sidebar title; absent = untitled (consumers fall back to id)
  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'
  usage?: TokenUsage
  config?: ThreadConfig            // { model?, taskId?, title?, … } at creation
}
```

Two things define a thread, both derived from the typed edges above. There is no `kind` column.

- **Where it lives.** `parentAgentId` means a DM. `parentThreadId` means a sub-job: a child iteration or a [`delegate_task`](./dispatch.md#delegated-coding-delegate_task). Neither set means a root house thread. `parent` is the union over those edges, and permissions walk the chain. A thread that owns children is just a parent thread — no separate primitive.
- **Status.** Whether a bot drives the thread picks the vocabulary. A chat rests at `open`. A subagent run goes `idle → running`, then ends `completed`, `failed`, or `cancelled`. Finer states (queued, streaming, why a run waits) live on the stream, not in a column.

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

```
arbe thread create <parent-ref> [--env <env>]   # row only, no trigger
arbe thread entries create <ref> "<msg>"        # thread or agent ref; 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
```

Footgun: creating an env-bound thread does **not** trigger pi — dispatch fires on the chat-entry POST, not on creation. A house owns a `primary_thread_id` provisioned at create time, where house-level narration (`signal.house.*`) lands.

**Lifecycle GC.** Stuck `running` rows self-heal on read. A `GET` runs `reconcileStuckThread`. It adopts a terminal already on the stream, or flips the row to `failed` with `signal.thread.pi_session_orphaned` once the thread goes silent past the threshold (90s after heartbeats, else 30 min). A healthy run heartbeats every ~5s. A dead one goes quiet and gets reaped, including a run whose sandbox died and took its runner with it. Reconcile only fires on read, so cold rows linger. Run it on demand with `arbe thread reconcile <id>`, or sweep with `arbe thread prune`. Reaping only flips status. The row and stream are durable and survive sandbox death. `deleteThread` drops the row, then the stream, and treats an already-gone stream as success, so delete is idempotent. Members can delete, not just owners — enforced by RLS.

Payloads on the stream union three groups: `ArbeChatPayload` (what humans/bots say), `ArbeLLMPayload` (`pi.assistant`, `pi.tool_result`, `pi.chunk`, `pi.compaction`), `ArbeSignalPayload` (`signal.thread.*`, `signal.house.*`, `signal.permission.*`, `signal.dispatch.*`). Predicates: `isChatPayload`, `isLLMPayload`, `isSignalPayload`. Access stays nested: `entry.payload.type === 'chat'` → narrow → `.text`.

Code: `@arbe/core/schemas/thread.ts`, `@arbe/core/schemas/stream-events/thread.ts` (`ArbeThreadPayload` union + predicates), `@arbe/core/threads` (row CRUD + status + rollup), `@arbe/core/entries` (stream r/w).<br>
See [streams](./streams.md), [dispatch](./dispatch.md), [surfaces#threads](../surfaces.md).

Agent-parented DMs work on both surfaces: the web (`/threads/new` "Agent (DM)" → `parent_kind: 'agent'`) and the CLI (`arbe thread create <agent-ref>` or `arbe thread entries create <agent-ref> "hi"`). Entry creation reuses an existing `(agent, kind:chat)` thread or creates one. `createThread` seeds a thread-scope `triggerMode: 'always'` for the parent agent so the lone bot replies to un-mentioned lines instead of being filtered out.

_Open: whether agent-parented DMs are the right home for "quick chat without picking a place first."_
