How to Handle Sandboxer Rate Limits and Concurrency Caps
Updated October 27, 2026 .md
Short answer: Size a client-side semaphore to your plan's free sandbox places from GET /limits, pace starts under the per-minute limit, and branch on the error code. Wait for Retry-After on rate_limited, queue on limit_reached, try the next size on no_ready_machine or capacity_exceeded, back off with jitter on 5xx, and give up on other 4xx.
An agent platform starts sandboxes in bursts: a user kicks off 40 tasks, or a planner fans out one sandbox per tool call. Sandboxer has three kinds of limits in the way: how many sandboxes may be active at once, how many starts per minute, hour and day, and how many API requests per minute. This page shows how to stay under them on the client, and what to do with each refusal. It ends with a Python and a bash helper that we tested against a mock API and then on production.
The full list of codes is in Sandboxer API error codes, and replaying creates is covered in Retry sandbox API calls safely with idempotency keys. This page doesn't repeat them. It's about the scheduling around them.
One caveat up front. Our test account is an internal account, and internal accounts skip the start windows and get a raised concurrency cap. So we could not trigger limit_reached, rate_limited or daily_limit_reached on production. Every limit, code, header and default below comes from the API source, the limits docs and the pricing table, as of 2026-10-27. The refusal paths of both helpers were tested against a mock that returns those exact codes and headers.
The limits you're designing for#
| Limit | Value (from source) | Refusal | What comes with it |
|---|---|---|---|
| Active at once | Trial 2, $20/month 100, $100 300, $500 1,000, $2,000 2,000 | 429 limit_reached |
details.limit, used, window: "concurrent". No Retry-After |
| Org member cap | Set by an org owner | 429 member_limit_reached |
No Retry-After |
| Starts per rolling minute | Trial 5, $20 12, $100 30, $500 65, $2,000 90 | 429 rate_limited |
Retry-After header, details.window: "minute", retryAfterSeconds, resetsAt |
| Starts per rolling hour | Trial 30, $20 60, $100 210, $500 420, $2,000 600 | 429 rate_limited |
Same, with window: "hour" |
| Starts per rolling day | Trial 100, $20 200, $100 840, $500 1,680, $2,000 2,400 | 429 daily_limit_reached |
Same, with window: "day" |
| API requests | 600 a minute per API key (per user for dashboard and CLI sessions), bursts up to 300. Covers the API and hosted MCP, separate from the start limits | 429 rate_limited |
Retry-After header and details.window: "requests", no resetsAt |
| Room on the fleet for a size | Changes minute to minute | 503 capacity_exceeded |
details.available, details.availableBySize |
| A ready machine right now | Only checked with failFast: true |
503 no_ready_machine |
details.retryable: true |
A few details from the source that change how you schedule:
- Creates, resumes and forks are starts. A
failFastrequest refused withno_ready_machineis not counted. - The start windows roll.
resetsAtis when enough old starts age out of the window, not midnight. With several windows full, it's the latest of them, and the code becomesdaily_limit_reachedonce the day window is full. - Order of checks. The account checks (billing, size, concurrency, then start windows) run before the capacity check. A
failFastcreate on a full fleet getscapacity_exceeded, notno_ready_machine. Resumes skip the capacity check because they go back to the node holding their data. - Active means these states:
provisioning,provisioned,cloning,ready,idle,running. A sandbox that is stopping, stopped or deleted doesn't count.
Wait, queue, fall back or give up#
Turn each refusal into one of five actions. The helpers below use exactly this mapping.
| Code | Action | Who frees it | How long to wait |
|---|---|---|---|
no_ready_machine, capacity_exceeded |
Try the next size now. After the last size, back off and start over | The fleet | Jitter, 0.5 s doubling to 30 s |
rate_limited, daily_limit_reached |
Wait, unless the wait passes your deadline | The clock | Retry-After (same as details.retryAfterSeconds) |
limit_reached, member_limit_reached |
Queue. Don't count it as a failure | You, by stopping or deleting a sandbox | Until one of yours ends; else slow jitter from 5 s |
idempotency_in_progress, card_check_pending, any 5xx, network errors |
Retry the same request with the same key | The server | Jitter, 0.5 s doubling to 30 s |
Every other 4xx |
Give up and report code and requestId |
Nobody, the request must change | Don't retry |
The "would the wait pass my deadline" check is what turns a wait into a give-up. A daily_limit_reached with Retry-After: 30000 (over 8 hours) should fail the task now and tell the user when starts free up. Holding a worker for hours helps nobody. The sbx CLI draws the same line: it waits out rate_limited when retryAfterSeconds is 60 or less, and returns at once otherwise.
Size the semaphore from GET /limits#
A semaphore stops you from finding the concurrency cap with a 429. Size it once at startup from GET /limits. This is our test account's real answer, trimmed to the fields that matter:
sbx limits --json | jq -c '{maxActiveSandboxes, activeSandboxes, activeStates, minute: .starts.minute.limit, startLimitsExempt: .currentLimits.startLimitsExempt, standardLimits}'{"maxActiveSandboxes":100,"activeSandboxes":6,"activeStates":["provisioning","provisioned","cloning","ready","idle","running"],"minute":1073741824,"startLimitsExempt":true,"standardLimits":{"activeSandboxes":100,"creationRatePerMinute":12,"creationRequestsPerDay":200,"creationRequestsPerHour":60}}startLimitsExempt: true and the huge minute limit are the internal-account exemption. On a customer account on the $20/month plan, starts.minute.limit is 12. The rules we follow:
- Slots =
maxActiveSandboxes - activeSandboxes, capped by whatever share you want this process to use. Other workers, teammates and forgotten sandboxes hold places too. That's whylimit_reachedstays a "queue" case even with a semaphore. - Hold the slot from create until the delete or stop is accepted. A sandbox counts while it's provisioning, so acquire before
POST /sandboxes. It stops counting once it leaves the active states, so release the slot after theDELETEis accepted, or once a stop has finished (sbx stop --wait). - Pace starts as well. A semaphore of 100 still lets you fire 100 creates in one second, and the $20 plan allows 12 a minute. Keep the times of recent starts and sleep when the window is full.
The Python helper#
gate.py uses only the standard library. Gate reads /limits once, Gate.start applies the table above, and Gate.sandbox wraps a job in slot, create and delete:
# gate.py: start Sandboxer sandboxes under your plan's limits (Python 3.9+, stdlib only)
import json, os, random, threading, time, urllib.error, urllib.request
from collections import deque
from contextlib import contextmanager
from datetime import datetime, timezone
API = os.environ.get("SANDBOXER_API_URL", "https://sandboxer.com/api/v1")
class GiveUp(Exception):
pass
def request(method, path, body=None, headers=None):
h = {"Authorization": f"Bearer {os.environ['SANDBOXER_API_KEY']}", **(headers or {})}
data = None
if body is not None:
h["Content-Type"] = "application/json"
data = json.dumps(body).encode()
req = urllib.request.Request(API + path, data=data, headers=h, method=method)
try:
with urllib.request.urlopen(req, timeout=90) as res:
return res.status, res.headers, json.loads(res.read() or b"{}")
except urllib.error.HTTPError as e:
return e.code, e.headers, json.loads(e.read() or b"{}")
def classify(status, code):
"""What a start refusal means for the caller."""
if status < 300:
return "ok"
if code in ("no_ready_machine", "capacity_exceeded"):
return "next_size" # nothing was created; try the next size now, back off after the last
if code in ("rate_limited", "daily_limit_reached"):
return "wait" # a start window is full; it frees at Retry-After / resetsAt
if code in ("limit_reached", "member_limit_reached"):
return "queue" # too many active; only a stop or delete frees a place
if code in ("idempotency_in_progress", "card_check_pending") or status >= 500:
return "retry" # transient; same key, backoff
return "fail" # every other 4xx: the request has to change
def backoff(attempt, base=0.5, cap=30.0):
return random.uniform(0, min(cap, base * 2 ** attempt)) # full jitter
class Gate:
def __init__(self, max_slots=None, max_wait=900, window=60.0):
_, _, lim = request("GET", "/limits")
free = max(1, lim["maxActiveSandboxes"] - lim["activeSandboxes"])
self.size = min(free, max_slots or free)
self.slots = threading.BoundedSemaphore(self.size)
self.per_window = min(lim["starts"]["minute"]["limit"], 10_000)
self.window, self.max_wait = window, max_wait
self.recent, self.lock = deque(), threading.Lock()
def _pace(self):
# Stay under the per-minute start limit instead of finding it with a 429.
with self.lock:
now = time.monotonic()
while self.recent and now - self.recent[0] > self.window:
self.recent.popleft()
if len(self.recent) >= self.per_window:
time.sleep(self.window - (now - self.recent[0]))
self.recent.popleft()
self.recent.append(time.monotonic())
def start(self, job_id, sizes=("default", "small"), **extra):
deadline, attempt = time.monotonic() + self.max_wait, 0
while True:
for size in sizes:
self._pace()
body = {"type": size, "name": job_id, "failFast": True, **extra}
# One key per job and size: a retry of the same body replays, a new size is a new request.
status, headers, data = request("POST", "/sandboxes", body, {"Idempotency-Key": f"{job_id}-{size}"})
kind = classify(status, data.get("code"))
if kind == "ok":
return data["sandbox"]["id"], size
if kind != "next_size":
break
if kind == "fail":
raise GiveUp(f"{status} {data.get('code')}: {data.get('message')} ({data.get('requestId')})")
details = (data.get("error") or {}).get("details") or {}
delay = float(headers.get("Retry-After") or details.get("retryAfterSeconds") or 0)
delay = delay or backoff(attempt, base=5.0 if kind == "queue" else 0.5)
if time.monotonic() + delay > deadline:
raise GiveUp(f"{status} {data.get('code')}: would wait {delay:.0f}s, past max_wait ({data.get('requestId')})")
print(f"{job_id}: {status} {data.get('code')} -> {kind}, sleeping {delay:.1f}s", flush=True)
time.sleep(delay)
attempt += 1
@contextmanager
def sandbox(self, job_id, **kw):
with self.slots: # held from create until the delete is accepted
sid, size = self.start(job_id, **kw)
try:
yield sid, size
finally:
request("DELETE", f"/sandboxes/{sid}", headers={"X-Sandboxer-Confirm-Delete": sid})
def ts():
return datetime.now(timezone.utc).strftime("%H:%M:%S")Three choices in it are easy to get wrong:
- The idempotency key includes the size. Falling back from
defaulttosmallchanges the body. Reusing the key would get409 idempotency_key_reused. Withjob-42-defaultandjob-42-small, a retry of either size replays safely. - Every size is sent with
failFast: true. Without it, a create waits for capacity, and you lose the chance to fall back. A refused fail-fast create bills nothing and isn't counted as a start. - The pacer counts attempts, not successes. That's conservative: a refused attempt may not count against your limit, but you don't need to know which ones do.
Tested against a mock#
We ran seven jobs at once against a local mock that answers like the API: the account allows 5 active sandboxes, 3 are already taken, and 3 starts are allowed per window. We shortened the window to 2 seconds so the test runs fast. Each job name triggers one scenario: no-default gets 503 no_ready_machine for default, rate gets one 429 rate_limited with Retry-After: 2, full gets 503 capacity_exceeded for both sizes once, daily gets 429 daily_limit_reached with Retry-After: 30000, and badsize gets 400 invalid_type.
import json, time, urllib.request
from concurrent.futures import ThreadPoolExecutor
from gate import Gate, GiveUp, request, ts
gate = Gate(max_wait=60, window=2.0) # mock minute limit is 3; a 2 s window keeps the test short
print("slots", gate.size, "starts per window", gate.per_window)
def job(name):
try:
with gate.sandbox(name) as (sid, size):
status, _, out = request("POST", f"/sandboxes/{sid}/commands", {"command": "echo ok", "timeoutSeconds": 60})
return f"{ts()} {name}: {sid} {size} exit={out['exitCode']}"
except GiveUp as e:
return f"{ts()} {name}: gave up: {e}"
jobs = ["job1", "job2-no-default", "job3-rate", "job4-full", "job5-daily", "job6-badsize", "job7"]
t0 = time.time()
with ThreadPoolExecutor(max_workers=len(jobs)) as pool:
for line in pool.map(job, jobs):
print(line)
stats = json.load(urllib.request.urlopen("http://127.0.0.1:8765/_stats")) # the mock's own counters
print("max sandboxes at once:", stats["maxConcurrent"])
print("creates:", [(round(t - t0, 1), j, s) for t, j, s in stats["creates"]])slots 2 starts per window 3
10:42:26 job1: sb_mock1 default exit=0
10:42:26 job2-no-default: sb_mock2 small exit=0
job3-rate: 429 rate_limited -> wait, sleeping 2.0s
job4-full: 503 capacity_exceeded -> next_size, sleeping 0.3s
10:42:30 job3-rate: sb_mock4 default exit=0
10:42:30 job4-full: sb_mock3 default exit=0
10:42:30 job5-daily: gave up: 429 daily_limit_reached: would wait 30000s, past max_wait (req_mock)
10:42:32 job6-badsize: gave up: 400 invalid_type: type must be one of small, default, large, xlarge. (req_mock)
10:42:32 job7: sb_mock5 default exit=0
max sandboxes at once: 2
creates: [(-0.0, 'job1', 'default'), (-0.0, 'job2-no-default', 'small'), (4.0, 'job4-full', 'default'), (4.0, 'job3-rate', 'default'), (6.0, 'job7', 'default')]The mock counted at most 2 sandboxes at once, matching the 2 free places, so it never had to send limit_reached. job2 fell back to small with no wait. job3 waited exactly the Retry-After. job5 gave up at once instead of sleeping 8 hours. job6 didn't retry.
Once on production#
Then the same helper on production, with the semaphore capped at one so the two jobs had to take turns:
from concurrent.futures import ThreadPoolExecutor
from gate import Gate, GiveUp, request, ts
gate = Gate(max_slots=1) # at most one of these sandboxes at a time
print(ts(), "slots", gate.size, "starts per minute", gate.per_window)
def job(name):
try:
with gate.sandbox(name, ttlSeconds=900) as (sid, size):
print(ts(), name, "started", sid, size, flush=True)
_, _, out = request("POST", f"/sandboxes/{sid}/commands", {"command": "nproc; uptime -p", "timeoutSeconds": 60})
print(ts(), name, "exit", out.get("exitCode"), repr(out.get("stdout")), flush=True)
print(ts(), name, "deleted", sid, flush=True)
return sid
except GiveUp as e:
print(ts(), name, "gave up:", e)
with ThreadPoolExecutor(max_workers=2) as pool:
ids = list(pool.map(job, ["batch-1", "batch-2"]))
for sid in ids:
status, _, data = request("GET", f"/sandboxes/{sid}")
print(sid, status, data.get("code") or data.get("sandbox", {}).get("state"))10:44:35 slots 1 starts per minute 10000
10:44:35 batch-1 started sb_u67mrj2lid default
10:44:36 batch-1 exit 0 '4\nup 0 minutes\n'
10:44:36 batch-1 deleted sb_u67mrj2lid
10:44:36 batch-2 started sb_uv2c5d66ze default
10:44:38 batch-2 exit 0 '4\nup 0 minutes\n'
10:44:38 batch-2 deleted sb_uv2c5d66ze
sb_u67mrj2lid 404 not_found
sb_uv2c5d66ze 404 not_foundThe second job started only after the first sandbox's delete was accepted. A command sent right after the 202 from create ran without any polling, because the commands endpoint waits up to 60 seconds for a starting sandbox. Both sandboxes returned 404 afterwards. The "starts per minute 10000" is the helper's cap on the internal account's exempt value.
The bash helper#
limits.sh does the same with curl and jq. Concurrency comes from xargs -P, sized by free_slots. It doesn't pace starts. It relies on Retry-After instead, so on a plan with 12 starts a minute, a burst of 30 will see a few rate_limited waits.
# limits.sh: source it. Needs curl and jq. Reads SANDBOXER_API_KEY (and SANDBOXER_API_URL for tests).
API=${SANDBOXER_API_URL:-https://sandboxer.com/api/v1}
MAX_WAIT=${MAX_WAIT:-900}
# start_sandbox <job-id> <size>... -> prints the sandbox id, or returns 1 with the reason on stderr
start_sandbox() {
local job=$1; shift
local tmp; tmp=$(mktemp -d)
local attempt=0 deadline=$(( $(date +%s) + MAX_WAIT )) status code kind size delay
while :; do
for size in "$@"; do
status=$(curl -sS -o "$tmp/body" -D "$tmp/hdr" -w '%{http_code}' -X POST "$API/sandboxes" \
-H "Authorization: Bearer $SANDBOXER_API_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $job-$size" \
-d "{\"type\":\"$size\",\"name\":\"$job\",\"failFast\":true}") || status=000
code=$(jq -r '.code // empty' "$tmp/body" 2>/dev/null)
case "$status:$code" in
2??:*) jq -r '.sandbox.id' "$tmp/body"; rm -rf "$tmp"; return 0 ;;
503:no_ready_machine|503:capacity_exceeded) kind=next_size; continue ;;
429:rate_limited|429:daily_limit_reached) kind=wait ;;
429:limit_reached|429:member_limit_reached) kind=queue ;;
409:idempotency_in_progress|409:card_check_pending|5??:*|000:*) kind=retry ;;
*) kind=fail ;;
esac
break
done
if [ "$kind" = fail ]; then
echo "$job: give up: $status $code $(jq -r '.message' "$tmp/body" 2>/dev/null)" >&2; rm -rf "$tmp"; return 1
fi
delay=$(awk 'tolower($1)=="retry-after:" {print $2+0}' "$tmp/hdr" 2>/dev/null)
if [ -z "$delay" ] || [ "$delay" = 0 ]; then # full jitter: random 0..min(30, base*2^attempt)
local base=0.5; [ "$kind" = queue ] && base=5
delay=$(awk -v b=$base -v a=$attempt 'BEGIN{srand(); c=b*2^a; if(c>30)c=30; printf "%.1f", rand()*c}')
fi
if [ $(( $(date +%s) + ${delay%.*} )) -gt "$deadline" ]; then
echo "$job: give up: $status $code, would wait ${delay}s" >&2; rm -rf "$tmp"; return 1
fi
echo "$job: $status $code -> $kind, sleeping ${delay}s" >&2
sleep "$delay"; attempt=$((attempt + 1))
done
}
# delete_sandbox <id>
delete_sandbox() {
curl -sS -o /dev/null -w '%{http_code}' -X DELETE "$API/sandboxes/$1" \
-H "Authorization: Bearer $SANDBOXER_API_KEY" -H "X-Sandboxer-Confirm-Delete: $1"
}
# free_slots [cap]: how many more sandboxes the account may run now, capped
free_slots() {
curl -sS "$API/limits" -H "Authorization: Bearer $SANDBOXER_API_KEY" |
jq --argjson cap "${1:-1000000}" '[.maxActiveSandboxes - .activeSandboxes, $cap] | min | if . < 1 then 1 else . end'
}The job runner runs one sandbox per job, with no more at once than the account has room for:
#!/usr/bin/env bash
# run_jobs.sh <job-id>...: one sandbox per job, never more at once than the account has room for
source "$(dirname "$0")/limits.sh"
run_job() {
local job=$1 id
id=$(start_sandbox "$job" default small) || return 1
curl -sS -X POST "$API/sandboxes/$id/commands" -H "Authorization: Bearer $SANDBOXER_API_KEY" \
-H "Content-Type: application/json" -d '{"command":"nproc; uptime -p","timeoutSeconds":60}' |
jq -c --arg job "$job" --arg id "$id" '{job:$job, id:$id, exitCode, stdout}'
echo "$job: deleted $id ($(delete_sandbox "$id"))" >&2
}
export -f run_job start_sandbox delete_sandbox; export API MAX_WAIT
slots=$(free_slots "${MAX_SLOTS:-}")
echo "running $# jobs, $slots at a time" >&2
printf '%s\n' "$@" | xargs -P "$slots" -I{} bash -c 'run_job "$1"' _ {}The same seven scenarios against the mock:
SANDBOXER_API_URL=http://127.0.0.1:8766 MAX_WAIT=60 ./run_jobs.sh job1 job2-no-default job3-rate job4-full job5-daily job6-badsize job7running 7 jobs, 2 at a time
{"job":"job1","id":"sb_mock1","exitCode":0,"stdout":"ok\n"}
job1: deleted sb_mock1 (202)
{"job":"job2-no-default","id":"sb_mock2","exitCode":0,"stdout":"ok\n"}
job2-no-default: deleted sb_mock2 (202)
job3-rate: 429 rate_limited -> wait, sleeping 2s
job4-full: 503 capacity_exceeded -> next_size, sleeping 0.1s
{"job":"job4-full","id":"sb_mock3","exitCode":0,"stdout":"ok\n"}
job4-full: deleted sb_mock3 (202)
job5-daily: give up: 429 daily_limit_reached, would wait 30000s
job6-badsize: give up: 400 invalid_type type must be one of small, default, large, xlarge.
{"job":"job7","id":"sb_mock4","exitCode":0,"stdout":"ok\n"}
job7: deleted sb_mock4 (202)
{"job":"job3-rate","id":"sb_mock5","exitCode":0,"stdout":"ok\n"}
job3-rate: deleted sb_mock5 (202)The mock again saw at most 2 sandboxes at once. xargs exited with 123 because two jobs gave up, which is what you want a CI step to see. Then one job on production:
MAX_SLOTS=1 ./run_jobs.sh batch-3running 1 jobs, 1 at a time
{"job":"batch-3","id":"sb_agbg2giamh","exitCode":0,"stdout":"4\nup 0 minutes\n"}
batch-3: deleted sb_agbg2giamh (202)sbx info sb_agbg2giamh then returned not_found with exit code 4.
If you use the SDKs or the CLI#
The SDKs retry on their own, three times by default. Inside your own loop that multiplies the waits, so turn it off on the client you use for scheduled creates:
from sandboxer import Sandboxer, SandboxerError
client = Sandboxer(max_retries=0) # your own loop decides; the SDK doesn't retry underneath it
try:
client.create(type="tiny", fail_fast=True, idempotency_key="job-42-tiny")
except SandboxerError as e:
print(e.status, e.code, e.retryable, e.request_id)
limits = client.api.limits()
print(limits.max_active_sandboxes - limits.active_sandboxes, "free places")400 invalid_type False req_nBWMsslDnWN8yqZEbo2k
96 free placesWhat each client does by itself, from the source:
| Client | 429 with Retry-After |
503 on create (no_ready_machine, capacity_exceeded) |
limit_reached |
|---|---|---|---|
Python SDK sandboxer-sdk |
Currently retries before raising, sleeping at most 60 s each time, even for a daily_limit_reached hours away. Catch RateLimitError and check .retry_after |
Retries up to 3 times, which delays your size fallback | Raises RateLimitError |
TypeScript SDK sandboxer-sdk |
Retries only when Retry-After is 60 s or less |
Retries up to 3 times | Throws |
sbx CLI |
Retries rate_limited when the wait is 60 s or less; returns daily_limit_reached at once |
Returns at once (sbx new --fail-fast for no_ready_machine) |
Returns at once |
With max_retries=0 (Python) or maxRetries: 0 (TypeScript), branch on e.code with the same table as the helpers.
Don't poll your way into the request limit#
The 600-a-minute request limit is per API key (per user for dashboard and CLI sessions), hosted MCP calls included, and every call counts, including GET /sandboxes/{id} in a wait loop. A hundred workers each polling once a second is 6,000 requests a minute: they drain the 300-request burst in about three seconds, then start getting rate_limited with details.window: "requests". Ways to stay under it:
- Don't poll for
readybefore running a command. As the production run shows, a command sent right after create waits for the sandbox. - If you must poll, poll every few seconds, and poll the list (
GET /sandboxes) once instead of each sandbox. - Give separate services separate API keys. Each key has its own bucket.
FAQ#
Should I retry limit_reached?#
Not on a timer alone. It means too many of your sandboxes are active, and only a stop or delete frees a place. Wait for one of your own jobs to finish, or back off slowly (we start at 5 seconds) if the places are held by another process.
What should I do with daily_limit_reached?#
Compare Retry-After (or details.resetsAt) with how long the task may wait. If it's past that, fail the task now and show the user resetsAt. The day window rolls, so starts free up as old ones age out, not at midnight.
Why send failFast on every create?#
Without it, a create waits for a machine instead of answering, so you can't fall back to another size. A fail-fast create refused with no_ready_machine creates nothing, bills nothing and isn't counted as a start.
Why did my fallback get idempotency_key_reused?#
You reused the key from the first size with a different type in the body. Make the key from the job and the size, like job-42-default and job-42-small.
Can I see my plan's limits before a burst?#
Yes. GET /limits (or sbx limits --json) returns maxActiveSandboxes, activeSandboxes and, under starts, the limit, used, remaining and resetsAt of each window.
Related#
More guides
For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.
For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.
The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.
If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.