Sandbox interface, so an Adapter does not need to know whether the current provider is local Docker, a Vercel micro-VM, E2B, or a third-party cloud service.
The Sandbox interface
Common Adapter operations include:
Choose a provider
Set thesandbox field in the experiment:
A missing SDK does not fail silently: NiceEval errors the moment it creates the sandbox and prints the install command above, for example
Docker sandbox requires 'dockerode'. Install it with: pnpm add dockerode @types/dockerode.
Docker Compose: workspaceService names the main Sandbox
When a task needs more than one container — an app container plus a database, say — declare the whole Compose environment with dockerComposeSandbox:
workspaceService names which service in the Compose file is the main Sandbox. The agent, t.sandbox commands and file operations, workdir, and the change diff all land on that one container. Every other service in the Compose file — a database, a mock server, whatever — is an accompanying resource that never goes through the Sandbox interface: you cannot runCommand against it, upload or read files on it, or see its diff. To reach an accompanying service, rely on the task’s own networking — for example, reaching db:5432 from the client container over Compose’s built-in DNS.
Lifecycle
TheSandboxLayer returned by dockerSandbox({ source: { type: "image", image } }) / vercelSandbox({ snapshotId }) / e2bSandbox({ template }) has chainable .prepare(command), .setup(fn), and .teardown(fn) methods. They handle the parts of the environment you only know at runtime — writing a small config per experiment, checking that a prebuilt tool is available, installing a hook, or loading and saving state between attempts.
When a shared helper needs to explicitly annotate a callback’s type, import it from the public entry point — do not reverse-engineer it from some provider’s spec:
.setup() does only a thin layer of dynamic configuration and fail-fast checks.
- Signature: a hook function is
(sandbox, ctx)and returns nothing. Thectxhere is a narrow Sandbox Hook Context — only the experiment identity, the cancellation signal, and the feedback methods. It carries no Agent session and no telemetry. - Immutable: every
.setup()/.teardown()returns a new spec and leaves the original untouched, so you can keep chaining. - Multiple hooks: multiple
.setup()hooks run in the order they were appended; multiple.teardown()hooks run in the reverse of that order (LIFO). If a.setup()chain throws partway through, the remaining.setup()hooks do not run, but the full.teardown()chain still runs to completion — a half-initialized Sandbox still needs cleanup. - Timing:
setuphooks run first — after the Sandbox is created and before the git baseline. The files they write go into the baseline and are not counted in the agent’s diff.teardownhooks run last — after the Adapter’steardownand before the Sandbox is destroyed. That is exactly the right moment to save state to an external store. - Trigger rule:
teardownruns if and only if the same Sandbox’ssetuppoint was reached — asetupthrow does not exempt it, so cleanup code must defend against handles that may not have been assigned; ifsetupnever ran (the Sandbox failed to be created),teardownis skipped too. - Failure semantics: if a
setuphook throws, the attempt is recorded aserrored(an environment problem, not the Agent getting the task wrong). Ateardownhook can report a diagnostic and, by default, does not change the verdict you already have. If some cleanup action is a precondition for the result to be valid, throw — the runner records it explicitly as a fatal error. - Agents without a sandbox: an Agent built with
defineAgenthas no sandbox, thesandboxfield does not apply to it, and the hooks never run.
Pass setup’s output to teardown
setup returns nothing. When teardown needs a handle that setup built — an already-open connection, a temp file path — it cannot be stored in an ordinary module variable: the same spec is reused by the same module across multiple concurrent attempts, and a module variable gets overwritten by whichever attempt runs later. Key it off the sandbox instance instead: each attempt has its own sandbox, which is a natural per-attempt key:
ctx.experimentId, and ordinary code is enough — see the next paragraph.
Inside a hook you can use ctx.experimentId (the experiment id derived from the path) as the key for isolating state — for example, so different experiments each keep their own cross-attempt cache. Loading and saving cross-attempt state is ordinary code you write in the hook yourself; NiceEval does not provide state storage. To make sure attempts of the same experiment do not read and write the same state concurrently, declare maxConcurrency: 1 on the experiment.
Report progress and problems from hooks
Long installs, warm-ups, and state restores can callctx.progress(...). It only updates the short-lived status of the current attempt; it does not write every step into the result. For problems you still want to see after the run finishes, use ctx.diagnostic(...):
progress nor diagnostic can pick a global phase, a color, or an output stream. The Runner knows the current callback belongs to sandbox.setup and shows the phase as Sandbox setup in the human terminal. A diagnostic is written to an Attempt-owned diagnostics channel and can later be reviewed with niceeval show --run <runId> --page attempt-<attemptId>. It does not change the Verdict by itself. Throw when the Sandbox cannot continue.
Each of the three setups has its own job: the Adapter’s setup handles connecting to the agent under test — for example, installing the CLI and writing auth config; the code at the top of test(t) in an eval prepares the task’s starting files; the Sandbox’s .setup() handles the current experiment’s extra environment setup. MCP servers, skills, and the model — the configuration of the agent under test — still only come in through the Adapter factory arguments.
Prebuilt environments and runtime checkpoints
NiceEval references prebuilt environments through a typed spec, but does not offer a fake universal build command:Build on the official baselines to speed things up
Content that is stable, large, and identical for every attempt — system packages, Agent CLIs, compiled binaries, large model caches — should be baked into the provider’s publishable artifact before you run evals, so every attempt starts from a prebuilt environment and skips the runtime install. All three built-in providers can derive from an official baseline, so you never have to install the agent from a blank environment. Their build tooling, credentials, and publishing semantics differ, though, so NiceEval only unifies how you consume the artifact ID (image / snapshotId / template) and does not fabricate a cross-provider build DSL.
Adapters and Sandboxes do not guess each other’s configuration: the Adapter checks for the CLI it needs, and the Sandbox spec picks the provider and the artifact. Claude Code and Codex fall back to a runtime install when the CLI is missing, so baking them in is purely a speed-up. Bub also verifies an install fingerprint over its version, OTel plugin, and Python plugin set, so command -v bub alone is not enough to skip the install — it requires a prebuilt environment. For all three providers, the build runs once when environment dependencies change, and the artifact takes a new versioned name. Do not push it into every attempt’s .setup().
E2B: derive from official and public templates
E2B already provides aclaude template for Claude Code and a codex template for Codex. NiceEval ships a thin E2B-specific wrapper that lets you keep chaining the native E2B API from those two official starting points. E2B has no official Bub template yet, so the Bub branch uses a NiceEval install recipe with pinned versions (a Bub release plus a same-generation OTel plugin).
NiceEval also publishes three public templates that any E2B team can reference. NiceEval itself maintains the full namespace and the verified release tag; downstream code just takes the complete reference:
2.1.207, Codex 0.144.1, Bub 0.4.0 (its install fingerprint moves with the version and OTel plugin pins). A template’s version follows the agent it ships (0.144.1-r1: agent version plus NiceEval’s recipe revision); the three agents are released independently, and NiceEval’s own library version never appears in the tag. So use the constants directly instead of copying these strings or tracking versions — including when a derived template needs to record which base it came from.
scripts/build-e2b-template.ts
e2bCodingAgentTemplate("claude-code" | "codex" | "bub") returns a native TemplateBuilder, not a private NiceEval build DSL. You can keep using .aptInstall(), .runCmd(), .copy(), and the rest of E2B’s capabilities. Building your own alias freezes the official starting point and your project’s dependencies into a single reproducible artifact; rebuild and pick a new versioned alias when dependencies change.
If the Bub Adapter is configured with pythonPlugins, pass the same set of packages to the factory when you build the template. Only then does the plugin set enter the compatibility fingerprint and actually hit the preinstalled environment:
Docker: use NiceEval-maintained images, or derive from the official node base image
To run NiceEval’s built-inclaude-code, codex, or bub Adapters directly, use the matching public image: niceeval/claude-code, niceeval/codex, or niceeval/bub. Each image contains only its own Agent CLI and publishes a manifest for linux/amd64 and linux/arm64. Its tag matches the corresponding E2B public template — the version position is the version of the agent inside the image. Stable CI should use the named constant or a digest, not the moving latest:
library/* Official Image. A new tag is published when the agent version or the build recipe changes, independently of NiceEval’s own release cadence.
If you only need one agent, or you also need to add project-specific dependencies, write a Dockerfile that derives from Docker’s official baseline, node:24-slim:
Dockerfile
USER, so the container runs commands as root by default, and /usr/local/bin is already on root’s PATH, so global binaries installed with npm install -g are visible out of the box. When you need a non-root identity (for example, Claude Code refuses --dangerously-skip-permissions under root), add USER node to the Dockerfile (the node:24-slim image already has that user), or override it explicitly with dockerSandbox({ source: { type: "image", image }, user: "node" }). If an agent installs somewhere else (for example into ~/.local/bin), remember to put that directory on the PATH. dockerSandbox requires an explicit image; stable CI should reference an immutable tag.
Vercel: snapshot the official runtime
Vercel has no E2B-style template registry and no Dockerfile; a Sandbox snapshot is taken from a microVM that is already running. Use the Vercel SDK to start a Sandbox from the official runtime (node24), install the Agent CLI, call .snapshot() to get a snap_..., then hand it to vercelSandbox({ snapshotId }):
scripts/build-vercel-snapshot.ts
snap_7sIjfs71xfmVly0WEUTGhTBoMGeL, but it is not a cross-account public ID.
Runtime checkpoints
createCheckpoint() / restoreCheckpoint() are a different thing. They pack the Linux paths you name into a Buffer, so you can restore a slice of the file system into an already-created Sandbox:
Transient error retries
When a built-in provider creates a Sandbox, transient failures — rate limiting,fetch failed, connection resets, 5xx, temporary network unreachability — automatically retry with exponential backoff. Configuration errors, such as a missing template or missing credentials, fail on the first try. Once retries are exhausted, the attempt is recorded as errored. defineSandbox’s custom provider create is your own function; NiceEval does not retry it for you.
readText, readBytes, writeText, writeBytes, pathExists, and the upload/download operations automatically make a bounded number of retries on transient transport errors: 429, 5xx, fetch failed, connection resets. Missing files, permission errors, cancellation, and a terminated Sandbox are not retried.
runCommand and runShell do not retry automatically. A command may already have produced side effects, so retry it explicitly in a hook or an eval only when you can confirm it is safe to repeat.
Docker
Docker is good for local development and standard CI. It is simple, controllable, and has no cloud dependency. The tradeoff is limited machine resources, plus slower cold starts and dependency installs.Vercel Sandbox
Vercel Sandbox is good when you want cloud isolation, more resources, or a more stable environment. It requires the right token or OIDC setup.Local directory
localSandbox() runs the agent directly inside a Git repository on your own machine — no Docker, no cloud credentials:
localSandbox({ dir: "/path/to/repo" }).
NiceEval only observes — it never reverts. Whatever the agent changes lands for real on your working tree; NiceEval captures the diff and scores it with its own private Git ledger, which never touches your .git, your staged changes, or your uncommitted work, and never runs git reset when the run finishes. Whether to keep or discard the agent’s changes is entirely up to you.
Three things to keep in mind before you use it:
- The agent runs commands as you, on your machine. Only use it when you trust the task and the prompt; use Docker or a cloud provider when you need isolation.
- The local directory runs only one attempt at a time (forced serial);
--max-concurrencycannot raise it. - When you run several evals back to back, one eval’s changes stay on the working tree and become the starting point for the next. Use a container provider when you need a clean starting point for every eval.
{ user: "..." } and --keep-sandbox are unavailable for the local directory and fail immediately: NiceEval does not escalate privileges or switch identity on your machine, and there is nothing to “retain” — the workspace already sits exactly where it was.
Custom provider
UsedefineSandbox to plug in another service. The feedback given to create is already bound to the sandbox.create phase, so it can report on allocating an instance, pulling an image, or restoring a Sandbox snapshot:
Sandbox interface. Do not write the provider SDK’s raw logs straight to the host process’s stdout / stderr. Short-lived status goes through feedback.progress, problems worth keeping go through feedback.diagnostic, and if the environment cannot be created, throw. That keeps the human dashboard from being torn apart by logs and keeps CI output single and ordered.
Permissions and execution identity
Providers differ in what they allow around execution identity, networking, the file system, and process lifecycle. When writing Fixtures, avoid depending on the host machine’s environment; put dependencies inpackage.json or in Fixture setup.
Performance advice
- Bake stable, heavy dependencies into an image/template/snapshot instead of reinstalling them in every attempt’s
.setup(). - Keep Fixture dependencies small.
- Use small, explicit caches or preflight checks for dynamic content.
- Tune
maxConcurrency(the experiment field or--max-concurrency) so local Docker does not run out of resources. - Split slow tests into required gates and optional soft checks.