Skip to main content
Brimble ships first-party SDKs for the sandbox API in three runtimes. They wrap the REST endpoints in idiomatic clients with built-in retries, ready-state polling, error hierarchies, and convenience helpers, so you don’t have to hand-roll any of it. The three SDKs are deliberately near-identical in surface: every method on one has a same-named counterpart on the others, every input shape matches, every default lines up. Pick the runtime that fits your stack and the rest of the page applies.
RuntimePackage
TypeScript / JavaScript@brimble/sandbox
Pythonbrimble-sandbox
Gogithub.com/brimblehq/brimble-sdks/sandbox-go

Install

npm install @brimble/sandbox
Runtime requirements: Node 20+, Python 3.10+, Go 1.22+. Go API docs are hosted at pkg.go.dev/github.com/brimblehq/brimble-sdks/sandbox-go.

Authenticate

All three SDKs read the same environment variable, BRIMBLE_SANDBOX_KEY, set it to your account-level API key from the profile drawer (avatar → API key) in the dashboard. You can also pass the key explicitly to the client constructor. See API keys for how to generate, rotate, and rate-limit.
import { Sandbox } from "@brimble/sandbox";

// reads BRIMBLE_SANDBOX_KEY
const client = new Sandbox();

// or pass explicitly
const client = new Sandbox({ apiKey: process.env.MY_KEY });
Other constructor options (all optional):
  • baseUrl / base_url / BaseURL sets the API root. Defaults to https://sandbox.brimble.io.
  • timeoutMs / timeout_ms / Timeout sets the per-request HTTP timeout. Defaults to 30 seconds.
  • retry / retry / Retry lets you override the retry policy (see Retries and idempotency).
  • TypeScript-only: fetchImpl supplies a custom fetch for tests.
  • Python-only: session supplies a requests.Session for connection pooling.
  • Go-only: HTTPClient supplies a custom *http.Client.
The client errors out immediately if neither the constructor arg nor the env var is set.

The client surface

Every client exposes three resource groups:
ResourceWhat it covers
client.sandboxesLifecycle: create, list, get, destroy, pause, resume. Discovery: list templates, list regions. Plus use(id) for scoped runtime ops.
client.snapshotsAccount-wide snapshot listing and deletion. (Per-sandbox snapshot ops live on the handle.)
client.volumesVolume lifecycle: create, list, get, delete.
Go also exposes client.Ping(ctx), a one-shot connectivity and auth check.

Quickstart presets

The shortest path to a running sandbox is a quickstart helper. They preconfigure the template, persistent disk, and ready-wait so you can get to work in a single call.
import { Sandbox } from "@brimble/sandbox";

const client = new Sandbox();

// Node 22, 20 GB persistent disk, waits for ready
const handle = await client.sandboxes.quickstartNode({ region: "auto" });

const result = await handle.exec({ cmd: "node -e 'console.log(2 + 2)'" });
console.log(result.stdout);

await handle.destroy();
The presets bake in a sensible default for getting started: persistent storage so your files survive a restart, and a 20 GB disk so you have headroom. For finer control, drop down to createReady (next section).

Create-and-wait helpers

createReady is the workhorse: provision a sandbox and block until it’s ready in one call. You don’t have to think about the startingready transition. region is optional, omit it (or pass "auto") to let the server pick. Pass a specific region ID from listRegions() to pin one.
const handle = await client.sandboxes.createReady({
  region: "auto",
  template: "python-3.12",
  persistent: true,
  persistentDiskGB: 20,
});
// handle is ready to use immediately
The matching getReady(id) does the same for an existing sandbox: fetch + wait, so you can pick a sandbox back up after a process restart without coordinating the state check yourself. If you want manual control over the wait (longer timeout, custom poll interval), call create() then handle.waitUntilReady() separately:
const handle = await client.sandboxes.create({
  region: "auto",
  template: "node-22",
});

await handle.waitUntilReady({
  timeoutMs: 120_000,
  pollIntervalMs: 1_000,
  signal: abortController.signal,  // optional
});

Create a sandbox alongside a fresh volume

withVolume provisions both in a single call. Use it when you want a per-sandbox persistent workspace and don’t already have a volume.
const handle = await client.sandboxes.withVolume({
  sandbox: {
    region: "auto",
    template: "node-22",
  },
  volume: {
    name: "alice-workspace",
    sizeGB: 20,
    region: "auto",
  },
});
The volume’s region and the sandbox’s region must resolve to the same region. Passing "auto" on both is the simplest path.

The sandbox handle

create(), createReady(), get(), getReady(), and withVolume() all return a handle, an object that bundles the sandbox ID with runtime helpers. Most of what you do with a sandbox happens through the handle, not the client. You can also grab a handle for any sandbox by ID:
const handle = await client.sandboxes.get(sandboxId);

