# Authenticate with your key

> Atlas Labs authenticates every request with `Authorization: Bearer lab_off_…` for the Head Office system or `lab_fac_…` for the Factory system, and answers in one JSON envelope that always carries `meta.simulation: true`. This page gives tested curl, JavaScript and Python snippets for creating a sandbox, reading, writing safely with an `Idempotency-Key`, backing off on a `429`, and connecting an AI agent.

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

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

*Reference table — Atlas Labs API fields, not part of the fictional simulation. From the API source, checked `16 Oct 2026`.*

| At a glance | Value |
| --- | --- |
| Base URL | `https://automationatlas.io/labs/api/v1` |
| Auth header | `Authorization: Bearer <key>`; `X-Api-Key: <key>` is an alias (Bearer wins if both are sent) |
| Key format | `lab_fac_` or `lab_off_` followed by `43` URL-safe base64 characters; shown once in the `201` from `POST /sandboxes` |
| Body header | `Content-Type: application/json`; bodies up to `16,384` bytes |
| Rate limit | `120` per minute per key (burst `30`); `429` carries `Retry-After` in seconds and `error.retry_at` |
| Never | A key in a URL path or query string is rejected with `400 key_in_url` and revoked on the spot |

> **Start a sandbox and your keys are filled in for you.** The snippets below read `ATLAS_OFFICE_KEY` and `ATLAS_FACTORY_KEY` from your environment; the create-sandbox snippet writes them to `atlas-keys.env`. After you [start a sandbox on the hub](https://automationatlas.io/labs/#start-sandbox-heading), the “Connect it” panel shows these same commands with your keys already in place.

## Which key do I use?

A sandbox has two keys because the fictional company has two systems that do not share data. Head Office routes (`/office/*`) take the `lab_off_` key, Factory routes (`/factory/*`) take the `lab_fac_` key, and the shared routes take either.

- Factory key `lab_fac_…` opens the Factory API (`/factory/*`): work orders, runs, stock.
- Head Office key `lab_off_…` opens the Head Office API (`/office/*`): orders, suppliers, cash.
- Shared routes accept either key: `/sandbox`, `/missions`, `/events`, `/webhooks`.
- The wrong key answers `403 wrong_system` with a `hint` naming the right prefix.

*JSON · A Factory key sent to a Head Office route: `403` `application/json`*

```json
{
  "meta": {
    "simulation": true,
    "request_id": "req_…",
    "server_time": "…",
    "sim_date": "1897-03-04",
    "expires_in_s": 1710,
    "api_version": "2026-09"
  },
  "error": {
    "code": "wrong_system",
    "message": "…",
    "retryable": false,
    "hint": "use a lab_off_ key",
    "docs_url": "https://automationatlas.io/labs/docs/quickstart/"
  }
}
```

## How do I create a sandbox from code?

`POST /sandboxes` needs no key. The `201` body is the only time both keys appear in machine-readable form, so the snippet saves them to an owner-only file. If Atlas Labs is full you get a `202` with a queue ticket to poll instead.

*curl · `snippets/01-create-sandbox.sh` · bash, curl, jq · proven against the live API*

```bash
#!/usr/bin/env bash
# Create an Atlas Labs sandbox and keep both keys (they are shown once).
# Needs: curl, jq. Afterwards run:  source atlas-keys.env
set -euo pipefail
BASE="${ATLAS_BASE:-https://automationatlas.io/labs/api/v1}"

resp=$(curl -sS -X POST "$BASE/sandboxes" \
  -H "Content-Type: application/json" \
  -d '{"preset":"starter"}' \
  -w '\n%{http_code}')
status=${resp##*$'\n'}
body=${resp%$'\n'*}

if [ "$status" != "201" ]; then
  # 202 = Atlas Labs is full and you hold a queue ticket (poll data.poll)
  # 428 = proof of work required (see the proof-of-work snippet)
  # 429 = ip_active_limit or ip_create_limit
  echo "HTTP $status: $body" >&2
  exit 1
fi

# Save the keys with owner-only permissions. Do not commit this file.
(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"
```

## How do I make my first authenticated read?

Either key works on `/sandbox` and `/missions`; the sales-order list needs the Head Office key.

*curl · `snippets/02-first-read.sh` · proven against the live API*

```bash
#!/usr/bin/env bash
# First authenticated read. Either key works on shared routes such as /sandbox and /missions.
# Head Office routes need lab_off_, Factory routes need lab_fac_ (wrong key = 403 wrong_system).
set -euo pipefail
BASE="${ATLAS_BASE:-https://automationatlas.io/labs/api/v1}"
: "${ATLAS_OFFICE_KEY:?run: source atlas-keys.env}"

# Status: cash, inventory, expires_in_s, extensions_left
curl -sS "$BASE/sandbox" -H "Authorization: Bearer $ATLAS_OFFICE_KEY" \
  | jq '{cash_minor: .data.cash_minor, expires_in_s: .data.expires_in_s, inventory: .data.inventory}'

# Head Office read: sales orders, newest first
curl -sS "$BASE/office/sales-orders?limit=5" -H "Authorization: Bearer $ATLAS_OFFICE_KEY" \
  | jq '.data.items[] | {id, quantity, status}'

# Missions: what to try next, detected from what you actually did
curl -sS "$BASE/missions" -H "Authorization: Bearer $ATLAS_OFFICE_KEY" \
  | jq '.data.items[] | select(.status == "active") | {id, objective, hint}'
```

*JavaScript · `snippets/02-first-read.mjs` · proven against the live API*

```javascript
// First authenticated read (Node 20+).  Run after: source atlas-keys.env
const BASE = process.env.ATLAS_BASE ?? "https://automationatlas.io/labs/api/v1";
const KEY = process.env.ATLAS_OFFICE_KEY; // lab_off_...; Factory routes need ATLAS_FACTORY_KEY
if (!KEY) throw new Error("run: source atlas-keys.env");

async function get(path) {
  const res = await fetch(`${BASE}${path}`, { headers: { Authorization: `Bearer ${KEY}` } });
  const json = await res.json();
  if (!res.ok) throw new Error(`${res.status} ${json.error.code}: ${json.error.hint ?? json.error.message}`);
  return json.data;
}

const status = await get("/sandbox");
console.log("cash_minor", status.cash_minor, "expires_in_s", status.expires_in_s);

const { items: orders } = await get("/office/sales-orders?limit=5");
for (const o of orders) console.log(o.id, o.quantity, o.status);

const { items: missions } = await get("/missions");
console.log(missions.filter((m) => m.status === "active").map((m) => m.objective));
```

*Python · `snippets/02-first-read.py` · proven against the live API*

```python
"""First authenticated read.  pip install requests; then: source atlas-keys.env"""
import os

import requests

BASE = os.environ.get("ATLAS_BASE", "https://automationatlas.io/labs/api/v1")
KEY = os.environ["ATLAS_OFFICE_KEY"]  # lab_off_...; Factory routes need ATLAS_FACTORY_KEY
HEADERS = {"Authorization": f"Bearer {KEY}"}


def get(path):
    res = requests.get(f"{BASE}{path}", headers=HEADERS, timeout=30)
    body = res.json()
    if not res.ok:
        err = body["error"]
        raise SystemExit(f"{res.status_code} {err['code']}: {err.get('hint') or err['message']}")
    return body["data"]


status = get("/sandbox")
print("cash_minor", status["cash_minor"], "expires_in_s", status["expires_in_s"])

for order in get("/office/sales-orders?limit=5")["items"]:
    print(order["id"], order["quantity"], order["status"])

print([m["objective"] for m in get("/missions")["items"] if m["status"] == "active"])
```

## How do I make a write that is safe to retry?

Send an `Idempotency-Key` header derived from the business action, not a random value. The same key with the same request returns the original response with `Idempotency-Replayed: true`; the same key with a different path or body is refused with `422 idempotency_key_reused`. The header is accepted on every write and required on the `13` routes listed after the snippet.

*curl · `snippets/03-first-write.sh` · proven against the live API, replay observed*

```bash
#!/usr/bin/env bash
# First write with an Idempotency-Key: accept a received sales order.
# Same key + same request = the original response again (header Idempotency-Replayed: true).
# Same key + a different path or body = 422 idempotency_key_reused.
set -euo pipefail
BASE="${ATLAS_BASE:-https://automationatlas.io/labs/api/v1}"
: "${ATLAS_OFFICE_KEY:?run: source atlas-keys.env}"

ORDER=$(curl -sS "$BASE/office/sales-orders?limit=20" -H "Authorization: Bearer $ATLAS_OFFICE_KEY" \
  | jq -r '[.data.items[] | select(.status == "received")][0].id')
[ "$ORDER" != "null" ] || { echo "no received sales order left to accept" >&2; exit 1; }
echo "accepting $ORDER"

# Derive the key from the action, so a retry after a crash reuses it (max 64 characters).
KEY="accept-$ORDER"
for attempt in 1 2; do
  curl -sS -D - -o body.tmp -X POST "$BASE/office/sales-orders/$ORDER/accept" \
    -H "Authorization: Bearer $ATLAS_OFFICE_KEY" \
    -H "Idempotency-Key: $KEY" | grep -iE '^(HTTP/|idempotency-replayed)' || true
  jq -c '.data | {id, status}' body.tmp
done
rm -f body.tmp
```

*JavaScript · `snippets/03-first-write.mjs` · proven against the live API, replay observed*

```javascript
// First write with an Idempotency-Key (Node 20+).  Run after: source atlas-keys.env
// Same key + same request = the original response again (Idempotency-Replayed: true).
// Same key + a different path or body = 422 idempotency_key_reused.
const BASE = process.env.ATLAS_BASE ?? "https://automationatlas.io/labs/api/v1";
const KEY = process.env.ATLAS_OFFICE_KEY;
if (!KEY) throw new Error("run: source atlas-keys.env");
const headers = { Authorization: `Bearer ${KEY}` };

const list = await (await fetch(`${BASE}/office/sales-orders?limit=20`, { headers })).json();
const order = list.data.items.find((o) => o.status === "received");
if (!order) throw new Error("no received sales order left to accept");

// Derive the key from the action so a retry after a crash reuses it (max 64 characters).
const idempotencyKey = `accept-${order.id}`;

for (const attempt of [1, 2]) {
  const res = await fetch(`${BASE}/office/sales-orders/${order.id}/accept`, {
    method: "POST",
    headers: { ...headers, "Idempotency-Key": idempotencyKey },
  });
  const json = await res.json();
  console.log(attempt, res.status, "replayed:", res.headers.get("idempotency-replayed") === "true", json.data?.status ?? json.error);
}
```

*Python · `snippets/03-first-write.py` · proven against the live API, replay observed*

```python
"""First write with an Idempotency-Key.  pip install requests; then: source atlas-keys.env

Same key + same request = the original response again (Idempotency-Replayed: true).
Same key + a different path or body = 422 idempotency_key_reused.
"""
import os

import requests

BASE = os.environ.get("ATLAS_BASE", "https://automationatlas.io/labs/api/v1")
HEADERS = {"Authorization": f"Bearer {os.environ['ATLAS_OFFICE_KEY']}"}

orders = requests.get(f"{BASE}/office/sales-orders?limit=20", headers=HEADERS, timeout=30).json()
order = next((o for o in orders["data"]["items"] if o["status"] == "received"), None)
if order is None:
    raise SystemExit("no received sales order left to accept")

# Derive the key from the action so a retry after a crash reuses it (max 64 characters).
idempotency_key = f"accept-{order['id']}"

for attempt in (1, 2):
    res = requests.post(
        f"{BASE}/office/sales-orders/{order['id']}/accept",
        headers={**HEADERS, "Idempotency-Key": idempotency_key},
        timeout=30,
    )
    body = res.json()
    print(attempt, res.status_code, "replayed:", res.headers.get("Idempotency-Replayed") == "true",
          body.get("data", {}).get("status") or body.get("error"))
```

`POST /office/sales-orders/{id}/accept` returns the sales-order row. Its `status` is `in_production`, with `bridged_work_order_id` set, when your sandbox's bridge is `auto` (the default `starter` preset), and `accepted` when it is `manual`. `POST /office/shipments` returns the shipment row, so the shipment's id is `id`, and the customer invoice it raised is nested under `invoice`.

*Reference table — the routes on which `Idempotency-Key` is required (`400 idempotency_required` without it). Atlas Labs API behaviour, not part of the fictional simulation.*

| Factory routes (`lab_fac_`) | Head Office routes (`lab_off_`) |
| --- | --- |
| `POST /factory/work-orders`<br>`POST /factory/print-runs`<br>`POST /factory/goods-receipts`<br>`POST /factory/shipments` | `POST /office/requisitions`<br>`POST /office/purchase-orders`<br>`POST /office/purchase-orders/{id}/approve`<br>`POST /office/goods-receipts`<br>`POST /office/supplier-invoices/{id}/pay`<br>`POST /office/shipments`<br>`POST /office/timesheets`<br>`POST /office/payroll-runs`<br>`POST /office/market` |

## If you send too many requests

Each key allows `120` requests a minute, in bursts of up to `30`. Go over and the answer is `429 rate_limited`. The `Retry-After` header says how many seconds to wait, `error.retry_at` gives the same moment as a time, and `error.message` and `error.hint` say what happened in plain words. Wait that long, then send the same request with the same `Idempotency-Key`: a refused request did nothing, so sending it again is safe.

*HTTP · What a `429` looks like*

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 1
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0

{
  "meta": { "simulation": true, "request_id": "req_…", "api_version": "2026-09" },
  "error": {
    "code": "rate_limited",
    "message": "Too many requests for this key. It allows 120 a minute, in bursts of up to 30. Wait 1 second before the next one.",
    "retryable": true,
    "retry_at": "2026-10-16T12:00:01.000Z",
    "hint": "Pause between requests, or wait for the Retry-After header before retrying. Requests sent in parallel share this budget. A loop with no delay, or too many requests at once, is the usual cause.",
    "docs_url": "https://automationatlas.io/labs/docs/tools/rest-api/#too-many-requests"
  }
}
```

The budget belongs to the key, so requests sent in parallel share it: more workers at once do not get more done, they only collect more `429`s. A key that keeps getting requests while it is over its limit goes into a short cooldown, whether they come from a loop with no delay or from too many workers in parallel, even when each one waits as told. Every request in the cooldown gets the same `429`, with `retry_at` set to its end, so stop, fix the loop or send fewer at once, and carry on then. Nothing is revoked, and your sandbox is as you left it. If a whole network keeps going regardless, it is blocked for a while with `403 banned`, which also carries `retry_at`; if you think a block is wrong, tell us through the [contact form](https://automationatlas.io/labs/contact/).

*Reference table — Atlas Labs rate limits, not part of the fictional simulation.*

| Budget | Limit | Where you see it |
| --- | --- | --- |
| Per key | `120` per minute, burst `30` | `X-RateLimit-Limit`, `X-RateLimit-Remaining` on every response |
| Per IP group | `360` per minute | `429 rate_limited`, `Retry-After: 1` |
| Unauthenticated, per IP group | `30` per minute | `429 rate_limited`, `Retry-After` up to `2` |
| Manual tick | `1` per second per sandbox | `429 tick_rate` |

Requests without a key, and requests whose key no longer works, share the unauthenticated budget. Keys stop working when their sandbox is deleted, a short grace period after it ends, so a script still sending an old key gets `401 unauthorized` and then `429`s: stop it, and create a new sandbox for fresh keys.

The snippet below is a small wrapper that does all of this: it waits for `Retry-After` (or `retry_at`) and resends the same request.

*JavaScript · `snippets/04-handle-429.mjs` · JavaScript, Node `20`+ · proven against the live API: 40 rapid reads, all 200 in the end*

```javascript
// A fetch wrapper that backs off on 429/503 and retryable errors (Node 20+).
// 429 carries the wait in the Retry-After header (seconds); its body gives the same
// moment as error.retry_at (an ISO time). Some 409s (for example press_busy) carry
// only error.retry_at. A key that keeps sending while asked to wait cools down.
// Keep the same Idempotency-Key across retries so a retried write cannot run twice.
const BASE = process.env.ATLAS_BASE ?? "https://automationatlas.io/labs/api/v1";
const KEY = process.env.ATLAS_OFFICE_KEY;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

function waitMs(res, body) {
  const retryAfter = Number(res.headers.get("retry-after"));
  if (Number.isFinite(retryAfter) && retryAfter > 0) return retryAfter * 1000;
  const retryAt = body?.error?.retry_at && Date.parse(body.error.retry_at);
  if (retryAt) return Math.max(250, retryAt - Date.now());
  return 1000;
}

export async function atlasFetch(path, init = {}, maxAttempts = 5) {
  const headers = { Authorization: `Bearer ${KEY}`, ...init.headers };
  for (let attempt = 1; ; attempt += 1) {
    const res = await fetch(`${BASE}${path}`, { ...init, headers });
    const body = await res.json().catch(() => null);
    const retryable = res.status === 429 || res.status === 503 || body?.error?.retryable === true;
    if (res.ok || !retryable || attempt === maxAttempts) return { res, body };
    await sleep(Math.min(waitMs(res, body), 10_000));
  }
}

// Demo: 40 quick reads. The first ~30 are the burst; the rest wait for the limiter instead of failing.
if (import.meta.url === `file://${process.argv[1]}`) {
  for (let i = 0; i < 40; i += 1) {
    const { res } = await atlasFetch("/sandbox");
    console.log(i + 1, res.status, "remaining", res.headers.get("x-ratelimit-remaining"));
  }
}
```

## How do I page through a list or get CSV?

Lists are newest first. Pass `limit` (`1` to `100`, default `50`), then hand `data.next_cursor` back unchanged as `cursor` until it is `null`. Add `updated_since` for only the rows changed since a time, send `If-None-Match` with the last `ETag` for a cheap `304`, and ask for CSV with `Accept: text/csv` or a `.csv` suffix.

*curl · `snippets/05-list-with-cursor.sh` · proven against the live API; the limit-1 variant walked three pages*

```bash
#!/usr/bin/env bash
# Page through a list. Lists are newest first; limit is 1..100 (default 50).
# Pass data.next_cursor back unchanged; null means the last page.
set -euo pipefail
BASE="${ATLAS_BASE:-https://automationatlas.io/labs/api/v1}"
: "${ATLAS_OFFICE_KEY:?run: source atlas-keys.env}"

cursor=""
while :; do
  url="$BASE/office/sales-orders?limit=100"
  [ -n "$cursor" ] && url="$url&cursor=$cursor"
  page=$(curl -sS "$url" -H "Authorization: Bearer $ATLAS_OFFICE_KEY")
  jq -r '.data.items[] | [.id, .status, .quantity] | @tsv' <<<"$page"
  cursor=$(jq -r '.data.next_cursor // empty' <<<"$page")
  [ -z "$cursor" ] && break
done

# Only rows changed since a time:      ?updated_since=2026-10-15T09:00:00Z
# CSV for a spreadsheet or Power Query: curl "$BASE/office/sales-orders.csv" -H "Authorization: Bearer $ATLAS_OFFICE_KEY"
# Cheap polling: send If-None-Match with the ETag header from the last response; a 304 means nothing changed.
```

*JavaScript · `snippets/05-list-with-cursor.mjs` · proven against the live API; the limit-1 variant walked three pages*

```javascript
// Page through a list (Node 20+).  Lists are newest first; limit is 1..100 (default 50).
// Pass data.next_cursor back unchanged; null means the last page.
const BASE = process.env.ATLAS_BASE ?? "https://automationatlas.io/labs/api/v1";
const headers = { Authorization: `Bearer ${process.env.ATLAS_OFFICE_KEY}` };

async function* listAll(path, params = {}) {
  let cursor = null;
  do {
    const query = new URLSearchParams({ limit: "100", ...params, ...(cursor ? { cursor } : {}) });
    const res = await fetch(`${BASE}${path}?${query}`, { headers });
    const json = await res.json();
    if (!res.ok) throw new Error(`${res.status} ${json.error.code}`);
    yield* json.data.items;
    cursor = json.data.next_cursor;
  } while (cursor);
}

for await (const order of listAll("/office/sales-orders")) {
  console.log(order.id, order.status, order.quantity);
}
// Only rows changed since a time: listAll(path, { updated_since: "2026-10-15T09:00:00Z" })
// CSV: GET /office/sales-orders.csv. Cheap polling: send If-None-Match with the last ETag; 304 means unchanged.
```

*Python · `snippets/05-list-with-cursor.py` · proven against the live API; the limit-1 variant walked three pages*

```python
"""Page through a list.  pip install requests; then: source atlas-keys.env

Lists are newest first; limit is 1..100 (default 50).
Pass data.next_cursor back unchanged; null means the last page.
"""
import os

import requests

BASE = os.environ.get("ATLAS_BASE", "https://automationatlas.io/labs/api/v1")
HEADERS = {"Authorization": f"Bearer {os.environ['ATLAS_OFFICE_KEY']}"}


def list_all(path, **params):
    cursor = None
    while True:
        query = {"limit": 100, **params, **({"cursor": cursor} if cursor else {})}
        res = requests.get(f"{BASE}{path}", headers=HEADERS, params=query, timeout=30)
        body = res.json()
        if not res.ok:
            raise SystemExit(f"{res.status_code} {body['error']['code']}")
        yield from body["data"]["items"]
        cursor = body["data"]["next_cursor"]
        if not cursor:
            return


for order in list_all("/office/sales-orders"):
    print(order["id"], order["status"], order["quantity"])
# Only rows changed since a time: list_all(path, updated_since="2026-10-15T09:00:00Z")
# CSV: GET /office/sales-orders.csv. Cheap polling: send If-None-Match with the last ETag; 304 means unchanged.
```

## What if Atlas Labs is full, or asks for proof of work?

When every slot is taken, `POST /sandboxes` answers `202` with a queue ticket instead of `201`; poll it until the `201` arrives with your keys. [Waiting for a sandbox](https://automationatlas.io/labs/docs/queue/) explains the ticket, the claim window and the `503 queue_full` case. If the operator has switched on proof-of-work, `POST /sandboxes` answers `428 pow_required` with a challenge to solve; the [proof-of-work solver](https://automationatlas.io/labs/docs/tools/rest-api/#pow) below finds the nonce.

*curl · `snippets/10-wait-in-queue.sh` · executed against the in-process API in the repo's tests, including the queue branch*

```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` · executed against the in-process API in the repo's tests, including the queue branch*

```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` · executed against the in-process API in the repo's tests, including the queue branch*

```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")
```

### Proof of work, when it is switched on

*JavaScript · `snippets/09-proof-of-work.mjs` · solvers agree with each other and with the server's checker; the end-to-end flag path was not exercised*

```javascript
// Only needed when POST /sandboxes answers 428 pow_required (an operator switch, off by default).
// Find a nonce so SHA-256(challenge + nonce) has at least `difficulty` leading zero bits, then retry
// with the header  Atlas-PoW: <challenge>.<nonce>. Take difficulty from the response, never hard-code it.
import { createHash } from "node:crypto";

const BASE = process.env.ATLAS_BASE ?? "https://automationatlas.io/labs/api/v1";

function leadingZeroBits(buf) {
  let bits = 0;
  for (const byte of buf) {
    if (byte === 0) { bits += 8; continue; }
    for (let mask = 0x80; mask > 0; mask >>= 1) {
      if (byte & mask) return bits;
      bits += 1;
    }
  }
  return bits;
}

export function solve(challenge, difficulty) {
  for (let nonce = 0; ; nonce += 1) {
    const digest = createHash("sha256").update(challenge + nonce, "utf8").digest();
    if (leadingZeroBits(digest) >= difficulty) return String(nonce);
  }
}

export async function createSandboxWithPow(body = {}) {
  const post = (headers = {}) =>
    fetch(`${BASE}/sandboxes`, {
      method: "POST",
      headers: { "Content-Type": "application/json", ...headers },
      body: JSON.stringify(body),
    });
  let res = await post();
  if (res.status === 428) {
    const { challenge, difficulty } = (await res.json()).error.details;
    res = await post({ "Atlas-PoW": `${challenge}.${solve(challenge, difficulty)}` });
  }
  return res;
}
```

*Python · `snippets/09-proof-of-work.py` · solvers agree with each other and with the server's checker; the end-to-end flag path was not exercised*

```python
"""Only needed when POST /sandboxes answers 428 pow_required (an operator switch, off by default).

Find a nonce so SHA-256(challenge + nonce) has at least `difficulty` leading zero bits, then retry
with the header  Atlas-PoW: <challenge>.<nonce>. Take difficulty from the response, never hard-code it.
"""
import hashlib
import os

import requests

BASE = os.environ.get("ATLAS_BASE", "https://automationatlas.io/labs/api/v1")


def leading_zero_bits(data: bytes) -> int:
    bits = 0
    for byte in data:
        if byte == 0:
            bits += 8
            continue
        for shift in range(7, -1, -1):
            if byte & (1 << shift):
                return bits
            bits += 1
    return bits


def solve(challenge: str, difficulty: int) -> str:
    nonce = 0
    while True:
        digest = hashlib.sha256((challenge + str(nonce)).encode()).digest()
        if leading_zero_bits(digest) >= difficulty:
            return str(nonce)
        nonce += 1


def create_sandbox_with_pow(body=None):
    body = body or {}
    res = requests.post(f"{BASE}/sandboxes", json=body, timeout=30)
    if res.status_code == 428:
        details = res.json()["error"]["details"]
        nonce = solve(details["challenge"], details["difficulty"])
        res = requests.post(
            f"{BASE}/sandboxes",
            json=body,
            headers={"Atlas-PoW": f"{details['challenge']}.{nonce}"},
            timeout=30,
        )
    return res
```

## How do I subscribe to and verify a webhook?

Subscribe a public HTTPS URL with `POST /webhooks`, send the test ping, and verify every delivery against the raw body. The tested curl, JavaScript, Python and openssl code is on the [Webhooks page](https://automationatlas.io/labs/docs/webhooks/#five-minutes).

## What does an error look like?

Every error is `{"meta": …, "error": {"code", "message", "retryable", "hint", "docs_url", "details"?, "retry_at"?} }`. Read `error.code`, never the message text. The ones you will meet first:

*Reference table — Atlas Labs error codes, not part of the fictional simulation.*

| Status | `error.code` | Fix |
| --- | --- | --- |
| `401` | `unauthorized` | The key does not work: unknown, revoked, or deleted with its sandbox. Stop any script still sending it, and create a new sandbox for fresh keys. An expired sandbox not yet deleted answers writes with `423 sandbox_expired` instead. |
| `403` | `wrong_system` | Swap keys: `/office/*` wants `lab_off_`, `/factory/*` wants `lab_fac_`. |
| `400` | `key_in_url` | The key was in the path or query string and has been revoked. Send it in the header and create a new sandbox. |
| `400` | `idempotency_required` | Add an `Idempotency-Key` header (one of the `13` routes above). |
| `422` | `idempotency_key_reused` | Same key, different request. Derive the key from the action, as the snippets do. |
| `429` | `rate_limited` | Wait `Retry-After` seconds (the same moment as `error.retry_at`), resend unchanged. |
| `423` | `sandbox_expired` / `sandbox_bankrupt` | Read-only now. Start a new sandbox. |
| `415` | `unsupported_media_type` | Send `Content-Type: application/json` with the body. |

## How do I connect an AI agent?

Point an MCP client at `https://automationatlas.io/labs/mcp` over Streamable HTTP and call the `create_sandbox` tool; the session then holds both keys, so no header is needed. An agent that cannot speak MCP can read `/labs/llms-full.txt` and call the REST API directly.

*Shell · `snippets/08-mcp-claude-code.sh` · Claude Code · command form from `docs/MCP.md`; the `--header` variant was not run*

```bash
# Claude Code: add the server (no key needed; call the create_sandbox tool, or send a key header)
claude mcp add --transport http atlas-labs https://automationatlas.io/labs/mcp
# With an existing sandbox's Head Office key:
claude mcp add --transport http atlas-labs https://automationatlas.io/labs/mcp \
  --header "Authorization: Bearer $ATLAS_OFFICE_KEY"
```

*JSON · `snippets/08-mcp-client-config.json` · Cursor and other clients that take a JSON server list · Claude Desktop remote-URL support not verified*

```json
{
  "mcpServers": {
    "atlas-labs": {
      "url": "https://automationatlas.io/labs/mcp"
    }
  }
}
```

Your client must send `Accept: application/json, text/event-stream` on every request, or the server replies `-32000 Not Acceptable`. The TypeScript SDK client and Python `mcp` `1.x` were run against the endpoint for real; the JavaScript and Python connection snippets are in the snippet pack.

## Questions people ask

**Can I put the key in the URL, like `?api_key=`?** No. Any `lab_fac_` or `lab_off_` value in a path or query string is rejected with `400 key_in_url` and that key is revoked immediately. Use the `Authorization` header.

**Why does my Factory key get `403` on `/office/sales-orders`?** Head Office routes only accept `lab_off_` keys. The error's `hint` tells you which prefix the route wants.

**Can I call the API from a browser page?** Not from another origin: there is no CORS, so a preflight gets `401`. Call it from a server, a tool, or the Atlas Labs console.

**Does a write return the same fields as a read?** Yes. A write returns the same row as the GET or list for that record, with the same snake_case fields. Money is an integer in minor units on every response, reads and writes alike: `unit_price_minor: 4500`, never a string.

**How long do the keys live?** The fictional sandbox lasts `30` minutes, extendable twice by `30` minutes from the console when `10` minutes or fewer remain. After expiry it is read-only for a short grace period, then deleted, and its keys stop working for good: every request with them gets `401 unauthorized`. Stop any script still using them, and create a new sandbox for fresh keys.

Sources: Atlas Labs API source (`plugins/auth.ts`, `plugins/ratelimit.ts`, `plugins/idempotency.ts`, `http/idempotency-required.ts`), observed live on `15 Oct 2026`. Snippets: `snippets/01`–`04`, `08`.

[Integrations](https://automationatlas.io/labs/docs/tools/) · [Zapier](https://automationatlas.io/labs/docs/tools/zapier/)
