Skip to content
View as .md

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

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.

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 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 for the entries a thread is made of and durable streams for the transport under them, then when bots replythread directordispatch for what happens after someone posts in one. The browser-side tour is chatting in arbe.