# 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.

```text
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 up
```

The 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. See [thread director](thread-director.md) and [streams](streams.md).

A thread's `environmentId` gives its bots sandbox tools. Selection and authorship otherwise stay identical.

## Activation

```text
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
  → run selected bot in-process                          (dispatch.ts)
messages = buildMessages(entries, bot)                   (build-messages.ts)
systemPrompt = ARBE context + bot prompt + voice + conditional directives
tools = buildAgentTools(turn context) → filterAgentTools(allow, deny)
runToolLoop(...) → durable assistant/tool entries + final reply
  → persist signal.thread_director.outcome
  → advance decided_offset and read again
```

The 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. A thread born as one human plus one bot seeds `triggerMode: 'always'` for that bot (so does the legacy agent-parented DM for its parent agent); other threads use their resolved trigger config. 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>] [--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-agents.md#from-an-arbe-chat) 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 dispatcher spawns a child thread (`parent.kind:'thread'`, inheriting the parent's `environmentId`) and launches pi on the sandbox via the same `arbe-pi-runner`. A pi extension — the mirror — posts the full `pi.*` work (tool calls, diffs, reasoning) onto the child as it happens, and the runner posts the terminal on exit. Files the coding agent wrote stay in the sandbox; the runner never publishes them to house files. 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 ──spawn C──▶  pi runs on sandbox (arbe-pi-runner)
  (tool returns child id, turn ends)         the mirror posts pi.* onto C
                                             runner posts the terminal on exit
                                             authorId = house system agent · survives tab close
reconcile notifies P:        ◀──terminal──  C reaches completed | failed
  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 child reaches a terminal, the parent gets a `signal.thread.child_finished` plus a `chat` authored as the house system agent that carries the result (`buildChildFinishedEntries`). The runner posts that pair itself on exit when it was launched with parent stream credentials; `reconcileStuckThread` (on a thread read or the prune sweep) is the fallback and posts the same pair, so a runner that died before reporting is still reconciled. A box Daytona already deleted is caught by reconcile's pull-confirm, before the silence threshold.

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.

Both runtimes now fire the detached runner and return; there is no in-sandbox cancel handle (that was the deleted relay's pgid). A run is bounded by the runner's runaway guard (`ARBE_PI_TIMEOUT`, ~3 days) and reconciled from its terminal. 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.

Authorship on the delegated-coding path follows [who may write what](authorship.md): 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](bot-tools.md).

Tools are advertised every turn via pi-ai `Context.tools` (native function calling, no MCP). `runToolLoop` (`tool-loop.ts`, dispatch-agnostic) runs the bot's model until it stops calling tools, then returns a clean final answer; the full transcript (each `pi.assistant` round + its `pi.tool_result`s, then the final) persists on the stream and replays intact.

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`. `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](../data/volumes.md).

A thread left `running` without a terminal self-heals on read via `reconcileStuckThread` (silent past 30 min → `failed`).

Code: `packages/core/dispatch/` — `dispatch.ts`, `tool-loop.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](agent-trigger-mode.md) for who is eligible and [thread director](thread-director.md) for who is chosen, then [daytona runtime](../sandboxes/sandbox-daytona.md), [streams](streams.md), [threads](threads.md), [system/environments](../sandboxes/environments.md), [system/secrets](../access/secrets.md).
