# Sandbox providers (/docs/coding-agent/sandbox-providers)



`@pleach/coding-agent/sandbox` ships a small facade — `SandboxProvider`
— that sits ABOVE the three endpoint-shape adapters in
`@pleach/sandbox` (`httpStream` / `httpPoll` / `childProcess`). The
adapters know how to talk to a vendor; the facade decides when to
acquire, when to reuse a pooled session, and what high-level value the
coding-agent loop holds across a turn.

The shape this page documents is the layer ABOVE the `SandboxAdapter`
contract in `@pleach/core` (see
[Sandbox](/docs/sandbox) for the lower layer) — not a replacement.

## What you get [#what-you-get]

Two factories. Both vendor-neutral; vendor coupling lives in the
underlying `@pleach/sandbox` adapter.

```ts
import {
  createPooledSandboxProvider,
  createSandboxComposite,
} from "@pleach/coding-agent/sandbox";
```

| Factory                                               | What it returns                                                                                       | When you reach for it                                                 |
| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `createPooledSandboxProvider({ adapter, poolSize? })` | `SandboxProvider` with `acquire()` / `release()`                                                      | Once per runtime. The provider holds a bounded pool of warm sessions. |
| `createSandboxComposite({ session })`                 | `SandboxComposite` with `install` / `upload` / `download` / `diff` / `env` / `gitClone` / `benchmark` | Once per acquired session, inside the tool loop.                      |

The provider is the long-lived value. The composite is the per-session
value. The coding-agent runtime holds the provider for its lifetime and
constructs a composite each time the agent's loop enters a turn.

## The three endpoint shapes (below) [#the-three-endpoint-shapes-below]

The provider's `adapter` field is one of the three endpoint-shape
adapters from `@pleach/sandbox`:

| Adapter                             | Wire shape                                                 | Where vendors compose in                       |
| ----------------------------------- | ---------------------------------------------------------- | ---------------------------------------------- |
| `createHttpStreamSandboxProvider`   | REST + `text/event-stream` for `/execute/stream`           | Modal, Vercel Sandbox, E2B (streaming variant) |
| `createHttpPollSandboxProvider`     | REST; `executeStream` buffers and emits one terminal chunk | Daytona, Azure Container Instances             |
| `createChildProcessSandboxProvider` | `child_process.spawn` + `node:fs/promises`                 | Local dev, in-CI sandboxing                    |

Plus a first-class AWS Fargate provider (`createFargateSandboxProvider`)
that owns the ECS task lifecycle and delegates the in-container surface
to the canonical HTTP + SSE shape.

Vendor wiring is one-page cookbook recipes — see
`packages/sandbox/ADAPTERS.md`
for full configurations covering Modal, E2B, Daytona, Vercel Sandbox,
AWS Fargate, Azure Container Instances, and local Docker. Each entry
shows the auth callback, path overrides, and capability hints for the
vendor.

## Pooled provider [#pooled-provider]

```ts
import { createPooledSandboxProvider } from "@pleach/coding-agent/sandbox";
import { createHttpStreamSandboxProvider } from "@pleach/sandbox";

const adapter = createHttpStreamSandboxProvider({
  baseURL: process.env.MODAL_SANDBOX_BASE_URL!,
  auth: async () => ({
    headers: { Authorization: `Bearer ${process.env.MODAL_TOKEN!}` },
  }),
});

export const provider = createPooledSandboxProvider({
  adapter,
  poolSize: 1, // default — per-runtime singleton
});
```

`poolSize` defaults to `1`, which matches the chat-app baseline (one
session warm at a time). Raise it for parallel-verify swarms — when
each sub-agent in a swarm needs its own isolated workspace, set
`poolSize: 4` (or however many sub-agents you fan out to).

### Acquire and release [#acquire-and-release]

```ts
const session = await provider.acquire({
  workspaceDir: "/workspace",
  runtime: { language: "node" },
});

try {
  await session.client.exec("npm install");
  // ...do work...
} finally {
  await provider.release(session);
}
```

The session is a `{ id, client, runtime, handle }` value. The
coding-agent tool factories ([file tools](/docs/coding-agent/file-tools))
consume `session.client` — the 3-method `SandboxClient` contract.
`session.handle` is the underlying `@pleach/sandbox` handle, opaque
for the typical case; reach for it only when you need the wider
8-method `@pleach/sandbox` `SandboxProvider` contract
(`listFiles` / `isAlive` / `capabilities` / `executeStream`).

### Idempotency on `sessionId` [#idempotency-on-sessionid]

```ts
const a = await provider.acquire({ sessionId: "chat-42" });
const b = await provider.acquire({ sessionId: "chat-42" });
// a === b — pool short-circuits on the caller-supplied id
```

When the caller supplies a `sessionId` that's already pooled, the
provider returns the existing session whether idle or in-use. This is
the load-bearing primitive for re-attaching to a session across a
restart — pair it with a persistent `chatId` → `sandboxSessionId`
mapping at the host layer.

## The composite tool surface [#the-composite-tool-surface]

`SandboxComposite` is the layer the coding-agent loop holds for the
session's lifetime. Seven composite ops, each a thin shell over
`session.client.exec` + `readFile` + `writeFile`.

