@molecule/api-code-sandbox-e2b

Provider bond · code-sandbox · API (Node) · v1.2.14 · Apache-2.0

E2B (e2b.dev) Firecracker microVM code sandbox provider — golden templates, fork, pause/resume

npm install @molecule/api-code-sandbox-e2b

npm · Source on GitHub · Implements @molecule/api-code-sandbox

How it works

@molecule/api-code-sandbox-e2b is a provider bond on the API (Node) side: it implements the code-sandbox core interface (@molecule/api-code-sandbox) with a concrete vendor or library behind it.

Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.

import { bond } from '@molecule/api-bond'
import { provider } from '@molecule/api-code-sandbox-e2b'

bond('codeSandbox', provider)
// Requires E2B_API_KEY (and E2B_TEMPLATE_ID for the golden superset template).

Works with: @molecule/api-bond, @molecule/api-i18n

Secrets: E2B_API_KEY, E2B_TEMPLATE_ID (optional)

Reference

E2B (e2b.dev) code sandbox provider.

E2B runs isolated Firecracker microVMs purpose-built for agent/dev workloads: a sandbox spawns from a golden template (a Dockerfile-built image with the whole dependency set baked in) in ~1s, exposes every internal port at https://<port>-<id>.e2b.app, pauses/resumes with filesystem + memory state preserved in ~1s, and governs outbound traffic by a DNS network policy. This bond maps that platform onto the @molecule/api-code-sandbox contract through the official e2b SDK.

The design that makes it fast: a single golden SUPERSET template carries the entire @molecule fleet node_modules + postgres + warmed Vite deps, so a boot only copies the ONE selected app's source in and starts the dev servers — no per-boot npm install. The app template sources are not baked into the image; they are copied in at boot.

Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit src/index.ts JSDoc, not this file.

Quick Start

import { bond } from '@molecule/api-bond'
import { provider } from '@molecule/api-code-sandbox-e2b'

bond('codeSandbox', provider)
// Requires E2B_API_KEY (and E2B_TEMPLATE_ID for the golden superset template).
import { createProvider } from '@molecule/api-code-sandbox-e2b'

const provider = createProvider({
  templateId: 'molecule-superset',
  defaultPreviewPort: 5173,
  // Deny-by-default egress: everything not listed here is blocked, raw IPs
  // included. An empty/omitted list applies NO policy at all.
  defaultAllowOut: ['registry.npmjs.org', '*.npmjs.org', 'github.com'],
})

Type

provider

Installation

npm install @molecule/api-code-sandbox-e2b @bufbuild/protobuf @molecule/api-bond @molecule/api-code-sandbox @molecule/api-i18n e2b

API

Interfaces

EgressProbeCodes

The three curl %{http_code} outputs an egress probe observes.

interface EgressProbeCodes {
  /** The allow-listed control host — must answer for the probe to mean anything. */
  allowed: string
  /** A non-allow-listed hostname. */
  deniedHost: string
  /** A raw IP outside the allowlist. */
  deniedIp: string
}

E2BConfig

Bond configuration. All fields have safe defaults; see createProvider.

