Sandbox providers
The SandboxProvider facade over @pleach/sandbox's three endpoint-shape adapters, plus the SandboxComposite high-level tool surface coding agents reach for.
@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 for the lower layer) — not a replacement.
What you get
Two factories. Both vendor-neutral; vendor coupling lives in the
underlying @pleach/sandbox adapter.
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 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
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
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)
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
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 idWhen 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
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.
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
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:
await composite.install(["zod", "valibot"], {
manager: "pnpm",
dev: true,
});env.set 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:
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
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 iterationThe composite does NOT warm up the workspace between iterations — callers handle that out of band. Pair with eval and replay to capture per-iteration durations into the audit ledger.
Wiring into a coding-agent runtime
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
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
Compliance · GDPR right-to-erasure
executeErasureRequest — the structural primitive for Article 17 erasure against an append-only hash chain. Reads the fundamental tension honestly; surfaces both approaches.
File tools
The eight vendor-neutral file / diff / exec tool factories — read_file, write_file, apply_diff, search_files, list_files, run_tests, git_clone, git_diff.