Depot Sandbox SDK reference | API | Depot Docs

API

Sandboxes are billed per vCPU-second at the Depot CI compute rate.

The @depot/sandbox package is the Node.js SDK for Depot sandboxes. It wraps the depot.sandbox.v1 API with ergonomic classes for creating sandboxes, running commands, streaming command output, and working with a sandbox's file system through a node:fs/promises-shaped interface. The source is on GitHub at depot/sandbox-sdk.

Sandboxes run on Depot Metal, Depot's compute and storage platform. No separate platform configuration is required.

The SDK requires Node.js 20 or newer. The package ships as ES modules.

Installation

pnpm add @depot/sandbox

The package ships its compiled protobuf bindings, so you don't need a separate proto module.

Quickstart

Set DEPOT_TOKEN in your environment, then create a client and use it to create a sandbox, run a command, read its output, write a file, and stop the sandbox:

import {createClient, Sandbox} from '@depot/sandbox'

const client = createClient({token: process.env.DEPOT_TOKEN!})

const sandbox = await Sandbox.create(client, {
  env: {NODE_ENV: 'development'},
})

const command = await sandbox.runCommand({cmd: '/bin/sh', args: ['-c', 'echo hello from depot']})
const finished = await command.wait()

console.log(finished.exitCode) // 0
console.log(await command.stdout()) // "hello from depot\n"

const fs = sandbox.fs()
await fs.writeFile('/tmp/message.txt', 'hello')
console.log(await fs.readFile('/tmp/message.txt', {encoding: 'utf8'})) // "hello"

await sandbox.stop({blocking: true})

Create a client

createClient builds an authenticated client that you pass to the static Sandbox entry points. Sandbox instances returned by the SDK keep a reference to that client, so instance methods don't take a client argument.

import {createClient, Sandbox} from '@depot/sandbox'

const client = createClient({token: process.env.DEPOT_TOKEN!})

createClient accepts the following options:

const client = createClient({
  token: process.env.DEPOT_TOKEN!,
  orgID: process.env.DEPOT_ORG_ID,
})

Sandboxes

A Sandbox represents one sandbox running on the server. You don't construct it directly: use the static Sandbox.create, Sandbox.get, Sandbox.list, or Sandbox.listAll entry points, each of which takes the client as its first argument.

Create a sandbox

Sandbox.create provisions a new sandbox. Every option is optional; the server fills in defaults for anything you leave unset.

const sandbox = await Sandbox.create(client, {
  name: 'my-sandbox',
  resources: {vcpus: 2, memoryMb: 4096, diskGb: 100},
  env: {NODE_ENV: 'development'},
  timeoutMinutes: 240,
})

Options (CreateSandboxOpts):

Choose x86 or ARM

Set architecture when creating a sandbox. Both architectures run Linux.

const sandbox = await Sandbox.create(client, {
  architecture: 'arm64',
})

Use 'amd64' for x86-64. You can read the selected architecture from sandbox.architecture.

Custom images must come from Depot Registry, using registry.depot.dev or a Depot registry subdomain that your organization can access. Public registries aren't supported. The image must include a variant for the chosen platform, such as linux/arm64; otherwise the sandbox fails during boot.

Use Depot CI secrets

Use existing Depot CI secrets without putting secret values in your code. Map each environment variable name to a CI secret name in configuration.secrets:

const sandbox = await Sandbox.create(client, {
  configuration: {
    secrets: {DATABASE_URL: 'DATABASE_URL'},
  },
})

Organization Members can select organization-wide secrets. To select secrets scoped to a repository or environment, use Owner access and supply repository, environment, or both:

const sandbox = await Sandbox.create(client, {
  configuration: {
    secrets: {DATABASE_URL: 'DATABASE_URL'},
    repository: 'my-org/my-repo',
    environment: 'staging',
  },
})

A non-reader organization token also satisfies the Owner requirement. Commands and file system access on a sandbox with scoped secrets require the same access. Sandboxes with repository- or environment-scoped secrets cannot be snapshotted.

