The thread director
The thread director exists so a group chat of humans and bots feels fluid, natural and fast, while respecting each house, thread and agent’s configuration. It gives a conversation the floor: one bot replies, or everybody stays quiet. It should notice a useful specialist, honor an explicit request, let humans finish a thought, and stop when the conversation needs nothing more.
It runs inside the backstage. A wake says that a thread may have new work; the director catches up with that thread’s messages under one lease.
message → wake → claim the thread lease ↓ read what’s new ↓ wait for a human burst, or choose ↓ one bot replies, or silence ↓ record progress → read again → idleWho gets the floor
Eligibility is a rule; usefulness is a judgment. The director applies the rules first and asks a model only when the remaining choice needs judgment. Being eligible does not by itself mean a bot should speak.
Only current, active participant bots can be chosen. The director refreshes the roster and configuration before each decision, so removing a bot or changing its trigger mode affects the next choice. Archiving a thread also stops selection after the current turn; archived history stays readable. A reply already in progress is not cancelled.
A bot’s own turn lands as an assistant entry; a chat entry from a bot can only arrive from outside the room (a post_to_thread from another thread, a workflow’s bot), so it restarts the bot-turn count exactly as a human message does. An empty bot chat (an uploaded attachment) does not.
Historical authors keep their human or bot identity after leaving the thread or house. Known System authors retain their identity, so workflow instructions can trigger the assigned always bot. System messages do not open mentions or reset the bot-turn count. An unresolved author stays unknown; it never becomes a human by default, resets the bot-turn count, or creates a mention obligation.
A bot’s trigger mode says when it is eligible: mention needs an open @handle, ambient needs something new from a human it could usefully answer, always owes a reply to any message it did not write, except a side exchange: a human message that @mentions only other bots, and their answers to it. An always pick is free, subject to its turn limit; when several owe one, the bot that spoke least recently goes first.
A plain name is a topic hint, not a summons. An @handle is a summons whoever writes it: the turn limit already bounds a chain of bots summoning each other, so the author doesn’t matter. A mention in a posted message (a human’s, or a bot’s chat post) is served even past the turn limit; one inside a bot’s reply lapses once its bot rests or the conversation settles. An unanswered mention stays open until that bot speaks or fails permanently; several mentions are served in order across successive decisions. A reply answers what its decision saw, plus any message steered into it (below). Anything else posted while a bot is mid-reply stays owed, and the next decision picks it up.
The oldest open mention is normally a free pick. If an older unanswered human message precedes it, the model considers that older need first. An always bot that owes a reply still takes a free turn before the model.
While the thread’s current participants are exactly one human and one bot, an ambient bot answers every new human message as a free pick: one-on-one, there is nobody else the message could be for. The roster is read on every decision and nothing is stored, so when a second bot or human joins, the bot goes back to judging who is being addressed. A mention bot stays mention-only one-on-one; someone chose that. The exact precedence belongs to the selection rules.
The judgment call is about what the human still needs. A useful answer should end the exchange; agreement, a paraphrase, or a goodbye does not earn another turn. Human-to-human conversation should be allowed to pass without a bot coda. A lone ambient bot with two or more humans still needs this judgment: one available speaker does not mean an answer is wanted.
The model sees a bounded conversation window, with older context separated from entries new since the last decision. Candidate cards carry each bot’s persona, trigger mode, and reason for eligibility. It returns one candidate or nobody. Unreadable entries appear as [unreadable entry], count toward the context budget, and cannot justify an invented answer. The decision prompt owns the wording of that judgment.
Director plugins
The judgment call is made by a director plugin. llm is the decision prompt above; jev asks TypeSafe’s Jev one Choice over the eligible bots plus nobody, and reports its confidence; routed wraps two of them: its primary (default Jev) decides, and its fallback (default the LLM) decides instead when the primary’s confidence is under minConfidence (default 0.4) or it fails, and if the fallback fails too the primary’s pick stands. The primary must report confidence, so an llm primary is rejected. Jev gives up after 1.5 s and counts against the house’s spending allowance like any arbe-funded call. On the 2026-10-15 scenarios Jev decided in ~190 ms against ~1.2 s and caught every miss below 0.4 (scorecard). Both get the same input: the bounded window and the eligible candidate cards. The rules before them (mentions, always bots, turn limits, one-on-one, trigger modes) stay in code under every plugin, so a plugin can only choose among bots the house’s configuration already allows.
A house or thread lists its plugins in the directors config. Exactly one acts; the rest are dryRun: they decide on the same input in parallel, are never awaited, and only record. Unset means [{"kind":"routed"}].
arbe config set house <id> --patch '{"directors":[{"kind":"llm"},{"kind":"jev","dryRun":true},{"kind":"llm","model":"openai/gpt-6-luna","dryRun":true}]}'judgment-call pass ─┬─► acting plugin ──► decision entry {pick, input, decidedBy} └─► dryRun plugins (same input, not awaited) └─► dry_run entry {pick, confidence, ms, cost} + usage rowEvery decision records what the director saw (input: the roster with personas, the ids of the messages in its window with the new ones marked, and on a judgment call the eligible bots and why). A judgment call also records decidedBy: the plugin’s label, confidence when it has one, time and cost. Adding a plugin is one adapter in packages/core/dispatch/ and one line in DIRECTOR_PLUGINS (thread-director-plugins.ts).
Pace and turn-taking
Threads with one active participant bot start deciding immediately by default. Multi-bot threads keep a three-second pause so human bursts such as “Plan a rainy afternoon in Oslo”, “With a six-year-old”, and “Near the station by five” can become one request. Each recent human message postpones the decision until typing pauses. An explicit dispatch.debounceMs inherited by or set on the thread wins over the one-bot default; zero disables the wait. A null leaf explicitly resets to the configured app default. Adding or removing bots recalculates the default on the next pass. The trace reports the effective pause; generic config reads show the scope-merged setting before participant-based defaults.
After the pause, the director also holds while any person is typing in the thread: people answering each other take five or ten seconds, and a bot picked in that gap cuts in. It looks again when they post, empty their composer, or their signal runs out (six seconds after the last beat), and it never holds longer than 20 seconds after the newest message, so a stuck signal can’t silence a thread. The hold applies in one-bot threads too; a message that @mentions exactly one bot goes ahead regardless. Each hold lands in the thread as a signal.thread_director.hold entry naming who was typing, so arbe thread trace shows it. People signal typing from the web composer, from arbe thread typing <id> --for 5s, or with POST /api/threads/:id/typing; bots can’t. The signal lives in the thread_typing table, and a start or stop reaches other browsers as a typing house stream event.
Debounce and the typing hold help before a decision starts. Neither revises a bot’s answer already in progress, and neither delays a message from steering into one.
Each bot has a turn limit, dispatch.botTurnLimit, default three: it rests after that many consecutive bot turns since the last human message. A contiguous run of entries by one bot is one turn, so a tool post or GIF is not an extra turn. A new posted message resets the run, a mention in a posted message overrides resting, and when every candidate is resting the thread goes quiet. Zero disables the limit; bots then continue until the house spend cap stops Arbe-funded calls, and your own provider key is uncapped. The limit bounds bot-to-bot talk; it does not replace the model’s job of stopping an ambient exchange once the human is answered.
Two more brakes. arbe thread pause <id> makes the thread mention-only and arbe thread resume <id> drops that override (it does not restore an older one); these are config shortcuts, the director never reads chat text as a command, and a reply in progress is not cancelled. An unlimited room also settles after dispatch.settledBotTurnThreshold consecutive bot replies (default eight) of at most eight words with no question and no tool call; the trace records conversation settled …. An open mention or a new human message wakes it; zero disables the rule.
Steering a running reply
A human message posted while a bot replies can join that reply instead of waiting for its own. The director decides with its usual rules, judging the message as if the running reply had just landed: a message that mentions the running bot joins; one that mentions only other bots does not; anything else joins when the free rules would pick the running bot, or when it is the only bot the paid decision could choose. So in a room where only that bot would answer, every human message steers; in a busier room only messages meant for it do. Only human chat steers.
The director routes the message to the reply’s task as an event (dispatch). The bot takes it in after its current tool round and answers it in the same reply. A reply past its last tool round takes nothing; the message stays owed and gets its own reply right after. The reply’s outcome lists what it took (steered= in arbe thread trace), and the rules read those messages as answered.
Catch-up and recovery
A wake carries no message to process. It asks the director to read past the thread’s decided offset—the point through which previous decisions have been settled. One decision covers only the entries already present when its window was read. Advancing that offset must never swallow a newer message.
One lease allows one active director to drive a thread. The holder renews it during work and checks its authority before writes, including writes inside the bot turn. A competing wake yields; the holder keeps reading until caught up. Lease loss stops the old holder from continuing to write.
Picks and silence decisions over new conversation are persisted, whether chosen by rules or a model. A bot attempt then records an outcome tied to its decision: a reply or a failure. Windows containing only bookkeeping advance without another decision, so the director does not keep reacting to its own records.
After a restart, a persisted decision without a settled outcome resumes its chosen bot instead of paying to choose again. If the outcome already landed, recovery advances progress. Transient failures retry the same decision; three failures, or one permanent failure such as an invalid model, end that attempt so the thread can move on. See settlement.
A pick hands the turn to one reply task keyed by the decision; the director stops there and is woken again when that task settles. Wakes in between, from a crash or new messages, find the pick still pending and its task alive, and leave both alone. A reply and its outcome land in one append, atomic per request. The task gets the decision’s pick and the bounded window the director decided on, so it never reads the thread. A resumed reply replays the model calls and tool runs its task already recorded and answers only what its decision covered (dispatch).
A turn refused for want of a model key or money (missing key, spent allowance, out of credit) gives up like any permanent failure, its outcome carries the refusal’s own reason, and the thread reads blocked. Until something changes the director doesn’t pick again: new messages get a nobody decision marked blocked: auth (auth block), so the thread stays blocked. A resume, any landed reply, or a person’s message five minutes or more after the failure lifts it; the last covers money added where arbe can’t see it. Saving the house’s model key, rotating or raising its funded key, or arbe thread wake on that thread resumes it: thread-resume appends a signal.thread_director.resume naming the latest message before the block and wakes the director, which reads that message as new again and drops the house’s cached keys first. It resumes only threads whose stored status is blocked on auth, newest twenty per house, one message each (the latest one said while blocked), and only if nothing was said after the latest block and it wasn’t resumed already; a key that is still broken blocks again and waits for the next save. Coding runs are not restarted; the next message continues them. Arbe’s own OpenRouter balance has no event, so after topping it up, wake the blocked threads by hand.
key saved · funded key rotated/raised · thread wake → blocked-auth threads → latest message before the block → append resume{entryId} → wake → director re-reads it → fresh pickA five-minute scan wakes stranded threads whose recorded head is ahead of their decided progress and which have no queued wake. A [thread-director.scan] stranded thread log means delivery or catch-up failed somewhere. Investigate it; the scan is the recovery path.
The backstage wake loop owns the lease, reads, decisions, turns, and progress writes. The conversation supplies replay evidence; the thread’s state row tracks the lease and catch-up position.
Inspect and prove it
Use an explicit house with every thread command:
arbe thread trace <thread> --house <house>arbe thread director <thread> --house <house>arbe thread entries list <thread> --house <house>trace shows persisted decisions, outcomes, and reply timings, with each dry-run plugin’s pick under the decision it shadowed. arbe thread director --compare --house <house> sums every plugin across a house: agreement with the acting pick, spoke-when-it-stayed-silent and silent-when-it-spoke as separate counts, latency and cost, plus a what-if for plugins that report confidence: how a routed director (that plugin decides, the acting one below a confidence floor) would have done at a few floors. To compare plugins on the scenarios, bun tests/run-scenario.ts <n...> --house new --directors '<json list>' sets the list before the first post. director shows whether the thread is caught up and whether a lease is held. entries list shows what people and bots actually said. A missing reply is not proof of intentional silence: inspect the director state and failures before judging the conversation. Intentional silence has a saved nobody decision with its reason.
Separate “who spoke?” from “was it natural?” Explicit mention order, no repeated ambient answer, turn limits, and eventual catch-up are mechanical checks. Relevance, timing, responding to the whole thought, and leaving humans room need the conversation itself. Reply latency must distinguish time spent waiting for the director, deciding, handing off, and generating; a correct choice can still arrive too late. The trace’s director, handoff, and turn lap lines split each of those into named steps, so a slow stretch names its own culprit.
The live scenarios describe these expectations as conversations. Follow run-scenarios, use a fresh test house, and keep thread IDs with the result. A scenario’s input must actually exercise its claim: labels for two humans are insufficient if both posts have the same author, and a slow test driver cannot prove burst handling.
For a stuck thread, start with debugging. arbe thread wake <thread> --house <house> requests catch-up again; it does not explain why the previous wake failed.