// or a thinner scope (no cached state, no auto-wait)
const scope = client.sandboxes.use(sandboxId);
The handle exposes the sandbox’s identity, last-known state, and the full set of runtime methods:
SurfaceTypeScriptPythonGo
Sandbox IDhandle.idsandbox.idhandle.ID()
Cached statushandle.statussandbox.statushandle.Status()
Full recordhandle.datasandbox.datahandle.Latest()
Refresh from APIhandle.refresh()sandbox.refresh()handle.Refresh(ctx)
Destroyhandle.destroy()sandbox.destroy()handle.Destroy(ctx)
Pause / resumehandle.pause() / handle.resume()sandbox.pause() / sandbox.resume()handle.Pause(ctx) / handle.Resume(ctx)
Wait until readyhandle.waitUntilReady()sandbox.wait_until_ready()handle.WaitUntilReady(ctx)
Snapshots namespacehandle.snapshots.create/listsandbox.snapshots.create/list/iteratehandle.Snapshots.Create/List

Auto-wait on runtime ops

Every runtime method (exec, runCode, putFile, getFile, stats, createSnapshot, listSnapshots) accepts an optional waitUntilReady flag. When set, the SDK polls the sandbox until it’s ready before sending the actual call, so you don’t have to coordinate the wait yourself.
const handle = await client.sandboxes.create({ region: "auto", template: "node-22" });

// auto-waits until ready, then runs the command
const result = await handle.exec(
  { cmd: "echo hello" },
  { waitUntilReady: true },
);

// or with custom wait config
const result2 = await handle.exec(
  { cmd: "echo hello" },
  { waitUntilReady: { timeoutMs: 120_000, pollIntervalMs: 1_000 } },
);
Without the flag, runtime methods return an error if the sandbox isn’t ready. The fast path for one-off scripts is therefore create() followed by exec(..., { waitUntilReady: true }). The fast path for a long-lived workflow is createReady() so the handle is hot the moment you reach for it.

Sandboxes resource (full surface)

Create

region is required (use "auto" to let the server pick); everything else is optional.
const handle = await client.sandboxes.create({
  region: "auto",                          // or a region ID
  template: "python-3.12",
  name: "scratch",
  teamId: "<team-id>",                     // omit for personal
  specs: { cpu: 500, memory: 512, disk: 2 },
  autoDestroy: true,
  destroyTimeout: "1h",                    // 30m, 1h, 3h, 6h, 12h, 18h
  oneShot: false,
  blockOutbound: false,
  persistent: true,
  persistentDiskGB: 20,
  // volumeId: "<existing-volume-id>",
  // fromSnapshot: "<snapshot-id>",
  snapshotMode: "automatic",
  snapshotFrequency: "0 */2 * * *",
});
Specs ranges: cpu 1 to 2000 (MHz units), memory 1 to 2048 MB, disk 1 to 5 GB ephemeral.

List, get, destroy

const page = await client.sandboxes.list({ page: 1, limit: 15, teamId: "<team>" });
const handle = await client.sandboxes.get(sandboxId);
await client.sandboxes.destroy(sandboxId);
destroy is idempotent: calling it on an already-destroyed sandbox is a no-op.

Iterate across pages

For walking every sandbox without hand-rolling pagination:
for await (const handle of client.sandboxes.iterate({ limit: 50 })) {
  console.log(handle.id, handle.status);
}

Pause and resume

await handle.pause();
await handle.resume();
Both are async; the response acknowledges the request and the sandbox transitions a few seconds later. The handle auto-refreshes its cached state, so handle.status reflects the new value immediately.

Runtime operations

Once the sandbox is ready (or you’ve passed waitUntilReady):

Exec

const result = await handle.exec({
  cmd: "echo hello && uname -a",
  timeout_seconds: 30,
  cwd: "/work",
});
console.log(result.stdout, result.stderr, result.exit_code, result.duration_ms);
By default exec buffers, you get stdout, stderr, exit_code, and duration_ms once the command finishes. For long-running commands where you want output as it arrives, see Stream exec output below.

Run code

const result = await handle.runCode({
  language: "python",       // or "node"
  code: "import sys; print(sys.version)",
});

Per-call environment variables

Both exec and runCode accept an env object that’s layered on top of the sandbox’s existing environment for a single call. Same-named keys override the sandbox-level value for that invocation only; the next call starts from the sandbox defaults again. Cleaner than shelling out export FOO=bar && in front of every command, and stops secrets from showing up in argv / shell history.
const result = await handle.exec({
  cmd: "node script.js",
  env: {
    NODE_ENV: "production",
    OPENAI_API_KEY: process.env.OPENAI_API_KEY!,
    REQUEST_ID: requestId,
  },
});