Secret values are injected into every command and override matching env values. Depot selects the secret variant when the sandbox is created and reads its current value for each command. Rotating that variant takes effect on the next command; deleting it causes subsequent commands to fail. Command output does not mask secret values, so avoid printing them.

Tailscale

Sandboxes automatically join your organization's tailnet when the Tailscale integration is configured and enabled for sandboxes. Commands can then access private services through the tailnet without a separate connection step in your SDK code.

To keep a sandbox off the tailnet, set disableTailnet: true in Sandbox.create.

The sandbox image must include tailscale, tailscaled, and curl. The connection starts in the background, so a sandbox can be running before it can reach tailnet addresses. Wait for connectivity before running commands that depend on private services. The sandbox uses your organization's configured Tailscale tags and can reach the resources those tags allow.

Depot Cache

Sandboxes use Depot Cache by default. Depot supplies cache credentials and configuration for supported build tools, so you don't need to pass a separate cache token. Existing tool-specific cache environment variables and configuration files take precedence over automatic configuration.

To disable automatic cache configuration for a sandbox, set disableDepotCache: true in Sandbox.create.

Depot configures these environment variables for each command:

Tool Environment variables
sccache SCCACHE_WEBDAV_ENDPOINT, SCCACHE_WEBDAV_TOKEN
Turborepo TURBO_API, TURBO_TEAM, TURBO_TEAMID, TURBO_TOKEN
Nx NX_SELF_HOSTED_REMOTE_CACHE_SERVER, NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN
Other tools DEPOT_CACHE_HOST, DEPOT_CACHE_TOKEN

If the sandbox's or command's env sets any variable in a tool's group, Depot skips the entire group. The cache token permits reads and writes within your organization's cache. Disable automatic cache configuration when running untrusted code that should not read cached outputs or write entries used by other workloads.

Depot also configures tools that read settings from files in the command's home directory:

Depot preserves existing configuration files. When a restored file contains a previous sandbox's Depot Cache token, Depot replaces that token with the new sandbox's token. Tools such as Go still need manual configuration; follow their integration guides using DEPOT_CACHE_TOKEN.

Snapshot and resume

Call sandbox.snapshot() while the sandbox is running to save its files. Pass the returned snapshot to Sandbox.create to restore those files into a new sandbox:

const sandbox = await Sandbox.create(client)
await sandbox.fs().writeFile('/home/runner/message.txt', 'Continue from here')

const snapshot = await sandbox.snapshot()
await sandbox.stop({blocking: true})

const resumed = await Sandbox.create(client, {snapshot})
console.log(await resumed.fs().readFile('/home/runner/message.txt', {encoding: 'utf8'}))

await resumed.stop({blocking: true})

You can store snapshot.snapshotId and pass that string as snapshot in a later call. Snapshots belong to your organization. The new sandbox uses the snapshot's runtime and architecture by default; an explicit architecture must match the snapshot. Sandbox.create returns after the files are restored.

Snapshots capture the sandbox user's home directory and /workspace when that directory exists and is writable. Home-directory caches and image-provided toolchains are excluded. Snapshots preserve files, not running processes or memory, and are limited to 25 GiB of file data. Files outside those directories, such as files in /tmp, are not included. Finish commands that write files before taking a snapshot.

The sandbox image needs /bin/sh and Node.js 22.19 or newer, available as node on PATH, for snapshot capture and restore. This requirement is separate from the SDK's Node.js 20 requirement on your own machine.

The entire writable /workspace directory is captured, including dependencies such as node_modules and .venv. In the home directory, snapshots exclude:

Wait for Sandbox.create to resolve before using a restored sandbox. A separate Sandbox.get call can report running before restoration finishes. If restoration fails, Depot cancels the new sandbox and Sandbox.create throws.

