# 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.

Canonical: `https://automationatlas.io/labs/docs/queue/` · Checked: `2026-10-16`

Atlas Labs is a fictional company's API; every figure in every response is simulation data.

*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 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. |

> **The website does all of this for you.** The “Start a sandbox” form on the [hub](https://automationatlas.io/labs/#start-sandbox-heading) 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
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.*

| 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 · The poll that claims the slot (the keys appear only here)*

```http
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
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*

```bash
#!/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*

```javascript
// 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*

```python
"""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.*

| 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](https://automationatlas.io/labs/docs/tools/rest-api/#pow); 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](https://automationatlas.io/labs/docs/tools/ai-agents/).

## 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`.

[Authenticate with your key](https://automationatlas.io/labs/docs/tools/rest-api/) · [Webhooks](https://automationatlas.io/labs/docs/webhooks/)
