Docs
MCP

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.

View as Markdown

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

  1. GET /api/onboard/agent/challenge{ nonce, difficulty }
  2. Solve the proof of work: find solution where sha256(nonce + solution) has at least difficulty leading zero bits
  3. POST /api/onboard/agent/start202 with a trial handle, session token, preview URL, and claim URL
  4. Connect to the hosted MCP server and run the guided setup
  5. 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

terminal
curl https://api.layers.com/api/onboard/agent/challenge
200 response
{ "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.

solve-pow.ts
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;
  }
}
solve_pow.py
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 += 1

Step 3 — Start the trial

POST the solved challenge with the URL you are onboarding — the user's website or App Store listing:

start-trial.ts
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
202 response
{
  "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_in is authoritative, in seconds). It authenticates the MCP connection and the trial-status reads below.
  • sessionHandle — refreshes that token: POST /api/onboard/agent/refresh with { "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:

  • startRequestId is 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 a 409.
  • Rate limits are tight by design: 5 starts per minute and 20 per day per IP, plus a global cap. A 429 carries Retry-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_HANDLE

Send two headers on every request:

  • Authorization: Bearer ACCESS_TOKEN — the trial session token from step 3
  • x-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.

terminal
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:

ToolInputWhat it does
getOnboardingBriefnoneReads 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).
getMarketingPlannoneReads starter-plan status and teaser. The full plan content unlocks only after the trial is claimed.
generateStarterPlannoneStarts 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.

terminal
# 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:

continuityWhat happenedWhat you get
same_accountThe 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.
browserThe 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):

terminal
curl "https://api.layers.com/api/onboard/agent/trials/TRIAL_HANDLE" \
  -H "Authorization: Bearer ACCESS_TOKEN"

The response carries buildState (reservedmintingdispatchingbuildingpreview_ready, or failed/expired), planState, claimState (unclaimedotp_pendingidentity_verifiedclaimed, 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:

terminal
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

MethodPathAuth
GET/api/onboard/agent/challengenone
POST/api/onboard/agent/startproof of work
POST/api/onboard/agent/refreshsessionHandle in the body
GET/api/onboard/agent/trials/:trialHandleBearer trial session token
POST/api/onboard/agent/trials/:trialHandle/workspace-keyBearer trial session token, post-claim

On this page