The handle model
The SDK exposes two stateful objects: CreateosSandboxClient and Sandbox. Understanding the relationship between them — and the transport layer that connects them — explains most of how the SDK behaves.
At a glance
- Package:
@nodeops-createos/sandbox(npm) - Import:
import { createClient } from "@nodeops-createos/sandbox" - Base URL:
https://api.sb.createos.sh— override withCREATEOS_SANDBOX_BASE_URL - Auth: API key via the
apiKeyoption orCREATEOS_SANDBOX_API_KEY
Two objects, one responsibility each
CreateosSandboxClient is the entry point and factory. It owns the resolved configuration (base URL, API key, default timeouts, retry policy) and surfaces catalog and cross-sandbox operations: listing sandboxes, templates, shapes, disks, and networks. Its job is to produce Sandbox handles.
Sandbox is the handle. It owns exactly one sandbox id. Every per-sandbox operation — lifecycle (pause, resume, destroy), command execution (runCommand, streamCommand), file transfer (files.upload, files.download), network and disk operations, and the waitUntil* pollers — lives on this object. Once you have a handle you rarely need the client again.
Data flow
CreateosSandboxClient --constructs--> CreateosSandboxHttp
CreateosSandboxClient --returns--> Sandbox handle
Sandbox handle --holds ref to-> CreateosSandboxHttp
CreateosSandboxHttp --auth · retries · timeouts · JSend--> control plane API
CreateosSandboxHttp is the transport layer. It handles URL construction, API key injection, JSend envelope unwrapping, exponential backoff with jitter, Retry-After honor, per-request timeouts, and AbortSignal composition. Neither the client nor the handle touches raw fetch directly — all wire calls go through the transport.
The critical structural detail: a Sandbox holds a reference to the transport (#http: CreateosSandboxHttp), not to the client. The client's only role is to configure the transport and pass it into the handle constructor:
TypeScript1// getSandbox — how the client wires things together2async getSandbox(id: string, options: RequestOptions = {}): Promise<Sandbox> {3 const view = await this.http.request<SandboxView>("GET", `/v1/sandboxes/${encodePath(id)}`, options);4 return new Sandbox(this.http, view);5}
Once new Sandbox(this.http, view) runs, the handle is self-sufficient. It carries the same transport object the client used, so it can make authenticated, retried, timed-out requests on its own.
This matters for three reasons:
- Handles outlive the client. Let the client go out of scope, hand the handle to another function or store it in a map — it keeps working. There is no hidden back-reference to the client that could become a dangling pointer or introduce unexpected shared mutable state.
- Handles are cheap to pass around. A
Sandboxis a thin wrapper over a transport reference and a cached view; copying or sharing it has no overhead beyond reference counting. - No circular dependencies.
SandboximportsCreateosSandboxHttpbut never importsCreateosSandboxClient. The dependency graph is a DAG.
Getting a handle
Two paths, same result type:
client.createSandbox(request, options) — provisions a new sandbox and returns a live handle. Under the hood it POST /v1/sandboxes, then does a follow-up GET to fetch the full SandboxView (the create response omits fields like status and created_at that the handle needs). The follow-up GET inherits the caller's timeout and retry options, not just the abort signal.
client.getSandbox(id, options) — reconnects to an existing sandbox by id. Issues a single GET /v1/sandboxes/:id and wraps the result in a handle. Use this when you stored an id from a previous session and want to resume work against it.
Both return Promise<Sandbox>. The handle type is identical regardless of how it was obtained.
There are also static convenience factories on Sandbox itself — Sandbox.create(...) and Sandbox.connect(...) — for situations where constructing a client first would be ceremony. They delegate to new CreateosSandboxClient(clientOpts).createSandbox(...) and .getSandbox(...) respectively.
Local view vs. server truth
Every Sandbox caches a snapshot of server state called the view (#data: SandboxView). The view holds the fields the server returned at the time the handle was created or last refreshed: id, status, ip, vcpu, mem_mib, disk_mib, created_at, ingress_enabled, and more.
Reading sandbox.status, sandbox.ip, or sandbox.data reads the last-known view — not a live server call. If the server state has changed (a sandbox finished booting, was paused by someone else, ran out of bandwidth quota), the handle does not know until it re-fetches.
Three things update the cached view:
sandbox.refresh()— issues aGET /v1/sandboxes/:idand replaces#datain place. Returnsthisso you can chain.- Mutating calls —
pause(),resume(),fork(),destroy(),resize(), and similar methods all replace#datawith the response the server returned. The view is always current after a successful mutating call. waitUntil*pollers — each poll iteration callsrefresh()internally (viapollUntil), so by the timewaitUntilRunning()resolves, the handle reflects the state the server confirmed.
toJSON() returns the same #data object that sandbox.data exposes, so JSON.stringify(sandbox) serializes the last-known view. Treat the returned object as read-only; mutating it corrupts the getters' backing store.
TypeScript1const sandbox = await client.createSandbox({ shape: "s-4vcpu-4gb", rootfs: "devbox:1" });23// Status here is whatever the create response reported — likely "creating".4console.log(sandbox.status);56// Wait until the control plane reports "running", polling with adaptive backoff.7await sandbox.waitUntilRunning();89// Now the cached view is up to date.10console.log(sandbox.status); // "running"11console.log(sandbox.ip); // "10.x.x.x"
Why a handle, not a flat client
The alternative — common in REST-shaped SDKs — is to put every operation on a single client, requiring callers to pass sandbox ids explicitly to every call:
TypeScript1// what a flat API looks like2await client.pause(sandboxId, options);3await client.waitUntilPaused(sandboxId);4await client.destroy(sandboxId, options);
That pattern has two costs. First, id strings leak into every call site; callers must track them manually. Second, any operation that requires polling (waiting for a state transition) needs a custom loop — fetch, check status, sleep, repeat — with all the timeout and error-handling logic that entails.
The handle model collapses both problems. The id is bound once at handle construction and never mentioned again. The waitUntil* helpers (waitUntilRunning, waitUntilPaused, waitUntilDestroyed) embed the polling loop with adaptive backoff (250ms start, ramps after 5s, 2s cap) and a configurable timeout budget. Lifecycle sequences read naturally:
TypeScript1try {2 const sandbox = await client.createSandbox({ shape: "s-4vcpu-4gb", rootfs: "devbox:1" });3 await sandbox.waitUntilRunning();4 const { result } = await sandbox.runCommand("python3", ["train.py"]);5 console.log(result.stdout);6} finally {7 await sandbox.destroy();8}
No id strings. No poll loop. No duplicated timeout logic. The handle carries its own state and knows how to wait for the server to catch up.
See also:
- Client reference — all factory methods and catalog operations
- Sandbox reference — full handle API surface
- Sandbox lifecycle — status transitions and what the
waitUntil*helpers guard against - Reliability and retries — how the transport's retry policy interacts with the pollers