Building agentsChapter 4 of 5
Migrating from an in-sandbox agent
The short answer
Move an agent out of its sandbox in steps, not all at once. Inventory what the sandbox does, put each workspace on storage that outlives it, send the tools to serverless calls, move the loop to a shared host, give long-running processes a persistent sandbox, and roll out behind a per-session flag. Once the files live outside the sandbox, the old path and the new one work on the same files, so you can move sessions over a few at a time and move them back.
Hunter LeathFounder & CEO, Archil
11 min readChecked November 2026Read as Markdown
Teams that move an agent out of its sandbox do it in steps, and moving the files out first makes the rest safe. Start with the agent most teams have. A user opens a session, and your backend starts a sandbox, clones the user’s repository into it, and starts the harness inside. The bash tool is a child process, the file tools read and write the sandbox’s disk, and the conversation history is a file next to the code. When the session ends, or the sandbox’s timer runs out, all of it goes away together.
// backend/session.ts: one sandbox per session, with the agent inside it.
// `Sandbox` stands in for your provider's SDK. E2B, Daytona, Modal, and
// Vercel Sandbox each name these calls differently.
import { Sandbox } from "./sandbox-provider";
export async function startSession(sessionId: string, repoUrl: string) {
const sandbox = await Sandbox.create({ template: "agent", cpus: 2, memoryMiB: 4096 });
await sandbox.run(`git clone ${repoUrl} /workspace`);
await sandbox.run(`node /agent/harness.js --session ${sessionId}`, { background: true });
return sandbox.id;
}// agent/tools.ts: runs inside the sandbox, next to the files.
import { exec } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import { promisify } from "node:util";
const sh = promisify(exec);
export const tools = {
bash: async ({ command }: { command: string }) => {
const r = await sh(command, { cwd: "/workspace" }).catch((err) => err);
return `${r.stdout}${r.stderr}`;
},
read: ({ path }: { path: string }) => readFile(`/workspace/${path}`, "utf8"),
write: ({ path, content }: { path: string; content: string }) =>
writeFile(`/workspace/${path}`, content),
};
// The loop calls the model, runs the tools it asks for, and appends each
// turn to /workspace/.agent/history.jsonl.The migration ends with the loop running in a process on your own host, every tool call running as a serverless call against the session’s own disk, and a persistent sandbox only for the sessions that keep something running. Two places to run an agent argues for that destination. This chapter is the route, in six steps, with Archil in the code. After step 2, the old path and the new one work on the same files.
Today. One sandbox per session holds the loop, the tools, and the files. Session 3 also runs a dev server.
- The loop runs
- Inside each session’s sandbox
- Tool calls run
- The same sandbox
- Files live on
- The sandbox’s own disk
- Running while the agent waits
- A sandbox per session, for the whole session
Today. One sandbox per session holds the loop, the tools, and the files. Session 3 also runs a dev server. The loop runs: Inside each session’s sandbox. Tool calls run: The same sandbox. Files live on: The sandbox’s own disk. Running while the agent waits: A sandbox per session, for the whole session.
An explorable of three user sessions through the migration. Today, each session has its own sandbox holding the agent loop, the bash tool, and the files, and session 3 also runs a dev server; a sandbox runs for every session for its whole length. Step 1, inventory, moves nothing: you log every tool call, timed, and look for processes that outlive a call, state outside the workspace, and secrets and network destinations. Step 2 gives each session a disk of its own, mounted in its sandbox, and the files move onto it. Step 3 sends the bash tool to a microVM per call with the session’s disk mounted, so the sandbox runs only the loop. Step 4 moves all three loops into one shared host process and the sandboxes go, which leaves session 3’s dev server with nowhere to run. Step 5 runs the dev server in a persistent sandbox that mounts session 3’s disk and pauses when idle. Step 6 adds a per-session flag, picked when a session starts, and keeps the old path for rollback.
An inventory of the sandbox§
Before you move anything, find out what the sandbox does that a serverless call can’t. On Archil, a call runs one command in a fresh microVM for up to 5 minutes, and only the disk outlives it.From Serverless execution and the comparison table in Persistent sandboxes. Your production traffic knows which commands break those rules. Wrap each tool so it logs what it ran and for how long, and leave the wrapper on for a while:
// Log every call, then query the log for the four kinds of trouble.
function inventory<A extends { command?: string }>(name: string, run: (args: A) => Promise<string>) {
return async (args: A) => {
const started = Date.now();
try {
return await run(args);
} finally {
log.info("tool_call", {
tool: name,
command: args.command,
ms: Date.now() - started,
background: /&\s*$|\bnohup\b|--watch\b/.test(args.command ?? ""),
});
}
};
}Also record what’s still running when each session ends. Before you stop the sandbox, ps -eo etimes,args --sort=-etimes lists every surviving process and how long it’s been up. Then sort what you find:
- Short commands that only touch files.
ls,cat,sed -i,npm test,git commit. These move to serverless calls unchanged. - Processes that outlive the call. Dev servers, file watchers, databases, and anything started with
&ornohup. These need a machine that stays up, which is step 5. - Commands that outrun the limit. A full build or an end-to-end suite. Split them, or run them in a persistent sandbox.
- State outside the workspace. Variables set with
export, acdthat later commands rely on, an activated virtual environment, packages installed withapt-getornpm install -g, secrets in the environment, and the hosts the tools reach. Each of these needs a new home before the tools move.
Check the harness too. Its history, memory files, and configuration live on the sandbox’s disk today, and they have to move with the workspace.
A session’s tool log of six example commands is sorted into four bins. ls src and npm test are short and only touch files, so they fit one serverless call. npm run dev & outlives the call and goes to a persistent sandbox. npm run e2e runs past 5 minutes in the log, so it’s split or moved to a sandbox. export CI=1 and git push depend on the sandbox’s environment, so they become a file on the disk or a trusted tool in the harness.
The workspace on a disk§
Move each session’s workspace to storage that outlives the sandbox and that per-call compute can mount too, and mount it in the sandbox you already have. Nothing else changes. The agent runs as before, but its files now outlive the sandbox, and your backend can read them without it. With Archil, each workspace is a disk. E2B, Daytona, and Vercel Sandbox all support FUSE, so the disk mounts as an ordinary directory. On Modal, mount it in VM sandboxes; from the default runtime, read and write it with the SDK instead.From our platform guides for E2B, Daytona, Modal, and Vercel Sandbox. Each one builds a template with the archil client installed.
// Before: the workspace lives on the sandbox's own disk.
await sandbox.run(`git clone ${repoUrl} /workspace`);// After: the workspace lives on a disk, mounted where the tool calls will mount it.
import * as archil from "disk";
const { disk, token } = await archil.createDisk({ name: `ws-${sessionId}` });
await db.sessions.update(sessionId, { diskId: disk.id });
await disk.putObject("workspace/", ""); // a subdirectory must exist before it's mounted
const dir = `/mnt/archil/${disk.id}`;
await sandbox.run(`mkdir -p ${dir} && archil mount ${disk.id}:/workspace ${dir} --region ${process.env.ARCHIL_REGION}`, {
user: "root",
env: { ARCHIL_MOUNT_TOKEN: token! },
});
await sandbox.run(`git clone ${repoUrl} ${dir}`);
// When the session ends, unmount before the sandbox stops.
// `archil unmount` returns once every write has reached the disk.
await sandbox.run(`archil unmount ${dir}`, { user: "root" });The token that createDisk() returns can mount this disk and no other. Two choices at this step save trouble later:
- Mount at the path the tool calls will use. A serverless call mounts a disk at
/mnt/archil/<key>, and the bash tool in step 3 uses the disk’s ID as the key and exposes only itsworkspace/directory. Mount that directory at/mnt/archil/<diskId>now, and point the agent’s prompt there. Anything that records absolute paths, such as the scripts in a Python virtual environment, then keeps working when the commands move.Thedisk:/pathform and the rule that the directory must already exist are in thearchilCLI reference. - Decide what a disk belongs to. A disk per session is the simplest choice. Each plan caps the disks in a region, at 1,000 on Developer and 25,000 on Team. A busy product either deletes a session’s disk when it’s done, or gives each user one disk and each session a directory on it, mounted with
subdirectory.From Limits. Thesubdirectoryoption is in Serverless execution.
Point the harness’s history and memory files at the mounted workspace too, so a lost sandbox no longer loses them. Step 4 moves the history out of the workspace, beyond the reach of the model’s commands.
Tools as serverless calls§
Next, change what the tools call. The bash tool sends each command to per-call compute with the session’s workspace mounted, and the read and write tools reach the same files from the harness. The model sees the same tools and the same files. With Archil, bash becomes archil.exec(), and read and write use the disk’s object API:
// agent/tools.ts, after: the same three tools, now bound to a session's disk.
import * as archil from "disk";
import { ArchilApiError, type Disk } from "disk";
export function archilTools(disk: Disk) {
const root = `/mnt/archil/${disk.id}`;
return {
bash: async ({ command }: { command: string }) => {
try {
const r = await archil.exec({
// Conditional mode lets the command delete and rename without a checkout.
disks: { [disk.id]: { disk, subdirectory: "workspace", conditional: true } },
command: `cd ${root} && ${command}`,
});
return `exit ${r.exitCode}\n${r.stdout}${r.stderr}`;
} catch (err) {
if (err instanceof ArchilApiError && err.status === 504) {
return "The command ran for 5 minutes and was stopped. Split it into shorter steps.";
}
throw err;
}
},
// Object keys start at the disk's root, so the workspace's files are under workspace/.
read: async ({ path }: { path: string }) =>
new TextDecoder().decode(await disk.getObject(`workspace/${path}`)),
write: async ({ path, content }: { path: string; content: string }) => {
await disk.putObject(`workspace/${path}`, content);
return `wrote ${path}`;
},
};
}A call commits its writes before it returns, and the disk is read-after-write consistent, so the read tool sees what the last command wrote.From Consistency with other clients and the Disk and ExecMountSpec types in the disk package. Serverless tool calls covers mount options, fan-out, and limits in full. Many agents, one machine weighs how a real harness’s file tools should reach the disk, and how to keep each path inside the workspace.
These tools need your Archil API key, which shouldn’t sit in a sandbox where the model’s commands run. So build and test step 3, and ship it with step 4. To test it, replay the commands from your step 1 log against a test disk through both tool sets, and compare exit codes and output.
The loop on a shared host§
Now the loop leaves the sandbox. The harness becomes a module in your backend, and each message runs the session’s loop in your process, with the tools bound to that session’s disk:
// Before: the backend starts the harness inside the session's sandbox.
await sandbox.run(`node /agent/harness.js --session ${sessionId}`, { background: true });// After: the backend runs the loop itself, for every session.
import * as archil from "disk";
import { runAgent } from "../agent/harness"; // the same loop, with new tools
import { archilTools } from "../agent/tools";
export async function onMessage(sessionId: string, text: string) {
const { diskId } = await db.sessions.get(sessionId);
const disk = await archil.getDisk(diskId);
// Outside workspace/, so no tool call can read or rewrite the history.
const historyKey = `sessions/${sessionId}.jsonl`;
await runAgent({
text,
tools: archilTools(disk),
history: {
load: async () => new TextDecoder().decode(await disk.getObject(historyKey).catch(() => new Uint8Array())),
append: (turn: object) => disk.appendObject(historyKey, `${JSON.stringify(turn)}\n`),
},
});
}The harness is your own code, so one process can run many sessions’ loops. Three things change about running it:
- Run one loop per session at a time. In the sandbox, a session had one process by construction. On a fleet of hosts, take a lease on the session in your database before
runAgent(), so two hosts never append to the same history. - Put the hosts in the disks’ region. Serverless calls run in our AWS regions, and every call is a round trip from the harness.From Region availability. BYOC deployments run serverless execution on other clouds too.
- Keep the API key on the host. The microVM gets the disks and the command, and nothing from the host’s environment.
One tool call before and after the migration. Before, the agent loop, the bash tool, and the files share the session’s sandbox. The loop asks for npm test, a child process runs it and writes test-output.txt to the sandbox’s disk, and the loop reads the file off the same machine. After, the loop runs on a shared host alongside other sessions’ loops. It sends npm test with archil.exec(), and a microVM starts with the session’s Archil disk mounted, runs the command, writes test-output.txt to the disk, and shuts down. The loop then reads the same file from the disk with disk.getObject().
How many loops fit on one host, how to keep tenants apart, and how to apply back-pressure are in Many agents, one machine.
What doesn’t fit§
The inventory’s second and third bins are still waiting: dev servers, watchers, databases, interpreters that hold state in memory, and builds that outrun a call’s limit. A serverless call stops everything it started when its command returns. Give those sessions a long-lived sandbox that mounts the same files. Archil’s persistent sandbox is a Firecracker microVM that stays up between commands, with no per-command timeout.From Persistent sandboxes, including Hosting network services and Setting a time to live. The archil-sandbox services command and the 0.0.0.0 requirement are from our coding agent guide.
// Before: the bash tool starts the server in the session's sandbox.
await tools.bash({ command: "npm run dev &" });// After: a tool in the harness starts the server in a persistent sandbox on the same disk.
const preview = await archil.createSandbox({
name: `preview-${sessionId}`,
baseImage: "node:24",
mounts: [{ disk: diskId, path: `/mnt/archil/${diskId}`, subdirectory: "workspace", conditional: true }],
idleTtlSeconds: 900,
});
// A supervised service restarts if it exits. It must listen on 0.0.0.0.
await preview.exec(
`archil-sandbox services create web --tcp-port 3000 --dir /mnt/archil/${diskId} -- npm run dev`,
);
const { hostname, token } = await preview.createPortToken(3000, { ttl: "1h" });Give the model a tool like start_preview that runs this code, and tell it in the prompt that background processes don’t survive a bash call. The serverless calls keep editing files on the disk, and the server sees each change.
The mount has to be conditional. A plain mount takes the whole disk for that sandbox, and the session’s serverless calls couldn’t write to it. A port token keeps the server private, and requests carry it in the X-Archil-Token header. Traffic to the service doesn’t hold the sandbox up. With no process connection open, it pauses at the idle timeout with its memory and processes intact, and the next request to the port resumes it.On mount modes, see Attaching an Archil disk. The default idle timeout is 30 minutes, and a session pauses after 24 hours; see Limits. A sandbox bills by the vCPU-hour and the GiB-hour while it runs, so only the sessions that need one should get one. For a build that runs too long for a serverless call, preview.exec() takes a timeoutSeconds of your choosing.
Some agents are mostly long-running processes, or edit their own environment. Those still belong in a sandbox, and What it saves says when.What comes after sandboxes names self-editing agents as the exception.
A dev server started with npm run dev & in a serverless call stops with the microVM, so the next call’s curl localhost:3000 is refused. A persistent sandbox then mounts the session’s Archil disk in conditional mode and runs the server as a service with archil-sandbox services create web --tcp-port 3000 -- npm run dev. The preview URL, an exposed port or a port token, answers 200 OK. A later serverless call edits src/page.tsx on the disk, and the server reloads the edit. When the sandbox sits idle, it pauses with the process frozen, and the next request to the preview URL resumes it.
A gradual rollout§
Pick the path when a session starts, and keep it for the session’s life. Both paths create the disk, so a session’s files are in the same place whichever path runs it:
export async function startSession(userId: string, repoUrl: string) {
const path = (await flags.enabled("agent-on-shared-host", userId)) ? "shared-host" : "sandbox";
const session = await db.sessions.create({ userId, path });
await createWorkspaceDisk(session, repoUrl); // step 2, on both paths
if (path === "sandbox") await startSandboxAgent(session); // the old path, unchanged
return session.id;
}Widen the flag in cohorts. Start with your own team’s sessions, then a share of new sessions, and leave until last the users whose inventory showed long-running processes. Compare the two paths on the numbers your agent already depends on: tool calls with a non-zero exit, Read-only file system errors, timeouts, and task success on your evaluations. Every serverless call also returns timing.queueMs, the time it spent before the command started, so you can watch the overhead directly.
Rolling back is a flag change for new sessions. A session in progress stays on the path it started on. To move a user’s disk from one path to the other, unmount it from the old sandbox first. A plain archil mount is exclusive, and other clients can’t write to the disk until it’s released.From Sharing disks, which also covers forcing a checkout when a client crashes without releasing it.
Five pitfalls§
Watch for five problems on the way:
-
The cap on each command. A command that runs past 5 minutes is stopped, and the call returns
504. Your step 1 log finds these commands, and the bash tool in step 3 tells the model what happened. -
Nothing but the disk survives a call.
cd,export, an activated virtual environment, and packages installed outside the disk are gone by the next call. Tell the model in its prompt, and keep the environment on the disk, where the bash tool can load it each time:TypeScript// Put this in front of every command the bash tool sends. const prelude = `cd ${root} && export PATH="$PWD/.tools/bin:$PATH" && { [ -f .agent/env ] && set -a && . ./.agent/env && set +a; true; } && `;Install tools onto the disk too, in the workspace or under
.tools/, rather than into the microVM’s own file system. -
Checkouts in shared mode. In the default shared mode, a command can’t delete or rename without a checkout of the parent directory, and fails with
Read-only file system. The bash tool in step 3 mounts in conditional mode for that reason. If one client checks out a path while a conditional client still has unsent writes there, those writes are lost and that client’s mount becomes read-only. So don’t mixarchil checkoutwith a persistent sandbox’s conditional mount on the same paths.From Sharing disks. Our docs call a checkout a delegation. The modes are compared in Serverless tool calls. -
Latency on every call. Each call starts a microVM before the command runs, which Price and speed measures. The cost a migrated agent notices is the cold cache: a command that walks a large tree reads it fresh each time. Use
disk.grep()for searches across many files. -
Network egress and secrets. The old sandbox may have had a network policy and secrets in its environment. A serverless call takes disks and a command, and nothing else. A secret on the disk is readable by every command the model writes, as it was in the sandbox’s environment, so keep it there only if a tool call needs it. Turn authenticated steps such as
git pushinto trusted tools in the harness, which holds the credential. Test the hosts from your step 1 inventory on the new path before the rollout reaches them. A tool that needs its own egress rules or brokered credentials belongs in a persistent sandbox, whose firewall can deny by default and add credentials outside the sandbox.From Sandbox firewall. Network policies, including deny-by-default rules and credential brokering, are available on the Developer plan and above. On the Free plan, sandboxes use a managed allowlist.
When the last cohort has moved, the inventory from step 1 has one more use. Its call counts and durations are the inputs to the calculator in What it saves, which prices your fleet before and after.