---
title: Custom Sandbox Providers
description: Connect eve to your own sandbox backend with defineSandboxProvider().
url: "https://eve.dev/docs/sandbox/custom-providers"
docs_index: /llms.txt
---

> For an index of all documentation, see [/llms.txt](/llms.txt).

A custom sandbox provider connects eve to a backend that eve does not ship, such as your own container service or virtual machine pool. You implement a byte-level connection to one sandbox and four lifecycle methods. eve derives the full sandbox session from the connection, seeds workspace and skill files into templates, hashes template keys, and maps `sandbox.stop()`, `sandbox.delete()`, and server shutdown onto the lifecycle.

## Define a provider

`defineSandboxProvider()` from `eve/sandbox/provider` takes a name and an `environment()` function that returns the lifecycle. This example targets `@acme/sdk`, a hypothetical client for a snapshot-capable machine service:

```ts title="agent/lib/acme-sandbox.ts"
import { createHash } from "node:crypto";
import {
  defineSandboxProvider,
  SANDBOX_AGENT_ENV,
  SandboxSessionGoneError,
  type SandboxConnection,
} from "eve/sandbox/provider";
import { AcmeClient, type AcmeMachine } from "@acme/sdk";

export const AcmeSandbox = defineSandboxProvider({
  name: "acme",
  environment(options: { readonly project: string }) {
    const acme = new AcmeClient({ project: options.project });
    // Session ids can be long, so hash them into a bounded machine name.
    const nameFor = (sessionId: string) =>
      `eve-${createHash("sha256").update(sessionId).digest("hex").slice(0, 32)}`;
    return {
      async prepare(ctx) {
        const template = `eve-tpl-${ctx.templateKey({ project: options.project, version: 1 }).slice(0, 32)}`;
        const cached = await acme.snapshots.find(template);
        if (cached !== null) return { snapshotId: cached.id };
        const machine = await acme.machines.create({
          env: SANDBOX_AGENT_ENV,
          image: "ubuntu:24.04",
        });
        try {
          await ctx.populate(connect(machine));
          return { snapshotId: (await machine.snapshot({ name: template })).id };
        } finally {
          await machine.destroy();
        }
      },
      async start(ctx, open: { readonly egress?: "allow-all" | "deny-all" } | undefined, artifact) {
        const name = nameFor(ctx.session.id);
        // Atomic create-if-absent: an existing machine is returned unchanged,
        // so `egress` and the snapshot apply only on creation.
        const machine = await acme.machines.createIfAbsent({
          egress: open?.egress ?? "allow-all",
          env: SANDBOX_AGENT_ENV,
          name,
          snapshotId: artifact.snapshotId,
        });
        return { connection: connect(machine), state: { name, version: 1 } };
      },
      async resume(_ctx, _artifact, state) {
        const machine = await acme.machines.get(state.name);
        if (machine === null)
          throw new SandboxSessionGoneError(`Acme machine "${state.name}" is gone.`);
        return connect(machine);
      },
      async onSessionEnd(ctx, state, { abortSignal }) {
        await acme.machines.destroyIfExists(state?.name ?? nameFor(ctx.session.id), {
          signal: abortSignal,
        });
      },
    };
  },
});

function connect(machine: AcmeMachine): SandboxConnection {
  return {
    spawn: ({ abortSignal, command, env, workingDirectory }) =>
      machine.exec({
        argv: ["bash", "-lc", command],
        cwd: workingDirectory,
        env,
        signal: abortSignal,
      }),
    // Acme returns `null` for a missing path and rejects for a directory.
    readFile: ({ abortSignal, path }) => machine.files.read(path, { signal: abortSignal }),
    writeFiles: ({ abortSignal, files }) =>
      machine.files.write(files, { mkdirs: true, signal: abortSignal }),
    removePath: ({ abortSignal, force, path, recursive }) =>
      machine.files.remove(path, { force, recursive, signal: abortSignal }),
    stop: () => machine.suspend(),
  };
}
```

Use the provider from a sandbox file like any built-in environment. Every provider accepts the standard `prepare` option:

```ts title="agent/sandbox.ts"
import { defineSandbox } from "eve/sandbox";
import { AcmeSandbox } from "./lib/acme-sandbox";

export const environment = AcmeSandbox.environment({
  project: "agents",
  prepare: async (sandbox) => {
    await sandbox.run({ command: "apt-get install -y ripgrep" });
  },
});

export default defineSandbox(() => environment.open({ egress: "deny-all" }));
```