interface E2BConfig {
  /**
   * E2B API key. Falls back to `E2B_API_KEY` in the environment. Required — the
   * provider throws on first use if neither is set.
   */
  apiKey?: string
  /**
   * The golden template id every sandbox boots from. Falls back to
   * `E2B_TEMPLATE_ID`, then E2B's `base` template. This is the caller's OWN
   * identifier for the superset template (fleet node_modules + postgres +
   * warmed vite deps), built out of band by the template pipeline.
   */
  templateId?: string
  /**
   * Default port the preview URL points at (the app's Vite dev server).
   * `getPreviewUrl()` uses this when no port is given.
   */
  defaultPreviewPort?: number
  /**
   * Default sandbox lifetime before E2B auto-pauses it, in milliseconds. The
   * control plane extends this per-heartbeat; this is only the initial ceiling.
   *
   * E2B caps this per account: 1 hour on Hobby, 24 hours on Pro. A value above
   * the account's cap is rejected at create time, so raising it is a decision
   * about the account, not just the config.
   */
  defaultTimeoutMs?: number
  /**
   * Egress allow-list (domains / CIDRs) applied to every sandbox at create
   * time; everything else is denied (`denyOut: [ALL_TRAFFIC]`). Wildcards like
   * `*.npmjs.org` are supported. Empty/omitted means the bond does NOT
   * constrain egress — prod must supply this or `verifyEgress` observes `open`
   * and the control plane refuses to boot.
   *
   * Verified against a live E2B sandbox: with `denyOut: [ALL_TRAFFIC]`, a
   * non-allowlisted host AND a raw destination IP are both blocked — a stronger
   * boundary than a DNS-only policy.
   */
  defaultAllowOut?: string[]
}

E2BCommandResultLike

