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();
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-retryas a dependency, never wires it in. - ComputeSDK, Morph, Beam, OpenSandbox, OpenComputer: no retry layer.
- CodeSandbox: fixed-delay, no backoff, no jitter.
-
Cloudflare: retries
503only, 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;
}
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 (6)
The retry table is the payoff here. One axis I'd push on: method-aware retries treat GET as safe and POST as risky, but the real property is idempotency, not method. A createSandbox that times out after the control plane already accepted it will leak a second microVM if you retry the POST blind. What saved us on a similar client was a client-generated idempotency key on every mutating call, so the scheduler dedups the retry instead of the SDK guessing. Did createos land on idempotency keys, or do you only retry POST on network-error-before-send?
The comments here all zeroed in on the retry table, but the quiet decision I liked most is "streaming requests are never retried, because a half-consumed NDJSON stream has no safe replay position." That's the kind of rule you only write after a stream resumed from the middle once and handed the caller garbage that looked valid. Your jitter reasoning is the other underrated one, since synchronized backoff after a host restart just recreates the thundering herd you were trying to avoid. Reading transport layers instead of READMEs is genuinely the move, that's where all the interesting scars are.
The retry table is the best part of the post, and it protects the layer you control while leaving the layer you sell to unprotected. Your line about POST retrying only on 429 and 503, "the two statuses where the server provably rejected the request before touching the handler", is exactly right as far as the SDK can see. But you also wrote the other half of the problem yourself: half the traffic is an LLM writing code. An agent that gets a network error on create() does not read your retry policy, it just calls create() again, because retrying is what agents do by disposition. The duplicate-VM spawn you engineered the SDK to prevent comes back one layer up, where your discipline cannot reach.
The fix that closes both layers is the Stripe pattern: an idempotency key on POST. Client generates a token, server dedupes on it, and suddenly POST moves into the retryable column for network errors and 5xx, for the SDK and for every naive agent loop above it. It also converts the genuinely nasty case, a 500 where you cannot know whether the handler ran, from an orphan-VM hunt into a safe replay. Looking at the create request surface today it is one field and one header away, and for an SDK whose pitch is agent-friendliness, it might be the single highest-leverage addition left.
The typed error hierarchy deserves more attention than it will get: machine-readable codes plus structured fields make errors part of the tool contract, and the instanceof-NotFound-returns-null example is the difference between an agent that handles "already destroyed" and one that loops on it. That plus llms.txt says you actually thought about who the caller is.
This is a great breakdown. Reading the source of 16 SDKs is exactly the kind of deep dive that separates a 'wrapper' from a robust production tool.
The decision to use method-aware retries is particularly smart. It’s a subtle but critical distinction for agent-based traffic where you're often dealing with unpredictable retry loops. I also really appreciate the move away from gRPC/OpenAPI codegen in favor of a hand-written, clean REST client, it makes the SDK much more maintainable and readable for developers who actually need to debug it.
Definitely agree with the comment regarding idempotency keys on POST requests; that seems like the final piece of the puzzle to fully protect the compute layer from agent-triggered duplicate operations. Fantastic work on the error hierarchy as well
Reading source before designing the SDK is underrated. Docs show the intended abstraction; source shows the failure model, naming habits, hidden assumptions, and what the authors optimized for. For sandboxing especially, the edge cases are the product, so source-level comparison seems like the right starting point.
Reading the source of the tools you build on before you build on them is the same discipline as reading the diff an agent hands you instead of trusting its summary. Both come from the same place, the documentation and the summary are what someone wants you to believe happened, the source and the diff are what actually happened. The idempotency gap your commenter raised is real and I have hit it from the agent side, not the SDK side. A coding agent driving a tool call over a flaky connection will happily retry a POST it thinks failed, and if the SDK does not carry a client generated idempotency key through that retry, you end up debugging a duplicate resource with no idea it came from a retry at all. Did you consider requiring an idempotency key on unsafe methods by default, rather than leaving it optional, given how easily an agent skips optional parameters?