The Sandbox SDK is in private beta. Methods might change before the SDK becomes generally available. Sandboxes are billed per vCPU-second at the Depot CI compute rate. Contact us to request access for your organization.
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.
The SDK requires Node.js 20 or newer. The package ships as ES modules.
pnpm add @depot/sandboxThe package ships its compiled protobuf bindings, so you don't need a separate proto module.
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})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:
DEPOT_TOKEN. Required.--org flag on the depot CLI.https://api.depot.dev.const client = createClient({
token: process.env.DEPOT_TOKEN!,
orgID: process.env.DEPOT_ORG_ID,
})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.
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):
{vcpus?, memoryMb?, diskGb?}, each a positive integer. The server fills a default for any field you omit: 2 vCPUs, 4096 MB of memory, and 100 GB of disk. Sandboxes support 2 to 64 vCPUs. Disk must be large enough to hold the base image, so very small values fail to start.Sandbox.get fetches a sandbox by its id.
const sandbox = await Sandbox.get(client, 'sandbox-id')
console.log(sandbox.status)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):
{pageSize?, pageToken?}. The server caps pageSize and picks a default when it's unset. pageToken is the opaque cursor from a previous call's nextPageToken.{states?, createdAfter?, createdBefore?}. states is an array of sandbox statuses, and the createdAfter / createdBefore bounds are Date values, each exclusive.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.
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})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.
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.
A Sandbox instance exposes read-only properties describing its current state, refreshed in place whenever a method receives an updated view from the server:
created, assigned, starting, running, finished, cancelled, or failed. Undefined when the server hasn't assigned one yet.{vcpus, memoryMb, diskGb}.Date values, set as the sandbox reaches each point.{ingressBytes, egressBytes}. Populated under the same conditions as activeCpuUsageMs.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']})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)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 onlyCollected output is capped at 16 MiB. For larger or long-running output, stream it instead with logs().
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:
'stdout' or 'stderr'.Date.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 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.
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:
Date.pending, running, finished, failed, or killed.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()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'}) // stringmkdir 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 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.
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.
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 betasymlink 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')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')) {
// ...
}The SDK exposes two error types.
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:
'ENOENT'. The catch-all 'OTHER' is used when the server can't classify a failure into a specific code.'stat' or 'open'.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')
}
}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
}
}The following sandbox capabilities are planned but not yet part of the SDK surface. All of the following are subject to change:
{named: 'node24'}). Sandboxes currently boot from Depot's default base image.