DEV Community

Cover image for We read the source of 16 sandbox SDKs before writing ours
pratikbin
pratikbin

Posted on

We read the source of 16 sandbox SDKs before writing ours

A source-level teardown of 16 sandbox and compute SDKs, and the design decisions that came out of it: method-aware retries, typed errors with metadata, zero runtime dependencies.

Before we wrote a line of the createos-sandbox SDK, we cloned 16 competitors and read their source. Not the docs. The source. E2B, Daytona, Modal, Cloudflare, CodeSandbox, Vercel, Morph, Islo, Blaxel, Superserve, Alibaba's OpenSandbox, Beam, Tensorlake, and a few more.

The survey turned into an 880-line internal doc. The decisions that shape an SDK live in its transport layer, not its README. What we copied, what we threw out, and why:

The parts everyone gets right

A few patterns showed up in almost every SDK, and they earned their place.

A stateful handle returned from a factory. You call createSandbox() and get back an object that knows its own ID and lets you act on it. E2B, Daytona, Modal, and CodeSandbox all land here. We did too:

import { CreateosSandboxClient } from "@nodeops-createos/sandbox";

const client = new CreateosSandboxClient();
const sandbox = await client.createSandbox({ shape: "s-4vcpu-4gb", rootfs: "devbox:1" });

const { result } = await sandbox.runCommand("node", ["--version"]);
console.log(result.stdout); // v20.x.x

await sandbox.destroy();
Enter fullscreen mode Exit fullscreen mode

One namespace, not five. Daytona nests .fs, .git, .process, .computerUse, .codeInterpreter off the handle. Cloudflare exposes a flat 40-method surface. ComputeSDK keeps everything flat except .filesystem, and that restraint reads better in an editor. We kept one .files namespace and left the rest flat on the handle. If the control plane has no git endpoint, the SDK has no .git.

Environment-variable config. CREATEOS_SANDBOX_API_KEY and CREATEOS_SANDBOX_BASE_URL, so a script needs zero setup to run. Standard, and every good SDK does it.

None of that sets us apart. The real gaps showed up one layer down, in how each SDK handles failure.

Retries, and why most SDKs get them wrong

A sandbox SDK talks to a microVM scheduler that sheds load with transient 503s, so it has to retry. Most don't:

  • E2B: no retries at all.
  • Daytona: ships axios-retry as a dependency, never wires it in.
  • ComputeSDK, Morph, Beam, OpenSandbox, OpenComputer: no retry layer.
  • CodeSandbox: fixed-delay, no backoff, no jitter.
  • Cloudflare: retries 503 only, no jitter, no network-error retry.

Vercel and Modal are the exceptions. Both do it well: exponential backoff, jitter, and they honor Retry-After. We matched that, then added one thing they don't split on cleanly: the retry policy is method-aware.

The question that decides whether a retry is safe: if the server already processed this request, does resending it cause harm? A repeated GET or DELETE is fine. A repeated POST might create a second sandbox or double-charge bandwidth. So idempotent and non-idempotent methods get different rules:

Trigger GET HEAD PUT DELETE POST PATCH
Network error (DNS, reset, refused) Retried Not retried
500 / 502 / 504 Retried Not retried
503 Service Unavailable Retried Retried
429 Too Many Requests Retried Retried

A POST retries only on 429 and 503, the two statuses where the server provably rejected the request before touching the handler. Every other failure on a POST surfaces immediately as a typed error and lets your code decide. Streaming requests are never retried, because a half-consumed NDJSON stream has no safe replay position.

Backoff is min(base × 2^attempt + random·base, cap). The jitter term is the point. When a host restarts and 200 clients all see the same 503, pure exponential backoff makes them retry in synchronized waves. The random offset spreads them out.

Typed errors that teach

The second gap: error models. ComputeSDK, Morph, Beam, and OpenSandbox throw new Error("HTTP " + status) with a stringified body. To branch on the failure you parse the message string, which breaks the moment the server changes its wording.

E2B, Daytona, and Cloudflare do this right, and E2B's touch is the one I liked most: a 502 error message tells you it's likely a sandbox timeout, call .setTimeout. The error teaches you the fix.

We shipped a 10-class hierarchy you narrow with instanceof, no string parsing:

try {
  await sandbox.runCommand("ls", ["/"]);
} catch (err) {
  if (err instanceof CreateosSandboxNotFoundError) {
    return null; // 404: already destroyed
  }
  if (err instanceof CreateosSandboxRateLimitError) {
    // retries already exhausted by the SDK
    await sleep((err.retryAfterSeconds ?? 5) * 1000);
  }
  throw err;
}
Enter fullscreen mode Exit fullscreen mode

Every HTTP error carries structured fields instead of a prose blob: statusCode, method, endpoint, resourceId parsed out of the path, requestId, and a machine-readable code. Quote the requestId in a support ticket and the operator finds your exact call in O(1). Modal has a good retry engine but its errors carry no code or requestId, so you lose that thread.

The things we said no to

Copying good ideas is easy. The rejections took more argument. Three we turned down:

gRPC and protobuf (Modal). Modal has the best retry implementation in the survey, built on gRPC over HTTP/2. It also carries checked-in codegen, two ts-proto patch hacks, and heavy dependencies. For a REST + NDJSON control plane, that's enormous build complexity for no user-facing gain. We passed.

OpenAPI codegen (Vercel, Islo, Blaxel). Vercel's SDK is the strongest proof that codegen scales: 40-plus namespaces, one hand-edited file. But it pays off at 40 operations, not at 12. Blaxel's generated build needs 11 sed patches and a perl rewrite after every regeneration. Our surface is small enough to hand-write and keep readable.

WebSocket data plane (CodeSandbox, Daytona). Elegant for resume and reconnect. Our control plane is REST + NDJSON with no socket endpoint, so a WS layer would model a connection that doesn't exist.

The result is zero runtime dependencies and nine modules, each under 300 lines. Cloudflare's sandbox.ts is a single 6,295-line file. Ours is a hand-written fetch client you can read in an afternoon.

Why this matters when the caller is an agent

Half the traffic to a sandbox SDK now comes from an LLM writing code, not a human. That changes what a good SDK owes its caller.

An agent can't read a 502 and intuit "call .setTimeout." It works off types and structured signals. Typed errors with a stable code field give it something deterministic to branch on. Method-aware retries mean an agent that fires a burst of POSTs during a retry storm won't silently spawn duplicate VMs. And zero dependencies matter more, not less, when the SDK runs inside a sandbox that another agent spawned.

We also ship llms.txt and a bundled llms-full.txt, so a coding agent can load the entire API surface as context instead of guessing from method names.

Read it yourself

The SDK is @nodeops-createos/sandbox: TypeScript, zero runtime dependencies, ESM, runs on Node 20+, Bun, Deno, Cloudflare Workers, and the browser. Docs follow the Diátaxis framework, and the full 16-SDK comparison lives in the repo.

If you maintain a sandbox or compute SDK, clone a few competitors and read the transport layer. It's the fastest way to find out what your own SDK is missing.

Refs

Quickstart: https://nodeops.network/createos/docs/Sandbox/Quickstart

Sandbox web: https://createos.sh/app/sandbox

SDK: https://github.com/NodeOps-app/createos-sandbox-sdk

SDK Examples: https://github.com/NodeOps-app/createos-sandbox-sdk

CLI: https://github.com/NodeOps-app/createos-cli

Docs: https://nodeops.network/createos/docs/Sandbox/Overview

Run Sandbox in your own infra: https://nodeops.network/createos/docs/Sandbox/Bring-Your-Own-Storage

Top comments (0)