Skip to content
pleach
Coding agent

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";
FactoryWhat it returnsWhen 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 / benchmarkOnce 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:

AdapterWire shapeWhere vendors compose in
createHttpStreamSandboxProviderREST + text/event-stream for /execute/streamModal, Vercel Sandbox, E2B (streaming variant)
createHttpPollSandboxProviderREST; executeStream buffers and emits one terminal chunkDaytona, Azure Container Instances
createChildProcessSandboxProviderchild_process.spawn + node:fs/promisesLocal 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.

The adapter guide, packages/sandbox/ADAPTERS.md, is organized by backend shape rather than by vendor: the HTTP wire shape, the AWS ECS Fargate provider, and the local child-process provider. It shows the auth callback and path overrides for each shape, and explains how to front a vendor whose API doesn't match the wire protocol (run a small gateway, or implement SandboxProvider directly). @pleach/sandbox ships no per-vendor packages or configurations; vendor coupling lives in your own gateway or adapter config.

Pooled provider

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

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

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

auth may return the wrapped form shown here or a flat header map (async () => ({ Authorization: `Bearer ${token}` })); both send the same request headers. A result counts as wrapped only when its single key is headers holding an object. { headers: null }, or returning null / undefined, means no auth headers. Every header value must be a string, and anything else throws a TypeError that names the header without echoing its value.

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

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 });
OpReturnsNotes
install(packages, opts?)SandboxExecResultProbes for lockfile (pnpm / yarn / bun / npm / poetry / uv / pip) when manager: "auto"; falls back to npm.
upload(local, remote, opts?)voidReads local via node:fs/promises (host) and writes via session.client.writeFile.
download(remote, local, opts?)voidSymmetric — 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?)SandboxExecResultDefaults: --depth 1, destination = repo basename.
benchmark(command, opts?)BenchmarkResultN 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 iteration

The 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.SANDBOX_BASE_URL!,
  auth: async () => ({
    headers: { Authorization: `Bearer ${process.env.SANDBOX_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