Dispatch
Group chat uses one thread director to choose one speaker or nobody. Origin does not matter: browser, CLI, SDK, and bot-authored messages all enter the same catch-up path.
Every conversation-visible append calls wake_thread. The wake task carries only the thread id; its entry key exists solely for queue deduplication. Local and deployed www both enqueue on dispatch, consumed by production backstage on Fly.
caller → append chat or final bot reply → wake_thread → dispatch queue ↓ claim the thread lease ↓ read past decided_offset + bounded tail ↓ free rule or one paid speaker decision ↓ ↓ nobody selected bot turn ↓ ↓ advance offset decision + outcome ↓ read again until caught upThe thread stream is the durable truth. thread_directors stores rebuildable progress, a bounded tail cache, wake counters, and the one live lease. A decision covers the stream head captured before deciding, so messages arriving during a turn remain unread for the next pass, unless the reply took them in mid-turn (steering). See thread director and streams.
A thread’s environmentId gives its bots sandbox tools. Selection and authorship otherwise stay identical.
Activation
wake task → runThreadDirectorWake (apps/backstage/src/thread-director-wake.ts) → parse unread entries and bounded context (thread-director-entry-view.ts) → run free rules or one paid decision (thread-director-rules.ts) → persist signal.thread_director.decision → spawn the decision's reply task, once, then stop (reply-task.ts)reply task → runReplyTask, keyed reply:<decision> (apps/backstage/src/reply-task.ts) → the pick and window the director passed, then the turn (dispatch.ts)messages = buildMessages(entries, bot) (build-messages.ts)systemPrompt = ARBE context + bot prompt + voice + conditional directivestools = buildAgentTools(turn context) → filterAgentTools(allow, deny)runAgentTurn(...) → pi Agent; each model call and tool run is an Absurd step → durable assistant/tool entries, then reply + signal.thread_director.outcome in one append → wake the director → it advances decided_offset and reads againThe free rules, eligibility, per-bot turn limit, open mentions, and paid candidate construction live in packages/core/thread-director/.
A thread with one bot has no dispatch shortcut. While a thread has one human and one bot, an ambient bot answers every human message; that is a director rule read from the live roster, not a stored setting. Every thread follows the same catch-up and selection path.
Sandbox access (env-bound threads)
A thread bound to an environment reaches that environment’s sandbox as its hands. Once per turn the dispatcher resolves the sandbox and passes it to each bot turn as ctx.sandbox. The run_command tool runs a shell there and returns the output for the bot to reply from; the provider credentials never reach the model.
No sandbox bound → the tool tells the bot there’s nowhere to run and the bot replies normally. Readiness is a per-call concern, not a turn gate. Provisioning a sandbox on demand is deferred; until then a missing sandbox is a fallback string, not an auto-spin-up.
End-to-end check: bun run scripts/remote-dispatch.ts [<env-ref>] --house <id> [--local] binds a thread to an env, plants a sandbox-only nonce, @mentions a bot to read it back, and asserts the reply carries the nonce and is authored by that bot.
Documentation access
arbe_docs lets normal chat turns read the already-published Markdown documentation, whether or not the thread has an environment. It is registered with the normal agent tools and follows the same allow/deny permissions. With no path it reads /docs/index.md, whose keyword table routes the agent to another page; copied HTML, docs-mounted, and Markdown-twin paths all resolve to the same twin. Long pages return bounded slices with continuation offsets.
The tool fetches only from arbe’s canonical public origin. It needs neither Pagefind, a separate search corpus, nor sandbox access. See For agents for usage.
Delegated coding (delegate_task)
run_command is a synchronous hand — one shell, output folded into the reply. delegate_task is a detached hand for multi-step jobs (clone a repo, run a coding agent, iterate) that take minutes and must survive the worker request and a closed tab.
The brain calls delegate_task({ repo, task }). The tool opens a child thread (parent.kind:'thread', inheriting the parent’s environmentId), posts the task there, and starts the run: one durable coding-run task in backstage, which claims the child, launches pi on the sandbox via arbe-pi-runner (it drives pi --mode rpc and posts the full pi.* work onto the child as it happens), and alone decides when the run ends. Files the coding agent wrote stay in the sandbox; the runner never publishes them to house files. The run gets a subset of the bot’s own tools (reading threads, house files, ask_thread, post_to_thread to threads other than its parent) through pi’s arbe-tools extension, acting as the delegating bot; see daytona runtime. The child thread is the provenance boundary: everything on it is the delegated job, so the UI collapses or replays it without per-entry tagging.
parent thread P (env-bound) child thread C (parent.kind:'thread')───────────────────────── ─────────────────────────────────────brain calls delegate_task ──open C, post task, start run──▶ coding-run task claims C (tool returns child id, turn ends) launches the runner; pi.* lands on C chats in C steer pi; stop aborts it runner reports when pi is idle task ends the run, then tells Ptask notifies P: ◀──terminal── C reaches completed | failed | cancelled signal.thread.child_finished + system-agent chat (child's result) (the chat wakes the director like any message)The tool returns the child id immediately. It does not hold the worker turn for the length of the job. When the run ends, its task posts the parent a signal.thread.child_finished plus a chat authored as the house system agent that carries the result (buildChildFinishedEntries), before the child’s row leaves running, so a later run of the same child can never land before this report. A runner that dies is noticed by its task within ~90s of silence and ended failed.
The chat is an ordinary message: if the thread has bots configured, it wakes the thread director like any other post, and the director’s pick replies to it. That is how the delegating bot gets to report the outcome in its own voice. Nothing about the signal itself wakes anyone; signals never reach the director or a bot’s context.
The chat carries a handover marker ({ childThreadId, status }, ChatHandoverSchema) that says what it is. The runner’s instructions ask the coding agent to end with a report (what changed, PR or branch, verification, anything needing attention) rather than a diary, and that final message is the chat’s text. The web folds the signal and the marked chat into one notice, “Coding completed ↗”, with the report underneath; the bot sees it labelled as a handover in its context and DELEGATION_HANDOVER_GUIDANCE tells it to answer the conversation in its own voice rather than repeat the report. The system agent is attribution, not a speaker: only handover chats get this treatment, so its other posts (workflow alerts, say) stay ordinary messages.
The child is a conversation with pi. A message posted in it while pi works reaches pi as a steer; one that lands as pi goes idle is taken in by the same run; stopping it (arbe thread stop, the web readout, or stopping the parent, which stops every run it delegated) ends it cancelled; a human message after it ended continues the same pi session as a new run. A coding-run child has no speaker to choose, so the director hands its wakes to nudgeCodingRun, which only wakes the run’s task or starts the next run. Details: daytona runtime. A run is otherwise bounded by the runner’s runaway guard (ARBE_PI_TIMEOUT, ~3 days). A daytona box outlives a run’s terminal — a box is a machine, not a run. A per-run box (sandboxes.ephemeral) is swept once no thread runs on it, with Daytona’s own auto-delete as a ~3-day backstop.
Known edges of the run lifecycle, not yet closed:
- Two launches of one run racing over a dead runner’s PID file can both start a runner; only sequential relaunches are refused.
- A new child continuing a busy box attaches to it outside the retirement lock, so the box can be retired under it.
- A runner’s self-kill at 60s and its task’s 90s silence are measured on two machines’ clocks, so skew can let a successor meet the old pi.
- An adopting task that itself dies for good is never replaced: the sweep keeps respawning the same exhausted adopt key.
- A box cut by an attempt that lost its claim mid-create stays up until Daytona deletes it, unless the next attempt finds it by its label.
Authorship on the delegated-coding path follows who may write what: the child’s transcript is stamped as the delegating bot (its stream token names ctx.agentId), while the child-finished notification on the parent (signal.thread.child_finished + the result chat) is authored by the house’s kind: system agent (resolved by ensureHouseSystemAgent) through the parent token, keeping attribution honest for audits. On first use, dispatch mints that agent’s actor client and wraps it in a delegation-only session service bound to the current house and parent thread; tools receive { authorId, sessions }, never the client or a general thread store. Only the parent’s own delegate_task delegation turn stays the brain’s. The thread boundary marks the work as delegated — the system agent is the author on the child, not a second bot.
Tool calling
The full list of tools and when each is offered: Bot tools.
Tools are advertised every turn as pi tool declarations (native function calling, no MCP). runAgentTurn (agent-turn.ts, dispatch-agnostic) runs the bot on pi-agent-core’s Agent until it stops calling tools, then returns a clean final answer; arguments are validated against each tool’s schema before it runs. How the turn ended comes from decideAgentOutcome, the decider a coding run uses too. The full transcript (each pi.assistant round + its pi.tool_results, then the final) persists on the stream and replays intact. Each round’s pi.assistant lands when the round finishes; there is no live typing text.
A turn belongs to the decision that picked it. The director spawns one reply task per pick, keyed reply:<decision id>, with the pick and the bounded window it decided on, and never runs the turn itself: a later wake, after a crash or a new message, finds that same task and leaves it alone, or settles the decision as failed if the task ended without an outcome. The task’s claim is its write authority. Claim renewal and a ten-minute cap start before the task reads anything, and run until its settlement lands; the task checks its claim before its own outcome writes, and the turn checks it before its appends and before any tool that changes something.
Inside the task every model call and every tool run is an Absurd step, so a redelivered task replays what was recorded instead of paying for the model again or rerunning a tool. Its first step fixes what the turn means — the model input as rendered then, its model and prompt, its tools and their argument shapes, and its environment — and a resumed turn runs on that snapshot; if the environment or a tool’s shape changed in between, it fails rather than mixing an old plan with a new target. A tool withdrawn since still replays what it already did and refuses only a new call. Replayed tool calls count toward the turn’s per-turn limits on new threads and posts, and a call cut off mid-run keeps its slot on every later replay.
While the turn runs, a wake that finds new human messages routes the ones its rules would hand to this bot into the reply task: each is an Absurd event on the dispatch queue, reply:<decision>:steer:<n>, numbered in the order sent (reply-steering.ts). After each tool round the turn reads the events it has not taken, without suspending, and steers them into pi’s loop (agent.steer()) before the next model call. What it took at each round is a checkpoint, so a redelivered turn steers exactly the same messages at the same point and never takes in a later one there. A live round that took something in appends signal.dispatch.steered right away, so the thread’s current view shows the message as taken in; the final append’s outcome lists the steered entry ids.
The turn’s appends go through one idempotent stream producer per decision, so a replayed append is a no-op, and the final reply lands in one append with its signal.thread_director.outcome; that append is recorded before it is written, so a redelivery only repeats it. Every paid model call, including a rejected or empty response and ask_thread’s own calls, is recorded in the task as an owed usage row before delivery is tried; delivery is keyed, and the task does not finish while a row is still owed. Spans and phase signals are only emitted for work done live. Tools declare replay: 'safe' when running again can’t repeat a side effect (reads, searches, listings); any other tool records its intent first, and if a crash cut it off the bot gets “may or may not have run” instead of a second run. A transient failure appends a failure outcome and lets Absurd retry the same task; the third, or one permanent failure, settles the decision and wakes the director.
Tool-choice guidance has one semantic owner: SANDBOX_TOOL_CHOICE_GUIDANCE in sandbox-tool-guidance.ts. The platform renders that same comparison into ARBE_CONTEXT and both tools’ spec.description, so bots get identical policy even when llm.arbeContext is disabled: run_command is one bounded synchronous shell operation that holds the current turn; delegate_task is detached autonomous multi-step coding in a child thread, reports later, and carries too much overhead for one quick command. Agent personas describe who the agent is, not platform tool policy.
Add a tool in the module that owns its capability, then register it through buildAgentTools(ctx) in agent-tools.ts. Mark it codingRuns: true if a delegated coding run should get it too; nothing in the box changes. A tolerated argument shape, such as a bare {path, content} for write_file, is a named repair in argument-repairs.ts; pi applies it before validating, in a reply and in a coding run’s box alike. bot-tools.ts owns bot and house actions; thread-tools.ts owns thread structure; volume-tools.ts owns canonical file CRUD; file-search-tools.ts owns canonical search_files; sandbox modules own environment actions. See volumes.
A non-coding thread left running without a terminal self-heals on read via reconcileStuckThread (silent past 30 min → failed); a coding run is ended only by its task.
Code: packages/core/dispatch/ — dispatch.ts, agent-turn.ts, agent-tools.ts, the focused *-tools.ts modules, and the sandbox modules. Long-running child execution lives in @arbe/sandbox.
See when bots reply for who is eligible and thread director for who is chosen, then daytona runtime, streams, threads, system/environments, system/secrets.