The returned Snapshot includes snapshotId, sourceSandboxId, createdAt, sizeBytes, fileCount, runtime, and architecture. Creation options such as env, secrets, and integration opt-outs are not restored from the snapshot; set them again as needed.

Get a sandbox

Sandbox.get fetches a sandbox by its id.

const sandbox = await Sandbox.get(client, 'sandbox-id')

console.log(sandbox.status)

List sandboxes

Sandbox.list fetches a single page of sandboxes. It returns the sandboxes on that page along with a nextPageToken you can pass back to fetch the following page.

const result = await Sandbox.list(client, {
  pagination: {pageSize: 50},
  filter: {states: ['running']},
})

console.log(result.sandboxes)
console.log(result.pagination.nextPageToken)

Options (ListSandboxesOpts):

List all sandboxes

Sandbox.listAll returns an async iterable over every sandbox, fetching pages as needed until the server has none left. Use it instead of paging manually.

for await (const sandbox of Sandbox.listAll(client, {filter: {states: ['running']}})) {
  console.log(sandbox.sandboxId, sandbox.status)
}

Options (ListAllSandboxesOpts): {pageSize?, filter?}, where filter has the same shape as in Sandbox.list.

Stop a sandbox

sandbox.stop stops the sandbox gracefully. The sandbox ends in the finished status. By default the call resolves as soon as the server records the stop request; pass {blocking: true} to wait for the sandbox to finish cleaning up before the call returns. The instance is updated in place from the server's response, and the call throws if the sandbox has already stopped.

await sandbox.stop()
// or wait for cleanup to complete:
await sandbox.stop({blocking: true})

Kill a sandbox

sandbox.kill cancels the sandbox immediately. The sandbox ends in the cancelled status. The call throws if the sandbox has already stopped.

await sandbox.kill()

The signal option (for example {signal: 'SIGKILL'}) is accepted but currently ignored: every kill is a hard cancel regardless of the signal you pass. Forwarding the signal to the sandbox is planned for a later release.

Set the timeout

sandbox.setTimeout resets a running sandbox's expiry to a fresh deadline, measured from when the server handles the request. The server clamps the new deadline to the sandbox's absolute maximum lifetime. The call throws if the sandbox has not started, has already expired, or has reached a terminal status.

await sandbox.setTimeout({timeoutMinutes: 240})

To keep a sandbox alive while it's in active use, call setTimeout on an interval shorter than the timeout you set. When the calls stop, the server terminates the sandbox once the deadline lapses.

Sandbox properties

A Sandbox instance exposes read-only properties describing its current state, refreshed in place whenever a method receives an updated view from the server:

Command execution

sandbox.runCommand runs a command in a sandbox and returns a SandboxCommandExecution. It's a server-streaming call: it returns as soon as the server reports the command has started, rather than waiting for it to finish, so you can attach output iterators and watch progress while the command is still running.

const command = await sandbox.runCommand({
  cmd: 'npm',
  args: ['install'],
  cwd: '/app',
  env: {CI: 'true'},
})

Options (RunCommandOpts):

runCommand runs cmd directly without a shell, passing [cmd, ...args] as argv. cmd must be an executable on the sandbox's PATH (or an absolute path), and shell features aren't interpreted: no pipes, redirects, globs, &&, variable expansion, or builtins like echo and cd. Commands therefore work even on images with no shell (distroless or scratch). To use shell features, invoke a shell explicitly:

await sandbox.runCommand({cmd: '/bin/sh', args: ['-c', 'echo "$HOME" && ls /tmp | wc -l']})

Wait for a command to finish

command.wait() resolves once the command reaches a terminal state. The resolved value is typed as SandboxCommandExecutionFinished, so you can read exitCode and finishedAt without checking for undefined.

const command = await sandbox.runCommand({cmd: '/bin/sh', args: ['-c', 'echo hi']})
const finished = await command.wait()

console.log(finished.exitCode)
console.log(finished.finishedAt)

Collect command output

