Daytona runtime
delegate_task is arbe’s coding tool. It runs a coding agent in the Daytona box bound to
an environment.
This doc is the daytona implementation. It runs pi in a Daytona sandbox, mirrors pi’s
session into one durable arbe thread, and resumes a run from that thread. The code is in
packages/sandbox/src/daytona/. Each layer has a runnable proof at proofs/prove-*.ts
(bunx varlock run -- bun proofs/<file> from that dir; see proofs/.env.schema).
The shape
The thread is the only durable handle, and the source of truth. A thread is a durable stream of entries. Everything else is a cache you can lose — pi’s session files on disk, the in-process sandbox handle, any real-time watcher — and every cache writes to the thread or reads from it.
The parts:
thread— the durable stream and source of truth. Entry types:chat,pi.*,signal.*.- finished-signal — the
signal.thread.*entry that ends a run. Status is one ofstatus_changed:completed,pi_failed, orpi_session_orphaned. - pi — the coding-agent process.
CodingAgent— the harness descriptor (binary, file extension, how a run ends) that selects pi, codex, or claude-code. Not arbe’sAgent, which is the persona (model plus instructions) composed onto aCodingAgent.arbe-pi-runner— starts pi inside the sandbox, owns pi’s exit code, and posts the finished-signal when pi exits. One per harness; lives insrc/runner.ts.- the mirror — a pi extension that posts
pi.*entries plus a heartbeat every ~5s. One extension, posting directly; no relay process. arbe-proxy— a Supabase Edge Function the sandbox calls arbe through, because it can’t reach the CF worker. Carries all of/api/*. Covered under egress below.
A run, end to end:
launchCodingAgent(pi, sandbox, task, { thread? }) spawns a session (or uses thread), │ provision + fire DETACHED returns the thread id at once ▼ ╭──────────────────── Daytona sandbox ────────────────────╮ │ arbe-pi-runner ─▶ pi ─▶ tool calls │ │ owns exit code ╰─ pi extension (the mirror) │ │ posts terminal on exit │ ╰─────────────────────────────┬───────────────────────────╯ │ pi.* + heartbeat, via scoped JWT ▼ edge fn (arbe-proxy) │ ▼ ╭───────────────────── thread ──────────────────────╮ │ source of truth · entries: chat | pi.* | signal.* │ │ a finished-signal ends a run │ ╰────────────────────────────────────────────────────╯A run is never silently terminal. Whoever is still alive when the run ends posts the finished-signal:
the mirror soft error (pi emits stopReason:"error") inside pi arbe-pi-runner pi crashed, sandbox alive (has exit code) → pi_failed pull-confirm the sandbox itself died (no exit code) → pi_session_orphanedThe heartbeat makes “dead” decidable from “still thinking” using the thread alone, so no watcher is needed.
The thread’s Postgres row is a lazy cache of the stream. Dispatch claims the child
idle → running when it fires the runner (launchOnDaytona posts the status_changed
readiness milestone), and that claim arms the read-path reconcile (reconcileStuckThread,
run on every thread GET): it adopts the stream’s finished-signal onto the row, or — when the
stream has gone silent past the orphan threshold — flips the row to failed with
pi_session_orphaned. Without the claim the row stays idle and reconcile does nothing, so
a run that dies in bootstrap, before pi ever mirrors, would hang dark forever. For a stuck
running thread, reconcile also asks Daytona whether the box still exists, so a dead box is
settled at once instead of waiting out the threshold — see box lifecycle.
When the reconciled thread is a delegated child (a delegate_task run,
parent.kind:'thread'), that same reconcile notifies the parent: it posts a
signal.thread.child_finished plus a brain-authored chat carrying the child’s result onto
the parent thread, with no dispatch re-fire. See dispatch.
Resume rebuilds the same handles from the thread, not from disk. pi’s disk --continue is
a cache that goes stale once the thread is continued on another device. The pi session
format and what reconstruction can and can’t recover are worked out in
resume notes.
same sandbox { resume } pi --continue <disk cache> L5 fresh sandbox { resume, hydrate } rebuild session from thread, L7 upload, pi --session <file> (no disk --continue)pi’s session is one portable JSONL file, but the mirror posts a lossy projection of it:
user turns become chat, assistant turns become pi.assistant, and tree ids, labels, and
model entries are dropped. So a fresh-sandbox resume can’t replay the thread directly. It
rebuilds a loadable linear session with pi.reconstructSession and loads it with
--session (not --continue, which scans a directory filtered by cwd and raced by mtime).
The prompt is canonical on the thread: run() posts it as a chat entry before driving
pi, so a rebuild reads it from the thread.
Library (src/)
thread.ts— the write side.openThread()mints a scoped grant and returns the stream endpoint; create, post, and read delegate to@arbe/core/entries.sandbox.ts— the compute side.createSandbox()returns a fresh Daytona sandbox with.execand.provision(agent).coding-agent.ts— the piCodingAgentdescriptor.decide-pi-outcome.ts—decidePiOutcomemaps pi’s stopReason and exit code to an outcome plus thesignal.thread.*entries to post.run.ts—run(), the synchronous host driver: provision, drive pi, read the thread back; the host posts the terminal entry. Used by the proofs andcli.ts.launch-coding-agent.ts—launchCodingAgent(), the daytona body ofdelegate_task: spawn a session (or use a given thread), firearbe-pi-runnerdetached, return the thread id at once. The CF worker can’t block for minutes, so dispatch needs the detached shape. Its default thread is anopenThread()grant and stream with no DB row; the parented child row withenvironmentIdis corecreateThread’s job.runner.ts—arbe-pi-runner, running in the sandbox: owns pi’s exit code, reads the thread back, runsdecidePiOutcome, and posts the terminal entry on exit.
run and launchCodingAgent are two callers of the same handles. run blocks on exec
and the host posts the terminal; launchCodingAgent returns at once and the in-sandbox
runner posts the terminal. Both feed decidePiOutcome the same two inputs, the thread and
the exit code. A second harness (codex, claude-code) is a second CodingAgent with its own
decideMessageType; the orchestrator does not change.
Egress and env
Daytona egress is whitelist-only and matched by domain. The sandbox can reach npm,
github*, cloudflare.com, and our Supabase host *.supabase.co (plus the other
default-allowlisted services: package
managers, git hosts, container registries, LLM APIs). It cannot reach the CF worker
arbe.0sk.ar, workers.dev, or any other host, and a tunnel doesn’t help because the
block is by domain. At our org tier the restriction cannot be overridden per sandbox;
lifting it means Daytona tier 3 (~400 EUR/mo), which we’ve decided against. So: the open
web is reachable only via a proxy on an allowlisted host, if a workload ever truly needs
it — none does today, since the harness itself only needs the allowlist (arbe-5783).
The signature of a blocked host: HTTPS gets a TLS reset (curl exit 35, 000 status),
plain HTTP gets a proxy 403.
Don’t take any of that on faith — measure it:
bunx varlock run -- bun run scripts/sandbox-egress-probe.ts <daytona-sandbox-id> [--url <extra>]It runs curl inside a real box and prints REACHED/BLOCKED per host. Run it before building anything that calls out from a sandbox, and to re-confirm the claims here rather than trusting a doc that has had time to go stale.
That is why sandbox→arbe traffic runs through a shim. *.supabase.co is reachable, so the
proxy is deployed as a Supabase Edge Function at supabase/functions/arbe-proxy. It
forwards /api/* verbatim — method, path, query, body (binary-safe), and the caller’s own
Authorization/Content-Type — to arbe.0sk.ar, and refuses anything outside that prefix.
One secret: ARBE_APP_URL=https://arbe.0sk.ar. Deploy with
bunx supabase functions deploy arbe-proxy (config pins verify_jwt=false).
The shim is not a trust boundary and must never become one. Every route behind it is already on the public internet under the same route-level auth; the proxy restores reachability from one room and nothing else. It verifies nothing, mints nothing, rewrites nothing — so the route stays the single place trust is decided. A proxy that starts making decisions is a second, weaker auth surface.
Everything a sandbox calls hangs off one base, API_URL in
packages/sandbox/src/daytona/config.ts (override: ARBE_API_URL), and STREAM_URL is
derived from it rather than hard-coding a second host.
Three more sandbox facts: every create explicitly follows Daytona’s managed daytona-small
snapshot and requests language: 'typescript'; pi installs in-sandbox with
npm i -g @earendil-works/pi-coding-agent@0.78.0; gh is not in the image, so a box the
house’s GitHub connection reaches installs a pinned release (gh.ts) before its first command;
and the sandbox runs as a non-root user, so upload artifacts to a HOME-relative path, not
/root.
The mirror’s write path:
mint: mintStreamWriteJwt(houseId, threadId, DURABLE_STREAMS_SECRET) -> { jwt, expiresAt } (2h TTL)write: POST <url>/api/stream/arbe-thread-<threadId> Authorization: Bearer <jwt> body = { id, ts, authorId?, payload } (payload: chat | pi.* | signal.*)read: GET <url>/api/stream/arbe-thread-<threadId>?offset=0 Authorization: Bearer <jwt> (NDJSON)A scoped token can only touch its own thread; a cross-thread write returns 403.
Alongside the plumbing below, a box also gets the house secrets its environment
binds — run_command exports them onto the command it execs, delegate_task into the
environment pi runs under. Bound names only, and never arbe’s own operator keys; the
boundary and its two rules are in secrets. Binding is the opt-in, so a
GITHUB_TOKEN the house holds but no environment binds reaches nothing.
The pi extension reads its config from these environment variables (via
readPiThreadMirrorEnv in packages/sandbox/src/pi-extension/thread-mirror.ts). The
sandbox must export the same canonical names dispatch uses:
| Env var | Required | Meaning |
|---|---|---|
ARBE_THREAD_ID | yes | target thread |
ARBE_STREAM_URL | yes | stream-write base URL — arbe-proxy from the sandbox; the CF worker only off-sandbox |
ARBE_STREAM_TOKEN | yes | scoped stream:write JWT (the jwt from mintStreamWriteJwt) |
ARBE_AUTHOR_ID | no | informational author hint; the stream proxy stamps from the token’s agent claim |
ARBE_PI_MIRROR_NEXT_INDEX | no | resume offset |
Publishing to the house files
A delegated coding agent can write an artifact into the house’s shared file tree. The
capability rides the parent stream token — dispatch mints it with caps: ['volume:write']
and the acting agent id (PARENT_GRANT), so the box supplies bytes and a path and never an
identity:
curl -sS -X PUT "$ARBE_API_URL/api/houses/$ARBE_HOUSE_ID/files/<path>" \ -H "Authorization: Bearer $ARBE_FILES_TOKEN" \ -H "Content-Type: text/markdown" --data-binary @<file>A capability-token write is always the raw file, so Content-Type means the file’s own
media type and is stored
as such — text/plain publishes a .txt, application/json publishes a .json. The JSON
write envelope ({ content, mime?, message? }) is the member/UI door only. text/plain
is an HTML form enctype and was refused by CSRF before the route ran; bearer requests are
now exempt (enforceCsrf in apps/www/src/hooks.server.ts, with SvelteKit’s builtin
csrf.checkOrigin off in svelte.config.js because it runs ahead of every hook).
| Env var | Required | Meaning |
|---|---|---|
ARBE_API_URL | no | arbe API base through the egress shim (API_URL) |
ARBE_HOUSE_ID | no | house the volume write targets |
ARBE_FILES_TOKEN | no | the parent JWT, when it carries volume:write |
All three are set together or not at all, and launchCodingAgent decodes the token’s own
claims to decide (carriesVolumeWrite) rather than trusting a flag — a prompt describing a
curl that would 403 is worse than saying nothing. When they are set, the harness appends the
publish instructions to pi’s system prompt; the persona’s instructionsSource still
describes the persona, not this note.
The route is the only place that decides anything: it verifies the signature, takes house,
author and thread from the claims (never the URL or an env var — anything in a sandbox can
forge ARBE_AUTHOR_ID), and refuses DELETE to a capability token. The stream proxy likewise
stamps every entry from the token’s agent claim and refuses authorless append tokens. Child
tokens name the delegating bot; parent handover tokens name the house system agent. Raw writes preserve the
file bytes and declared media type, including binary artifacts. scripts/volume-publish-proof.ts
drives the single-file PUT door with real curl, including every refusal. The changeset POST door
still needs an equivalent live proof.
Sandbox files stay in the sandbox
Finishing a run publishes nothing. arbe-pi-runner never reads the working tree after pi
exits and never sends a volume request; files the coding agent wrote stay on the box, reachable
through the normal sandbox workflow (arbe sandbox, run_command, a resumed session). Putting a
file in the house is the agent’s own explicit act during the run, through the curl door above.
delegate_task accepts no file list: sandbox work and house-file publication are separate
actions.
Box lifecycle
A sandbox is a machine, with a lifecycle independent of any run. pi is one process inside it; many threads, commands, and runs can share a box, and the box outlives any single run. A pi terminal ends a process, never the machine — box teardown is never keyed on a run’s terminal, and never on a wall-clock timeout against the run.
There is no run timeout. A coding agent runs until it finishes; ARBE_PI_TIMEOUT is a
~3-day runaway guard for a hung process, not a run length.
Reaping is arbe-owned and idle-based. A box stays up while any thread on it
(threads.sandbox_id) is running; once none is, the reconcile/prune sweep
(reconcileStuckThread / pruneStuckThreads) deletes it — the same seam that clears stale
threads. When a run finishes there, reapSandboxIfIdle deletes the box (via an injected
reapBox, so @arbe/core stays provider-free) and tombstones its row. Resume re-resolves
to the environment’s live box, or makes a fresh one, so idle means delete; a stopped box
buys nothing. The discriminator is sandboxes.ephemeral: a replacement box created only to
resume an old child is reapable; a box attached to the parent for later delegated work and an
environment’s shared run_command box are retained.
Death is pull-confirmed, never webhook-driven — one org-wide webhook is a forged-event risk
against other houses’ boxes. confirmSandboxLiveness(sandboxId, probeBox) asks Daytona
about a house-scoped provider_ref through that house’s own runtime: a missing box (404) or
terminal-fault state (error/build_failed/removing) tombstones the sandboxes row to
dead and orphans the threads on it; a live/unknown verdict is a no-op, so a network
blip never tombstones a real box. It rides the reaper’s two seams: the read-path/prune
reconcile for the current thread and GET /api/sandboxes?house_id=<id>&reconcile=1 for a
cold-row sweep; there is no cron. probeBox is injected by the worker, mirroring reapBox.
Daytona’s own auto-stop/delete is the dumb backstop. createSandbox sets autoStopInterval
from the runaway guard plus a buffer (Daytona counts only API calls as activity, and a
detached run makes none, so the clock runs from launch — the value must clear the guard or
it would stop a live run) and autoDeleteInterval: 0. Before a detached run reuses an existing
box, connectSandbox raises that box’s interval to the same guard-plus-buffer value; otherwise
a shared box’s default 15-minute stop could kill a healthy agent. This catches an abandoned
box (worker dead, runner crashed) after ~3 days; it is not the primary reaper.
Spawned boxes are labelled arbe.house / arbe.thread / arbe.environment on create, so
arbe sandbox list shows which environment or run owns a box.
See dispatch, environments, secrets, runtime.