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.
| 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 |
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, 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.
lab_fac_… /factory/* lab_off_… /office/* /sandbox /missions /events /webhooks 403 wrong_system with a hint naming the right prefix.
403 application/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.
snippets/01-create-sandbox.sh bash, curl, jq proven against the live API #!/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.
snippets/02-first-read.sh proven against the live API #!/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}'
snippets/02-first-read.mjs proven against the live API // 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));
snippets/02-first-read.py proven against the live API """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.
snippets/03-first-write.sh proven against the live API, replay observed #!/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
snippets/03-first-write.mjs proven against the live API, replay observed // 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);
}
snippets/03-first-write.py proven against the live API, replay observed """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.
Factory routes (lab_fac_) | Head Office routes (lab_off_) |
|---|---|
POST/factory/work-ordersPOST/factory/print-runsPOST/factory/goods-receiptsPOST/factory/shipments | POST/office/requisitionsPOST/office/purchase-ordersPOST/office/purchase-orders/{id}/approvePOST/office/goods-receiptsPOST/office/supplier-invoices/{id}/payPOST/office/shipmentsPOST/office/timesheetsPOST/office/payroll-runsPOST/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.
429 looks like 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 429s. 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.
| 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 429s: 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.
snippets/04-handle-429.mjs JavaScript, Node 20+ proven against the live API: 40 rapid reads, all 200 in the end // 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.
snippets/05-list-with-cursor.sh proven against the live API; the limit-1 variant walked three pages #!/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.
snippets/05-list-with-cursor.mjs proven against the live API; the limit-1 variant walked three pages // 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.
snippets/05-list-with-cursor.py proven against the live API; the limit-1 variant walked three pages """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 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 below finds the nonce.
snippets/10-wait-in-queue.sh executed against the in-process API in the repo's tests, including the queue branch #!/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 executed against the in-process API in the repo's tests, including the queue branch // 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 executed against the in-process API in the repo's tests, including the queue branch """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
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 // 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;
}
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 """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.
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:
| 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.
snippets/08-mcp-claude-code.sh Claude Code · command form from docs/MCP.md; the --header variant was not run # 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"
snippets/08-mcp-client-config.json Cursor and other clients that take a JSON server list Claude Desktop remote-URL support not verified {
"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_orlab_off_value in a path or query string is rejected with400 key_in_urland that key is revoked immediately. Use theAuthorizationheader. - Why does my Factory key get
403on/office/sales-orders? - Head Office routes only accept
lab_off_keys. The error'shinttells 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
30minutes, extendable twice by30minutes from the console when10minutes 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 gets401 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.