`environment(options)` always receives an object, without `prepare`: `{}` when the sandbox file passes no options, so make every field optional or require the caller to pass it. TypeScript infers the environment options, open options, artifact, state, and connection extensions from the implementation, so you do not pass type arguments. Write `start()` before `resume()` so the state type comes from `start()`'s return value, or annotate `resume()`'s `state` parameter.

## Implement the connection

A `SandboxConnection` is the byte-level link to one sandbox. Each method has a fixed contract:

- `spawn({ command, workingDirectory, env, abortSignal })` runs `command` as a Bash login-shell script (`bash -lc`) in `workingDirectory`, with the sandbox's environment plus `env`. `env` holds only the per-call variables. Aborting kills the process tree. It resolves to a process with `stdout` and `stderr` byte streams, `wait()`, and `kill()`.
- `readFile({ path, abortSignal })` resolves to the file's bytes or a byte stream, or to `null` only when the path does not exist. A directory or an unreadable path rejects.
- `writeFiles({ files, abortSignal })` writes every file, creating missing parent directories and overwriting existing files. It is the only write primitive.
- `removePath({ path, force, recursive, abortSignal })` removes a file or directory. `force` ignores a missing path, and `recursive` removes a non-empty directory.
- `stop()` releases compute and keeps the sandbox's files.
- `close()` is optional. It releases what this process holds for the connection, such as a local interpreter, without touching the sandbox. eve calls it whenever it lets go of a connection: after `stop()`, before deletion and terminal cleanup, and when a keyed call ends.
- `extensions` is optional. It is a plain object whose own properties eve merges onto the session, such as `setNetworkPolicy()`. Use closures rather than class methods, because eve does not copy inherited members.

Every path a connection receives is absolute, and `workingDirectory` is always set. eve resolves relative paths and a missing or relative working directory from `/workspace` before it calls the connection.

eve builds the rest of the session from these methods: `run()` over `spawn()`, the binary and text read and write variants, encodings and line ranges, `writeFiles()`, `resolvePath()`, and abort-signal binding. An extension member named like a session method, or `stop` or `delete`, is a type error, and eve throws when it opens the connection:

```text
Sandbox connection extensions cannot replace the session members eve provides: run. Rename them.
```

Type the connection with its extensions, such as `SandboxConnection<{ setNetworkPolicy(policy: SandboxNetworkPolicy): Promise<void> }>`, and `environment.open()` and `ctx.getSandbox(environment)` return a session that includes them.

## Implement the lifecycle

eve calls the lifecycle methods at these points:

