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.
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 reactThe decision is drawn as a flowchart in flows; 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 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 closereconcile 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_results, 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.
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.
See daytona runtime, sandbox-sprite, streams, threads, system/environments, system/secrets.