Skip to content
Guides · Agent cookbook

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 failFast request refused with no_ready_machine is not counted.
  • The start windows roll. resetsAt is when enough old starts age out of the window, not midnight. With several windows full, it's the latest of them, and the code becomes daily_limit_reached once the day window is full.
  • Order of checks. The account checks (billing, size, concurrency, then start windows) run before the capacity check. A failFast create on a full fleet gets capacity_exceeded, not no_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 why limit_reached stays 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 the DELETE is 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 default to small changes the body. Reusing the key would get 409 idempotency_key_reused. With job-42-default and job-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_found

The 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 job7
running 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-3
running 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 places

What 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 ready before 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.

More guides

Top AI sandbox platforms for code execution in 2026, ranked

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.

Read the guide
Best sandboxes for running coding agents (Claude Code, Codex, Gemini CLI) in 2026, ranked

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.

Read the guide
Best Daytona sandbox alternatives in 2026, ranked

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.

Read the guide
Best E2B alternatives in 2026, ranked

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.

Read the guide