command.output() collects the command's output, waiting until the command finishes. Pass 'both' (the default) to get stdout and stderr interleaved in arrival order, or 'stdout' / 'stderr' to get just one stream. command.stdout() and command.stderr() are shorthands for the single-stream calls.

const command = await sandbox.runCommand({cmd: 'ls', args: ['-la']})
await command.wait()

console.log(await command.output()) // stdout + stderr interleaved
console.log(await command.stdout()) // stdout only
console.log(await command.stderr()) // stderr only

Collected output is capped at 16 MiB. For larger or long-running output, stream it instead with logs().

Stream command output

command.logs() streams the command's output as decoded UTF-8 chunks, with stdout and stderr interleaved in the order they arrived. You can call it more than once: each call returns its own independent iterator over the same shared output, so several consumers can read at once without interfering.

const command = await sandbox.runCommand({cmd: 'npm', args: ['run', 'build']})

for await (const chunk of command.logs()) {
  process.stdout.write(`[${chunk.stream}] ${chunk.data}`)
}

Each chunk (SandboxCommandExecutionLogChunk) has:

A consumer that attaches after the command has started first replays whatever recent output is still held in the buffer, then catches up to live output. If your loop reads more slowly than the command produces output, it can fall behind what the buffer holds, and the SDK ends the loop with a SlowConsumerError (see SlowConsumerError). Other consumers on the same command are unaffected.

Detached commands

Detached mode is in beta. When you set detached: true, the command runs fire-and-forget: runCommand returns as soon as the server reports it started, and the command keeps running in the sandbox afterward.

await sandbox.runCommand({cmd: 'node', args: ['worker.js'], detached: true})

A detached command's output is not retained and cannot be retrieved yet (reattach and log replay is a future API). As a result, logs(), output(), and wait() on the returned execution throw, and the SDK logs a warning when detached is used.

Command properties

The SandboxCommandExecution returned by runCommand (the command in the examples above) exposes read-only properties describing the command's invocation and current state, refreshed in place as the server reports progress:

File system

sandbox.fs() returns a FileSystem bound to the sandbox. Its methods mirror node:fs/promises, so existing fs code can drop in with little friction, and a failure throws a FileSystemError carrying the same code / syscall / path / errno fields Node attaches to its own fs errors. Each call to fs() returns a fresh, lightweight instance bound to the same sandbox.

const fs = sandbox.fs()

Read and write files

readFile returns a Buffer, or a string when you pass an encoding. writeFile replaces any existing contents, and appendFile adds to the end of the file, creating it if needed. Both accept {mode?, recursive?}; with recursive: true, missing parent directories are created first.

await fs.writeFile('/home/runner/hello.txt', 'hi', {recursive: true})
await fs.appendFile('/home/runner/hello.txt', ' there')

const buf = await fs.readFile('/home/runner/hello.txt') // Buffer
const text = await fs.readFile('/home/runner/hello.txt', {encoding: 'utf8'}) // string

Directories

mkdir creates a directory, readdir lists a directory's entries, and mkdtemp creates a directory with a unique name.

import {DirEntry} from '@depot/sandbox'

await fs.mkdir('/home/runner/nested', {recursive: true, mode: 0o755})

const names = await fs.readdir('/home/runner') // string[]
const entries = (await fs.readdir('/home/runner', {withFileTypes: true})) as DirEntry[]
for (const entry of entries) {
  console.log(entry.name, entry.isDirectory())
}

const tmp = await fs.mkdtemp('/tmp/build-') // e.g. "/tmp/build-a1b2c3"

readdir returns a string[] | DirEntry[] union: plain string[] by default, or DirEntry[] when withFileTypes is set. Because the return type is a union, narrow the withFileTypes result to DirEntry[] (imported from @depot/sandbox) before reading entry fields. A DirEntry has a name, a type, and isFile() / isDirectory() / isSymbolicLink() helpers. mkdtemp creates a uniquely named temporary directory by suffixing your prefix with a random string.