```ts
import { createSandboxComposite } from "@pleach/coding-agent/sandbox";

const composite = createSandboxComposite({ session });
```

| Op                                                      | Returns                                                     | Notes                                                                                                                        |
| ------------------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `install(packages, opts?)`                              | `SandboxExecResult`                                         | Probes for lockfile (`pnpm` / `yarn` / `bun` / `npm` / `poetry` / `uv` / `pip`) when `manager: "auto"`; falls back to `npm`. |
| `upload(local, remote, opts?)`                          | `void`                                                      | Reads `local` via `node:fs/promises` (host) and writes via `session.client.writeFile`.                                       |
| `download(remote, local, opts?)`                        | `void`                                                      | Symmetric — reads from sandbox, writes to host.                                                                              |
| `diff(pathA, pathB, opts?)`                             | `{ unifiedDiff, differs }`                                  | Shells out to `diff -u`; `differs` is `true` when exit code is `1`.                                                          |
| `env.get(name)` / `env.set(name, value)` / `env.list()` | `string \| undefined` / `string` / `Record<string, string>` | `set` writes to `.pleach-env`; consumer sources it before subsequent execs.                                                  |
| `gitClone(url, opts?)`                                  | `SandboxExecResult`                                         | Defaults: `--depth 1`, destination = repo basename.                                                                          |
| `benchmark(command, opts?)`                             | `BenchmarkResult`                                           | N iterations (default 3); returns durations, mean, min, max, per-iteration exit codes.                                       |

### `install` — package-manager probe [#install--package-manager-probe]

The `auto` mode runs one probe exec, looking for lockfiles in order:
`pnpm-lock.yaml`, `yarn.lock`, `bun.lockb`, `package-lock.json`,
`poetry.lock`, `uv.lock`, `requirements.txt`. First hit wins; no
match falls through to `npm`. Pin a manager to skip the probe:

```ts
await composite.install(["zod", "valibot"], {
  manager: "pnpm",
  dev: true,
});
```

### `env.set` and the `.pleach-env` file [#envset-and-the-pleach-env-file]

The composite does NOT mutate session-wide env in-band — most sandbox
transports don't expose a "persist env" wire op. Instead, `env.set`
appends to a `.pleach-env` file at the workspace root; subsequent
execs source it explicitly:

```ts
await composite.env.set("FOO", "bar");

await session.client.exec("set -a; . .pleach-env; set +a && my-tool");
```

`env.list` runs `printenv` against the live session — its result
reflects what the sandbox sees right now, not what `.pleach-env`
contains. Treat the two as orthogonal.

### `benchmark` for cost-aware loops [#benchmark-for-cost-aware-loops]

```ts
const result = await composite.benchmark("pnpm vitest run", {
  iterations: 5,
  timeoutMs: 60_000,
});
console.log(result.meanMs, result.minMs, result.maxMs);
console.log(result.exitCodes); // any non-zero indicates a failed iteration
```

The composite does NOT warm up the workspace between iterations —
callers handle that out of band. Pair with [eval and replay](/docs/eval-and-replay)
to capture per-iteration durations into the audit ledger.

## Wiring into a coding-agent runtime [#wiring-into-a-coding-agent-runtime]

```ts
import { createCodingAgentRuntime } from "@pleach/coding-agent/runtime";
import { createPooledSandboxProvider } from "@pleach/coding-agent/sandbox";
import { createHttpStreamSandboxProvider } from "@pleach/sandbox";

const adapter = createHttpStreamSandboxProvider({
  baseURL: process.env.MODAL_SANDBOX_BASE_URL!,
  auth: async () => ({
    headers: { Authorization: `Bearer ${process.env.MODAL_TOKEN!}` },
  }),
});

const provider = createPooledSandboxProvider({ adapter, poolSize: 1 });

const runtime = createCodingAgentRuntime({
  sandboxProvider: provider,
  // ...rest of SessionRuntimeConfig
});

await runtime.start({ sessionLabel: "chat-42" });
```

The runtime owns the provider's lifetime — `runtime.stop()` invokes the
provider's `dispose` / `destroy` / `shutdown` if present (duck-type
dispatch).

## What's vendor-neutral, what isn't [#whats-vendor-neutral-what-isnt]

The provider, the composite, and the tool factories are vendor-neutral
by construction. The only vendor-coupling point is the `adapter` you
pass to `createPooledSandboxProvider`. Swap the adapter — keep
everything else.

The composite does NOT branch on `session.handle.provider`. Vendor
optimizations (a vendor's bulk-upload endpoint, a vendor's faster
exec shape) belong on the underlying adapter, not in the composite.
This keeps the surface stable as vendors come and go.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="File tools" href="/docs/coding-agent/file-tools" description="The 8 file/diff/exec tools that consume SandboxClient." />

  <Card title="Sandbox" href="/docs/sandbox" description="The lower-layer SandboxAdapter contract in @pleach/core." />

  <Card title="Multi-synthesize per turn" href="/docs/coding-agent/multi-synthesize" description="Roadmap — the per-runtime maxSynthesizePerTurn knob." />

  <Card title="SWE-Bench recipe" href="/docs/coding-agent/swe-bench-recipe" description="EvalLab + SWE-Bench Lite + DivergenceReporter composition." />
</Cards>
