# Dispatch

Every `chat` entry written to a thread makes agents react. `POST /api/threads/:id/entries` hands the dispatcher returned by `createThreadDispatcher()` to `waitUntil`; origin (CLI, web UI, JS client, another bot) is irrelevant. There is one path: bots react in-process inside the worker (pi-ai + tool loop).

A thread's `environmentId` gives its bots a sandbox to reach. An env-bound thread is otherwise a normal thread — same selection, same authorship — whose bots happen to have hands. How the sandbox reaches a turn is below.

The thread stream is the only log — no Durable Object, no watermarks, no activation table. Envelope, client boundary, and event contract live in [streams](./streams.md).

## Activation

```
load recent entries + scope → resolve sandbox (env-bound only) → ctx.sandbox
  per bot (skip author), fold a Decision (selection.ts):
    mention → always · ambient → ambientDelayMs (human triggers) → Haiku gate · always → past cooldown
  applyCaps → depth cap 8
→ runToolLoop → final pi.assistant authored by the bot
→ recurse depth+1 so other bots can react
```

The decision is drawn as a flowchart in [flows](./flows.md#bot-activation-decision); the cooldown, gate, and cap rules live as pure functions in `selection.ts`.

**DM threads** have no dispatch shortcut. `createThread` seeds a thread-scope config (`dispatch.perAgent[agentId].triggerMode = 'always'`) when `parent.kind === 'agent'`, so the one bot in a DM replies without `@mention`. Same selection path as every other thread — overridable via config.

## 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 mints a house-scoped `SPRITES_TOKEN` (the injected `SandboxAccess` dep), passed 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 token never reaches 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.

## 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` on both runtimes (daytona default, [sprite](./sandbox-sprite.md#detached-work-arbe-pi-runner) legacy). 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. 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 = brain · survives tab close
reconcile notifies P:        ◀──terminal──  C reaches completed | failed
  signal.thread.child_finished
  + brain chat (child's result)
  (no dispatch re-fire)
```


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 finds out at the reconcile seam. `reconcileStuckThread` (on a thread read or the prune sweep) posts a `signal.thread.child_finished` plus a `chat` authored as the brain that carries the result. This is a plain notification. It does not re-enter dispatch, so the brain isn't re-invoked. Timing is lazy: it waits for that reconcile, not the instant the child exits. A box Daytona already deleted is caught by reconcile's pull-confirm, before the silence threshold.

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 keeps the same invariant everywhere: `authorId` is always the brain (pi has no identity) — on the parent (the delegation turn and the child-finished notification) and on the child (pi's stream). The thread boundary, not a second agent, marks the work as delegated — so the selection/authorship guarantee can't regress.

## Tool calling

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.

Add a tool by implementing it in the focused module (`bot-tools.ts` for bot/house tools, `thread-tools.ts` for thread-structure tools, `sandbox-tools.ts` for environment hands) and pushing its `AgentTool` into `buildAgentTools(ctx)` in `agent-tools.ts`. The base set is `hello_world`, `send_gif`, `run_command`, and `delegate_task` (the last two act only when `ctx.sandbox` is present — `run_command` synchronous, `delegate_task` detached onto a child thread); `create_agent`, `create_thread`, and `list_threads` are added when the turn has a bot-scoped client; `post_to_thread` additionally needs dispatcher plumbing. `post_to_thread` posts a chat entry into ANOTHER thread in the bot's house and wakes it through the same entry-write hook (`submitThreadEntries`), so a bare `@mention` in the posted text triggers dispatch there — the seam an orchestrator uses to seed an isolated research thread and the seam the agent there uses to post results back. It carries the source turn's depth+1 so a cross-thread wake chain is bounded by `MAX_DEPTH` (no cycle detection — bounded only), and is width-capped per turn. Row-writing tools act as the acting bot — `runBotTurn` mints a bot-scoped Supabase client (`makeActorClient`) so RLS, not a service-role client, bounds what a tool can do; house scope (cross-house create/post is denied) falls out of the `threads` RLS for free. Per-agent tool gating is opt-in via config — see [agents](./agents.md#cross-thread-posting).

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` (assembly), `bot-tools.ts`, `thread-tools.ts` (`create_thread`, `list_threads`, `post_to_thread`), `sandbox-tools.ts` (`run_command` + `delegate_task`), `sandbox-access.ts` (the `SandboxAccess` dep + `resolveSandboxName` / `killRemoteTurn`). The detached launcher + in-sandbox runner live in `packages/sandbox/src/daytona/{launch-coding-agent,runner}.ts`; the sprite handle in `packages/sandbox/src/sprite-handle.ts`.<br>
See [daytona runtime](./sandbox-daytona.md), [sandbox-sprite](./sandbox-sprite.md), [streams](./streams.md), [threads](./threads.md), [system/environments](./environments.md), [system/secrets](./secrets.md).