// Same shape on runCode
await handle.runCode({
  language: "python",
  code: "import os; print(os.environ['MY_KEY'])",
  env: { MY_KEY: "value" },
});
Values must be strings; numbers and booleans need to be stringified first. The map is scoped to the one call, sandbox-wide environment changes are not supported.

Stream exec output

Both exec and runCode can stream output as it’s produced, useful for long-running commands, AI-agent runs, or anywhere you want to surface partial output to a user instead of waiting for the whole command to finish. The server emits one NDJSON frame per chunk (stdout, stderr, done, or error); each SDK gives you back a stream you read with native primitives.
// Pass `stream: true` and the return type narrows to ReadableStream<Uint8Array>
const stream = await handle.exec({
  cmd: "pip install -r requirements.txt",
  stream: true,
});

const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });
  let nl;
  while ((nl = buffer.indexOf("\n")) >= 0) {
    const frame = JSON.parse(buffer.slice(0, nl));
    buffer = buffer.slice(nl + 1);
    if (frame.type === "stdout" || frame.type === "stderr") process.stdout.write(frame.data);
    if (frame.type === "done") console.log("\nexit:", frame.exit_code);
  }
}
The Python and Go SDKs also expose dedicated run_code_stream / RunCodeStream for streaming the language-aware variant. In TypeScript, the same stream: true opt-in works on runCode as well, the return type narrows to a readable stream. Frame shapes (typed as ExecStreamFrame in TS, returned as JSON in all three):
  • { "type": "stdout", "data": "..." }, a chunk of stdout text.
  • { "type": "stderr", "data": "..." }, a chunk of stderr text.
  • { "type": "done", "exit_code": 0, "duration_ms": 142 }, the final frame on a clean run.
  • { "type": "error", "message": "..." }, a transport-level error after streaming began.

Upload and download files

await handle.putFile("/app/index.js", "console.log('hello')");

const stream = await handle.getFile("/app/index.js");
// stream is a ReadableStream<Uint8Array>
Parent directories must exist; uploads to a non-existent directory fail with a 400.

Batch file uploads

For seeding many small files in one round trip (a fresh repo checkout, a set of config files, a directory of fixtures), use the batch upload. It sends the files as base64-encoded JSON in a single POST and returns a per-file success/failure summary.
const summary = await handle.putFiles([
  { path: "/work/package.json", body: JSON.stringify({ name: "demo" }) },
  { path: "/work/index.js", body: "console.log('hello')" },
  { path: "/work/README.md", body: "# demo\n" },
]);

console.log(summary.uploaded, "uploaded,", summary.failed, "failed");
for (const r of summary.results) {
  if (!r.success) console.warn("failed:", r.path, r.error);
}
Per-call cap is 100 files, and the payload counts toward the same file-size limit as single uploads, the SDKs reject more than 100 files client-side. For larger transfers (single big files, or more than 100 files), loop putFile instead.

Stats

const stats = await handle.stats({ hoursAgo: 1 });
Returns averages and a time-series of CPU%, memory%, and network bytes/sec.

Snapshots

Per-sandbox snapshot ops live on the handle. Account-wide ops live on client.snapshots.
// per-sandbox
const snap = await handle.snapshots.create({ name: "before-migration" });
const list = await handle.snapshots.list({ page: 1, limit: 15 });

// account-wide
const all = await client.snapshots.listAll({ limit: 50 });
await client.snapshots.delete(snapshotId);

// iterate every snapshot you own
for await (const snap of client.snapshots.iterateAll({ limit: 100 })) {
  console.log(snap.id, snap.status);
}
Snapshot names match ^[a-z0-9-]{1,40}$. Creation is async: the response returns status: "creating" and the snapshot flips to ready (or failed) a few minutes later. Poll list to see the transition.

Restore from a snapshot

Pass fromSnapshot at sandbox create time to seed the new sandbox with the snapshot’s filesystem:
const handle = await client.sandboxes.createReady({
  region: "auto",
  fromSnapshot: "<snapshot-id>",
});

Volumes

The SDKs only create sandbox-type volumes (type: "sandbox"); the web type is reserved for the dashboard’s persistent-disk toggle on a project. All three SDKs enforce this client-side and reject any other value before the HTTP call.
const volume = await client.volumes.create({
  name: "node-cache",
  sizeGB: 20,                // min 10, max 50
  region: "auto",
  teamId: "<team-id>",       // optional
});

const list = await client.volumes.list({ page: 1, limit: 15 });
const fetched = await client.volumes.get(volume.id);
await client.volumes.delete(volume.id);
Attach a volume to a sandbox by passing volumeId on create. See the Volumes doc for the full attach model.

Discovery

Both templates and regions are first-class on the client. No need to hit a separate API.
const templates = await client.sandboxes.listTemplates();
const node = await client.sandboxes.getTemplate("node-22");

const regions = await client.sandboxes.listRegions();
The Brimble team adds and retires templates without warning; calling listTemplates() is the authoritative way to see what’s currently available.

