Waiting for a sandbox
When every sandbox slot is taken, POST /sandboxes answers 202 with a queue ticket instead of keys, and you poll GET /queue/{ticket_id} until a poll answers 201 with your keys. Those keys come back once, and you must claim them within the claim window or the ticket is forfeited.
| At a glance | Value |
|---|---|
| Why a queue | Atlas Labs runs at most 500 sandboxes at once. A request that finds no free slot, or arrives while the server is shedding load, gets a ticket instead. |
| Create | POST/sandboxes (no key): 201 with your keys, or 202 with {ticket_id, position, eta_s, poll}. |
| Poll | GET/queue/{ticket_id} (no key): 202 while you wait, then the 201 body, once. |
| Claim window | 120 seconds from the moment the ticket turns ready. After that, 410 ticket_forfeited. |
| Queue size | At most 300 open tickets. Beyond that, 503 queue_full with Retry-After: 30. |
| One ticket | One open ticket per IP group; a second POST/sandboxes while the queue branch applies answers 409 queue_ticket_open. |
| Extending | The second POST/sandbox/extend answers 409 queue_waiting while anyone is waiting. |
Why is there a queue?
Atlas Labs is a free tool with limited capacity. It keeps at most 500 sandboxes running at once, and a ready ticket holds its slot until it is claimed or forfeited. When all the slots are taken, new requests wait in line instead of failing. The same queue is used as a pressure valve: when the server reports load above normal, creation queues even though slots are free, and the poll then opens the slot straight away.
What does POST /sandboxes return when Atlas Labs is full?
A 202 Accepted with the ticket in the usual envelope. There is no key in it yet.
POST /labs/api/v1/sandboxes HTTP/1.1 202 Accepted
{
"meta": { "simulation": true, "request_id": "req_…", "api_version": "2026-09" },
"data": {
"ticket_id": "tkt_…",
"position": 3,
"eta_s": 180,
"poll": "/labs/api/v1/queue/tkt_…"
}
} ticket_idstartstkt_and is a bearer secret (random, with no timestamp): whoever holds it can claim your keys, so do not log or share it.positioncounts the waiting tickets created at or before yours, so1is next in line. It is counted again on every poll, so it falls as the tickets ahead of yours are promoted.eta_sispositiontimes60, an estimate and not a measurement.pollis a path, not a URL. Puthttps://automationatlas.ioin front of it.
How do I poll the queue?
GET /labs/api/v1/queue/{ticket_id} needs no key. Poll about every 3 seconds, as the website does: polls count against the unauthenticated budget of 30 per minute per IP group. Treat 429 and 5xx answers as “keep my place, wait Retry-After”, not as a lost ticket.
| Status | What it means |
|---|---|
202 | Still waiting. The body is the same shape as the original 202: ticket_id, position, eta_s, poll. |
201 | A slot opened and this poll claimed it. The body is the sandbox, with both keys and keys_shown_once: true. It is returned once. |
410 ticket_forfeited | The ticket turned ready but was not claimed within 120 seconds. Start again with a new POST/sandboxes. |
404 not_found | The id is unknown or malformed, or the keys were already claimed: a claimed ticket never returns them twice. |
429 rate_limited | Too many unauthenticated calls from your IP group. Wait the seconds in Retry-After (up to 2) and poll again; the ticket is untouched. |
429 ip_active_limit | Your IP group already has two active sandboxes, so the claim was refused. The ticket stays ready, and its claim window keeps running. |
HTTP/1.1 201 Created
{
"meta": { "simulation": true, "expires_in_s": 1800 },
"data": {
"sandbox_id": "sbx_…",
"public_name": "…",
"factory_key": "lab_fac_…",
"office_key": "lab_off_…",
"expires_at": "…",
"keys_shown_once": true
}
} How long do I have to claim my keys?
When a slot frees up, the oldest waiting ticket turns ready. A ready ticket counts as an occupied slot, so nobody else can take it, and it is forfeited if no poll claims it within 120 seconds. The poll that finds the ticket ready creates the sandbox and returns the 201 in the same call.
201 is lost, the keys cannot be fetched again: the sandbox keeps running until it expires and counts towards your IP group's two active sandboxes. Write the keys to an owner-only file the moment they arrive, as the snippet below does. What if the queue is full too?
With 300 tickets already open (waiting plus ready), POST /sandboxes answers 503 queue_full with a Retry-After: 30 header and creates no ticket. Wait that long and try again. A 503 feature_disabled means the operator has paused creation; it also carries a Retry-After.
HTTP/1.1 503 Service Unavailable
Retry-After: 30
{
"meta": { "simulation": true },
"error": { "code": "queue_full", "message": "the sandbox queue is full", "retryable": true }
} How do I handle 201, 202, 503 and 410 in code?
The tested snippet creates a sandbox, takes the 201 straight away or polls a 202 ticket until the keys arrive, waits Retry-After on 503 and on 429, and stops on 410 or 404. It then saves the keys to atlas-keys.env with owner-only permissions.
snippets/10-wait-in-queue.sh run against the in-process API by the repo's tests, with the queued branch forced by load shedding #!/usr/bin/env bash
# Get a sandbox, waiting in the queue when Atlas Labs is full.
# POST /sandboxes answers 201 with your keys, or 202 with a queue ticket when every slot is taken.
# Poll the ticket until a poll answers 201: that body is the only time the keys appear, and a
# ready ticket must be claimed within 120 s or it is forfeited (410 ticket_forfeited).
# Needs: curl, jq.
set -euo pipefail
BASE="${ATLAS_BASE:-https://automationatlas.io/labs/api/v1}"
ORIGIN=$(printf '%s' "$BASE" | sed -E 's#^(https?://[^/]+).*#\1#') # `poll` is a path, not a URL
headers=$(mktemp) body=$(mktemp)
trap 'rm -f "$headers" "$body"' EXIT
# Run one request; sets $status, leaves the response in $body and its headers in $headers.
call() { status=$(curl -sS -D "$headers" -o "$body" -w '%{http_code}' "$@"); }
retry_after() { awk -F': *' 'tolower($1) == "retry-after" { gsub(/\r/, "", $2); print $2 }' "$headers"; }
for attempt in 1 2 3 4 5; do
call -X POST "$BASE/sandboxes" -H "Content-Type: application/json" -d '{"preset":"starter"}'
# 503 queue_full: the queue itself is full. Retry-After says when to try again (30 s).
if [ "$status" = 503 ] && [ "$attempt" -lt 5 ]; then
pause=$(retry_after); sleep "${pause:-30}"; continue
fi
break
done
if [ "$status" = 202 ]; then
poll=$(jq -r .data.poll "$body")
echo "queued at position $(jq -r .data.position "$body"), about $(jq -r .data.eta_s "$body") seconds"
while :; do
pause=$(retry_after); pause=${pause:-3}
[ "$pause" -ge 3 ] 2>/dev/null || pause=3 # unauthenticated calls share 30 per minute per IP group
sleep "$pause"
call "$ORIGIN$poll"
case "$status" in
202) echo "position $(jq -r .data.position "$body")" ;;
429 | 5??) ;; # keep the place, wait Retry-After
*) break ;; # 201 keys, 410 forfeited, 404 unknown or already claimed
esac
done
fi
if [ "$status" != 201 ]; then
echo "HTTP $status: $(cat "$body")" >&2
exit 1
fi
# Owner-only file. Do not commit it.
(umask 077
printf 'export ATLAS_FACTORY_KEY=%s\nexport ATLAS_OFFICE_KEY=%s\n' \
"$(jq -r .data.factory_key "$body")" "$(jq -r .data.office_key "$body")" > atlas-keys.env)
chmod 600 atlas-keys.env # umask only applies to a new file
jq '{sandbox_id: .data.sandbox_id, name: .data.public_name, expires_at: .data.expires_at}' "$body"
echo "Keys saved to atlas-keys.env. Run: source atlas-keys.env"
snippets/10-wait-in-queue.mjs run against the in-process API by the repo's tests, with the queued branch forced by load shedding // Get a sandbox, waiting in the queue when Atlas Labs is full (Node 20+, no dependencies).
// POST /sandboxes answers 201 with your keys, or 202 with a queue ticket when every slot is taken.
// Poll the ticket until a poll answers 201: that body is the only time the keys appear, and a
// ready ticket must be claimed within 120 s or it is forfeited (410 ticket_forfeited).
import { chmodSync, writeFileSync } from "node:fs";
const BASE = process.env.ATLAS_BASE ?? "https://automationatlas.io/labs/api/v1";
const ORIGIN = new URL(BASE).origin; // `poll` is a path such as /labs/api/v1/queue/tkt_..., not a URL
const POLL_FLOOR_MS = 3000; // unauthenticated calls share 30 per minute per IP group
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const retryAfterMs = (res, fallbackMs) => {
const seconds = Number(res.headers.get("retry-after"));
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : fallbackMs;
};
async function create(body) {
for (let attempt = 1; ; attempt += 1) {
const res = await fetch(`${BASE}/sandboxes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
// 503 queue_full: the queue itself is full. Retry-After says when to try again (30 s).
if (res.status === 503 && attempt < 5) {
await sleep(retryAfterMs(res, 30_000));
continue;
}
return res;
}
}
let res = await create({ preset: "starter" });
let json = await res.json();
if (res.status === 202) {
const { poll } = json.data;
console.log("queued at position", json.data.position, "about", json.data.eta_s, "seconds");
for (;;) {
await sleep(Math.max(POLL_FLOOR_MS, retryAfterMs(res, 0)));
res = await fetch(ORIGIN + poll);
json = await res.json().catch(() => ({}));
if (res.status === 202) {
console.log("position", json.data.position);
continue;
}
if (res.status === 429 || res.status >= 500) continue; // keep the place, wait Retry-After
break; // 201 keys, 410 forfeited, 404 unknown or already claimed
}
}
if (res.status !== 201) {
const e = json.error ?? {};
throw new Error(`HTTP ${res.status} ${e.code}: ${e.hint ?? e.message}`);
}
const sandbox = json.data;
// Owner-only file. Do not commit it.
writeFileSync(
"atlas-keys.env",
`export ATLAS_FACTORY_KEY=${sandbox.factory_key}\nexport ATLAS_OFFICE_KEY=${sandbox.office_key}\n`,
{ mode: 0o600 },
);
// `mode` only applies when the file is created; tighten one that already existed.
chmodSync("atlas-keys.env", 0o600);
console.log(sandbox.sandbox_id, sandbox.public_name, "expires", sandbox.expires_at);
console.log("Keys saved to atlas-keys.env. Run: source atlas-keys.env");
snippets/10-wait-in-queue.py run against the in-process API by the repo's tests, with the queued branch forced by load shedding """Get a sandbox, waiting in the queue when Atlas Labs is full.
POST /sandboxes answers 201 with your keys, or 202 with a queue ticket when every slot is taken.
Poll the ticket until a poll answers 201: that body is the only time the keys appear, and a
ready ticket must be claimed within 120 s or it is forfeited (410 ticket_forfeited).
Needs: pip install requests
"""
import os
import time
from urllib.parse import urlsplit
import requests
BASE = os.environ.get("ATLAS_BASE", "https://automationatlas.io/labs/api/v1")
parts = urlsplit(BASE)
ORIGIN = f"{parts.scheme}://{parts.netloc}" # `poll` is a path such as /labs/api/v1/queue/tkt_..., not a URL
POLL_FLOOR_S = 3 # unauthenticated calls share 30 per minute per IP group
def retry_after(res, fallback):
try:
seconds = float(res.headers.get("Retry-After", ""))
return seconds if seconds > 0 else fallback
except ValueError:
return fallback
def create(body):
for attempt in range(1, 6):
res = requests.post(f"{BASE}/sandboxes", json=body, timeout=30)
# 503 queue_full: the queue itself is full. Retry-After says when to try again (30 s).
if res.status_code == 503 and attempt < 5:
time.sleep(retry_after(res, 30))
continue
return res
return res
res = create({"preset": "starter"})
if res.status_code == 202:
data = res.json()["data"]
poll = data["poll"]
print("queued at position", data["position"], "about", data["eta_s"], "seconds")
while True:
time.sleep(max(POLL_FLOOR_S, retry_after(res, 0)))
res = requests.get(ORIGIN + poll, timeout=30)
if res.status_code == 202:
print("position", res.json()["data"]["position"])
continue
if res.status_code == 429 or res.status_code >= 500:
continue # keep the place, wait Retry-After
break # 201 keys, 410 forfeited, 404 unknown or already claimed
if res.status_code != 201:
err = res.json().get("error", {})
raise SystemExit(f"HTTP {res.status_code} {err.get('code')}: {err.get('hint') or err.get('message')}")
sandbox = res.json()["data"]
# Owner-only file. Do not commit it.
fd = os.open("atlas-keys.env", os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
f.write(f"export ATLAS_FACTORY_KEY={sandbox['factory_key']}\n")
f.write(f"export ATLAS_OFFICE_KEY={sandbox['office_key']}\n")
os.chmod("atlas-keys.env", 0o600) # the mode above only applies to a new file
print(sandbox["sandbox_id"], sandbox["public_name"], "expires", sandbox["expires_at"])
print("Keys saved to atlas-keys.env. Run: source atlas-keys.env")
How does extending a sandbox affect people waiting?
A sandbox lasts 30 minutes and can be extended by 30 minutes at most twice, only when 10 minutes or fewer remain. The first extension is always granted. The second is refused with 409 queue_waiting while any ticket is waiting, so people in line are not kept out by someone extending twice. 409 extend_exhausted means both extensions are used.
Which rate limits apply to creating and waiting?
| Limit | Value | What you see |
|---|---|---|
| Unauthenticated calls (create and poll) | 30 per minute per IP group | 429 rate_limited with Retry-After up to 2 |
| Active sandboxes per IP group | 2 | 429 ip_active_limit, on the create and on a claim |
| Sandboxes created per IP group | 10 in a rolling day | 429 ip_create_limit |
| Open tickets per IP group | 1 | 409 queue_ticket_open |
| Open tickets in total | 300 | 503 queue_full, Retry-After: 30 |
An IP group is the network address your requests come from, stored only as a one-way hash. The queue never stores your address.
What about proof of work?
Proof of work is off by default. If the operator switches it on, POST /sandboxes answers 428 pow_required with a challenge before any queueing: solve it and send the same request again with an Atlas-PoW header. Polling a ticket needs no proof. The challenge format and the tested solvers are in Authenticate with your key; the full protocol is docs/POW.md in the repository.
What does the website show while I wait?
The start form gives way to the line “Atlas Labs is full right now. You're number N in the queue, about M minutes to go”, with the note “Your place is held. When a slot frees up the gate opens and your keys appear here.” Beside it is the queue scene: delivery trucks waiting outside a closed gate. When a poll returns the 201, the gate turns green and the first truck drives through, then the keys appear. The page polls about every 3 seconds, keeps its place through 429 and 5xx answers, and keeps polling for as long as the tab is open. It stops only on a definitive answer: 410 ticket_forfeited (“Your slot opened but was not claimed in time”) or 404. If you press Start while an earlier ticket is still open, for example after closing the tab, the answer is 409 queue_ticket_open and the page says your earlier place is still held; it cannot rejoin it, because it does not store the ticket id.
What does an AI agent get?
The MCP tool create_sandbox returns {"queued": true, "ticket_id": …, "position": …, "eta_s": …, "poll": …} when the sandbox is queued. There is no MCP tool for polling: poll the poll path over REST, and send the keys in the 201 as Authorization: Bearer headers. See Connect an AI agent.
Questions people ask
- Why did I get a ticket when the world map shows fewer than
500active sandboxes? - Ready tickets hold a slot too, and the server queues creation whenever its own load is above normal. In both cases the next poll can answer
201within seconds. - How long will I wait?
eta_sis only the position times60seconds, and the position is counted afresh on every poll. A slot opens when a sandbox ends, so the real wait depends on how many sessions are running out.- Is my place kept if I close the tab?
- The ticket stays in line on the server, but the page does not store the id, so a closed tab cannot poll it, and starting again in a new tab answers
409 queue_ticket_openuntil the old ticket reaches the front and goes unclaimed for120seconds. Restarting does not help; keep the tab open, or script the wait and keep theticket_id. A script can poll for as long as it likes; a ticket that turns ready and is not claimed within120seconds is forfeited. - Can I hold two tickets?
- No. One open ticket per IP group; asking again while you hold one, including from another tab, answers
409 queue_ticket_open. The earlier ticket keeps its place and is released only once it is forfeited. - What does
410 ticket_forfeitedmean? - Your slot opened but no poll claimed it within
120seconds, so it went to the next ticket. Start again with a newPOST /sandboxes; that works now because the forfeited ticket is no longer open. - How can I see how long the queue is?
GET /labs/api/v1/healthandGET /labs/api/v1/worldboth reportqueue_lengthwithout a key. It counts waiting and ready tickets, and it is service data about Atlas Labs itself, not simulation data.- Is the ticket id a secret?
- Yes. Anyone who has it can claim your keys, so keep it out of logs and chat.
Sources (checked 16 Oct 2026): Atlas Labs API source, services/sandboxes.ts (createOrEnqueue, pollQueue, extend), routes/public.ts, plugins/ratelimit.ts, packages/db/src/lifecycle.ts (promoteQueue, forfeitExpiredReady), packages/sim-core/src/constants.ts (CAPACITY, LIMITS) and the start flow in packages/web/src/scripts/start-sandbox.ts. Snippets: snippets/10.