# Atlas Labs — full guide for agents Base URL: https://automationatlas.io/labs/api/v1 Every response: {"meta":{"simulation":true,...},"data":{...}}. Nothing here describes a real company. ## The canonical loop 1. Create a sandbox (no auth): POST /sandboxes. A 201 holds both keys, shown once; a 202 is a queue ticket to poll. 2. GET /sandbox to see both systems' status. 3. GET /missions to see what to try next. 4. Act: send a write with an Idempotency-Key header. Replaying the same key+body is free; a different body with the same key is rejected (422). 5. On 429 or 503 wait Retry-After seconds (a 429 body gives the same moment as error.retry_at); on a retryable 409 sleep until error.retry_at, or for the Retry-After header if it is absent. Then retry the same call with the same key. 6. Repeat from step 3. ```bash BASE=https://automationatlas.io/labs/api/v1 KEY=$(curl -s -X POST $BASE/sandboxes -H "Content-Type: application/json" -d '{"preset":"starter"}' | jq -r .data.office_key) curl -s $BASE/sandbox -H "Authorization: Bearer $KEY" curl -s $BASE/missions -H "Authorization: Bearer $KEY" ORDER=$(curl -s "$BASE/office/sales-orders?limit=20" -H "Authorization: Bearer $KEY" | jq -r '[.data.items[]|select(.status=="received")][0].id') curl -s -X POST $BASE/office/sales-orders/$ORDER/accept -H "Authorization: Bearer $KEY" -H "Idempotency-Key: accept-$ORDER" # 429 or 503: sleep for the Retry-After header, resend unchanged. Retryable 409: sleep until .error.retry_at. ``` ## Two systems, one bridge Factory key (lab_fac_...): work orders, print runs, inventory, requisitions, deliveries, shipments, timesheets. Head Office key (lab_off_...): sales orders, contracts, requisition approvals, suppliers, quotes, purchase orders, invoices, payments, market, ledger, HR. The wrong system's key on a route returns 403 wrong_system, naming the key you need. Shared routes (/sandbox, /missions, /events, /webhooks) take either key. Bridge: Head Office -> Factory (accepted sales order -> POST /factory/work-orders); Factory -> Head Office (POST /office/requisitions, /office/goods-receipts, /office/shipments, /office/timesheets). bridge:"auto" copies these for you within one tick; bridge:"manual" requires you to post them. With bridge:"auto", accepting a sales order makes its work order in the same call, so the accept returns the order already in_production. ## Authentication and keys - Header: Authorization: Bearer . X-Api-Key: is an alias (Bearer wins if both are sent). - A key is lab_fac_ or lab_off_ followed by 43 URL-safe base64 characters, shown once in the 201 from POST /sandboxes. - A key anywhere in a URL (path or query) is rejected with 400 key_in_url and revoked on the spot. - A sandbox goes read-only when it ends: for a short grace period its keys can still read, and writes get 423 sandbox_expired. Keys stop working when their sandbox is deleted, at the end of that period: every request with them then gets 401 unauthorized, the same answer for any key that does not work. Stop any script still using old keys, and create a new sandbox for fresh ones. - Bodies are JSON up to 16,384 bytes with Content-Type: application/json (otherwise 415 unsupported_media_type). - There is no CORS: call the API from a server, a tool or an agent, not from another origin's browser page. - 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). ## Envelope and errors Success: {"meta":{"simulation":true,"request_id","server_time","sim_date","expires_in_s","api_version"},"data":{...},"next_actions"?:[...]} Error: {"meta":{...},"error":{"code","message","retryable","hint"?,"docs_url","details"?,"retry_at"?}} Read error.code, never the message text. The ones you will meet first (every code each operation can return is in the OpenAPI document): | 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. | | 409 | idempotency_in_progress | The first request with this key is still running. Wait Retry-After seconds (there is no retry_at), then resend the same request with the same key. | | 409 | idempotency_committed | It already happened but the response was lost. Read the result back; use a new key only for a new request. | | 429 | rate_limited | Wait Retry-After seconds (the same moment as error.retry_at), resend unchanged. Parallel requests share the key's budget; a key that keeps getting requests while over its limit cools down until retry_at. | | 423 | sandbox_expired / sandbox_bankrupt | Read-only now. Start a new sandbox. | | 415 | unsupported_media_type | Send Content-Type: application/json with the body. | | 422 | webhook_url_rejected | The URL must be public https:// on port 443; error.details.reason names the rule. | | 202 | (queued) | POST /sandboxes answered a queue ticket, not keys: poll data.poll until the 201. | | 503 | overloaded | The service is busy. Wait Retry-After seconds, then resend the same request (with the same Idempotency-Key if it had one). | | 503 | queue_full | The queue is full too: wait Retry-After (30) seconds and try again. | | 428 | pow_required | The operator switched on proof-of-work: solve error.details.challenge and retry with Atlas-PoW. | ## Rate limits and retry On 429 or 503 wait the Retry-After header (seconds), then resend the same request with the same Idempotency-Key. A 429 body also carries error.retry_at (the same moment), a plain message and a hint; retry_at also appears on some 409 responses (for example press_busy) and on 503 overloaded. Requests sent in parallel share one key's budget, so send one or two at a time. A key that keeps getting requests while it is over its limit, from a loop or from too many in parallel, cools down for a while: every request gets the same 429 until retry_at, and nothing is revoked. A network that keeps going regardless is blocked for a while with 403 banned, which also carries retry_at. There is no X-RateLimit-Reset header. | Budget | Limit | Where you see it | | --- | --- | --- | | Per key | 120 per minute, burst 30 | X-RateLimit-Limit and X-RateLimit-Remaining on every response | | Per IP group | 360 per minute | 429 rate_limited with Retry-After: 1 | | Unauthenticated, per IP group | 30 per minute | 429 rate_limited with Retry-After up to 2 | | Manual tick | 1 per second per sandbox | 429 tick_rate | *Reference table — Atlas Labs API behaviour, not part of the fictional simulation.* ## Idempotency Idempotency-Key (1 to 64 characters) is accepted on every authenticated write and returns the original response with Idempotency-Replayed: true when the same key repeats with the same request; the same key with a different path or body is 422 idempotency_key_reused. It is REQUIRED (400 idempotency_required without it) on exactly these 13 routes: - Factory key: POST /factory/work-orders; POST /factory/print-runs; POST /factory/goods-receipts; POST /factory/shipments - Head Office key: POST /office/requisitions; POST /office/purchase-orders; POST /office/purchase-orders/{id}/approve; POST /office/goods-receipts; POST /office/supplier-invoices/{id}/pay; POST /office/shipments; POST /office/timesheets; POST /office/payroll-runs; POST /office/market Use a value that is stable per business event (for example accept- or the webhook event id), never a timestamp or a random value. ## Lists, pagination and CSV Lists are newest first: ?limit= (1 to 100, default 50), ?cursor= (pass data.next_cursor back unchanged; null means the last page), ?updated_since= (ISO-8601). ETag on lists; If-None-Match with the same tag returns 304. Accept: text/csv or a .csv suffix returns CSV. POST /exports returns a tokenised CSV URL valid for 30 minutes (no Authorization header needed, for Power Query or IMPORTDATA). ## Discovery GET /labs/api/v1/ — self-describing index (no key). GET /labs/api/v1/openapi.json (3.1), /openapi-3.0.json, /swagger-2.0.json — generated from the live route schemas, never hand-written. Every operation has a unique camelCase operationId (for example listSalesOrders, approvePurchaseOrder) and a short summary. The 3.x documents declare one server, https://automationatlas.io (the paths already include /labs/api/v1). GET /labs/api/v1/world, /world/continents, /world/major-order — public, anonymous, aggregate only. active_sandboxes (/world) is the number of factories open now, the sum of active_factories over /world/continents. Everything on these routes is fictional simulation data. ## Missions Detected from events you actually produced. GET /labs/api/v1/missions returns {id,title,objective,hint,endpoints[],status}. ## Waiting for a sandbox (the queue) ## Waiting for a sandbox When every sandbox slot is taken, `POST /sandboxes` answers `202` with a queue ticket instead of keys, and you poll `GET /queue/{ticket_id}` until a poll answers `201` with your keys. Those keys come back once, and you must claim them within the claim window or the ticket is forfeited. *Reference table — how the Atlas Labs sandbox queue behaves, not part of the fictional simulation. From the API source, checked `16 Oct 2026`.* | At a 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. | Full guide: https://automationatlas.io/labs/docs/queue/index.md ## Quickstart The four steps in order, each with the call to make. Every example uses only curl. To make your first Atlas Labs write, create a sandbox with one `curl` call, check its status, read your missions and send a write with an `Idempotency-Key`: four steps, no sign-up, against a fictional printing company. Every response carries `meta.simulation: true`. ### 1. Create a sandbox No account, no email, no payment. Pick a preset or leave the body empty for `starter`. Call: Create a sandbox — `POST` `/labs/api/v1/sandboxes` ### 2. Check your sandbox's status Either key works on shared endpoints like this one. Call: Check sandbox status — `GET` `/labs/api/v1/sandbox` ### 3. Read your missions Missions are detected from what actually happened in the sandbox, not from a checkbox you tick. They tell you what to try next. Call: List missions — `GET` `/labs/api/v1/missions` ### 4. Make your first write Every write accepts an `Idempotency-Key` header, and the routes that move money or stock (`13` of them, listed on [Authenticate with your key](https://automationatlas.io/labs/docs/tools/rest-api/#safe-write)) refuse a write without one. Send the same key twice with the same body and you get the original response back with `Idempotency-Replayed: true`; send it twice with a different body and the second call is rejected, so a retried request can never double-charge or double-ship. Call: Accept a sales order — `POST` `/labs/api/v1/office/sales-orders/{id}/accept` ### Presets “Automate” names the side or sides you run yourself; an NPC autopilot runs only the other side, and with `both` there is no NPC. On `starter` you run the Head Office and the Factory runs itself, so you can focus entirely on the Head Office API. Switch sides at any time with `PATCH /labs/api/v1/sandbox`. ### Limits and next steps - Sessions last `30 minutes` and can be extended twice from the console, up to `90 minutes` total. - A sandbox goes read-only when it ends: for a short grace period its keys can still read, and writes get `423 sandbox_expired`. Keys stop working when their sandbox is deleted, at the end of that period: from then on every request with them gets `401 unauthorized`. Stop any script still using old keys, and create a new sandbox for fresh ones. - Requests are rate-limited per key, per IP group, and (without a key) per IP group again; every authenticated response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`, and a `429` carries `Retry-After` in seconds. Wait that long and resend the same request. - Read [Concepts](https://automationatlas.io/labs/docs/concepts/) for how the Factory and Head Office systems connect through the bridge. - Read [Webhooks](https://automationatlas.io/labs/docs/webhooks/) to get events pushed to you instead of polling. - Read [Integrations](https://automationatlas.io/labs/docs/tools/) for Zapier, Make, n8n, Power Automate, Pipedream, Frends, Moxo and AI-agent (MCP) recipes, and [Authenticate with your key](https://automationatlas.io/labs/docs/tools/rest-api/) for tested curl, JavaScript and Python snippets. Full page: https://automationatlas.io/labs/docs/quickstart/index.md ## Concepts How the two systems, the bridge, autopilot, the sim clock and missions fit together. An Atlas Labs sandbox is one fictional company, Atlas Print Works & Co., split into a Factory and a Head Office that hold separate data behind separate keys. Records cross between them only over the bridge, which the platform runs for you in `auto` mode and your code runs in `manual` mode. ### Factory and Head Office A Factory-scoped route rejects a Head Office key and vice versa, with a `403 wrong_system` error that names the key you actually need. A misconfigured integration therefore fails loudly and early, not with a confusing empty result. ### The bridge The simulation always knows the ground truth. What the bridge controls is whether your code has to notice a change and post it across, or whether the platform copies it for you. - **Head Office to Factory:** an accepted sales order becomes a work order (`POST /factory/work-orders`). - **Factory to Head Office:** a requisition, a goods receipt, a shipment, or timesheets (HR). ### Autopilot `automate` is `office`, `factory` or `both`. Whichever side you don't automate is run by an NPC, after a short in-sim “thinking” delay, so its actions show up as ordinary events and writes rather than teleporting. Switch sides mid-session with `PATCH /labs/api/v1/sandbox`; the side you take over stops its autopilot immediately. ### The sim clock Every response carries `meta.sim_date`. At `pace: "normal"` one sim day passes roughly once a minute, which is fast enough to watch live and slow enough to read. At `pace: "slow"` a sim day takes longer, which suits polling-only tools. At `pace: "manual"` nothing advances until you call `POST /sandbox/tick`, which suits CI and deterministic tests. ### HR module HR is off by default. Turn it on with `modules.hr` and staffing starts to matter: fewer press operators slows the press, a missing forklift driver blocks goods receipts, staff occasionally call in sick or ask for leave, and payroll has to run on time from the timesheets bridged into the Head Office, or morale drops for everyone. With HR off, staffing is assumed perfect and the HR endpoints return `404 module_disabled` with a hint on how to turn it on. ### Continents and scoring Every sandbox joins one of six continent teams (assigned from your network location; only the continent is ever stored, never a country or IP address), plus an opt-in “Antarctic Research Press” for anyone who picks it deliberately. Continent standings on the [world map](https://automationatlas.io/labs/) are computed per factory-hour, so a continent with few active sandboxes can outrank a busier one, and need a handful of active factories before they're shown ranked at all. The map shows each continent's factories, atlases shipped and Major Order progress as plain totals. ### Missions Missions are detected from the events your sandbox actually produces, never from a flag you set: reading your sales orders, accepting one, bridging a shipment, reacting to a webhook, and so on. List them with `GET /labs/api/v1/missions`; each one names the endpoints it needs. Full page: https://automationatlas.io/labs/docs/concepts/index.md ## Webhooks POST /labs/api/v1/webhooks {url, events[], description?} -> 201 with a whsec_ secret shown once (the URL itself is never returned, only url_host). The URL must be public https:// on port 443 (no userinfo, no redirects, answer 2xx within 5 seconds); otherwise 422 webhook_url_rejected with details.reason (scheme_not_https, port_not_allowed, userinfo_not_allowed, hostname_not_public, ip_not_public, denylisted, invalid_url). A new subscription is pending_verification and turns active when POST /webhooks/{id}/test gets a 2xx. Up to 3 subscriptions per sandbox, 60 deliveries per minute per sandbox. Retries after 10 s, 60 s and 300 s; 5 consecutive failures disable the subscription (re-enable with PATCH /webhooks/{id}). Delivery is at-least-once and unordered after a retry: deduplicate on the event id, order by created_at, or read GET /events?after_seq=N (the authoritative ordered feed). Delivery headers: webhook-id (the event id, the same on every retry), webhook-timestamp (integer seconds, changes on every attempt), webhook-signature (v1,, key = the base64-decoded part of the whsec_ secret; for one day after rotate-secret it holds two space-separated values "v1, v1,": accept either), content-type, user-agent. Verify against the raw body, refuse a timestamp more than 300 seconds from your clock, and compare in constant time. Body: {"id":"evt_...","type":"...","created_at":"...","sim_date":"1897-03-04","sandbox_id":"sbx_...","system":"office","simulation":true,"data":{...},"links":{"self":"/labs/api/v1/..."}}. A body over 4,096 bytes keeps only short scalars in data, plus "truncated": true. Event types (subscribe to any of these or "*"; the sandbox.webhook.* bookkeeping events are never delivered): - office.sales_order.received - office.sales_order.accepted - office.sales_order.declined - office.sales_order.expired - office.requisition.received - office.requisition.approved - office.requisition.rejected - office.purchase_order.created - office.purchase_order.approval_required - office.purchase_order.approved - office.supplier_invoice.received - office.supplier_invoice.paid - office.customer_invoice.created - office.customer_invoice.paid - office.customer_invoice.overdue - office.payment.received - office.payment.overdue - office.goods_receipt.recorded - office.contract_lot.offered - office.contract_lot.accepted - office.contract_lot.declined - office.contract_lot.expired - office.contract_lot.delivered - office.market_trade.executed - factory.work_order.created - factory.work_order.ready - factory.shipment.created - factory.print_run.started - factory.print_run.completed - factory.requisition.created - factory.stock.below_reorder_point - factory.delivery.arrived - factory.goods_receipt.recorded - factory.market_delivery.received - hr.employee.sick - hr.employee.returned - hr.employee.quit - hr.employee.dismissed - hr.employee.hired - hr.leave_request.created - hr.payroll.due - hr.payroll.late - hr.payroll.paid - world.major_order.updated - world.lore_event.started - sandbox.mission.completed - sandbox.achievement.unlocked - sandbox.expiring - sandbox.bankrupt - sandbox.disruption.ended ## Tested snippets Each file below was run against the real API (or, for 10-wait-in-queue, executed by the repository's tests) and is shown unchanged. They read ATLAS_BASE (default https://automationatlas.io/labs/api/v1), ATLAS_OFFICE_KEY and ATLAS_FACTORY_KEY from the environment; the create-sandbox snippets write the keys to atlas-keys.env (run: source atlas-keys.env). ### Create a sandbox and keep the keys JavaScript: snippets/01-create-sandbox.mjs ```javascript // Create an Atlas Labs sandbox and keep both keys (they are shown once). // Node 20+, no dependencies. Run: node 01-create-sandbox.mjs && source atlas-keys.env import { chmodSync, writeFileSync } from "node:fs"; const BASE = process.env.ATLAS_BASE ?? "https://automationatlas.io/labs/api/v1"; const ORIGIN = new URL(BASE).origin; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function createSandbox(body = { preset: "starter" }) { let res = await fetch(`${BASE}/sandboxes`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); let json = await res.json(); // 202 means Atlas Labs is full and you hold a queue ticket. Poll it until // the 201 arrives with your keys (claim within about two minutes). while (res.status === 202) { await sleep(3000); res = await fetch(ORIGIN + json.data.poll); json = await res.json(); } if (res.status !== 201) { const e = json.error ?? {}; throw new Error(`HTTP ${res.status} ${e.code}: ${e.hint ?? e.message}`); } return json.data; } const sandbox = await createSandbox(); // 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/01-create-sandbox.py ```python """Create an Atlas Labs sandbox and keep both keys (they are shown once). Needs: pip install requests Run: python 01-create-sandbox.py && source atlas-keys.env """ 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}" def create_sandbox(body=None): res = requests.post(f"{BASE}/sandboxes", json=body or {"preset": "starter"}, timeout=30) # 202 means Atlas Labs is full and you hold a queue ticket. Poll it until # the 201 arrives with your keys (claim within about two minutes). while res.status_code == 202: time.sleep(3) res = requests.get(ORIGIN + res.json()["data"]["poll"], timeout=30) 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')}") return res.json()["data"] sandbox = create_sandbox() # 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") ``` curl: snippets/01-create-sandbox.sh ```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" ``` ### First authenticated read JavaScript: snippets/02-first-read.mjs ```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 ```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"]) ``` curl: snippets/02-first-read.sh ```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}' ``` ### First write with an Idempotency-Key JavaScript: snippets/03-first-write.mjs ```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 ```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")) ``` curl: snippets/03-first-write.sh ```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 ``` ### Back off on 429 JavaScript: snippets/04-handle-429.mjs ```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")); } } ``` Python: snippets/04-handle-429.py ```python """A requests wrapper that backs off on 429/503 and retryable errors. 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. """ import os import time from datetime import datetime, timezone import requests BASE = os.environ.get("ATLAS_BASE", "https://automationatlas.io/labs/api/v1") KEY = os.environ["ATLAS_OFFICE_KEY"] def wait_seconds(res, body): try: retry_after = float(res.headers.get("Retry-After", "")) if retry_after > 0: return retry_after except ValueError: pass retry_at = (body or {}).get("error", {}).get("retry_at") if retry_at: when = datetime.fromisoformat(retry_at.replace("Z", "+00:00")) return max(0.25, (when - datetime.now(timezone.utc)).total_seconds()) return 1.0 def atlas_request(method, path, max_attempts=5, **kwargs): headers = {"Authorization": f"Bearer {KEY}", **kwargs.pop("headers", {})} for attempt in range(1, max_attempts + 1): res = requests.request(method, f"{BASE}{path}", headers=headers, timeout=30, **kwargs) try: body = res.json() except ValueError: body = None retryable = res.status_code in (429, 503) or (body or {}).get("error", {}).get("retryable") is True if res.ok or not retryable or attempt == max_attempts: return res, body time.sleep(min(wait_seconds(res, body), 10)) if __name__ == "__main__": # Demo: 40 quick reads. The first ~30 are the burst; the rest wait for the limiter instead of failing. for i in range(1, 41): res, _ = atlas_request("GET", "/sandbox") print(i, res.status_code, "remaining", res.headers.get("X-RateLimit-Remaining")) ``` curl: snippets/04-handle-429.sh ```bash #!/usr/bin/env bash # Back off on 429 using the Retry-After header (the body gives the same moment as error.retry_at). # A key that keeps sending while asked to wait cools down, so always wait before resending. set -euo pipefail BASE="${ATLAS_BASE:-https://automationatlas.io/labs/api/v1}" : "${ATLAS_OFFICE_KEY:?run: source atlas-keys.env}" call() { # usage: call [extra curl args...]; retries up to 5 times, reusing the same headers local path=$1; shift for attempt in 1 2 3 4 5; do code=$(curl -sS -D headers.tmp -o body.tmp -w '%{http_code}' "$BASE$path" \ -H "Authorization: Bearer $ATLAS_OFFICE_KEY" "$@") if [ "$code" != "429" ] && [ "$code" != "503" ]; then cat body.tmp; return 0; fi wait=$(awk 'tolower($1)=="retry-after:" {print $2+0}' headers.tmp) echo "HTTP $code, waiting ${wait:-1}s (attempt $attempt)" >&2 sleep "${wait:-1}" done echo "gave up after 5 attempts" >&2; return 1 } for i in $(seq 1 40); do call /sandbox | jq -c '.meta.expires_in_s'; done ``` ### List with a cursor JavaScript: snippets/05-list-with-cursor.mjs ```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 ```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. ``` curl: snippets/05-list-with-cursor.sh ```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. ``` ### Subscribe a webhook JavaScript: snippets/06-subscribe-webhook.mjs ```javascript // Subscribe a public HTTPS URL (port 443) to events, keep the secret, send the test ping (Node 20+). // Up to 3 subscriptions per sandbox. Run: WEBHOOK_URL=https://... node 06-subscribe-webhook.mjs import { chmodSync, writeFileSync } from "node:fs"; const BASE = process.env.ATLAS_BASE ?? "https://automationatlas.io/labs/api/v1"; const headers = { Authorization: `Bearer ${process.env.ATLAS_OFFICE_KEY}`, "Content-Type": "application/json" }; const res = await fetch(`${BASE}/webhooks`, { method: "POST", headers: { ...headers, "Idempotency-Key": "subscribe-orders-1" }, body: JSON.stringify({ url: process.env.WEBHOOK_URL, events: ["office.sales_order.received"], // or ["*"] for every event type description: "new orders", }), }); const { data, error } = await res.json(); if (!res.ok) throw new Error(`${res.status} ${error.code} ${JSON.stringify(error.details ?? {})}`); // The whsec_ secret is returned once. Replaying the same Idempotency-Key never repeats it (secret is null). if (data.secret) { writeFileSync("atlas-webhook-secret.txt", data.secret, { mode: 0o600 }); chmodSync("atlas-webhook-secret.txt", 0o600); // `mode` only applies to a new file } else console.warn("no secret in this response (a replay?)"); console.log(data.id, data.status, data.url_host); // The subscription starts as pending_verification and activates when this ping gets a 2xx. const ping = await fetch(`${BASE}/webhooks/${data.id}/test`, { method: "POST", headers }); console.log("test ping", ping.status); ``` Python: snippets/06-subscribe-webhook.py ```python """Subscribe a public HTTPS URL (port 443) to events, keep the secret, send the test ping. Up to 3 subscriptions per sandbox. pip install requests Run: WEBHOOK_URL=https://... python 06-subscribe-webhook.py """ 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']}"} res = requests.post( f"{BASE}/webhooks", headers={**HEADERS, "Idempotency-Key": "subscribe-orders-1"}, json={ "url": os.environ["WEBHOOK_URL"], "events": ["office.sales_order.received"], # or ["*"] for every event type "description": "new orders", }, timeout=30, ) body = res.json() if not res.ok: err = body["error"] raise SystemExit(f"{res.status_code} {err['code']} {err.get('details')}") hook = body["data"] # The whsec_ secret is returned once. Replaying the same Idempotency-Key never repeats it (secret is null). if hook.get("secret"): fd = os.open("atlas-webhook-secret.txt", os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w") as f: f.write(hook["secret"]) os.chmod("atlas-webhook-secret.txt", 0o600) # the mode above only applies to a new file else: print("no secret in this response (a replay?)") print(hook["id"], hook["status"], hook["url_host"]) # The subscription starts as pending_verification and activates when this ping gets a 2xx. ping = requests.post(f"{BASE}/webhooks/{hook['id']}/test", headers=HEADERS, timeout=30) print("test ping", ping.status_code) ``` curl: snippets/06-subscribe-webhook.sh ```bash #!/usr/bin/env bash # Subscribe a public HTTPS URL (port 443) to events, keep the secret, send the test ping. # Up to 3 subscriptions per sandbox. Needs WEBHOOK_URL, for example a Zapier Catch Hook URL. set -euo pipefail BASE="${ATLAS_BASE:-https://automationatlas.io/labs/api/v1}" : "${ATLAS_OFFICE_KEY:?run: source atlas-keys.env}" : "${WEBHOOK_URL:?set WEBHOOK_URL to your public https:// endpoint}" created=$(curl -sS -X POST "$BASE/webhooks" \ -H "Authorization: Bearer $ATLAS_OFFICE_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: subscribe-orders-1" \ -d "$(jq -n --arg url "$WEBHOOK_URL" '{url: $url, events: ["office.sales_order.received"], description: "new orders"}')") echo "$created" | jq '.data | {id, status, url_host, events}' # The whsec_ secret is returned once. Replaying the same Idempotency-Key never repeats it (secret is null). secret=$(jq -r '.data.secret // empty' <<<"$created") [ -n "$secret" ] && (umask 077; printf '%s' "$secret" > atlas-webhook-secret.txt; chmod 600 atlas-webhook-secret.txt) || echo "no secret in this response (a replay?)" >&2 ID=$(jq -r '.data.id' <<<"$created") # The subscription starts as pending_verification and activates when this ping gets a 2xx. curl -sS -X POST "$BASE/webhooks/$ID/test" -H "Authorization: Bearer $ATLAS_OFFICE_KEY" | jq .data curl -sS "$BASE/webhooks/$ID" -H "Authorization: Bearer $ATLAS_OFFICE_KEY" | jq '.data | {status, consecutive_failures, last_error_class}' ``` ### Verify a Standard Webhooks signature JavaScript: snippets/07-verify-webhook.mjs ```javascript // Receive and verify an Atlas Labs webhook (Node 20+, no dependencies). // Standard Webhooks: headers webhook-id, webhook-timestamp, webhook-signature. // signature = "v1," + base64(HMAC-SHA256(key, `${id}.${timestamp}.${rawBody}`)), where key is // the base64-decoded part of your whsec_ secret. During the 24 h after rotate-secret the header // holds two space-separated values ("v1, v1,"); accept either. import { createHmac, timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; const SECRET = process.env.ATLAS_WEBHOOK_SECRET; // whsec_... const TOLERANCE_S = 300; export function verify(secret, headers, rawBody, nowS = Math.floor(Date.now() / 1000)) { const id = headers["webhook-id"]; const timestamp = headers["webhook-timestamp"]; const header = headers["webhook-signature"]; if (!id || !timestamp || !header) return false; if (Math.abs(nowS - Number(timestamp)) > TOLERANCE_S) return false; // stale or replayed const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`, "utf8").digest(); return header.split(" ").some((part) => { const [version, value] = part.split(","); if (version !== "v1" || !value) return false; const given = Buffer.from(value, "base64"); return given.length === expected.length && timingSafeEqual(given, expected); }); } if (import.meta.url === `file://${process.argv[1]}`) { const seen = new Set(); // deliveries are at-least-once: de-duplicate on the event id createServer((req, res) => { const chunks = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => { const rawBody = Buffer.concat(chunks).toString("utf8"); // verify the RAW body, before JSON.parse if (!verify(SECRET, req.headers, rawBody)) { res.writeHead(401).end(); return; } const event = JSON.parse(rawBody); if (!seen.has(event.id)) { seen.add(event.id); console.log(event.type, event.id, event.links?.self); } res.writeHead(200).end(); // reply 2xx quickly: the timeout is 5 s and 5 consecutive failures disable the subscription }); }).listen(Number(process.env.PORT ?? 3000)); } ``` Python: snippets/07-verify-webhook.py ```python """Receive and verify an Atlas Labs webhook (Python 3.9+, standard library only). Standard Webhooks: headers webhook-id, webhook-timestamp, webhook-signature. signature = "v1," + base64(HMAC-SHA256(key, f"{id}.{timestamp}.{raw_body}")), where key is the base64-decoded part of your whsec_ secret. During the 24 h after rotate-secret the header holds two space-separated values ("v1, v1,"); accept either. """ import base64 import hashlib import hmac import json import os import time from http.server import BaseHTTPRequestHandler, HTTPServer SECRET = os.environ.get("ATLAS_WEBHOOK_SECRET", "") # whsec_... TOLERANCE_S = 300 seen = set() # deliveries are at-least-once: de-duplicate on the event id def verify(secret, headers, raw_body: bytes, now=None) -> bool: msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp") header = headers.get("webhook-signature") if not (msg_id and timestamp and header): return False if abs((now or time.time()) - int(timestamp)) > TOLERANCE_S: # stale or replayed return False key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + raw_body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() for part in header.split(" "): version, _, value = part.partition(",") if version == "v1" and hmac.compare_digest(value, expected): return True return False class Handler(BaseHTTPRequestHandler): def do_POST(self): raw = self.rfile.read(int(self.headers.get("content-length", 0))) # verify the RAW body if not verify(SECRET, self.headers, raw): self.send_response(401) self.end_headers() return event = json.loads(raw) if event["id"] not in seen: seen.add(event["id"]) print(event["type"], event["id"], event.get("links", {}).get("self")) self.send_response(200) # reply 2xx quickly: the timeout is 5 s and 5 consecutive failures disable the subscription self.end_headers() if __name__ == "__main__": HTTPServer(("0.0.0.0", int(os.environ.get("PORT", 3000))), Handler).serve_forever() ``` Shell: snippets/07-verify-webhook.sh ```bash #!/usr/bin/env bash # Check one saved delivery by hand with openssl (handy when debugging a Zapier/Make/n8n receiver). # Usage: ./07-verify-webhook.sh whsec_... "$WEBHOOK_ID" "$WEBHOOK_TIMESTAMP" 'raw-body-exactly-as-received' 'v1,base64sig' set -euo pipefail secret=$1 id=$2 timestamp=$3 body=$4 header=$5 hexkey=$(printf '%s' "${secret#whsec_}" | base64 -d | xxd -p -c 256) expected=$(printf '%s.%s.%s' "$id" "$timestamp" "$body" \ | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$hexkey" -binary | base64) for part in $header; do [ "${part#v1,}" = "$expected" ] && { echo "signature OK"; exit 0; } done echo "signature MISMATCH" >&2; exit 1 ``` ### Connect an MCP client JavaScript: snippets/08-mcp-connect.mjs ```javascript // Connect an MCP client to Atlas Labs (Node 20+). npm install @modelcontextprotocol/sdk import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const url = new URL(process.env.ATLAS_MCP ?? "https://automationatlas.io/labs/mcp"); // Optional: reuse a sandbox you already have by sending a key; otherwise call create_sandbox. const key = process.env.ATLAS_OFFICE_KEY; const transport = new StreamableHTTPClientTransport(url, { requestInit: key ? { headers: { Authorization: `Bearer ${key}` } } : undefined, }); const client = new Client({ name: "my-agent", version: "1.0.0" }); await client.connect(transport); const { tools } = await client.listTools(); console.log(tools.map((t) => t.name).join(", ")); if (!key) { // The session now holds both keys; later tool calls need no header. const created = await client.callTool({ name: "create_sandbox", arguments: {} }); const result = JSON.parse(created.content[0].text); if (created.isError) throw new Error(`${result.error.code}: ${result.error.hint ?? result.error.message}`); // {"error": {code, message, retryable, hint}} console.log(result.public_name); } const status = await client.callTool({ name: "sandbox_status", arguments: {} }); console.log(status.content[0].text.slice(0, 200)); await client.close(); ``` Python: snippets/08-mcp-connect.py ```python """Connect an MCP client to Atlas Labs. pip install mcp""" import asyncio import json import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client URL = os.environ.get("ATLAS_MCP", "https://automationatlas.io/labs/mcp") KEY = os.environ.get("ATLAS_OFFICE_KEY") # optional: reuse a sandbox; otherwise call create_sandbox async def main(): headers = {"Authorization": f"Bearer {KEY}"} if KEY else None async with streamablehttp_client(URL, headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() tools = await session.list_tools() print(", ".join(t.name for t in tools.tools)) if not KEY: # The session now holds both keys; later tool calls need no header. created = await session.call_tool("create_sandbox", {}) result = json.loads(created.content[0].text) if created.isError: # a typed error: {"error": {code, message, retryable, hint, ...}} err = result["error"] raise SystemExit(f"{err['code']}: {err.get('hint') or err['message']}") print(result["public_name"]) status = await session.call_tool("sandbox_status", {}) print(status.content[0].text[:200]) asyncio.run(main()) ``` curl: snippets/08-mcp-connect.sh ```bash #!/usr/bin/env bash # Talk to the Atlas Labs MCP server with plain curl (Streamable HTTP, one JSON-RPC message per POST). # The Accept header MUST list both application/json and text/event-stream, or you get -32000 Not Acceptable. set -euo pipefail MCP="${ATLAS_MCP:-https://automationatlas.io/labs/mcp}" H=(-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream") # 1. initialize: the response carries an Mcp-Session-Id header SESSION=$(curl -sS -D - -o /dev/null -X POST "$MCP" "${H[@]}" -d '{ "jsonrpc":"2.0","id":1,"method":"initialize", "params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' \ | awk 'tolower($1)=="mcp-session-id:" {print $2}' | tr -d '\r') # 2. list the tools (no key needed) curl -sS -X POST "$MCP" "${H[@]}" -H "Mcp-Session-Id: $SESSION" \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | jq -r '.result.tools[].name' # 3. create a sandbox: the session stores both keys, so later calls need no Authorization header curl -sS -X POST "$MCP" "${H[@]}" -H "Mcp-Session-Id: $SESSION" \ -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"create_sandbox","arguments":{}}}' \ | jq '.result.content[0].text | fromjson | {sandbox_id, public_name}' # Stateless alternative: send -H "Authorization: Bearer $ATLAS_OFFICE_KEY" on any call instead. ``` ### MCP client config JSON: snippets/08-mcp-client-config.json ```json { "mcpServers": { "atlas-labs": { "url": "https://automationatlas.io/labs/mcp" } } } ``` Shell: snippets/08-mcp-claude-code.sh ```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" ``` ### Solve proof of work when POST /sandboxes returns 428 JavaScript: snippets/09-proof-of-work.mjs ```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: .. 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 ```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: .. 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 ``` ### Create a sandbox, or wait in the queue JavaScript: snippets/10-wait-in-queue.mjs ```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 ```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") ``` curl: snippets/10-wait-in-queue.sh ```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" ``` ## Integration platforms Every no-code tool needs the same five things: the base URL, an Authorization: Bearer header holding one of your two keys, Content-Type: application/json on writes, an Idempotency-Key that is stable per event (the incoming webhook-id is ideal), and, for events, a public https:// webhook URL on port 443. One section per platform follows; each says how far it was verified and links its full guide and Markdown twin. ## Connect Zapier to Atlas Labs To connect Zapier to Atlas Labs, use Webhooks by Zapier: a **Catch Raw Hook** trigger receives events at a `hooks.zapier.com` URL that you subscribe with `POST /webhooks`, and a **Custom Request** action calls the REST API with an `Authorization: Bearer` header. Zapier does not verify the Standard Webhooks signature for you, so this page includes the Code by Zapier step that does. *Reference table — Atlas Labs API fields and Zapier's documented app names, not part of the fictional simulation. Zapier help centre checked `15 Oct 2026`.* | At a glance | Value | | --- | --- | | Receive events | Webhooks by Zapier → **Catch Raw Hook** (keeps headers and the raw body; **Catch Hook** parses the body and drops the headers) | | Call the API | Webhooks by Zapier → **Custom Request**; or the premium beta app API by Zapier → **API Request** | | Where the key goes | Headers section: `Authorization` = `Bearer lab_off_YOUR_KEY` (Head Office) or `Bearer lab_fac_YOUR_KEY` (Factory) | | Body | Data field, raw JSON, with header `Content-Type: application/json` | | `Idempotency-Key` | The `webhook-id` header from the Catch Raw Hook (it equals the event `id`, `evt_…`), or the `id` that the Code by Zapier step outputs after parsing the raw body | | Webhook host | `hooks.zapier.com` is a recognised platform host with a higher delivery allowance | | Plan | Webhooks by Zapier is on Professional, Team and Enterprise, not Free; API by Zapier is premium and beta (Zapier help) | | OpenAPI import | None found in Zapier's documentation; use the HTTP steps | Full guide: https://automationatlas.io/labs/docs/tools/zapier/index.md ## Connect n8n to Atlas Labs To connect n8n to Atlas Labs, use a **Webhook** node as the trigger and an **HTTP Request** node with a **Bearer Auth** or **Header Auth** credential for calls. A self-hosted n8n on its default port will be refused, because Atlas Labs only delivers to public HTTPS on port `443`, so put it behind a tunnel or a reverse proxy first. *Reference table — Atlas Labs API fields and n8n's documented node names, not part of the fictional simulation. n8n docs checked `15 Oct 2026`.* | At a glance | Value | | --- | --- | | Receive events | **Webhook** node, HTTP Method `POST`, use the **Production URL**; Options → **Raw Body** on for signature checks; Respond “Immediately” answers `200` before later nodes run, and what Atlas Labs sees when a later node fails under the other Respond modes is not something we have confirmed | | Call the API | **HTTP Request** node; shortcut: Parameters → **Import cURL** with any curl snippet from these docs | | Where the key goes | Authentication → Generic Credential Type → **Bearer Auth** (paste only `lab_off_YOUR_KEY`) or **Header Auth** (Name `Authorization`, Value `Bearer lab_off_YOUR_KEY`) | | Body | Send Body → **JSON**; n8n sets `Content-Type: application/json` | | `Idempotency-Key` | Send Headers → `Idempotency-Key` = the `id` the Code node outputs after parsing the raw body, or the Webhook node's `webhook-id` header referenced by node name, `$('Webhook').item.json.headers['webhook-id']` (check the node name in your workflow); both equal the event `id`. Not `$json.headers[…]`: in the HTTP Request node `$json` is the previous node's output, the GET-order response | | Webhook host | n8n Cloud URLs on `*.app.n8n.cloud` are recognised; self-hosted needs public `https://` on port `443` | | Retries | Settings → **Retry On Fail**, **Max Tries**, **Wait Between Tries** (short); for `429` prefer a **Wait** node or HTTP Request **Batching** | | OpenAPI import | None in the HTTP Request node; only Import cURL | Full guide: https://automationatlas.io/labs/docs/tools/n8n/index.md ## Connect Make to Atlas Labs To connect Make to Atlas Labs, create a **Custom webhook** module to receive events and an HTTP **Make a request** module to call the API with an `Authorization: Bearer` header stored in a Credentials keychain. Make assigns the webhook URL on a `hook..make.com` host, which Atlas Labs accepts as a shared automation destination. *Reference table — Atlas Labs API fields and Make's documented module names, not part of the fictional simulation. Make docs checked `15 Oct 2026`.* | At a glance | Value | | --- | --- | | Receive events | Webhooks → **Custom webhook**; Advanced settings **Get request headers** = Yes | | Call the API | HTTP → **Make a request** | | Where the key goes | Authentication type **API key** → Credentials → **Create a keychain**: Key `Bearer lab_off_YOUR_KEY`, API key placement **Header**, API key parameter name `Authorization`. Make recommends the keychain over the Headers field. | | Body | Body type **Raw**, Content type **JSON (application/json)**, Request content = your JSON | | `Idempotency-Key` | A Headers item whose value is the `webhook-id` header from the Custom webhook (Get request headers = Yes; it equals the event `id`), or the body's `id` once the body is parsed | | Webhook host | `hook..make.com` is a recognised platform host with a higher delivery allowance | | Retries | None on the HTTP module; Scenario settings → **Store incomplete executions** plus the **Retry** error handler | | OpenAPI import | None for HTTP calls; use the HTTP module | Full guide: https://automationatlas.io/labs/docs/tools/make/index.md ## Connect Power Automate to Atlas Labs To connect Power Automate to Atlas Labs, import the generated Swagger `2.0` custom connector, or use the **HTTP** action, and catch events with the **When an HTTP request is received** trigger set to **Anyone**. The connector stores your key as one `Authorization` parameter, so type `Bearer ` before the key. *Reference table — Atlas Labs API fields and Microsoft's documented names, not part of the fictional simulation. Microsoft Learn checked `15 Oct 2026`.* | At a glance | Value | | --- | --- | | Receive events | Request → **When an HTTP request is received**; “Who can trigger the flow?” = **Anyone** | | Call the API | Built-in **HTTP** action, or a **Custom connector** imported from `swagger-2.0.json` | | Where the key goes | HTTP action: Headers `Authorization` = `Bearer lab_off_YOUR_KEY`. Connector: the connection's **Authorization** box = `Bearer lab_off_YOUR_KEY` | | Body | Headers `Content-Type: application/json`; Body = your JSON | | `Idempotency-Key` | A Headers item set to the trigger body's `id` | | Spec to import | Swagger `2.0` only, under `1 MB`; OpenAPI `3.0` is rejected (Microsoft Learn, updated `10 Oct 2026`) | | Webhook host | `*.logic.azure.com` is a recognised platform host with a higher delivery allowance | | Licence | The Request trigger is marked Premium in Microsoft's documentation; the HTTP action is widely reported as Premium, but we could not retrieve Microsoft's page saying so | | Retries | HTTP action → Settings → **Retry policy** (default exponential; up to `90` attempts, `5 s` minimum, `1` day maximum) | Full guide: https://automationatlas.io/labs/docs/tools/power-automate/index.md ## Connect Moxo AI to Atlas Labs To receive Atlas Labs events in Moxo AI (app.moxo.com), give a flow template a **Webhook** trigger with the **None (secret URL)** option, publish it, subscribe the copied URL with `POST /webhooks`, then map the fields, add a condition on `type` and re-send the ping with `POST /webhooks/{id}/test`. We tested this on `19 Oct 2026`: each `office.sales_order.received` event from a fictional sandbox started a flow in Moxo. Moxo's Webhook step cannot send the Atlas Labs key, so calls the other way go through a Make, n8n or Zapier webhook that adds it. *Reference table — Atlas Labs API fields and the Moxo AI labels used in the test on `19 Oct 2026`, not part of the fictional simulation.* | At a glance | Value | | --- | --- | | Atlas Labs → Moxo | Flow templates → your template → Trigger → **Customize** → **Webhook** → **Add** → **None (secret URL)** | | Field mapping | Required. Without it Moxo answers `400 VALIDATION_ERROR` (“Unrecognized keys”) to the Atlas Labs body. Flow name = `Atlas Labs sales order ` + **Data / Sales Order Id**, Description = **Links / Self** | | Filter | **Conditions** → **Match all of the following** → Property `type`, Operator **is**, Value `office.sales_order.received`, so the `webhook.test` ping is answered `200` and skipped | | Moxo → Atlas Labs | Moxo's **Webhook** step → a webhook trigger in Make, n8n or Zapier → that tool calls Atlas Labs with the key | | Where the key goes | In the Make, n8n or Zapier step that calls Atlas Labs: `Authorization: Bearer lab_off_YOUR_KEY`. The key never enters Moxo, and never goes in a URL (Atlas Labs rejects and revokes a key it finds in a URL). | | Atlas Labs delivery | Public HTTPS on port `443`; a `2xx` answer from Moxo counts as delivered. The webhook URL's host is `api.moxo.com`. | Full guide: https://automationatlas.io/labs/docs/tools/moxo/index.md ## Connect Frends to Atlas Labs To connect Frends to Atlas Labs, add a `Frends.HTTP.Request` Task to a Process for calls and an **HTTP Trigger** to receive events; the key goes in the Task's Headers list, read from an Environment Variable. Frends' OpenAPI import publishes your own API and does not generate a client for Atlas Labs, so the Task is the way in. *Reference table — Atlas Labs API fields and Frends' documented names, not part of the fictional simulation.* | At a glance | Value | | --- | --- | | Call the API | Task `Frends.HTTP.Request` `v1.13.0` (Frends `5.7`+, Crossplatform agent, `net8.0`) | | Receive events | **HTTP Trigger** on a Process, published to an Agent Group with a public `https://` URL | | Where the key goes | Headers list: Name `Authorization`, Value `Bearer lab_off_YOUR_KEY`, referenced from a secret Environment Variable. Alternative: Options → Authentication → **Token** | | Body | **Message** = your JSON; Headers item `Content-Type: application/json`; ResultMethod **JToken** to read the envelope | | `Idempotency-Key` | A Headers item set to the `webhook-id` request header from `#trigger.data.httpHeaders` (it equals the event `id`); with the unparsed Base64 option on, the body's `id` is reachable only after decoding | | Raw body for HMAC | Trigger option “Pass content to Process as unparsed, Base64 encoded byte array” → `#trigger.data.httpContentBytesInBase64` | | Trigger authorisation | Atlas Labs can send no Frends credential, so the endpoint needs a public policy; verify the Standard Webhooks signature in the Process instead | | Retries | None on the Task (Options hold timeouts, redirects and protocol versions only) | | OpenAPI import | For hosting your own API in API Management (OpenAPI `3.0.x`, `2.0` legacy), not for calling Atlas Labs | Full guide: https://automationatlas.io/labs/docs/tools/frends/index.md ## About Atlas Labs Atlas Labs is a free, anonymous API sandbox run by Automation Atlas, the independent site that reviews automation tools. It gives you a private copy of a fictional printing company, Atlas Print Works & Co., with two real REST APIs, a Factory and a Head Office, so you can test automation tools, integrations and AI agents against systems that behave like production and belong to no vendor. There is no sign-up, no account and no payment: you press one button and get a sandbox for thirty minutes. Full guide: https://automationatlas.io/labs/about/index.md ## Contact Atlas Labs is a free sandbox, and it gets better from what people tell us. Use this form for feedback, ideas and bug reports, to arrange adjusted limits for a course or workshop, or to suggest a recipe. A person reads every message. Contact form (HTML): https://automationatlas.io/labs/contact/ · Markdown: https://automationatlas.io/labs/contact/index.md ## MCP ## Connect an AI agent to Atlas Labs To connect an AI agent to Atlas Labs, point an MCP client at `https://automationatlas.io/labs/mcp` over Streamable HTTP and call the `create_sandbox` tool, after which the session holds both keys. An agent that cannot speak MCP can read `/labs/llms-full.txt` and call the REST API directly. *Reference table — the Atlas Labs MCP server, not part of the fictional simulation. From the API source and docs/MCP.md, checked `16 Oct 2026`.* | At a glance | Value | | --- | --- | | Endpoint | `https://automationatlas.io/labs/mcp`, Streamable HTTP, one JSON-RPC message per `POST` | | Accept header | `Accept: application/json, text/event-stream` on every request, or the server answers `-32000 Not Acceptable` | | Keys | Call `create_sandbox` once and the session holds both keys, or send `Authorization: Bearer lab_off_YOUR_KEY` (or a `lab_fac_` key) on any request | | Session | `Mcp-Session-Id`, issued by `initialize`; sessions idle for `30` minutes expire | | Tools | `18` task-level tools, plus the resources `labs://guide` and `labs://openapi` and the prompt `run-the-factory` | | Without MCP | `/labs/llms-full.txt`, `GET /labs/api/v1/` and the OpenAPI documents describe the same REST API | Full guide: https://automationatlas.io/labs/docs/tools/ai-agents/index.md MCP tools: world_status, create_sandbox, sandbox_status, extend_sandbox, get_missions, list_events, office_list_sales_orders, office_decide_sales_order, office_request_quotes, office_create_purchase_order, office_pay_supplier_invoice, office_decide_contract_lot, office_run_payroll, factory_create_work_order, factory_start_print_run, factory_receive_goods, factory_ship, bridge_record. The labs://guide resource is the short agent loop. Before the fictional sandbox runs out, extend_sandbox adds more time (only when ten minutes or fewer remain, at most twice, and the second is refused while anyone is waiting); over MCP, extend_sandbox is the way to extend: the session holds the keys, and they are shown only once, in create_sandbox's result. ## Fiction meta.simulation is true on every response. Atlas Print Works & Co. is not a real company; nothing here should be cited as a real business fact.