| When                                                  | eve calls                                                                                                                                                      |
| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eve build`, or the first sandbox access in `eve dev` | `prepare(ctx)`, and stores the artifact it returns with the deployment                                                                                         |
| The first sandbox access in a session                 | `start(ctx, options, artifact)` from `environment.open(options)` in the selector                                                                               |
| A later workflow step or process                      | `resume(ctx, artifact, state)`                                                                                                                                 |
| `sandbox.stop()`                                      | `connection.stop()`, then `connection.close()`                                                                                                                 |
| `sandbox.delete()`                                    | `connection.close()`, then `onSessionEnd(ctx, state, { reason: "deleted" })`                                                                                   |
| The eve server shuts down                             | `connection.stop()`, then `connection.close()`, on every open session connection; eve logs failures and does not throw them                                    |
| The owning session reaches a terminal outcome         | `connection.close()` on this process's connections for the session, then `onSessionEnd(ctx, state, { reason })` with `"completed"`, `"expired"`, or `"failed"` |

eve calls `close()` at most once per connection, retrying only after a failure. `stop()` can run again after the session uses a stopped connection, so make it a no-op when the sandbox is already stopped. A failed `close()` never prevents `onSessionEnd()` from deleting the sandbox.

### `prepare()`

`prepare()` builds a reusable template and returns a JSON-compatible artifact, such as a snapshot or image id. It runs during `eve build`, so the deployment ships with the artifact, and the production runtime never calls it. A running sandbox keeps the template it started from: `resume()` receives the current deployment's artifact, but reconnects existing state.

`ctx.isEmpty` is `true` when the agent has no workspace files, no skill files, and no authored `prepare`. A provider can skip building a template in that case. See [Bake files into templates](#bake-files-into-templates) for `ctx.populate()` and `ctx.templateKey()`.

### `start()`

`start()` returns the sandbox for `ctx.session.id`, creating it when none exists, together with the minimal JSON-compatible state eve checkpoints for the session. It must be an idempotent upsert: repeated, concurrent, cross-process, and cross-deployment calls for one session id return the same sandbox while it exists and is usable. A subagent that shares its parent's sandbox can open it before the parent does, and two processes can race to create it.

Derive the sandbox's native identity from `ctx.session.id` and a provider-level namespace, such as a project or account, only. Never derive it from the open options or the artifact, which can differ between callers and deployments. Apply them only when `start()` creates the sandbox.

`ctx.session.id` is the id of the session that owns the sandbox. For a subagent that shares its parent's sandbox it is the parent's id. `ctx.kind` is `"tool-session"` for a keyed `invokeTool` call's sandbox and `"session"` otherwise.

### `resume()`

`resume()` reconnects to the sandbox that `state` names and never recreates it. Open options are not passed to `resume()`, so put anything reconnection needs in the state. When the sandbox no longer exists, throw `SandboxSessionGoneError`: eve clears the session's state and reruns the selector, which starts a fresh sandbox without the lost sandbox's files. Let other failures, such as an outage, surface unchanged.

Validate the shape of your own state in `resume()`. When you change it incompatibly, raise `stateProtocolVersion` in the provider definition: state recorded under another version starts a fresh sandbox instead of reaching `resume()`.

### `onSessionEnd()`

`onSessionEnd()` deletes whatever exists for `ctx.session.id`. `state` is `null` when the session never recorded it, for example when only a subagent that shares the sandbox opened it, or when a step failed before its checkpoint, so find the sandbox from the session id. The method must succeed when nothing exists and must be idempotent, because eve retries the cleanup step. eve logs an exhausted failure without replacing the session's outcome.

`sandbox.delete()` uses the same method with `reason: "deleted"` and the caller's `abortSignal`, so a provider has one deletion path. A subagent that shares its parent's sandbox never calls it, because cleanup belongs to the parent. An `invokeTool` call without a key calls it when the call ends if the tool requested the sandbox.

## Bake files into templates

Call `ctx.populate(connection)` on the build instance before you capture it. `populate()` writes the agent's workspace files to `/workspace` and its skill files to `$HOME/.agents/skills` in one `writeFiles()` batch, then runs the authored `prepare` callback with a session over the same connection. eve finds `$HOME` by running a command through `spawn()`. When the sandbox has no usable home directory, skills go to `/workspace/skills`.

A provider that can mount host directories can avoid writing every file. `ctx.resourcesDirectory()` resolves to a stable read-only host directory that contains `workspace/` and `skills/`, or to `undefined` when there is none. Mount it in the build instance and pass its sandbox path:

```ts
const resources = await ctx.resourcesDirectory();
const machine = await acme.machines.create({
  mounts:
    resources === undefined ? [] : [{ host: resources, path: "/eve/resources", readOnly: true }],
});
await ctx.populate(
  connect(machine),
  resources === undefined ? undefined : { mountedAt: "/eve/resources" },
);
```

With `mountedAt`, `populate()` copies `<mountedAt>/workspace/.` into `/workspace` and `<mountedAt>/skills/.` into `$HOME/.agents/skills` with `cp -a`. When eve has no resource directory, `populate()` ignores `mountedAt` and writes the files instead.

`ctx.templateKey(identity)` returns a SHA-256 hex key that changes whenever `identity`, the workspace files, the skill files, or the sandbox file's source changes. Pass every option that affects the template, such as the base image, and use the key to name or look up a cached template. `identity` accepts JSON values, `undefined` (omitted), plain objects and arrays, and functions (hashed by their source). `resources` and `sourceRevision` are reserved, and eve throws if `identity` sets either one. The source revision covers only the sandbox file, so a `prepare` callback imported from another module does not change the key.

Only build time seeds files. A sandbox that `start()` creates gets them from the template, so a provider whose templates cannot hold files cannot provide workspace or skill files.

## Keep sandboxes for keyed tool calls

Set `toolSessions: true` on the implementation to let keyed `invokeTool` calls keep their sandbox between calls, as Vercel Sandbox and just-bash do. A keyed call does not own its sandbox: eve runs the selector and `start()` on every call, calls `connection.close()` when the call ends, and never calls `onSessionEnd()`. Because nothing ever ends a keyed sandbox, the provider must bound its retention, for example with snapshot expiration.

Without `toolSessions`, a keyed call fails when its tool requests the sandbox:

```text
Sandbox provider "acme" cannot keep a sandbox between invokeTool calls, so a call with a key cannot open one.
```

A keyed call whose tool never requests the sandbox runs normally. See [MCP tool sessions](/docs/channels/mcp#tool-sessions).

## Enforce network policy

eve has no built-in network control for custom providers. To offer one, accept a `SandboxNetworkPolicy` from `eve/sandbox` as an open option, as a `setNetworkPolicy()` extension, or both. Reject any part of a policy that the backend cannot enforce instead of weakening it. For example, a backend that can only switch egress on or off accepts `"allow-all"` and `"deny-all"` and throws on allow-lists, subnets, and header transforms, as the Docker provider does.

## Set environment variables

`SANDBOX_AGENT_ENV` is `{ AI_AGENT: "eve" }`, which tells agent-aware CLIs to skip interactive prompts. Apply it beneath your environment-level variables when you create the sandbox, as the built-in providers do. A per-call `env` passed to `spawn()` overrides both. If the backend has no sandbox-level environment, merge it beneath the per-call `env` in `spawn()` instead, as in `{ ...SANDBOX_AGENT_ENV, ...env }`.

## Report missing templates

Throw `SandboxTemplateNotProvisionedError` from `start()` or `resume()` when the artifact names an image, snapshot, or template that no longer exists:

```ts
import { SandboxTemplateNotProvisionedError } from "eve/sandbox/provider";