Pagination

All list endpoints accept { page, limit } (plus teamId on team-scoped calls). Defaults are page = 1, limit = 15, max limit = 100. Responses include totalCount, currentPage, totalPages, limit, and a data array. For walking every result, prefer the iterate / iterate_all / Iterate helpers shown above; they handle pagination internally.

Errors

Every SDK exposes a typed error hierarchy. All subclasses extend the base type so a single catch / except / errors.As block can still handle “anything from the API,” but you can narrow when you want to.
ClassWhen
SandboxApiError / APIErrorBase type. Any non-2xx response.
AuthError401 / 403. Missing key, revoked key, insufficient permission.
ValidationError400 / 422. Bad input shape, invalid state transition.
NotFoundError404. Sandbox / volume / snapshot doesn’t exist or isn’t yours.
RateLimitError429. Carries retryAfterSeconds / retry_after_seconds / RetryAfterSeconds when the server provides it.
Every instance carries status, message, endpoint, responseBody, and requestId.
import { RateLimitError, NotFoundError, SandboxApiError } from "@brimble/sandbox";

try {
  await client.sandboxes.get("missing");
} catch (err) {
  if (err instanceof RateLimitError) {
    await new Promise((r) => setTimeout(r, (err.retryAfterSeconds ?? 1) * 1000));
    // retry
  } else if (err instanceof NotFoundError) {
    console.warn("sandbox is gone");
  } else if (err instanceof SandboxApiError) {
    console.error(err.status, err.message, err.requestId);
  } else {
    throw err;
  }
}
A few SDK-specific sentinels:
  • Python: wait_until_ready() raises TimeoutError on deadline. Sandbox(api_key=...) raises ValueError if no key is found. volumes.create() raises ValueError for size or type violations before the HTTP call.
  • TypeScript: the constructor throws a plain Error if no key is found.
  • Go: context.DeadlineExceeded / context.Canceled for caller-driven cancellation; check with errors.Is.

Retries and idempotency

Each SDK ships a built-in retry policy you can configure at the client or per call. Defaults are conservative: one attempt (no retries) by default, base delay 300 ms, max delay 3 s, retry on 408, 429, 500, 502, 503, 504. Bump maxAttempts to opt in.
const client = new Sandbox({
  retry: {
    maxAttempts: 3,
    baseDelayMs: 300,
    maxDelayMs: 3_000,
    retryStatuses: [408, 429, 500, 502, 503, 504],
  },
});

// per-call override
await client.sandboxes.create(input, {
  retry: { maxAttempts: 5 },
  idempotencyKey: crypto.randomUUID(),
});

// or disable retries on a single call
await client.sandboxes.destroy(id, { retry: false });
Idempotency keys make lifecycle calls safe to retry. The server deduplicates retries that carry the same idempotencyKey within a short window: create, createReady, destroy, pause, resume, createSnapshot, deleteSnapshot, and volume create / delete all accept the option. Pass a stable, unique-per-operation value (a UUID, a job ID, the SHA of the request body) and you can retry network failures without spinning up a duplicate sandbox.

Timeouts and cancellation

OperationTypeScriptPythonGo
Default HTTP timeout30s30s30s
Per-call overridetimeoutMs in optionstimeout_ms=<n>ctx deadline / RequestOptions.Timeout
Cancel mid-flightsignal: AbortSignalnot directly cancellablectx.Done()
Wait-until-ready default60s timeout, 2s poll60s timeout, 2s poll60s timeout, 2s poll
Wait-until-ready cancelsignal: AbortSignalraises TimeoutErrorctx.Done()
Best practice in Go: pass a context.Context with a deadline matched to the operation. The SDK respects ctx.Done() everywhere, including the wait-until-ready polling loop.

Cleanup

None of the SDKs auto-destroy sandboxes when your process exits. Either set autoDestroy: true with a destroyTimeout, use oneShot: true so the sandbox terminates when its main process exits, or wrap your work in a cleanup block:
const handle = await client.sandboxes.createReady({ region: "auto", template: "node-22" });
try {
  // ... do work
} finally {
  await handle.destroy().catch(() => {});
}

Versioning and stability

All three SDKs are at 0.x today; the public surface is shaped to stay stable, but minor bumps may break compatibility while we shake things out. Pin a specific version in production until we tag 1.0:
npm install @brimble/sandbox@^0.1.2

Next steps

  • Sandboxes overview, the lifecycle and billing model.
  • Quickstart, the five-minute end-to-end walkthrough with all three SDKs side by side.
  • Cookbook, recipes for the highest-traffic use cases.
  • Snapshots, deep-dive on the snapshot lifecycle.
  • Volumes, persistent volumes that survive sandbox destruction.
  • Sandbox API tab, the REST contract the SDKs wrap.
Last modified on May 24, 2026