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.
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.
parentAgentIdmeans a DM.parentThreadIdmeans a sub-job: a child iteration or adelegate_task. Neither set means a root house thread.parentis 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 goesidle → running, then endscompleted,failed, orcancelled. 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 triggerarbe thread entries create <ref> "<msg>" # thread or agent ref; POST fires dispatcharbe thread entries list <id> [--follow] # tail the stream (raw entries)arbe thread entries read <id> # tail and render pi text; exits on dispatch terminalsarbe thread diagnose <id> # classify last-dispatch stage; exits 2 failed / 3 stalledarbe thread delete <id> # hard-delete row + stream (idempotent: re-runs converge)arbe thread prune # GC stranded `running` rows: reconcile or hard-delete orphansarbe thread reconcile <id> # run reconcile now → reports `running → failed` or no changeFootgun: 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).
See streams, dispatch, surfaces#threads.
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.”