Stat

stat follows symlinks; lstat stats the link itself. Both return a StatResult. realpath resolves a path to its canonical form by walking symlinks.

const stat = await fs.stat('/home/runner/hello.txt')

console.log(stat.size, stat.mode, stat.uname, stat.gname, stat.mtime)
console.log(stat.isFile(), stat.isDirectory())

const real = await fs.realpath('/home/runner/link')

A StatResult carries path, size, mode (POSIX permission bits as an octal integer), uname, gname, mtime (a Date), and type, along with isFile(), isDirectory(), isSymbolicLink(), isBlockDevice(), isCharacterDevice(), isFIFO(), and isSocket(). isSymbolicLink() is only true for a value that came from lstat.

Move, copy, and remove

rename moves or renames a path, copyFile copies a file, unlink / rm / rmdir remove files and directories, and truncate resizes a file.

await fs.rename('/home/runner/old.txt', '/home/runner/new.txt')
await fs.copyFile('/home/runner/new.txt', '/home/runner/copy.txt', {preserveMetadata: true})

await fs.unlink('/home/runner/copy.txt') // remove a file or symlink
await fs.rm('/home/runner/nested', {recursive: true, force: true}) // remove a tree
await fs.rmdir('/home/runner/empty') // remove an empty directory

await fs.truncate('/home/runner/new.txt', 1024) // resize to 1024 bytes (default 0)

copyFile accepts {preserveMetadata?} to keep mode, owner, and timestamps where possible (like cp -p). rm accepts {recursive?, force?}. rmdir fails with ENOTEMPTY if the directory isn't empty.

Permissions and ownership

chmod accepts an octal integer (0o755) or an octal string ("755"). chown takes a numeric uid and gid.

await fs.chmod('/home/runner/script.sh', 0o755)
await fs.chown('/home/runner/script.sh', 1000, 1000) // needs root, which file system operations don't have in the beta

Symlinks

symlink creates a symbolic link at linkPath pointing to target, and readlink returns the target path a link points to.

await fs.symlink('/home/runner/target.txt', '/home/runner/link.txt') // symlink(target, linkPath)
const target = await fs.readlink('/home/runner/link.txt')

Check existence and access

access checks access to a path, resolving on success and rejecting with a FileSystemError otherwise. exists is an SDK convenience composed from stat, returning a boolean; errors other than ENOENT (for example EACCES on an unreadable parent) propagate.

await fs.access('/home/runner/hello.txt') // throws if not accessible

if (await fs.exists('/home/runner/hello.txt')) {
  // ...
}

Errors

The SDK exposes two error types.

FileSystemError

Every sandbox.fs() method throws a FileSystemError on failure. It carries the same fields Node attaches to its own fs errors, so code written against node:fs/promises can branch on err.code without caring that the operation crossed the wire:

import {FileSystemError} from '@depot/sandbox'

try {
  await fs.readFile('/missing.txt')
} catch (err) {
  if (err instanceof FileSystemError && err.code === 'ENOENT') {
    console.log('file not found')
  }
}

SlowConsumerError

command.logs() holds recent output in a fixed-size buffer so each consumer can catch up from where it left off. If your loop processes chunks more slowly than the command produces them, the buffer fills and the oldest unread output is dropped. When that happens, the SDK ends your loop with a SlowConsumerError rather than silently skipping output, so wrap the loop in a try/catch to handle it. The same applies to command.output(), which fails with a SlowConsumerError if the buffer drops output before the call finishes reading. Other consumers on the same command, and the command itself, keep running.

import {SlowConsumerError} from '@depot/sandbox'

try {
  for await (const chunk of command.logs()) {
    await slowProcess(chunk)
  }
} catch (err) {
  if (err instanceof SlowConsumerError) {
    // fell behind the replay buffer
  }
}
}

Coming soon

The following sandbox capabilities are planned but not yet part of the SDK surface. All of the following are subject to change: