Skip to content
View as .md

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. parentAgentId means a DM. parentThreadId means a sub-job: a child iteration or a 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).
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.”