# How to build agents

Source: https://archil.com/guides/building-agents

What we’ve learned from the teams building agents at scale: run the loop outside the sandbox, give each tool call only the compute it needs, keep state where both can see it, and serve thousands of agents from one machine.

## Contents

1. [Two places to run an agent](https://archil.com/guides/building-agents/two-places-to-run-an-agent.md): Run the agent loop outside the sandbox, and send only the tool calls into one. The loop is code your team wrote, so many sessions can share a host, and each tool call needs isolated compute only for as long as it runs. Most loops stay inside because of files, so the design works only when the loop and every tool call see the same storage.
2. [Serverless tool calls](https://archil.com/guides/building-agents/serverless-tool-calls.md): A serverless tool call runs one of the model’s commands on fresh, isolated compute with the agent’s workspace already mounted, and leaves nothing running. It should let the harness decide per call what the command can reach and change, commit its writes before it returns, and bill for the call instead of a machine-hour. On Archil, that call is `disk.exec()`.
3. [Many agents, one machine](https://archil.com/guides/building-agents/many-agents-one-machine.md): Run the harness as ordinary multi-tenant server code. One process holds every open conversation, each command runs on per-call compute that sees only its tenant’s workspace, and each conversation’s history lives with that tenant’s files, so any process can pick it up. This chapter builds it with pi as the harness, and Archil’s serverless execution, S3 API, and disks underneath.
4. [Migrating from an in-sandbox agent](https://archil.com/guides/building-agents/migrating-from-an-in-sandbox-agent.md): 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.
5. [What it saves](https://archil.com/guides/building-agents/what-it-saves.md): Measure what moving the harness out saves with your own numbers, because it depends on how much of each hour your tools spend running. A sandbox holds its vCPUs for the whole session, and a serverless tool call holds compute only while its command runs. This chapter’s calculator prices both, with Archil’s per-call price. Agents that edit their own environment, long-running services, heavy continuous compute, and calls over Archil’s 5-minute limit still belong in a sandbox.

## 1. Two places to run an agent

Source: https://archil.com/guides/building-agents/two-places-to-run-an-agent · Part of How to build agents · Facts checked 2026-11-05

**The short answer:** Run the agent loop outside the sandbox, and send only the tool calls into one. The loop is code your team wrote, so many sessions can share a host, and each tool call needs isolated compute only for as long as it runs. Most loops stay inside because of files, so the design works only when the loop and every tool call see the same storage.

Teams that build agents well run the agent loop outside the sandbox and send only the tool calls into one. The reason starts with what an agent is. Every agent is a loop. The harness sends the conversation and a list of tools to a model. The model answers with a tool call: run `npm test`, read `src/app.ts`, change a line. The harness runs the call, adds the output to the conversation, and asks again. It stops when the model answers without a tool call.

That gives an agent three parts: an inference provider, a harness, and a set of tools. The model is someone else’s API. The other two run on your infrastructure, and they’re very different kinds of code.

**The agent loop**

The agent loop has three parts. The harness sends the conversation and its list of tools to the model, which is an inference provider. The model answers with a tool call, `bash: npm test`. The harness runs it in the tools and gets back the exit code and output, then sends those to the model, which writes its next step. The loop repeats until the model answers without a tool call. The harness is your own reviewed code, and many loops can share one process. The tools run the model’s commands, like `eval`, and every call needs isolation.

The harness is code your team wrote and reviewed, like the rest of your product. It can’t decide to misbehave, so it’s safe to multi-tenant: one process can run many users’ loops, the way one web server handles many users’ requests. The tools are the opposite. A tool call runs whatever the model wrote, which makes it closer to JavaScript’s `eval` than to an API call. Nobody reviewed it, and each call needs isolation from the host and from every other tenant. (This split, and most of this chapter’s argument, comes from Hunter Leath’s [What comes after sandboxes](https://x.com/jhleath/status/2110407137616928811), published October 14, 2026, after a year of working “closely with nearly all of the frontier companies building in the AI application layer.”)

So the tools have to run in a sandbox. The open question is where the harness runs.

### Two places to put the loop

Most teams put the harness in the sandbox too. A user starts a session, you start a sandbox, and the harness and its tools run there together until the session ends. The practice this guide recommends runs the harness on a host that many sessions share, and sends each tool call somewhere isolated: a serverless tool call, which starts compute such as a microVM for one command and stops it when the command returns.

**One session, two designs**

One agent session under two designs, side by side, as an illustration rather than a measurement. The session has four short tool calls (`git status`, `npm test`, a `sed` edit, and `npm test` again) between model turns, and two waits on the person. In the sandbox, the harness and its tools share one sandbox per session. The sandbox is held from the first message to the last and is busy only during the four tool calls. Outside the sandbox, the harness is one of many loops on a shared host. Each tool call runs in its own microVM, which exists only while the call runs, so the CPU held for this session is the four tool calls plus a slice of the shared host. Between calls, no microVM is running.

Both designs do the same work. They differ in what they hold while the agent waits. Inside, every session holds a whole machine, sized for its heaviest step. Outside, the harness is an ordinary service that you deploy, scale, and monitor like the rest of your backend.

### Why the loop ends up in the sandbox

Teams have two good reasons to keep the loop inside. The first is operational. A sandbox per session is one piece of compute to manage. Splitting the agent means two services to run, scale, and debug instead of one.

The second reason is subtler, and it usually decides the design. The harness has file tools too. Suppose the agent’s bash tool runs `echo hello > hello.txt` in the sandbox, and the model then asks the harness’s read tool for `hello.txt`. If the harness runs somewhere else, it has its own files, and `hello.txt` isn’t among them. Every file a tool writes has to reach the harness, and every file the harness writes has to reach the next tool call. An `npm install` writes thousands of files that the harness’s search tool should see. An edit has to land before the test run that checks it.

Teams that move the loop out end up writing that synchronization themselves. They copy files back after each call, push edits before the next one, and decide what happens when both sides changed the same file. With the harness in the sandbox, the problem disappears, because there’s only one copy of the files.

**Where the files are**

The same four steps under three designs. Step 1, a tool runs `echo hello > hello.txt`. Step 2, the harness’s read tool reads `hello.txt`. Step 3, the harness’s edit tool writes `notes.md`. Step 4, the next tool call runs `cat notes.md`. With the loop in the sandbox, all four steps work, because both sides use the sandbox’s disk, and one sandbox stays up for the whole session. With the loop outside and the files in a sandbox, steps 1 and 3 work, but steps 2 and 4 fail with no such file: the harness host and the sandbox each have their own files, a sandbox still stays up for the session, and you write the code that copies files each way. With the loop outside and one shared disk, all four steps work: the tool’s write is committed to the disk before the call returns, the harness reads and writes the same disk, and the next microVM sees the same file. Between calls, nothing runs for the agent except its loop on the shared host.

### What the sandbox does all session

A sandbox that holds the loop has to stay up as long as the loop does. Most of that time, the loop is waiting on the model or on the person reading its output.

We measured this on our own engineering team’s coding agents. Across a month of agent loops and about 37,000 tool calls, the median tool call finished in 0.5 s. Suppose each loop had started a sandbox at its first tool call and stopped it after 15 minutes without one. That sandbox would have sat idle for 65% of the time it ran. (The method is in [Do AI agents need a sandbox running for the whole session?](https://archil.com/article/do-ai-agents-need-a-sandbox-running-for-the-whole-session) We read only when each model turn and tool call started and ended, not what they contained. [The work outlives the machine](https://archil.com/guides/storage-for-agents/the-work-outlives-the-machine) follows the same data through a day.) A shorter timer idles less, but it stops the sandbox between bursts of work, and the next call waits for a new one.

Idle time is only part of the cost. You also size the sandbox for the heaviest step the agent might take, such as a build. It holds that size through every wait. The same article estimates that, “depending on the kind of agent session and the kind of work being done,” agents use “100x to 1000x more CPU resources than they actually need.” (From [What comes after sandboxes](https://x.com/jhleath/status/2110407137616928811). This is an estimate, not a measurement. [What it saves](https://archil.com/guides/building-agents/what-it-saves) does the arithmetic for your own fleet.)

Moving the loop out doesn’t fix this by itself. Suppose the harness runs outside but sends its tools to a sandbox per session. That sandbox can start at the first tool call instead of the first message. From then on, it’s held at the same size for the rest of the session.

**Drag the auto-stop timer**

An explorable chart of 31,422 pauses between tool calls, from 363 of our agent loops, on log scales from 0.1 s to about 3 weeks. Most pauses last 1 to 10 s, while the model writes its next step. A long, thin tail runs through minutes and hours to pauses of days. Pauses of 10 minutes or more are 1.5% of all pauses but hold 98% of the waiting time. Dragging the auto-stop timer splits the pauses: shorter ones keep the sandbox up and idle, and longer ones stop it. Over all 384 loops, a 1-minute timer leaves the sandbox idle 28% of the time it runs (54% in the median loop), for 243 hours of sandbox time against 174 hours of tool work, and 1,303 pauses outlast it, about 4.6 starts per loop. A 5-minute timer: idle 48% (75% in the median loop), 337 hours, 669 pauses, 2.8 starts per loop. A 15-minute timer: idle 65% (88%), 493 hours, 384 pauses, 2.1 starts per loop. A 1-hour timer: idle 83% (96%), 1,019 hours, 226 pauses, 1.6 starts per loop. Pause and start counts come from the 363 loops.

### Storage that both sides see

The sync problem exists because the harness and the tools see different files. Put the files on storage that both can reach, and it goes away. That storage has to make a tool call’s writes visible to the harness and the next call by the time the call returns.

Other platforms pair short-lived compute with shared files too. A Lambda function can mount Amazon EFS or S3 Files to a local directory. A Modal Volume attaches to both Modal Functions and Modal Sandboxes, and a running container sees another’s changes only after they’re committed and it reloads the volume. (From AWS’s [file system access for Lambda](https://docs.aws.amazon.com/lambda/latest/dg/configuration-filesystem.html) and Modal’s [Volumes guide](https://modal.com/docs/guide/volumes). [The obvious ways to build it](https://archil.com/guides/storage-for-agents/the-obvious-ways-to-build-it) tests shared designs such as NFS and JuiceFS on an agent’s workload.)

Here’s how it looks with Archil. An Archil disk is a file system that the harness and every tool call can reach at once. A serverless tool call, `disk.exec()`, runs one command in a microVM with the disk mounted as its working directory. The call commits the command’s writes to the disk before it returns. The harness reads and writes the same disk from wherever it runs, and the disk’s object API returns every committed write. (From [Serverless execution](https://docs.archil.com/compute/serverless-execution) and the [S3-compatible API](https://docs.archil.com/protocols/s3-api), which is strongly read-after-write consistent with committed writes. Which interface the harness’s own file tools should use is a choice we make carefully in [Many agents, one machine](https://archil.com/guides/building-agents/many-agents-one-machine).) With the tool and the harness on different machines, the `hello.txt` example becomes:

```typescript
import * as archil from "disk";

const disk = await archil.getDisk(process.env.ARCHIL_DISK_ID!);

// The bash tool: one command, in a microVM, with the disk as its working directory.
await disk.exec("echo hello > hello.txt");

// The harness's read tool, on the harness host, reads the same file.
const text = new TextDecoder().decode(await disk.getObject("hello.txt"));
```

There’s no second copy to keep in sync, so the loop is free to leave the sandbox. Once the files don’t live on a machine, no machine has to stay up to hold them. The harness holds only its share of a host that runs many loops.

**One host, one disk per tenant**

One harness host runs the loops for tenants A, B, and C, and each tenant has its own disk. Tenant A’s loop sends `echo hello > hello.txt` as a tool call. A microVM starts with disk A mounted, writes `hello.txt` to the disk, and stops. The harness then reads `hello.txt` from the same disk, with no copy in between. Tenants B and C run `npm test` and `python report.py` at the same time, each in its own microVM on its own disk, writing `coverage/` and `report.csv`. Between calls, no microVM is running, and each tenant’s files stay on its disk.

This design doesn’t fit every agent. An agent built to edit itself, changing its own harness or environment, still belongs in a sandbox, the exception that [What comes after sandboxes](https://x.com/jhleath/status/2110407137616928811) makes. So does a process that has to outlive a single command, such as a dev server or a file watcher. For that step, a long-lived sandbox can mount the same storage, as an Archil [persistent sandbox](https://docs.archil.com/compute/sandboxes/introduction) does, so the rest of the agent stays outside.

The rest of this guide builds the outside design, starting with the call that replaces the sandbox. [Serverless tool calls](https://archil.com/guides/building-agents/serverless-tool-calls) covers what that call has to provide, and how Archil’s provides it.

## 2. Serverless tool calls

Source: https://archil.com/guides/building-agents/serverless-tool-calls · Part of How to build agents · Facts checked 2026-11-05

**The short answer:** A serverless tool call runs one of the model’s commands on fresh, isolated compute with the agent’s workspace already mounted, and leaves nothing running. It should let the harness decide per call what the command can reach and change, commit its writes before it returns, and bill for the call instead of a machine-hour. On Archil, that call is `disk.exec()`.

A serverless tool call runs one of the model’s commands on compute that exists only for that command, not on a sandbox that’s up all session. [What comes after sandboxes](https://x.com/jhleath/status/2110407137616928811) describes the alternative: “the harness itself doesn’t actually care how or where the tools are run. It just needs to fire off a function call to something that then runs deterministic code and returns a result.” (From Hunter Leath’s post of October 14, 2026. Why the harness belongs outside the sandbox is the subject of [Two places to run an agent](https://archil.com/guides/building-agents/two-places-to-run-an-agent).)

Whatever runs that call has to provide six things:

- Isolation for each call, with nothing left running after it
- The workspace, mounted before the command starts
- Access to only what the call needs
- Writes that the loop and the next call can see
- Limits you can design around
- A price and an overhead that follow the work

Each section takes one and shows how Archil’s serverless execution provides it, starting from one function against the agent’s disk:

```typescript
import * as archil from "disk";

const disk = await archil.getDisk("dsk-0123456789abcdef");
const { exitCode, stdout, stderr, timing } = await disk.exec("npm test");
```

### Isolation for each call

Every call runs code nobody reviewed, so it gets compute of its own that’s gone when the command returns. On Archil, `disk.exec()` sends the command to Archil, not to a machine you manage. Archil starts a Firecracker microVM, with its own kernel, next to the disk’s data, mounts the disk, and runs the command with the disk as its working directory. When the command exits, the microVM unmounts the disk, which commits the command’s writes and hands back anything it checked out. Then the result comes back to your harness, and the microVM is gone. (From [Serverless execution](https://docs.archil.com/compute/serverless-execution) and the [TypeScript SDK](https://docs.archil.com/sdks/typescript#executing-commands). That the unmount commits and checks in before the result returns is from Archil’s container runtime.)

**One tool call, start to finish**

One serverless tool call. The harness calls `disk.exec("npm test")`. A fresh Firecracker microVM starts for the call, the workspace disk is mounted as its working directory, and `npm test` runs. When it exits with code 0, the disk is unmounted, which commits the writes and checks in anything the call checked out. The result, carrying stdout, the exit code, and timing, travels back to the harness, and the microVM is gone. The disk keeps every file. Under the drawing, the result’s timing fields: `queueMs` covers queueing, scheduling, booting the microVM, and mounting the disk; `executeMs` covers the command; and `totalMs` is the whole request, measured on the server.

Because the compute is new for every call, the workspace is the only thing that carries over. On Archil, files written to the disk persist, including installed packages and a virtual environment kept inside the workspace. Running processes, memory, environment variables, and files written anywhere but the disk don’t. Two habits follow. Install dependencies into the workspace once and reuse them, and chain related steps in one command, such as `cd app && npm run build && npm test`, instead of relying on an earlier call’s `cd` or `export`. (From [How to run agent tool calls without keeping a VM open](https://archil.com/article/how-to-run-agent-tool-calls-without-keeping-a-vm-open).)

### The workspace, mounted

The command has to find the repository, its packages, and the agent’s earlier changes at a path the model can rely on. With Archil, a harness that serves many tenants holds one client and mounts each tenant’s disk under its own ID, the form [Many agents, one machine](https://archil.com/guides/building-agents/many-agents-one-machine) wires into pi:

```typescript
import { Archil, ArchilApiError } from "disk";

const archil = new Archil({ apiKey: process.env.ARCHIL_API_KEY, region: "aws-us-east-1" });

// The bash tool. Each call gets a fresh microVM with the tenant's workspace at /mnt/archil/<diskId>.
export async function bash(diskId: string, command: string) {
  try {
    const { exitCode, stdout, stderr } = await archil.exec({
      disks: { [diskId]: { disk: diskId, subdirectory: "workspace", conditional: true } },
      command: `cd /mnt/archil/${diskId} && ${command}`,
    });
    return { exitCode, stdout, stderr };
  } catch (e) {
    if (e instanceof ArchilApiError && e.status === 504) {
      // The command ran past the per-call limit and was stopped.
      return { exitCode: 124, stdout: "", stderr: "Timed out. Split the work into smaller commands." };
    }
    throw e;
  }
}
```

Mounting only `workspace/` keeps the rest of the tenant’s disk, such as the conversation history, out of the command’s reach. `conditional: true` lets the command write without a checkout, for reasons later in this chapter. Tell the model the rules in its instructions: each command starts fresh in the workspace, and anything worth keeping belongs on the disk. The SDK retries an exec only when it couldn’t connect at all, so a retry never runs a command twice. (From the `disk` package’s retry policy and its [changelog](https://www.npmjs.com/package/disk) for version 1.6.0: creations, execs, and deletes retry only when no connection could be established.)

### Access to only what the call needs

The harness, not the model, should decide what each call can reach and what it can change. On Archil, `disk.exec()` mounts one disk at its root. `archil.exec()` takes a map instead, and mounts each disk at `/mnt/archil/<key>`. A common shape is an agent that reads a customer’s documents and writes to its own session workspace:

```typescript
const result = await archil.exec({
  disks: {
    workspace: { disk: sessionDiskId, conditional: true },
    docs: { disk: customerDocsDiskId, readOnly: true },
    team: { disk: teamDiskId, subdirectory: "users/ana", readOnly: true },
  },
  command: "grep -ril refund /mnt/archil/docs /mnt/archil/team > /mnt/archil/workspace/refunds.txt",
});
```

Each entry is either a disk ID, which mounts the disk’s root, or an object with these options:

- **`readOnly: true`** mounts the disk read-only. Every write from inside the microVM fails with `EROFS`, so a command can’t damage files that other threads or tenants read.
- **`subdirectory`** exposes only part of the disk. The path is relative, with no `.` or `..` segments, and the rest of the disk isn’t in the microVM at all.
- **`conditional: true`** mounts in conditional mode, which the next section explains.
- **`checkoutPaths`** and **`queueMs`** check paths out before the command starts, also explained in the next section. (The options are the `ExecMountSpec` type in the `disk` package and the `ExecMount` schema in our API. [Serverless execution](https://docs.archil.com/compute/serverless-execution#specifying-disk-options) documents `readOnly` and `subdirectory`; [Sharing disks](https://docs.archil.com/concepts/sharing-disks#selecting-a-mode-on-serverless-execution) documents `conditional`.)

A disk that isn’t in the map doesn’t exist inside the microVM. The request carries only the command and its mounts, so your API key stays in the harness.

**Compose a call**

An explorable with four disks: a session workspace (this thread’s repository and notes), customer documents (the tenant’s files, shared by every thread), a team drive with one folder per user, and app logs from every service. Each can be off, read-only, shared, or conditional, and the team drive and the logs can expose only `users/ana` or `app/logs`. It starts with the workspace conditional and the documents read-only. The figure lists what the command sees under `/mnt/archil/`. A read-only mount can change nothing, and every write fails with `EROFS`. A shared mount can create files and directories and edit files it opens, but deleting or renaming needs `archil checkout` on the parent. A conditional mount can change anything nobody else has checked out, and the last write to reach the disk wins. A subdirectory mount hides the rest of the disk. Disks that are off are out of reach. The figure also writes the matching `archil.exec` call, and says that a call needs at least one disk.

### Writes the loop can see

When a call returns, the harness and the next call have to see everything it wrote. When several writers share a workspace, you need to know whose write wins. Archil’s serverless execution has the same read-after-write consistency as any other Archil client. A file one command writes is visible to the next command that reads it, and a call’s writes are committed before its result returns. If committing fails, the exit code is non-zero and stderr says `archil: command output may not have been durably committed`, so a harness that checks `exitCode` already catches it. (From [Consistency with other clients](https://docs.archil.com/compute/serverless-execution#consistency-with-other-clients) and [Limits](https://docs.archil.com/compute/serverless-execution#limits) in our docs.)

How a call may write depends on the mount mode. By default, serverless execution mounts each disk in shared mode, which lets many clients mount one disk at once. In shared mode, the disk reads as read-only except where this client holds a checkout. Creating a file or a directory, or opening a file to write, checks it out automatically. Deleting or renaming is a change to the parent directory, so it needs a checkout of the parent, and without one it fails with `Read-only file system`. That surprises agents, which create a file in one call and can’t remove it in the next. (From [Automatic delegations in shared mode](https://docs.archil.com/concepts/sharing-disks#automatic-delegations-in-shared-mode). Our docs call a checkout a delegation.)

You have three ways to give a call the writes it needs:

- **Check out inside the command.** Start the command with `archil checkout .` to hold the whole disk for the call, as exclusive mode would. Writes then run at local-disk speed.
- **Check out before the command starts.** Pass `checkoutPaths: ["src"]` in the mount, and add `queueMs` to wait that many milliseconds for each checkout if another client holds it. `queueMs` alone checks out the mount’s root.
- **Mount in conditional mode.** With `conditional: true`, the call writes anywhere nobody else has checked out, with no checkout at all. Changes run on the server, at the speed of a remote file system, and if two clients write the same part of a file, the last write to reach the disk wins.

Checkouts last only as long as the call. We recommend conditional mode for an agent’s general-purpose bash tool. For one call that writes heavily to paths no other call touches, such as a dependency install, a checkout trades sharing for local speed. [Can many machines use it at once?](https://archil.com/guides/evaluating-storage/can-many-machines-use-it-at-once) compares the modes in depth.

**Three commands, three mounts**

The same three commands run in three calls against one disk: `echo ok > new.txt`, `echo '- ship' >> plan.md`, and `rm old.log`. In shared mode, the default, the create and the append are committed, and `rm old.log` fails with `Read-only file system`, because a delete needs a checkout of the parent directory. In shared mode with `archil checkout .` at the start of the command, all three are committed. With `conditional: true`, all three are committed without a checkout.

### Many calls at once

Each call runs in its own microVM, so `Promise.all` over a list of calls is a fan-out with no pool to manage. A harness can lint, type-check, and run test shards at once against the agent’s latest changes:

```typescript
const shards = [1, 2, 3, 4];

// The bash tool's mount, so every call writes without a checkout.
// Each call writes its own output file, so no two calls write the same path.
const runs = await Promise.all(
  shards.map((n) =>
    archil.exec({
      disks: { [diskId]: { disk: diskId, subdirectory: "workspace", conditional: true } },
      command: `cd /mnt/archil/${diskId} && mkdir -p results && npx vitest run --shard=${n}/4 > results/${n}.txt 2>&1`,
    }),
  ),
);
const failed = runs.filter((r) => r.exitCode !== 0).length;
```

The next call reads `results/`, and your harness reads `workspace/results/` from the same disk. Branches that only read should mount `readOnly: true`, so no branch can damage the files the others are reading. Branches that must not see each other’s writes at all need separate copies, which is what [checkpoints and branches](https://archil.com/guides/evaluating-storage/can-you-fork-it) are for. (From [How to fan out agent tool calls in parallel against one workspace](https://archil.com/article/how-to-fan-out-agent-tool-calls-in-parallel-against-one-workspace).)

**A fan-out against one workspace**

The harness starts its tool calls at once with `Promise.all`: lint, a type check, and four test shards. Each call gets its own microVM with the same workspace disk mounted, runs its command, writes its output to its own file under `results/`, and exits as soon as its command finishes. The microVMs finish at different times. All the results return to the harness, and the output files stay on the disk for the next call. Nothing is left running.

Parallel calls cost little extra time. In our measurement, calls in flight together took no longer than calls made one at a time, until the largest burst, which added about 160 ms at the median. Each account can run hundreds of commands in parallel, and we raise that to thousands on request. (Measured from a laptop to AWS `us-west-2`; see [How to fan out agent tool calls in parallel](https://archil.com/article/how-to-fan-out-agent-tool-calls-in-parallel-against-one-workspace). The parallelism limit is in [Serverless execution](https://docs.archil.com/compute/serverless-execution#limits).)

To search many files, don’t write the fan-out yourself. `disk.grep()` splits the listing and the matching across many microVMs and returns structured matches, and the whole search counts as one call however many workers it uses. (From [Search files](https://docs.archil.com/compute/search-files) and the [TypeScript SDK](https://docs.archil.com/sdks/typescript#searching-files).)

### Limits

Per-call compute trades a machine’s open-endedness for caps on each call, and the caps decide which steps still need a sandbox. Archil’s caps are in the following table, with what to do at each one.

| Limit                  | Value                                                       | What to do                                                                                                                                                                                                             |
| ---------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Command duration       | 5 minutes, then `504` with `Exec timed out`                 | Split long work, or run it in a [persistent sandbox](https://docs.archil.com/compute/sandboxes/introduction). Writes flushed with `fsync` before the timeout are durable; the rest are flushed on a best-effort basis. |
| Compute per command    | 2 vCPU and 4 GiB of memory, with no per-call sizing         | Move heavier steps to a persistent sandbox with the same disk mounted.                                                                                                                                                 |
| Options per call       | No timeout, abort signal, or environment variables          | Put variables in the command. A call the user cancels runs until it finishes or is stopped at the duration cap.                                                                                                        |
| Command length         | 100 KiB                                                     | Write long scripts to the disk and run the file.                                                                                                                                                                       |
| Output in the response | The trailing 128 KiB of stdout and of stderr                | Redirect large output to a file, and read the file back.                                                                                                                                                               |
| State between calls    | Only the disk                                               | Keep dev servers, watchers, and loaded interpreters in a persistent sandbox.                                                                                                                                           |
| Regions                | `aws-us-east-1`, `aws-us-west-2`, `aws-eu-west-1`, and BYOC | Create disks that run commands in an AWS region. A disk in `gcp-us-central1` stores data, but `exec` returns `Exec is not enabled`.                                                                                    |

Every microVM also starts with an empty client cache, so a command that walks thousands of files reads them fresh in every call. In our test, a second `rg` over the repository in the same microVM took about 0.12 s, under a third of its time in a fresh one. That gap is the reason to use `disk.grep()` for wide searches. (The limits are from [Limits](https://docs.archil.com/reference/limits#serverless-execution), [Serverless execution](https://docs.archil.com/compute/serverless-execution#limits), and [Region availability](https://docs.archil.com/reference/regions#compute-availability). BYOC deployments run serverless execution on other clouds and on bare metal; see [BYOC](https://docs.archil.com/reference/byoc). The cache measurement is from [How fast is a serverless tool call for an AI agent?](https://archil.com/article/how-fast-is-a-serverless-tool-call-for-an-ai-agent)) The steps that don’t fit, and how to move an agent that depends on them, are in [Migrating from an in-sandbox agent](https://archil.com/guides/building-agents/migrating-from-an-in-sandbox-agent).

### Price and speed

Per-call compute pays off only when the price follows the calls and the overhead is small next to a model turn. On Archil, each `disk.exec()` or `archil.exec()` invocation is one serverless tool call, even when it chains several commands, at $0.05 per 1,000 calls on the Developer and Team plans. The call’s CPU and memory are included, and the Free plan includes 1,000 calls a month. For scale, a persistent sandbox with the same CPU and memory as a call costs $0.19 an hour at our sandbox rates, or about $0.00005 a second. One call costs about what one second of that sandbox does, however long the command runs within its limit. (From [Pricing & Billing](https://docs.archil.com/administration/billing#usage-rates): $0.055 per vCPU-hour and $0.020 per GiB-hour for sandboxes. Storage and data transfer are metered separately, and model inference isn’t included.) What that means across a fleet of agents, idle time included, is the subject of [What it saves](https://archil.com/guides/building-agents/what-it-saves).

Speed is the other half. We timed calls against one disk holding the Express repository, from a laptop about 40 ms from the region, with 50 calls per row.

**How long a tool call takes**

Median end-to-end latency of serverless tool calls, measured from a laptop on the public internet to one disk in AWS `us-west-2`, 50 calls per row. The first part of each bar is the median `queueMs`. Quick commands, on a 0 to 300 ms scale: `true`, one at a time, 82 ms, with 37 ms of `queueMs`; `cat package.json` 92 ms (36); `ls -la lib` 84 ms (36); `true` ten at a time 81 ms (42); `true` fifty at once 245 ms (138). Real work, on a 0 to 5 s scale: `rg -n params .` over the repository 422 ms (35); `npm test`, with 1,288 tests, 4.7 s (36); `npm test` ten at a time 4.7 s (36); `npm test` fifty at once 4.8 s.

Read each bar as overhead plus work. Starting a microVM with the disk mounted took 35 to 40 ms when calls arrived at a normal pace, and a harness in the same region skips the laptop’s round trip. Our engineering team’s coding agents spent a median of 2.7 s on each model turn, so tens of milliseconds per tool call barely move how long a task takes.

Two cases are slower. The first call against a newly created disk took 331 ms at the median across five new disks. A call after the disk sat untouched for 11 to 20 minutes took between 0.25 s and 1.1 s, with `queueMs` of 74 to 219 ms, and most of the rest was the client opening a new connection. (All timings are from [How fast is a serverless tool call for an AI agent?](https://archil.com/article/how-fast-is-a-serverless-tool-call-for-an-ai-agent), which includes a script to reproduce them. The model-turn median is from 30 days of our team’s agent transcripts, described in [Do AI agents need a sandbox running for the whole session?](https://archil.com/article/do-ai-agents-need-a-sandbox-running-for-the-whole-session))

Your numbers depend on your region and your harness’s distance from it. Every result carries `timing.queueMs` and `timing.executeMs`, so log both with each tool call and measure the overhead and the work where your agents run. [Many agents, one machine](https://archil.com/guides/building-agents/many-agents-one-machine) puts this call behind a harness that serves every tenant from one process.

## 3. Many agents, one machine

Source: https://archil.com/guides/building-agents/many-agents-one-machine · Part of How to build agents · Facts checked 2026-11-05

**The short answer:** Run the harness as ordinary multi-tenant server code. One process holds every open conversation, each command runs on per-call compute that sees only its tenant’s workspace, and each conversation’s history lives with that tenant’s files, so any process can pick it up. This chapter builds it with pi as the harness, and Archil’s serverless execution, S3 API, and disks underneath.

Serve many agents by running the harness as ordinary multi-tenant server code, the design that [Two places to run an agent](https://archil.com/guides/building-agents/two-places-to-run-an-agent) argues for. Follow one message through it. A user types “the build is red, fix it,” and it reaches a harness process that already holds hundreds of other conversations. The process finds this conversation’s pi session, or loads it from the user’s disk, and calls `prompt()`. The model answers with `bash: npm test`, which runs in a microVM with only this user’s workspace mounted, and the output comes back to the same process. The process never ran the test, and it never held a machine for this user.

This chapter builds that process with pi as the harness and Archil for the tool calls and storage. The part that needs the most care is how each of pi’s tools reaches the right tenant’s files. After that come history, isolation, load, and where to run the host.

### One process, many sessions

[pi](https://github.com/badlogic/pi-mono) is an open-source coding agent whose SDK, `@earendil-works/pi-coding-agent`, runs the whole agent inside your own Node process. `createAgentSession()` returns an `AgentSession` that owns one conversation: its model, its tools, its queued messages, and its history. Nothing ties a session to the process’s working directory or to a machine, so one process can hold a session for every open conversation. (From pi’s [SDK guide](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/sdk.md) and the `createAgentSession` options in [`sdk.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/sdk.ts).)

Here’s a session, opened for one conversation of one tenant:

```typescript
import * as archil from "disk";
import { getModel } from "@earendil-works/pi-ai/compat";
import { createAgentSession, ModelRuntime, SettingsManager } from "@earendil-works/pi-coding-agent";

// One model and one provider key for the whole process.
const modelRuntime = await ModelRuntime.create({ authPath: "/srv/harness/auth.json", modelsPath: "/srv/harness/models.json" });
await modelRuntime.setRuntimeApiKey("anthropic", process.env.ANTHROPIC_API_KEY!);
const model = getModel("anthropic", "claude-sonnet-4-5")!;

export async function openSession(diskId: string, threadId: string) {
  const disk = await archil.getDisk(diskId);
  // Each tenant's files appear at a path of their own; the next section explains why.
  const cwd = `/mnt/archil/${diskId}`;
  const history = await loadHistory(disk, threadId, cwd);

  const { session } = await createAgentSession({
    cwd,
    model,
    modelRuntime,
    settingsManager: SettingsManager.inMemory(),
    sessionManager: history.manager,
    tools: ["read", "bash", "edit", "write"],
    customTools: diskTools(disk, cwd),
  });
  saveHistory(session, history, disk, threadId);
  return session;
}
```

The `tools` list keeps pi’s four default tools and nothing else. `customTools` replaces each of them with a version that works on the tenant’s disk, because a custom tool with a built-in’s name takes its place. An in-memory `SettingsManager` keeps one tenant’s settings out of the files every session would otherwise share. A message that arrives while the session is mid-run goes through `session.steer()` or `session.followUp()`, which pi requires instead of a second `prompt()`.

**One process, every conversation**

A harness host runs one Node process with a pi session for each open conversation: tenants a, b, c, and d. Tenant a’s session runs `npm test` and tenant c’s runs `rg TODO src`; each starts a microVM that mounts only that tenant’s disk, returns its output to the session, and is gone. Tenant b’s session reads `src/app.ts`, tenant d’s edits `README.md`, and tenant a’s appends to its history; each of those goes to the tenant’s disk through Archil’s S3 API, with no microVM. Each disk holds a `workspace/` directory and a `sessions/` directory. Between calls, no microVM runs.

### Bash on serverless execution

pi’s bash tool doesn’t spawn processes itself. It calls a `BashOperations` object with `exec(command, cwd, options)`, and the SDK lets you pass your own. Ours sends each command to Archil’s serverless execution:

```typescript
import * as archil from "disk";
import type { BashOperations } from "@earendil-works/pi-coding-agent";

function bashOps(diskId: string): BashOperations {
  return {
    async exec(command, cwd, { onData }) {
      const { stdout, stderr, exitCode } = await limit(() =>
        archil.exec({
          // Mounted at /mnt/archil/<diskId>: the workspace only, writable without a checkout.
          disks: { [diskId]: { disk: diskId, subdirectory: "workspace", conditional: true } },
          command: `cd ${cwd} && ${command}`,
        }),
      );
      onData(Buffer.from(stdout + stderr));
      return { exitCode };
    },
  };
}
```

Mounting the disk under its own ID gives every tenant’s files a different path, which the file tools rely on. `subdirectory: "workspace"` keeps the conversation history, stored beside it, out of the command’s reach. `conditional: true` lets the command write without a checkout, as [Serverless tool calls](https://archil.com/guides/building-agents/serverless-tool-calls) recommends for a bash tool. `limit()` is the back-pressure wrapper from later in this chapter.

Some of pi’s local behavior doesn’t carry over. The output arrives in one piece when the command finishes, instead of streaming. A call takes no timeout or abort signal, so a call the user cancels keeps running until it finishes or reaches the 5-minute cap. Nothing but the disk survives between calls, including the environment, so `exposeSessionEnvironment: false` turns off the `PI_*` variables pi would otherwise try to pass. The other limits are in [Serverless tool calls](https://archil.com/guides/building-agents/serverless-tool-calls#limits). (From [Serverless execution](https://docs.archil.com/compute/serverless-execution#limits) and the `exec` signatures in the [`disk` package](https://www.npmjs.com/package/disk). pi’s `BashOperations` and `exposeSessionEnvironment` are in [`bash.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/bash.ts).)

**A serverless tool call**

The agent’s harness sends `npm test` to the disk. A microVM starts with the disk mounted, runs the command, returns its output, and shuts down. The call bills $0.00005. Until the next call, nothing runs and no compute bills, and the files stay on the disk.

### File tools in the harness

pi’s file tools are different: `read`, `write`, and `edit` run inside the harness process and expect a local path. Here the files aren’t local, so each tool needs another way to reach the tenant’s storage. There are three, and Archil supports each.

**Run every file operation as a tool call.** `read` becomes `cat` in a microVM. Each read then pays for a microVM start and counts as a call. A read is capped at the output limit, and a write has to fit its whole contents into the command.

**Use an object API from the harness.** Every Archil disk is also an S3-compatible bucket, and the `disk` package calls it with `getObject`, `putObject`, and `headObject`. A read is one HTTPS request, with no microVM. The S3 view is strongly read-after-write consistent with committed writes, and a tool call commits its writes before it returns. So the harness reads what `npm test` wrote, and the next tool call reads what `edit` saved. Our own agent tools for the AI SDK, Mastra, and LangChain work this way. (From the [S3 API](https://docs.archil.com/protocols/s3-api) and [TypeScript SDK](https://docs.archil.com/sdks/typescript#reading-and-writing-objects) docs. The `read_file` and `write_file` tools in our [SDK repository](https://github.com/archil-data/archil-sdk) call `getObject` and `putObject`, and their bash tool calls `exec`.)

**Mount every tenant’s storage on the harness host.** pi’s tools then work unchanged, but every tenant’s files sit in one directory tree on one machine. pi’s `read` accepts absolute paths and `~`, so a model that asks for `../tenant-b/.env` reaches another tenant, and only your own path check prevents it. A mount also serves from its own cache, which can lag a tool call’s writes until it refreshes.

We use Archil’s S3 API. It keeps the harness off any per-tenant mount, and each session holds a `Disk` object that can only address its own tenant’s bucket.

**Three ways to read one file**

Three ways for pi’s read tool, running in the harness, to reach `src/app.ts` on tenant a’s disk. Through exec, a microVM starts, runs `cat src/app.ts`, and returns stdout, so every read costs a microVM and a billed call. Over the S3 API, a `GetObject` request for the key `workspace/src/app.ts` returns the bytes from tenant a’s disk. With every tenant’s disk mounted on the host, the read reaches `/mnt/tenant-a`, and a read of `../tenant-b/.env` reaches another tenant’s files, which only the harness’s path check would stop.

pi’s `ReadOperations`, `WriteOperations`, and `EditOperations` interfaces take absolute paths, so the only work is mapping a path to a key and refusing anything outside the workspace:

```typescript
import { isAbsolute, relative, sep } from "node:path";
import type { Disk } from "disk";
import {
  createBashToolDefinition, createEditToolDefinition, createReadToolDefinition, createWriteToolDefinition,
  type ReadOperations, type ToolDefinition,
} from "@earendil-works/pi-coding-agent";

function diskTools(disk: Disk, cwd: string): ToolDefinition[] {
  // /mnt/archil/<diskId>/src/app.ts -> workspace/src/app.ts; nothing outside cwd.
  const key = (path: string) => {
    const rel = relative(cwd, path);
    if (!rel || isAbsolute(rel) || rel.split(sep)[0] === "..") throw new Error(`${path} is outside the workspace`);
    return `workspace/${rel}`;
  };
  const readFile = async (path: string) => Buffer.from(await disk.getObject(key(path)));
  const writeFile = async (path: string, content: string) => {
    await disk.putObject(key(path), content);
  };
  const access = async (path: string) => {
    if (!(await disk.objectExists(key(path)))) throw new Error(`No such file: ${path}`);
  };
  const read: ReadOperations = { readFile, access };

  return [
    createBashToolDefinition(cwd, { operations: bashOps(disk.id), exposeSessionEnvironment: false }),
    createReadToolDefinition(cwd, { operations: read }),
    createEditToolDefinition(cwd, { operations: { readFile, writeFile, access } }),
    // PutObject creates parent directories, so mkdir has nothing to do.
    createWriteToolDefinition(cwd, { operations: { writeFile, mkdir: async () => {} } }),
  ];
}
```

A few details follow from this design:

- **Parallel calls.** pi runs the tool calls in one model response concurrently. Its `edit` and `write` queue per absolute path, process-wide, so a separate `cwd` per tenant keeps two tenants’ `src/app.ts` from waiting on each other. A bash call isn’t in that queue, so it can race an edit to the same file, as on a laptop.
- **Checkouts.** If a command checks out a path, a `PutObject` to that path returns `409 Conflict` while the command writes it. pi reports a thrown error to the model as a failed tool call.
- **Search.** pi’s `grep` always runs `rg` on the local machine, so we leave it out, along with `find` and `ls`. The model runs `rg` through bash, or you can wrap [`disk.grep`](https://docs.archil.com/compute/search-files) as a custom tool for large trees.
- **Large and binary files.** Without a `createReadStream`, pi’s `read` downloads the whole file before it picks lines. Without a `detectImageMimeType`, it treats every file as text. (From pi’s [extensions guide](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md#remote-execution), the operations interfaces in [`read.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/read.ts), [`edit.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/edit.ts), and [`write.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/write.ts), and the [S3 API’s limitations](https://docs.archil.com/protocols/s3-api#limitations).)

### History on the tenant’s disk

A conversation’s history belongs with the tenant’s files, not on whichever host ran it. By default, pi’s `SessionManager` appends a conversation’s entries to a JSONL file on the local machine, which ties the conversation to one host. We keep the file on the tenant’s disk instead, under `sessions/` and outside every tool call’s mount, so the model’s code can’t read or rewrite its own history.

`SessionManager.inMemory()` accepts entries held outside the file system, and the disk’s `appendObject` adds bytes to the end of a file. Load the history when a conversation opens, and append new entries after each turn:

```typescript
import { parseSessionEntries, SessionManager, type AgentSession } from "@earendil-works/pi-coding-agent";

const historyKey = (threadId: string) => `sessions/${threadId}.jsonl`;

async function loadHistory(disk: Disk, threadId: string, cwd: string) {
  const key = historyKey(threadId);
  const saved = (await disk.objectExists(key))
    ? parseSessionEntries(new TextDecoder().decode(await disk.getObject(key)))
    : undefined;
  return { manager: SessionManager.inMemory(cwd, undefined, saved), written: saved?.length ?? 0 };
}

function saveHistory(session: AgentSession, history: { manager: SessionManager; written: number }, disk: Disk, threadId: string) {
  let saving = Promise.resolve();
  session.subscribe((event) => {
    if (event.type !== "turn_end" && event.type !== "agent_settled") return;
    saving = saving.then(async () => {
      const entries = [history.manager.getHeader()!, ...history.manager.getEntries()];
      const lines = entries.slice(history.written).map((e) => `${JSON.stringify(e)}\n`);
      if (lines.length) await disk.appendObject(historyKey(threadId), lines.join(""));
      history.written = entries.length;
    });
  });
}
```

pi appends a turn’s entries before it ends the turn, so each `turn_end` saves a finished turn. Each append can carry at most 1 MiB, so split a larger batch. The SDK doesn’t retry an append after a transient error, because a retry could write the bytes twice, so check the file’s size before you append again. (From pi’s [SDK guide](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/sdk.md#session-storage) and [`session-manager.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/session-manager.ts), and our [S3 API](https://docs.archil.com/protocols/s3-api#appending) and [TypeScript SDK](https://docs.archil.com/sdks/typescript#reading-and-writing-objects) docs.)

With the history on the disk, a process can exit, and the next message can go to any other process, which reads the same file and carries on. The disk doesn’t decide which process owns a conversation, though. Route each conversation to one process at a time, with sticky routing or a lease in your own database.

**A conversation changes hands**

Harness process A runs thread 42 for tenant a and appends each finished turn to `sessions/thread-42.jsonl` on tenant a’s disk: turns 1, 2, and 3. Then process A exits. The user’s next message is routed to harness process B, which loads the 3 turns from the disk and appends turn 4.

### Isolation and credentials

The design has three trust zones, and each holds only what it needs:

- **The harness is trusted.** It holds the model provider’s key, the Archil API key, and every tenant’s session. An API key can read, delete, and run sandboxes on every disk in its organization and region, so it never leaves the harness.
- **Each tool call is untrusted.** The exec request carries only the command and its mounts. The microVM sees one tenant’s `workspace/` directory, and nothing else from Archil: not that tenant’s history, and not any other tenant’s disk.
- **Each tenant’s data is on its own disk.** One disk per tenant makes the disk the unit of isolation and of deletion. Plans cap disks per region at 1,000 on Developer and 25,000 on Team, so with a disk per tenant, those are your tenant caps too. Enterprise has no cap. (From [Authorization](https://docs.archil.com/concepts/authorization#api-key-authorization), the [S3 API’s authentication](https://docs.archil.com/protocols/s3-api#authentication), and our [limits](https://docs.archil.com/reference/limits#disks-per-plan).)

Inside the harness, the boundary between tenants is your code: one `Disk` object per session, and the `key()` check. To have the S3 API enforce it too, give each session disk-scoped credentials from `disk.createS3Credentials({ nickname, ttl })` and use a standard S3 client. They return `AccessDenied` on any other disk. `exec` has no per-disk credential, so the API key stays in the process either way.

A tool call that needs a secret, such as a private registry token, is the hard case. `exec` takes no environment, so the secret travels in the command or sits on the disk, where the model’s code can read it. That’s no worse than a sandbox, but give tool calls only the credentials they need.

**What each part can reach**

Your harness, trusted code, holds the model API key, the Archil API key, and every tenant’s sessions. It sends a microVM only the command text. The microVM, which runs the model’s untrusted command, mounts tenant a’s `workspace/` directory. Tenant a’s `sessions/` directory isn’t mounted, and neither is tenant b’s disk, so attempts to reach them fail. The keys never leave the harness process.

### How much one host can take

Each session spends almost all of its time awaiting the model or the user, and one event loop interleaves those waits. The work per session is small: streaming the response, truncating output, and applying edits. The tool calls run in microVMs, not on the host. The host has to absorb three kinds of pressure:

- **Tool calls in flight.** An account can run hundreds of exec calls at once by default, and thousands on request. A limiter in the process keeps a burst from turning into errors.
- **The model provider’s rate limits.** pi retries failed model requests, and its settings set how often.
- **Open sessions.** Each one holds its history in memory, so cap how many one process opens and route the rest elsewhere.

Here’s the limiter `bashOps` uses. A finishing call hands its slot directly to the next waiting one:

```typescript
const MAX_IN_FLIGHT = 100; // under your account's exec limit
let inFlight = 0;
const waiting: (() => void)[] = [];

async function limit<T>(run: () => Promise<T>): Promise<T> {
  if (inFlight < MAX_IN_FLIGHT) inFlight++;
  else await new Promise<void>((go) => waiting.push(go));
  try {
    return await run();
  } finally {
    const next = waiting.shift();
    if (next) next();
    else inFlight--;
  }
}
```

How many calls are in flight depends on how much of each session is tool calls. The next figure simulates one host from the loops measured in [Two places to run an agent](https://archil.com/guides/building-agents/two-places-to-run-an-agent). It treats sessions as independent, and real traffic bunches up, as when a team starts work at nine, so set the limiter from the peaks you observe. (The account limit is from [Serverless execution](https://docs.archil.com/compute/serverless-execution#limits), and the retry settings from pi’s [settings reference](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/settings.md).)

**Many sessions, one host**

An explorable host running from 10 to 1,000 pi sessions, shown as dots that light up while a session is in a tool call, with a square for each microVM in flight and a chart of vCPU over the last moments. Each call counts as the 2 vCPU a serverless call gets, and the comparison is one 2 vCPU sandbox per agent, kept up for the whole session. The share of time in tool calls comes from 384 of our coding-agent loops with a 15-minute auto-stop: 12% in the median loop and 35% across all loops. The harness host’s size is the reader’s choice: 4, 8, or 16 vCPU. At 500 sessions, 12%, and an 8 vCPU host, one sandbox per agent reserves 1,000 vCPU; the host and microVMs use 128 vCPU on average and 174 at the busiest moment in 1,000, which is 17% of the sandboxes. At 35%, they use 358 on average and 424 at the busiest moment, 42%. At 1,000 sessions and 12%, sandboxes reserve 2,000 vCPU, and the host and microVMs use 248 on average and 314 at the busiest moment. At 10 sessions, the host and microVMs reach 18 vCPU at the busiest moment, 90% of the sandboxes’ 20, and the host is most of that.

### Where the harness runs

The harness is a long-running Node service with no files of its own, so any machine that reaches your execution and storage APIs can run it. With Archil, two homes fit.

**One Archil persistent sandbox.** A sandbox can have up to 32 vCPU and 64 GiB of memory, it runs in the same region as your disks, and transfer within Archil is free, so the harness’s S3 reads cost nothing to move. Set `idleTtlSeconds: 0`, or the sandbox pauses after 30 idle minutes, and expose the port your API server calls. A session still ends after 24 hours: the sandbox pauses with its processes kept, and the next request on the exposed port resumes it. Maintenance can pause it too. Requests in flight when it pauses can fail, and those conversations resume from their disks. Run it on the Developer plan or above. There, a sandbox created without a network policy has unrestricted egress, and if you set a deny-by-default policy, allow your model provider and Archil’s API. On the Free plan, every sandbox uses a managed allowlist, and you can’t add rules of your own.

**Your own servers or containers.** The harness deploys like the rest of your backend, with health checks, rolling deploys, and more than one replica. The cost is transfer: data leaving Archil, which includes the harness’s S3 reads, costs $0.07 per GB. (From [Persistent sandboxes](https://docs.archil.com/compute/sandboxes/introduction#setting-a-time-to-live), the [sandbox firewall](https://docs.archil.com/compute/sandboxes/firewall), our [limits](https://docs.archil.com/reference/limits#sandboxes), and [Pricing & Billing](https://docs.archil.com/administration/billing#data-transfer).)

Start on one persistent sandbox. It’s the shortest path to every conversation on one machine, and the disks make the daily pause a short interruption. Move to your own platform when you need a second host or deploys without a pause. Nothing else in this chapter changes, because every conversation’s state is on its tenant’s disk.

**A day on one persistent sandbox**

Thirty hours of a harness on one persistent sandbox with its idle timeout off. It runs until its 24-hour session limit, then pauses with its processes kept. A request on its exposed port at about 25.5 hours resumes it, starting a new session. The tenant disks hold the workspaces and conversation histories the whole time.

Most teams start from an agent that already lives in a sandbox. [Migrating from an in-sandbox agent](https://archil.com/guides/building-agents/migrating-from-an-in-sandbox-agent) moves that agent onto this design one step at a time.

## 4. Migrating from an in-sandbox agent

Source: https://archil.com/guides/building-agents/migrating-from-an-in-sandbox-agent · Part of How to build agents · Facts checked 2026-11-05

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

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.

```typescript
// 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;
}
```

```typescript
// 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](https://archil.com/guides/building-agents/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.

**Six steps, one session at a time**

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](https://docs.archil.com/compute/serverless-execution#limits) and the comparison table in [Persistent sandboxes](https://docs.archil.com/compute/sandboxes/introduction#one-shot-execution-or-a-persistent-sandbox).) 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:

```typescript
// 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 `&` or `nohup`. 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`, a `cd` that later commands rely on, an activated virtual environment, packages installed with `apt-get` or `npm 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.

**Sorting the tool log**

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](https://docs.archil.com/guides/platforms/e2b), [Daytona](https://docs.archil.com/guides/platforms/daytona), [Modal](https://docs.archil.com/guides/platforms/modal), and [Vercel Sandbox](https://docs.archil.com/guides/platforms/vercel-sandbox). Each one builds a template with the `archil` client installed.)

```typescript
// Before: the workspace lives on the sandbox's own disk.
await sandbox.run(`git clone ${repoUrl} /workspace`);
```

```typescript
// 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 its `workspace/` 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. (The `disk:/path` form and the rule that the directory must already exist are in the [`archil` CLI reference](https://docs.archil.com/reference/archil-cli#subdirectory-mounts).)
- **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](https://docs.archil.com/reference/limits#disks-per-plan). The `subdirectory` option is in [Serverless execution](https://docs.archil.com/compute/serverless-execution#specifying-disk-options).)

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:

```typescript
// 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](https://docs.archil.com/compute/serverless-execution#consistency-with-other-clients) and the `Disk` and `ExecMountSpec` types in the `disk` package. [Serverless tool calls](https://archil.com/guides/building-agents/serverless-tool-calls) covers mount options, fan-out, and limits in full.) [Many agents, one machine](https://archil.com/guides/building-agents/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:

```typescript
// Before: the backend starts the harness inside the session's sandbox.
await sandbox.run(`node /agent/harness.js --session ${sessionId}`, { background: true });
```

```typescript
// 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](https://docs.archil.com/reference/regions#compute-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**

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](https://archil.com/guides/building-agents/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](https://docs.archil.com/compute/sandboxes/introduction), including [Hosting network services](https://docs.archil.com/compute/sandboxes/introduction#hosting-network-services) and [Setting a time to live](https://docs.archil.com/compute/sandboxes/introduction#setting-a-time-to-live). The `archil-sandbox services` command and the `0.0.0.0` requirement are from our [coding agent guide](https://docs.archil.com/guides/agents/coding-agent).)

```typescript
// Before: the bash tool starts the server in the session's sandbox.
await tools.bash({ command: "npm run dev &" });
```

```typescript
// 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](https://docs.archil.com/compute/sandboxes/introduction#attaching-an-archil-disk). The default idle timeout is 30 minutes, and a session pauses after 24 hours; see [Limits](https://docs.archil.com/reference/limits#sandboxes).) 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](https://archil.com/guides/building-agents/what-it-saves) says when. ([What comes after sandboxes](https://x.com/jhleath/status/2110407137616928811) names self-editing agents as the exception.)

**A dev server finds a home**

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:

```typescript
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](https://docs.archil.com/concepts/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 mix `archil checkout` with a persistent sandbox’s conditional mount on the same paths. (From [Sharing disks](https://docs.archil.com/concepts/sharing-disks#automatic-delegations-in-shared-mode). Our docs call a checkout a delegation. The modes are compared in [Serverless tool calls](https://archil.com/guides/building-agents/serverless-tool-calls#writes-the-loop-can-see).)
- **Latency on every call.** Each call starts a microVM before the command runs, which [Price and speed](https://archil.com/guides/building-agents/serverless-tool-calls#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 push` into 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](https://docs.archil.com/compute/sandboxes/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](https://archil.com/guides/building-agents/what-it-saves), which prices your fleet before and after.

## 5. What it saves

Source: https://archil.com/guides/building-agents/what-it-saves · Part of How to build agents · Facts checked 2026-11-05

**The short answer:** Measure what moving the harness out saves with your own numbers, because it depends on how much of each hour your tools spend running. A sandbox holds its vCPUs for the whole session, and a serverless tool call holds compute only while its command runs. This chapter’s calculator prices both, with Archil’s per-call price. Agents that edit their own environment, long-running services, heavy continuous compute, and calls over Archil’s 5-minute limit still belong in a sandbox.

Measure what moving an agent’s loop out of its sandbox saves, because the answer depends on how your own agents spend each hour. When I ran 1,000 agent loops multi-tenanted on one server, with each tool call on Archil’s serverless execution, the CPUs needed at peak came to something like 180. Giving each loop its own sandbox would have needed something like 2,000. (From [What comes after sandboxes](https://x.com/jhleath/status/2110407137616928811), October 14, 2026. Both numbers are rounded, from one demonstration.)

A demonstration isn’t a forecast for your agents. This chapter works through the arithmetic behind numbers like those. It covers what each design holds over an hour, a calculator that prices a fleet for a month, where the calculator’s defaults come from, and the cases where a sandbox is still the better home.

**The demonstration, to scale**

The demonstration of 1,000 agent loops, drawn to scale with each square standing for 4 vCPUs. With one agent per 2 vCPU sandbox, all 500 squares, about 2,000 vCPUs, are held from the start of the session to its end. With the harness multi-tenanted on one server and each tool call on serverless execution, squares light up while calls run and go dark when they return. At the peak, 45 squares are held, about 180 vCPUs. The numbers are rounded, from one demonstration.

### What each design holds

An agent in a sandbox holds the sandbox’s full size for every hour the sandbox runs. The model writing its next step, a person reading a diff, and a test suite all hold the same vCPUs. For a fleet, this is one line:

```text
vCPUs held = agents × sandbox vCPUs
```

With the harness outside, two things hold CPU. The harness host runs all the time, but one process carries many agents, because a harness spends nearly all its time waiting on the model. [Many agents, one machine](https://archil.com/guides/building-agents/many-agents-one-machine) runs many pi sessions in one Node process this way. Each tool call holds its compute only until the command returns. On Archil, that’s a microVM with 2 vCPUs and 4 GiB. (The per-command size is in [Limits](https://docs.archil.com/reference/limits#serverless-execution).) On average:

```text
tool share = tool calls per hour of session × mean seconds per call ÷ 3,600
vCPUs held = harness vCPUs + agents × tool share × vCPUs per call
```

The tool share is the only usage term, so it decides the answer. When tools run for a small part of each hour, the outside design holds a small fraction of what the sandboxes hold. When they run for most of it, the two designs converge.

An average isn’t a peak, and you size capacity for the peak. Tool calls from independent sessions overlap at random, so at the busiest moment more are in flight than the average suggests. The explorable in [How much one host can take](https://archil.com/guides/building-agents/many-agents-one-machine#how-much-one-host-can-take) models that moment exactly, and this chapter’s calculator uses the same model. The peak depends on two things: the tool share, and the vCPUs each call holds.

That explains the gap between the demonstration and our own measurements. If each call held 2 vCPUs, a peak near 180 across 1,000 sessions means fewer than 90 calls in flight at the busiest moment, which fits a tool share of about 6%. Our engineering team’s agents are busier. At the tool share of our median loop, that explorable puts the busiest moment for 1,000 sessions near 314 vCPUs.

Dollars follow a different meter. A sandbox bills by the vCPU-hour and GiB-hour for as long as it runs. Archil bills a serverless tool call at a flat $0.05 per 1,000 calls with execution included, however long the command takes within its 5-minute limit. (See [Pricing & Billing](https://docs.archil.com/administration/billing#serverless-tool-calls).) So the outside design’s CPU follows how long your calls run, and on Archil its bill follows how many you make.

**One hour, held two ways**

One hour of one agent’s session. A 2 vCPU sandbox holds 120 vCPU-minutes. The hour has 75 tool calls, our measured rate per hour of sandbox time, in an example shaped like our measured loops: 50 of half a second, 23 of 2 to 60 seconds, one of 4 minutes, and one of 10.5 minutes. That’s about 21.5 minutes of tool time, between model turns and three waits on a person. At 2 vCPUs per call, the calls hold 43 vCPU-minutes. About half of that is the 10.5-minute call, which is over the 5-minute limit for a serverless call and has to run in a sandbox.

### A fleet, priced

The calculator runs both designs for a fleet that keeps the same number of sessions open around the clock, and prices a 730-hour month at list prices. Each total has its arithmetic underneath it. It leaves out storage and data transfer; for what the disk costs, see [What you pay for](https://archil.com/guides/storage-for-agents/what-you-pay-for).

**Price your fleet**

A calculator for a fleet’s month under two designs. At its defaults, 1,000 agents run at once in 1.3-hour sessions, with 75 tool calls per hour of session, 17 seconds each on average, and half of tool time in calls over 5 minutes. The sandboxes are E2B’s 2 vCPU, 4 GiB size at list price, and one 8 vCPU, 16 GiB harness host, priced at Archil’s sandbox rates, carries all 1,000 agents. Agents in sandboxes hold 2,000 vCPUs all the time, 1,460,000 vCPU-hours a month, and cost $120,888, or $0.215 per session. With the harness outside, a tool runs for 35.4% of each hour. The fleet holds 716 vCPUs on average and 810 at its busiest moment in 1,000, 522,680 vCPU-hours a month, and costs $24,700, or $0.044 per session: $2,738 for 54,750,000 tool calls, $21,407 for the long calls’ sandbox time, and $555 for the harness host. That’s 2.8 times fewer vCPUs held on average and 4.9 times less a month. With no calls over 5 minutes, the month costs $3,292, 36.7 times less. The inputs are agents running at once, session length, tool calls per hour, mean tool-call duration, the share of tool time in calls over 5 minutes, the sandbox provider (E2B, Daytona, Modal, or Archil) and size, the harness host’s size, agents per host, and Archil’s price per 1,000 tool calls.

Three of its choices are worth knowing before you trust a number from it:

- **The busiest moment assumes independent sessions.** Real traffic bunches up, as when a team starts work at nine, so watch your own peaks before you size a host.
- **Long calls run in a sandbox.** The share of tool time you put in calls over 5 minutes runs in a sandbox of the size you chose, started for the call and stopped after it. Those calls are also billed per call, which errs high.
- **The harness host is an assumption.** Its price uses our sandbox rates, and its size matches the host in [Many agents, one machine](https://archil.com/guides/building-agents/many-agents-one-machine). How many agents one host carries depends on what your harness does between model calls, so measure it under load.

Not every sandbox bills a waiting agent’s full size. Vercel Sandbox bills memory for as long as a sandbox is provisioned but CPU only while it’s actively used, and Fly.io Sprites bill only the CPU and memory actually used. Both bill an idle agent far less than a reserved sandbox does. The calculator leaves them out because their meters don’t reduce to a rate per vCPU-hour. (From [What does an idle agent sandbox cost per hour?](https://archil.com/article/what-does-an-idle-agent-sandbox-cost-per-hour), which links each provider’s pricing page.)

### Where the defaults come from

The usage defaults are our engineering team’s own coding agents: 30 days of sessions, 384 agent loops, and about 37,000 tool calls, read from timestamps only. We modeled each loop with a sandbox started at its first tool call and stopped after 15 minutes without one, and the calculator’s session is that sandbox time. Those sandboxes would have run for 493 hours, and the tools ran for 174 of them. (The method is in [Do AI agents need a sandbox running for the whole session?](https://archil.com/article/do-ai-agents-need-a-sandbox-running-for-the-whole-session), and the hours are in [Where an agent’s hours go](https://archil.com/guides/storage-for-agents/the-work-outlives-the-machine#where-an-agents-hours-go).) Each default follows from those figures:

- **Session length:** 493 hours across 384 loops.
- **Tool calls per hour of session:** about 37,000 calls over 493 hours of sandbox time. Counted per hour of activity alone, without the waits on a person that the timer kept the sandbox up through, the rate is about 160.
- **Mean tool-call duration:** 174 hours over about 37,000 calls.
- **Tool share:** together, those give the share of all loops that the host explorable uses, 35%.
- **Calls over 5 minutes:** about half of all tool time, as the next figure shows.
- **Prices:** E2B’s default sandbox size at E2B’s list price, and an 8 vCPU harness host carrying 1,000 agents, the count from the demonstration.

The mean sits far higher than the median, because a few long calls hold much of the time:

**Where tool time goes**

How long our agents’ tool calls ran, on a log scale from 0.1 seconds to an hour. The median call took half a second, and 89% finished within 10 seconds. About 1% ran longer than 5 minutes, the limit for a serverless call, and that 1% held about half of all the time tools spent running.

That makes our agents a demanding case. They run builds, test suites, and benchmarks, so their tools stay busy for a good share of each hour, and the calculator’s default saving in CPU is far smaller than the demonstration’s. An agent whose calls look like the median, reading files, searching, and making small edits, spends much less of each hour in tools. Set the mean to a few seconds and the long-call share to zero, and the fleet holds about a tenth of what the sandboxes hold, or less.

To put your own agents in the calculator, measure the two usage inputs where your tools run today. This wrapper goes around whatever function runs a command, in a sandbox or on serverless execution:

```typescript
type Run<T> = (command: string) => Promise<T>;

// Wrap the function your bash tool calls, and record how long each call takes.
// Create it when the sandbox starts, so its hours match what the sandbox bills.
export function measured<T>(run: Run<T>) {
  const started = performance.now();
  const durations: number[] = [];

  async function tool(command: string): Promise<T> {
    const t0 = performance.now();
    try {
      return await run(command);
    } finally {
      durations.push((performance.now() - t0) / 1000);
    }
  }

  // The calculator's two usage inputs, for the session so far.
  function usage() {
    const hours = (performance.now() - started) / 3_600_000;
    const seconds = durations.reduce((sum, d) => sum + d, 0);
    return { callsPerHour: durations.length / hours, meanSeconds: seconds / durations.length };
  }

  return { tool, usage };
}
```

Collect `usage()` from many sessions, not one, because a single session’s tool share swings widely.

### When the sandbox still wins

The arithmetic favors the harness outside when tools run for a small part of each hour and every call fits in a short-lived microVM. These cases don’t, and a sandbox is still the right home for them:

- **Agents that edit their own environment.** If the agent is meant to change the machine it runs on, its loop and its tools belong together in one sandbox. This is the exception [What comes after sandboxes](https://x.com/jhleath/status/2110407137616928811) carves out.
- **Long-running services.** A dev server, a file watcher, a database, or anything listening on a port has to stay up between calls. A serverless call keeps nothing between calls except what it writes to the disk.
- **Heavy, continuous compute.** As the tool share approaches the whole hour, the outside design holds as many vCPUs as the sandbox, and per-call billing loses its edge too. A 2 vCPU, 4 GiB E2B sandbox costs the same as about 3,300 calls an hour, close to one call a second, sustained. (From [What does an idle agent sandbox cost per hour?](https://archil.com/article/what-does-an-idle-agent-sandbox-cost-per-hour))
- **Calls over the per-call limit.** On Archil, a serverless command that runs past 5 minutes is stopped, and the call returns `504` with `Exec timed out`. (See [Serverless execution limits](https://docs.archil.com/compute/serverless-execution#limits).) In our loops, those calls were few but held much of the tool time.
- **Loops where per-call startup matters.** Each serverless call starts a microVM, mounts the disk, and begins with an empty client cache, so a search that a warm sandbox answers from memory reads its files again. A model turn hides tens of milliseconds, but a loop of many tiny calls back to back doesn’t. [Price and speed](https://archil.com/guides/building-agents/serverless-tool-calls#price-and-speed) has the measurements.

Most agents end up with a split rather than one side. Keep the harness outside, send everyday tool calls to serverless execution, and start a persistent sandbox, with the same disk mounted, only for the step that needs one. A persistent sandbox bills by the vCPU-hour and GiB-hour while it runs and pauses after 30 idle minutes by default, so stop it when the step is done. (See [Persistent sandboxes](https://docs.archil.com/compute/sandboxes/introduction) and [Limits](https://docs.archil.com/reference/limits#sandboxes).)

**The usual split**

The harness runs outside any sandbox and carries many agents per process. Everyday tool calls, such as a search, reading a file, running the tests, and a diff, go to serverless execution, one microVM per call, gone when it returns. A dev server goes to a persistent sandbox, which stays up between calls, as would a build longer than 5 minutes. The harness, every call, and the sandbox see the same disk.

[Migrating from an in-sandbox agent](https://archil.com/guides/building-agents/migrating-from-an-in-sandbox-agent) moves an in-sandbox agent to this shape one step at a time, including what to do with the parts that have to stay up.