throw new SandboxTemplateNotProvisionedError({
  providerName: "acme",
  reason: "The snapshot expired.",
  templateKey: artifact.snapshotId,
});
```

eve surfaces the error to the caller and never retries or rebuilds the template at runtime. eve throws the same error when a deployment has no artifact for the sandbox. Template retention and pruning stay with the provider. See [Sandbox troubleshooting](/docs/sandbox/troubleshooting#sandbox-template--is-not-available-to-this-runtime).

## Migrate from the handle-based API

Earlier releases asked a provider to return a handle with a complete sandbox session. To move a provider to the current API:

- Return `{ connection, state }` from `start()` and a connection from `resume()`, instead of a handle. Move provider-specific session methods to `extensions`.
- Remove `onSandboxStop()` and `onRuntimeShutdown()`; implement `connection.stop()`. Move `onSandboxDelete()` into `onSessionEnd()`, which receives `reason: "deleted"`.
- Replace hashes of `ctx.resources` and `ctx.sourceRevision` with `ctx.templateKey()`, and replace writes of resource files plus your own `prepare` call with `ctx.populate()`. Remove `prepare` from your environment options; eve owns it.
- Replace `ctx.host.resolveProjectPath()` with `ctx.resolveProjectPath()`. `ctx.host.loadOptionalPackage()` is no longer available; import your SDK directly.
- Drop the explicit type arguments to `defineSandboxProvider()` and let TypeScript infer them. `environment()` now receives `{}` instead of `undefined` when the sandbox file passes no options.
- Rename `SandboxPreparedArtifact` to `SandboxJson`, `SandboxProviderSessionContext` to `SandboxSessionContext`, and `SandboxProviderPrepareContext` to `SandboxPrepareContext`. `SandboxProviderHandle`, `SandboxProviderResources`, `SandboxProviderResourceTree`, `SandboxProviderResourceFile`, `SandboxProviderResourceSource`, and `SandboxProviderTargetFile` are removed.

Two changes in `eve/sandbox` also affect code outside providers:

- `SandboxSession` has a required `writeFiles()` method, so an object you build by hand as a `SandboxSession`, such as a test double, needs one.
- `SandboxNetworkPolicy` is an eve-owned type with `readonly` members instead of the Vercel Sandbox SDK's `NetworkPolicy`. Code that passes a policy to the SDK directly needs its own conversion.

---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)