Agent onboarding
Start a real Layers workspace from an agent session with no account and no API key — a proof-of-work challenge, one POST, and a hosted MCP endpoint.
The keyless onboarding path lets an agent set up Layers for its user before anyone has an account. Your agent proves a little work, POSTs a URL, and gets back a live trial workspace — plus a hosted MCP endpoint it can connect to for the guided setup. No API key at any step, including the claim.
The @layers/mcp-server npm package speaks this flow for you. Run it with no key and it starts in keyless onboarding mode, drives the steps below itself, and exposes onboard_start, onboard_claim_begin and onboard_claim_verify as tools — so the whole arc, claim included, happens in the agent session. Give it an lp_... key instead and the same package starts in keyed mode against the full tool surface. The raw HTTP contract documented here is for building your own client.
The flow
GET /api/onboard/agent/challenge→{ nonce, difficulty }- Solve the proof of work: find
solutionwheresha256(nonce + solution)has at leastdifficultyleading zero bits POST /api/onboard/agent/start→202with a trial handle, session token, preview URL, and claim URL- Connect to the hosted MCP server and run the guided setup
- Hand the claim URL to the human
All HTTP endpoints on this page live on https://api.layers.com under /api/onboard/... — this is not the partner /v1 surface, and no Authorization header is needed until step 4.
Step 1 — Get a challenge
curl https://api.layers.com/api/onboard/agent/challenge{ "nonce": "3f469198c9f80e929bbc34fc1a967202", "difficulty": 20 }The challenge is single-use and expires after 10 minutes. Fetch one right before you start; don't stockpile them.
Step 2 — Solve the proof of work
Find any string solution such that sha256(nonce + solution) — plain UTF-8 concatenation, no separator — has at least difficulty leading zero bits. Bits, not hex characters. At the current difficulty of 20 that is about a million hashes on average: well under a second in any language. A counter loop is the whole algorithm.
import { createHash } from 'node:crypto';
function leadingZeroBits(digest: Buffer): number {
let bits = 0;
for (const byte of digest) {
if (byte === 0) {
bits += 8;
continue;
}
return bits + Math.clz32(byte) - 24;
}
return bits;
}
export function solvePow(nonce: string, difficulty: number): string {
for (let i = 0; ; i++) {
const solution = String(i);
const digest = createHash('sha256').update(nonce + solution, 'utf8').digest();
if (leadingZeroBits(digest) >= difficulty) return solution;
}
}import hashlib
def leading_zero_bits(digest: bytes) -> int:
bits = 0
for byte in digest:
if byte == 0:
bits += 8
continue
return bits + 8 - byte.bit_length()
return bits
def solve_pow(nonce: str, difficulty: int) -> str:
i = 0
while True:
solution = str(i)
digest = hashlib.sha256((nonce + solution).encode()).digest()
if leading_zero_bits(digest) >= difficulty:
return solution
i += 1Step 3 — Start the trial
POST the solved challenge with the URL you are onboarding — the user's website or App Store listing:
import { randomUUID } from 'node:crypto';
import { solvePow } from './solve-pow.js';
const API = 'https://api.layers.com';
const challengeRes = await fetch(`${API}/api/onboard/agent/challenge`);
const { nonce, difficulty } = await challengeRes.json();
const startRes = await fetch(`${API}/api/onboard/agent/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: 'https://example.com',
startRequestId: randomUUID(),
powNonce: nonce,
powSolution: solvePow(nonce, difficulty),
}),
});
const trial = await startRes.json(); // 202{
"trialHandle": "9f3c62d41b8e4f0a...",
"previewUrl": "https://app.layers.com/p/...",
"claimUrl": "https://app.layers.com/claim?token=...",
"expiresAt": "2026-08-12T09:00:00.000Z",
"session": { "access_token": "eyJ...", "expires_in": 3600 },
"sessionHandle": "..."
}What you're holding:
trialHandle— names the trial in every later call, including the MCP connection.session.access_token— a short-lived anonymous session JWT (about an hour;expires_inis authoritative, in seconds). It authenticates the MCP connection and the trial-status reads below.sessionHandle— refreshes that token:POST /api/onboard/agent/refreshwith{ "sessionHandle": "..." }returns a fresh{ access_token, expires_in }. No auth header needed.previewUrl/claimUrl— browser pages for the human. Show the preview early; hold the claim URL for step 5.expiresAt— the trial lives for 7 days, then everything about it starts returning 404.
Behavior worth knowing:
startRequestIdis your idempotency key. If the 202 gets lost, replay the same UUID + URL and you get the same trial back (pre-claim only). The same UUID with a different URL is a409.- Rate limits are tight by design: 5 starts per minute and 20 per day per IP, plus a global cap. A
429carriesRetry-After. One user's onboarding needs exactly one start — if you're hitting these limits, the bug is on your side.
Step 4 — Connect to the hosted MCP server
The trial-scoped MCP server speaks streamable HTTP at:
https://mcp.layers.com/api/mcp/onboarding/mcp?trial=TRIAL_HANDLESend two headers on every request:
Authorization: Bearer ACCESS_TOKEN— the trial session token from step 3x-layers-onboard-trial: TRIAL_HANDLE
Send the header even though the handle is also in the URL. The header is what the hosted guide's tools read to recover the trial binding; the ?trial= query parameter is a back-compat fallback for the ingress gate only. A handle your session isn't bound to gets a 404 — you can only ever reach your own trial.
claude mcp add layers-onboarding \
--transport http \
"https://mcp.layers.com/api/mcp/onboarding/mcp?trial=TRIAL_HANDLE" \
--header "Authorization: Bearer ACCESS_TOKEN" \
--header "x-layers-onboard-trial: TRIAL_HANDLE"{
"mcpServers": {
"layers-onboarding": {
"type": "http",
"url": "https://mcp.layers.com/api/mcp/onboarding/mcp?trial=TRIAL_HANDLE",
"headers": {
"Authorization": "Bearer ACCESS_TOKEN",
"x-layers-onboard-trial": "TRIAL_HANDLE"
}
}
}
}A Browser Integrity Check at the edge rejects a few default scripting
user-agents before the request reaches the server — Python's stdlib
urllib is the one people hit. It fails with HTTP 403 and an
error_code: 1010 body rather than anything MCP-shaped. curl, Node, Go and
the official Python MCP SDK (which uses httpx) all pass. If you are writing
a raw client, set a real User-Agent.
The server identifies itself as Layers Onboarding and exposes five direct tools:
| Tool | Input | What it does |
|---|---|---|
getOnboardingBrief | none | Reads the trial: buildState, planState, claimState, the brand preview, saved answers, remainingIntakeQuestions, and a deterministic nextAction (ask_intake or hand_off). Call this first, and re-read it after every save. |
saveIntakeAnswer | { "question": "...", "option": "..." } | Saves one answer to the canonical setup intake. question is an intake field name; option is the 1-based number of the pick ("2") or the option's value. A multi-select question takes every pick in one call, comma separated ("1,3,4") — one call per pick overwrites the earlier picks. |
saveAnswer | { "key": "...", "value": "..." } | Saves one free-form answer outside the canonical intake (value up to 2,000 characters). |
getMarketingPlan | none | Reads starter-plan status and teaser. The full plan content unlocks only after the trial is claimed. |
generateStarterPlan | none | Starts the legacy starter-plan background job, then you poll getMarketingPlan. The post-claim assets on the preview page do not need this. |
There is also one agent tool, ask_onboardingGuide — the hosted onboarding guide. It runs the same tools conversationally, asks each intake question with its exact canonical copy, and ends every turn with either a question or a Next step: line, so a thin client can just relay its text.
Don't hardcode the intake question list. The valid question names, their exact copy, and their option lists ship in saveIntakeAnswer's own tool description and in the trial status response — read them from the server; the set changes.
Step 5 — Claim the workspace
Claiming turns the anonymous trial into a permanent workspace. It happens in the agent session — two endpoints, no browser required. The human's only job is to read a code out of their email.
claimToken is the token query parameter of the claimUrl you got back from start.
# 1. Ask for a code. The human gets an email.
curl -X POST "https://api.layers.com/api/onboard/claim/begin" \
-H "Content-Type: application/json" \
-d '{"claimToken":"CLAIM_TOKEN","email":"them@example.com"}'
# 2. Send back what they read you.
curl -X POST "https://api.layers.com/api/onboard/claim/verify" \
-H "Content-Type: application/json" \
-d '{"claimToken":"CLAIM_TOKEN","email":"them@example.com","code":"123456","surface":"agent"}'Set surface: "agent" so the claim is attributed to the agent door rather than the web one.
Read continuity on the response before you assume you can keep going. It has two values, and they mean very different things for your session:
continuity | What happened | What you get |
|---|---|---|
same_account | The email was new. The anonymous user was upgraded in place — same underlying account, so everything the trial already built stays attached to it. | session (access + refresh token) and usually the workspace API key. You can go straight to the keyed surface. |
browser | The email already belongs to a Layers account. The trial cannot silently absorb an existing identity, so a normal sign-in code was sent to that account instead. | No session. The one-time code is already spent, so nothing in your session can re-mint one. |
On browser, stop and hand off: the human signs in to their existing account in a browser and the workspace is reconciled there. Do not retry verify and do not ask for another code — you will not get a session out of this path no matter how many times you try.
session and apiKey are both optional in the contract for this reason. Branch on continuity, not on whether a field happens to be present.
If the human would rather finish in a browser from the start, claimUrl still works and does exactly the same thing — it is an alternative, not the only route.
While you wait, poll the trial status (every 5 seconds is plenty):
curl "https://api.layers.com/api/onboard/agent/trials/TRIAL_HANDLE" \
-H "Authorization: Bearer ACCESS_TOKEN"The response carries buildState (reserved → minting → dispatching → building → preview_ready, or failed/expired), planState, claimState (unclaimed → otp_pending → identity_verified → claimed, or failed), a claimed boolean, the outstanding intake questions, and — once claimed — workspaceUrl. Unknown, unbound, and expired handles all return the same 404.
After the claim lands, your session can mint a partner API key for the new workspace and graduate to the keyed surface:
curl -X POST "https://api.layers.com/api/onboard/agent/trials/TRIAL_HANDLE/workspace-key" \
-H "Authorization: Bearer ACCESS_TOKEN"It returns { secret, prefix, organizationId }. The secret is returned exactly once and never retrievable again — store it before you do anything else. Before the claim, this endpoint returns 409. From here, the API integration and the local MCP server both work with that key.
Endpoint summary
| Method | Path | Auth |
|---|---|---|
| GET | /api/onboard/agent/challenge | none |
| POST | /api/onboard/agent/start | proof of work |
| POST | /api/onboard/agent/refresh | sessionHandle in the body |
| GET | /api/onboard/agent/trials/:trialHandle | Bearer trial session token |
| POST | /api/onboard/agent/trials/:trialHandle/workspace-key | Bearer trial session token, post-claim |