# Agent onboarding (/docs/mcp/onboarding)



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`](/docs/mcp/quickstart) 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 [#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/start` → `202` 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 [#step-1--get-a-challenge]

```sh title="terminal"
curl https://api.layers.com/api/onboard/agent/challenge
```

```json title="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 [#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.

<Tabs items="['TypeScript', 'Python']">
  <Tab value="TypeScript">
    ```ts title="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;
      }
    }
    ```
  </Tab>

  <Tab value="Python">
    ```py title="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
    ```
  </Tab>
</Tabs>

## Step 3 — Start the trial [#step-3--start-the-trial]

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

```ts title="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
```

```json title="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`*&#x2A; / &#x2A;*`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 [#step-4--connect-to-the-hosted-mcp-server]

The trial-scoped MCP server speaks streamable HTTP at:

```text
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.

<Tabs items="['Claude Code', 'Generic MCP client']">
  <Tab value="Claude Code">
    ```sh title="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"
    ```
  </Tab>

  <Tab value="Generic MCP client">
    ```json
    {
      "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"
          }
        }
      }
    }
    ```
  </Tab>
</Tabs>

<Callout type="warn">
  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`.
</Callout>

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 [#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`.

```sh title="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:

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

```sh title="terminal"
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:

```sh title="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](/docs/api) and the [local MCP server](/docs/mcp/quickstart) both work with that key.

## Endpoint summary [#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 |
