NodeOps
UK

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 with CREATEOS_SANDBOX_BASE_URL
  • Auth: API key via the apiKey option or CREATEOS_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:

TypeScript
1// getSandbox — how the client wires things together
2async 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:

  1. 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.
  2. Handles are cheap to pass around. A Sandbox is a thin wrapper over a transport reference and a cached view; copying or sharing it has no overhead beyond reference counting.
  3. No circular dependencies. Sandbox imports CreateosSandboxHttp but never imports CreateosSandboxClient. 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 a GET /v1/sandboxes/:id and replaces #data in place. Returns this so you can chain.
  • Mutating callspause(), resume(), fork(), destroy(), resize(), and similar methods all replace #data with the response the server returned. The view is always current after a successful mutating call.
  • waitUntil* pollers — each poll iteration calls refresh() internally (via pollUntil), so by the time waitUntilRunning() 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.

TypeScript
1const sandbox = await client.createSandbox({ shape: "s-4vcpu-4gb", rootfs: "devbox:1" });
2
3// Status here is whatever the create response reported — likely "creating".
4console.log(sandbox.status);
5
6// Wait until the control plane reports "running", polling with adaptive backoff.
7await sandbox.waitUntilRunning();
8
9// 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:

TypeScript
1// what a flat API looks like
2await 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:

TypeScript
1try {
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:

100,000+ Builders. One Workspace.

Get product updates, builder stories, and early access to features that help you ship faster.

CreateOS is a unified intelligent workspace where ideas move seamlessly from concept to live deployment, eliminating context-switching across tools, infrastructure, and workflows with the opportunity to monetize ideas immediately on the CreateOS Marketplace.