Skip to content

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.

Reference table — how the Atlas Labs sandbox queue behaves, not part of the fictional simulation. From the API source, checked 16 Oct 2026.
At a glanceValue
Why a queueAtlas 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.
CreatePOST/sandboxes (no key): 201 with your keys, or 202 with {ticket_id, position, eta_s, poll}.
PollGET/queue/{ticket_id} (no key): 202 while you wait, then the 201 body, once.
Claim window120 seconds from the moment the ticket turns ready. After that, 410 ticket_forfeited.
Queue sizeAt most 300 open tickets. Beyond that, 503 queue_full with Retry-After: 30.
One ticketOne open ticket per IP group; a second POST/sandboxes while the queue branch applies answers 409 queue_ticket_open.
ExtendingThe second POST/sandbox/extend answers 409 queue_waiting while anyone is waiting.
The website does all of this for you. The “Start a sandbox” form on the hub takes the ticket, polls it and shows your keys when the slot opens. This page is for scripts and agents that call the API themselves.

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.

HTTP The queued answer to 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_id starts tkt_ and is a bearer secret (random, with no timestamp): whoever holds it can claim your keys, so do not log or share it.
  • position counts the waiting tickets created at or before yours, so 1 is next in line. It is counted again on every poll, so it falls as the tickets ahead of yours are promoted.
  • eta_s is position times 60, an estimate and not a measurement.
  • poll is a path, not a URL. Put https://automationatlas.io in 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.

Reference table — what a poll of GET /queue/{ticket_id} can return, not part of the fictional simulation.
StatusWhat it means
202Still waiting. The body is the same shape as the original 202: ticket_id, position, eta_s, poll.
201A 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_forfeitedThe ticket turned ready but was not claimed within 120 seconds. Start again with a new POST/sandboxes.
404 not_foundThe id is unknown or malformed, or the keys were already claimed: a claimed ticket never returns them twice.
429 rate_limitedToo 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_limitYour IP group already has two active sandboxes, so the claim was refused. The ticket stays ready, and its claim window keeps running.
HTTP The poll that claims the slot (the keys appear only here)
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.

Save the response before you do anything else. If that 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 When the queue itself is full
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.

curl 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"
JavaScript 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");
Python 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?

Reference table — limits that apply to creating a sandbox and polling a ticket, not part of the fictional simulation.
LimitValueWhat you see
Unauthenticated calls (create and poll)30 per minute per IP group429 rate_limited with Retry-After up to 2
Active sandboxes per IP group2429 ip_active_limit, on the create and on a claim
Sandboxes created per IP group10 in a rolling day429 ip_create_limit
Open tickets per IP group1409 queue_ticket_open
Open tickets in total300503 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 500 active 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 201 within seconds.
How long will I wait?
eta_s is only the position times 60 seconds, 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_open until the old ticket reaches the front and goes unclaimed for 120 seconds. Restarting does not help; keep the tab open, or script the wait and keep the ticket_id. A script can poll for as long as it likes; a ticket that turns ready and is not claimed within 120 seconds 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_forfeited mean?
Your slot opened but no poll claimed it within 120 seconds, so it went to the next ticket. Start again with a new POST /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/health and GET /labs/api/v1/world both report queue_length without 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.