Result of an E2B command run (subset of the SDK's CommandResult).

interface E2BCommandResultLike {
  stdout: string
  stderr: string
  exitCode: number
}

E2BFilesystemLike

Subset of the SDK's Filesystem the bond uses.

interface E2BFilesystemLike {
  read(path: string): Promise<string>
  /** Binary read (`format: 'bytes'`) — used by `exportFiles` to stream a tar out. */
  read(path: string, opts: { format: 'bytes'; requestTimeoutMs?: number }): Promise<Uint8Array>
  /** Accepts text or binary; `importFiles` writes a tar blob in. */
  write(path: string, data: string | Uint8Array | ArrayBuffer | Blob): Promise<unknown>
  list(path: string): Promise<Array<{ name: string; type?: string; size?: number }>>
  remove(path: string): Promise<void>
}

E2BCommandHandleLike

Handle to a command started with background: true (the SDK's CommandHandle).

The handle is what makes a backgrounded start HONEST: it carries the pid, and wait() resolves with the real exit code once the started process exits — even when it left a detached child behind. A launcher therefore learns whether its command was accepted instead of being handed a fabricated success.

interface E2BCommandHandleLike {
  /** The started process's pid inside the sandbox. */
  pid: number
  /** Output accumulated so far — readable before `E2BCommandHandleLike.wait` settles. */
  stdout?: string
  /** Error output accumulated so far. */
  stderr?: string
  /** Set once the process's exit is known; absent while it is still streaming. */
  exitCode?: number
  /**
   * Resolve when the started process exits. Rejects with the SDK's
   * `CommandExitError` (carrying `.result`) on a non-zero exit, and with a
   * timeout error when `timeoutMs` elapses first.
   */
  wait(): Promise<E2BCommandResultLike>
  /** Write to the process's stdin (requires `stdin: true` at start). */
  sendStdin(data: string | Uint8Array): Promise<void>
  /** Kill the process. */
  kill(): Promise<boolean>
  /** Stop streaming without killing the process. */
  disconnect?(): Promise<void>
}

E2BCommandRunOpts

Options accepted by the SDK's commands.run.

interface E2BCommandRunOpts {
  cwd?: string
  timeoutMs?: number
  envs?: Record<string, string>
  /** Keep stdin open so `E2BCommandHandleLike.sendStdin` works. */
  stdin?: boolean
  onStdout?: (data: string) => void
  onStderr?: (data: string) => void
}

E2BCommandsLike

Subset of the SDK's Commands the bond uses.

interface E2BCommandsLike {
  /**
   * Start a command and return a handle immediately. The bond always uses this
   * form: waiting inline blocks until the whole process GROUP ends, which never
   * happens for a launch that leaves a dev server running.
   */
  run(cmd: string, opts: E2BCommandRunOpts & { background: true }): Promise<E2BCommandHandleLike>
  /** Run a command to completion (the SDK's default). */
  run(cmd: string, opts?: E2BCommandRunOpts & { background?: false }): Promise<E2BCommandResultLike>
}

E2BPtyLike

Subset of the SDK's Pty module the bond uses.

A PTY is a different mechanism from a command, not a flag on one: it has its own create/input/resize/kill endpoints, and only it gives the sandbox side a controlling terminal (job control, so Ctrl-C becomes SIGINT; a negotiated width, so tools format to the real panel size).

interface E2BPtyLike {
  create(opts: {
    cols: number
    rows: number
    onData: (data: Uint8Array) => void
    cwd?: string
    envs?: Record<string, string>
    timeoutMs?: number
  }): Promise<E2BCommandHandleLike>
  sendInput(pid: number, data: Uint8Array): Promise<void>
  resize(pid: number, size: { cols: number; rows: number }): Promise<void>
  kill(pid: number): Promise<boolean>
}

E2BSandboxLike

Subset of the SDK's Sandbox instance the bond uses.

interface E2BSandboxLike {
  sandboxId: string
  commands: E2BCommandsLike
  /** Present on SDK builds that support pseudo-terminals; absent means no PTY. */
  pty?: E2BPtyLike
  files: E2BFilesystemLike
  getHost(port: number): string
  setTimeout(ms: number): Promise<void>
  kill(): Promise<void>
  /**
   * Suspend the sandbox (FS + memory snapshot). Resolves `false` when the API
   * answered 409 because it was ALREADY paused — which is a success, not a
   * failure, and the reason this is not typed as `void`.
   */
  pause?(): Promise<boolean>
  /** Deprecated alias of `E2BSandboxLike.pause`; same endpoint. */
  betaPause?(): Promise<boolean>
  isRunning(): Promise<boolean>
  updateNetwork?(opts: { allowOut?: string[]; denyOut?: string[] }): Promise<void>
}

E2BSandboxInfoLike

Subset of the SDK's SandboxInfo the bond reads.

This is what a sandbox looks like when you only LOOK at it. connect — which is how a handle is obtained — RESUMES a paused sandbox and extends its deadline, so it can never be used to answer "is this thing asleep?".

interface E2BSandboxInfoLike {
  sandboxId: string
  templateId?: string
  /** `'running'` or `'paused'`; anything else is treated as running. */
  state?: string
  /** Caller metadata supplied at create (the bond puts `projectId` here). */
  metadata?: Record<string, string>
  /** When the sandbox last started running. */
  startedAt?: Date | string
  /** When the sandbox's current deadline expires. */
  endAt?: Date | string
  /** Volumes mounted into the sandbox, when the account uses them. */
  volumeMounts?: Array<{ name: string; path: string }>
}

E2BSandboxListItem

One row of a sandbox listing.

name is the template the sandbox booted from (the SDK's alias) and volumeMounts is what it has attached. Both are here for the same reason: they are the only way to observe whether a volume or a snapshot is still IN USE, and deleting one that is destroys a running sandbox.

interface E2BSandboxListItem {
  sandboxId: string
  state?: string
  /** Template/snapshot name the sandbox booted from, when the API reports one. */
  name?: string
  /** Volumes attached to this sandbox. */
  volumeMounts?: Array<{ name: string; path: string }>
}

E2BVolumeLike

A team volume as E2B reports it.

interface E2BVolumeLike {
  name: string
  volumeId: string
}

E2BSnapshotLike

A snapshot as E2B reports it.

snapshotId is the namespaced, tag-qualified reference (<team-slug>/<name>:<tag>) — opaque, and the thing Sandbox.create() boots from. The bond's OWN identifier is the bare name the caller supplied.

interface E2BSnapshotLike {
  snapshotId: string
  names?: string[]
}

E2BSandboxClientLike

Subset of the SDK's Sandbox static surface the bond uses.

interface E2BSandboxClientLike {
  create(templateId: string, opts?: Record<string, unknown>): Promise<E2BSandboxLike>
  connect(sandboxId: string, opts?: Record<string, unknown>): Promise<E2BSandboxLike>
  list(
    opts?: Record<string, unknown>,
  ): Promise<E2BSandboxListItem[] | { sandboxes?: E2BSandboxListItem[] }>
  kill?(sandboxId: string, opts?: Record<string, unknown>): Promise<boolean>
  /**
   * Create a team volume. Optional: the volume API is a private beta on E2B, so a
   * client built against an account without it does not expose these at all.
   */
  createVolume?(name: string): Promise<E2BVolumeLike>
  /** Enumerate team volumes. */
  listVolumes?(): Promise<E2BVolumeLike[]>
  /** Destroy a volume by its provider-native id. */
  destroyVolume?(volumeId: string): Promise<boolean>
  /**
   * Capture a sandbox's filesystem + memory as a named snapshot. Re-using a name
   * assigns a new build to the SAME snapshot rather than creating a second one,
   * which is what makes a per-project restore point a fixed-size resource.
   */
  createSnapshot?(sandboxId: string, name: string): Promise<E2BSnapshotLike>
  /** Enumerate snapshots, optionally filtered to one exact name. */
  listSnapshots?(opts?: { name?: string; limit?: number }): Promise<E2BSnapshotLike[]>
  /** Delete a snapshot. Resolves `false` when there was nothing to delete. */
  deleteSnapshot?(snapshotId: string): Promise<boolean>
  /**
   * Read a sandbox's record WITHOUT connecting to it — the only lookup that does
   * not resume a paused sandbox. Throws `SandboxNotFoundError` on a 404.
   */
  getInfo?(sandboxId: string, opts?: Record<string, unknown>): Promise<E2BSandboxInfoLike>
  /**
   * Whether an error means "this sandbox does not exist", as opposed to "the
   * lookup failed". The distinction cannot be recovered from the error's shape by
   * a consumer, and getting it wrong is what makes a control plane treat a
   * provider outage as a destroyed sandbox.
   */
  isNotFound?(error: unknown): boolean
}

Classes

E2BSandboxProvider

E2B implementation of SandboxProvider.

The required surface (create/get/list/destroy) plus the optionals the control plane uses are all wired here: describe, volumes (createVolume/removeVolume/volumeExists/listVolumes), snapshots (commitTemplate/getTemplate/listTemplates/removeTemplate) and verifyEgress. verifyEgress proves deny-by-default by OBSERVING a throwaway probe sandbox, and answers inconclusive whenever it cannot look — a failed create, a dead probe — so "could not observe" never wears the shape of the safe verdict.

class E2BSandboxProvider implements SandboxProvider {
  readonly name = 'e2b'

  private readonly config: Required<Omit<E2BConfig, 'apiKey'>> & { apiKey: string }
  private clientPromise: Promise<E2BSandboxClientLike> | null = null
  private readonly clientOverride?: E2BSandboxClientLike

  /**
   * Construct the provider from config, resolving the API key and template id.
   *
   * @param config - Bond configuration; API key falls back to `E2B_API_KEY`.
   * @param clientOverride - Inject a fake client (tests).
   */
  constructor(config: E2BConfig = {}, clientOverride?: E2BSandboxClientLike) {
    const apiKey = config.apiKey ?? process.env.E2B_API_KEY ?? ''
    this.config = {
      apiKey,
      templateId: config.templateId ?? process.env.E2B_TEMPLATE_ID ?? 'base',
      defaultPreviewPort: config.defaultPreviewPort ?? DEFAULT_PREVIEW_PORT,
      defaultTimeoutMs: config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS,
      defaultAllowOut: config.defaultAllowOut ?? [],
    }
    this.clientOverride = clientOverride
  }

  /**
   * Resolve the SDK client, memoized. Throws when no API key is configured.
   *
   * A FAILED load is never left cached: `defaultClient` awaits `import('e2b')`,
   * and memoizing its rejected promise made one transient load failure
   * permanent — every later call (`create`, `get`, `list`, `destroy`, …) awaited
   * the same rejection for the life of the process, with no path back. The
   * cache is dropped on rejection so the next call retries, the same eviction
   * the other long-lived caches in this tree apply (the Whistle engine cache,
   * and that package's per-provider engine memo).
   *
   * @returns The E2B client (injected override or real SDK adapter).
   */
  private async client(): Promise<E2BSandboxClientLike> {
    if (this.clientOverride) return this.clientOverride
    if (!this.config.apiKey) {
      throw new Error('E2B provider requires an API key (config.apiKey or E2B_API_KEY)')
    }
    const cached = this.clientPromise
    if (cached) return cached
    const promise = defaultClient(this.config.apiKey)
    this.clientPromise = promise
    // The rejection still reaches the caller through `promise` itself; this
    // handler only evicts. Evict only OUR entry — a later retry may already have
    // replaced it.
    promise.catch((_error: unknown) => {
      if (this.clientPromise === promise) this.clientPromise = null
    })
    return promise
  }

  /**
   * Create a new sandbox from the golden template and apply the egress policy.
   *
   * The sandbox is created to PAUSE at its timeout, not to be killed. E2B's
   * default is `onTimeout: 'kill'`, which means a sandbox nothing has touched for
   * its lifetime is destroyed — and on this platform the sandbox is the only copy
   * of the project, so the default turns "the tab was closed over lunch" into
   * permanent data loss. `keepMemory` stays on: the pause snapshot restores the
   * process tree, which is what makes `resume()` honest when it reports
   * `processesPreserved: true`. A filesystem-only snapshot would cold-boot
   * instead, leaving a sandbox that is running with every dev server dead.
   *
   * `config.volumeName` mounts a persistent volume, which E2B can only attach at
   * CREATE time — there is no attach-to-a-running-sandbox call — so a sandbox
   * claimed from a pre-warmed pool can never be given one afterwards. A mount
   * path is REQUIRED alongside it: E2B mounts shadow whatever the image had at
   * that path, and this bond's superset template keeps a 2.4 GB
   * `/workspace/node_modules` there, so defaulting to the workspace root would
   * hide the entire dependency fleet behind an empty volume and boot a project
   * that cannot resolve a single import.
   *
   * @param config - Project id, env, optional per-boot templateId + labels, and
   *   an optional `volumeName` + `volumeMountPath` pair.
   * @returns A live sandbox handle.
   */
  async create(config: SandboxConfig): Promise<Sandbox> {
    return this.createSandbox(config, 'pause')
  }

  /**
   * `create` with the sandbox's timeout action chosen by the caller.
   *
   * `'pause'` is E2B's `lifecycle: { onTimeout: { action: 'pause', keepMemory:
   * true } }` — the data-safe default for anything that may hold a project
   * (see `create`). `'kill'` is `lifecycle: { onTimeout: 'kill' }` — for a
   * THROWAWAY sandbox this bond destroys itself (the egress probe): pausing one
   * at its deadline would write a full memory + filesystem snapshot to the
   * host's disk for a sandbox nobody will ever resume. E2B cannot change a
   * sandbox's `onTimeout` after create, so the choice is made here, once.
   *
   * @param config - As for `create`.
   * @param onTimeout - What E2B does at the sandbox's deadline.
   * @returns A live sandbox handle.
   */
  private async createSandbox(
    config: SandboxConfig,
    onTimeout: 'pause' | 'kill',
  ): Promise<Sandbox> {
    const client = await this.client()
    const templateId = config.templateId ?? this.config.templateId
    if (config.volumeName && !config.volumeMountPath) {
      // Refusing beats guessing: the only default that could be picked here is
      // the workspace root, and that is the one value which silently breaks the
      // sandbox it was meant to protect.
      throw new Error(
        `E2B requires a volumeMountPath alongside volumeName ("${config.volumeName}"): a volume mounted at the workspace root would shadow the template's node_modules`,
      )
    }
    const sbx = await client.create(templateId, {
      timeoutMs: this.config.defaultTimeoutMs,
      envs: config.env ?? {},
      metadata: { projectId: config.projectId, ...(config.labels ?? {}) },
      lifecycle:
        onTimeout === 'kill'
          ? { onTimeout: 'kill' }
          : { onTimeout: { action: 'pause', keepMemory: true } },
      ...(config.volumeName
        ? { volumeMounts: { [config.volumeMountPath as string]: config.volumeName } }
        : {}),
    })
    const handle = new E2BSandbox(sbx, client, {
      previewPort: this.config.defaultPreviewPort,
      timeoutMs: this.config.defaultTimeoutMs,
    })
    // Apply the egress allow-list immediately, before any user code runs.
    if (this.config.defaultAllowOut.length > 0) {
      try {
        await handle.applyNetwork(this.config.defaultAllowOut)
      } catch (error) {
        // The sandbox exists on E2B but the caller never receives its handle, so
        // nothing else can ever destroy it: it would run until its timeout and
        // then sit paused (billed storage) forever. Kill it before rethrowing.
        await this.destroy(sbx.sandboxId).catch((destroyError: unknown) => {
          throw new Error(
            `E2B sandbox ${sbx.sandboxId} was created but its egress policy failed to apply, and destroying it failed too — it is orphaned: ${destroyError instanceof Error ? destroyError.message : String(destroyError)}`,
            { cause: error },
          )
        })
        throw error
      }
    }
    return handle
  }

  /**
   * Resolve an existing sandbox by id.
   *
   * This is the caller-is-about-to-USE-it lookup, so it connects WITH the
   * configured timeout: E2B applies `timeoutMs` as the sandbox's new deadline
   * (`POST /v2/sandboxes/{id}/connect` carries `timeout` once a timeout is
   * given), which is what makes a `get()` of a paused sandbox both resume it
   * and give it a working deadline. Callers that must not touch a sandbox's
   * lifecycle read `describe` instead; enumeration goes through
   * `connectHandle` with the deadline reset switched OFF.
   *
   * @param id - The sandbox id.
   * @returns A live handle, or `null` if no sandbox has that id.
   */
  async get(id: string): Promise<Sandbox | null> {
    return this.connectHandle(id, true)
  }

  /**
   * Connect to `id` and wrap the live instance in a handle.
   *
   * `resetDeadline` decides whether the connect carries a `timeoutMs`, and with
// … truncated — the full declaration is in node_modules/@molecule/api-code-sandbox-e2b/dist/provider.d.ts

Functions

classifyEgressProbe(codes)

Turn the probe's curl codes into a verdict.

  • open only when a denied probe answered a real HTTP status — reachable egress was OBSERVED;
  • inconclusive whenever the allow-listed control host did not answer — a probe sandbox with no network at all blocks everything, which says nothing about the policy;
  • filtered only when the control host answered AND both denied probes were blocked (000 / empty).
function classifyEgressProbe(codes: EgressProbeCodes): EgressVerdict
  • codes — The observed curl codes.

Returns: The verdict.

createProvider(config, clientOverride)

Create an E2B provider with the given configuration.

function createProvider(
  config?: E2BConfig,
  clientOverride?: E2BSandboxClientLike,
): E2BSandboxProvider
  • config — Bond configuration; API key falls back to E2B_API_KEY.
  • clientOverride — Inject a fake client (tests).

Returns: A configured provider ready to bond('codeSandbox', provider).

Constants

NON_PUBLIC_IPV4_CIDRS

Every IPv4 range that is not the public internet: private (RFC 1918), shared/CGN, loopback, link-local (cloud metadata lives here), IETF protocol assignments, the documentation and benchmarking nets, and multicast/reserved. An "open" policy denies exactly these, so a sandbox can reach any public host and none of the network it runs in. E2B applies a CIDR in denyOut to resolved addresses as well, so a hostname that resolves into one of these ranges is refused too (verified live, 2026-10-14).

0.0.0.0/8 ("this network") is left out on purpose: E2B refuses it as a denied CIDR (400: invalid denied CIDR), which fails the WHOLE update, and it is not a routable destination anyway. Every entry here is one E2B accepts (each probed live, 2026-10-14).

const NON_PUBLIC_IPV4_CIDRS: readonly string[]

provider

Default provider instance, configured from the environment.

const provider: SandboxProvider

Core Interface

Implements @molecule/api-code-sandbox interface.

Bond Wiring

Setup function to register this provider with the core interface:

import { setProvider } from '@molecule/api-code-sandbox'
import { provider } from '@molecule/api-code-sandbox-e2b'

export function setupCodeSandboxE2b(): void {
  setProvider(provider)
}

Injection Notes

Requirements

Peer dependencies:

  • @molecule/api-bond ^1.0.1
  • @molecule/api-code-sandbox ^1.2.0
  • @molecule/api-i18n ^1.0.1

Environment Variables

  • E2B_API_KEY (required) — E2B API key
  • E2B_TEMPLATE_ID (optional) — E2B golden template id
    • Setup: The superset template id every sandbox boots from; defaults to E2B base.
    • Example: molecule-superset

Runtime Dependencies

  • @bufbuild/protobuf

  • @molecule/api-bond

  • @molecule/api-code-sandbox

  • @molecule/api-i18n

  • e2b

  • importFiles spools an incoming archive into the sandbox in 8 MB pieces whatever the size of the chunks it is given; exportFiles reads the whole archive into memory once, so bound what you export.

  • importFiles trusts the stream's length: an archive cut exactly between two tar members extracts cleanly (tar exits 0) and is reported complete. Give it a stream that errors on truncation (an authenticated encrypted stream does) or check a length or hash yourself. verifyEgress OBSERVES, it never attests. It boots a throwaway sandbox, applies { allowOut: [npm], denyOut: [ALL_TRAFFIC] }, and curls an allow-listed host, a non-allow-listed host AND a raw IP from inside it; filtered requires the last two to be blocked while the first answers. Any failure to run that probe is inconclusive — never filtered, because "I could not look" must not reach a control plane as "I looked, and it is safe".

A probe that sees NO network is inconclusive, not open. When the allow-listed control host does not answer either, the probe sandbox itself has no network and observed nothing about the policy. open requires a denied probe to answer a real HTTP status. (A no-network probe once read as open and kept a control plane from booting.)

E2B pauses, it does not stop. sleep()/stop() both map to E2B pause (FS + memory snapshot); wake()/start() reconnect by id. hibernate()/ resume() report processesPreserved: true because the memory snapshot restores the process tree — unlike a Docker stop, a resumed E2B sandbox's dev servers are still running.

get() RESUMES a paused sandbox — use describe() to look at one. Obtaining a handle is POST /sandboxes/{id}/connect, which resumes a paused sandbox and extends its deadline. That is right for a caller about to USE the sandbox and wrong for every status check: polling get() every few seconds silently un-hibernates every sleeping project and bills for the compute while the UI still says "asleep". describe(id) reads the record instead, reports sleeping for a paused sandbox, and changes nothing.

get() returns null ONLY for a sandbox that does not exist. Every other failure throws. A control plane reads null as "gone", detaches the project and rebuilds it from a template — and since an E2B microVM is the only copy of a project's files, answering a transient 5xx with null destroys the user's code. "I could not look" must never be delivered as "I looked, and it is not there". The same rule governs describe().

hibernate()/stop()/sleep() pause or THROW. They never resolve a success-shaped outcome for a sandbox that is still running: a caller's next act is to record the sandbox as stopped, and a control plane that believes a running sandbox is asleep bills for it and — with the kill timeout — watches it die at its deadline instead of hibernating. Note the converse trap: a pause is undone by the very next get(), since obtaining a handle connects. Anything that polls a stopped sandbox (status, logs, files) must go through describe().

exec() starts the command and waits on its HANDLE, never inline. E2B's inline commands.run waits for the whole process GROUP, so any command that leaves a detached child behind — nohup … >log 2>&1 &, i.e. every dev-server launch — blocks until the request deadline and then throws while the child is running perfectly. Starting in the background and awaiting the handle returns the STARTED process's real exit code in milliseconds, so a launcher learns whether its command was accepted. Do not reintroduce the old shortcut of sniffing the command string for a trailing &: it classified shell text instead of observing the process, and got both halves wrong — a launch shaped … & fi did not match and hung, while a user's npm run build & was answered with a fabricated empty success.

spawn() is what an editor and a terminal need, and exec() cannot give. It returns a live process: streaming stdout/stderr, writable stdin, kill(). Pass pty: { cols, rows } for a real controlling terminal — then Ctrl-C (0x03) becomes SIGINT for the foreground job and handle.resize({cols,rows}) renegotiates the width. Omit it for a language server, whose framed JSON-RPC a PTY would corrupt with echo and CR translation. A PTY request is REJECTED rather than downgraded when the SDK build has no pty module, because a terminal that silently got pipes is a terminal whose Ctrl-C does nothing.

Two ways to make a project outlive its sandbox, and they are not the same strength. A volume (SandboxConfig.volumeName + volumeMountPath) is continuous: every write already lives outside the microVM, so losing the sandbox loses nothing. A snapshot (commitTemplate, restored by create({ templateId })) is point-in-time: a persistent image that survives the sandbox, at the cost of everything written since the capture. Prefer the volume; take snapshots when the account has no volumes, or as restore points alongside one.

A volume needs a mount path, and create() refuses without one. E2B mounts shadow whatever the image had at that path, and this bond's superset template keeps a multi-GB /workspace/node_modules there — so the obvious default (the workspace root) is the one value that boots a project unable to resolve a single import. Mount the app directory instead (/workspace/<appDir>): the durable source lands on the volume and the regenerable tooling stays on the faster image-backed rootfs.

A volume can only be attached when the sandbox is created. There is no attach-to-a-running-sandbox call, so a sandbox claimed from a pre-warmed pool can never be given a project's volume afterwards — a project that needs one must be booted fresh with it.

Volumes are a private beta on E2B. An account without them answers 403 use of volumes is not enabled; ask E2B support to enable them. Every volume method throws in that state rather than no-op-ing, because a control plane that believes it has durable storage and does not is the failure this bond exists to prevent.

Snapshots ARE available and they do survive a kill — verified live: a sandbox was killed and a new one created from its snapshot came up with the same files, in ~2.3 s. Capturing takes well under a second, leaves the sandbox running, and re-using a name replaces that snapshot rather than adding one, so a per-project restore point is a fixed-size resource. It BRIEFLY pauses the sandbox and drops open connections (PTYs, command streams, websockets), so capture when a project goes quiet — never underneath a live terminal.

Sandboxes are created to PAUSE at their timeout, not to be killed. E2B's default is onTimeout: 'kill', so a sandbox nothing touched for its lifetime would be destroyed with its files. This bond creates every sandbox with lifecycle: { onTimeout: { action: 'pause', keepMemory: true } }; the memory snapshot is what lets resume() truthfully report processesPreserved: true. The one exception is the throwaway verifyEgress() probe, created with lifecycle: { onTimeout: 'kill' }: a pause writes a full snapshot to the host's disk, and nobody resumes a probe. Every pause and every commitTemplate() is a new snapshot build on a self-hosted box's disk, so a pause-on-timeout sandbox you do not need back is disk you pay for — keep a sandbox you intend to keep warm alive with keepAlive(ms), and destroy one you are done with rather than letting it time out. Extending the deadline is keepAlive(ms) — call it from a real activity signal (an open editor's heartbeat), never as a side effect of polling.