> ## Documentation Index
> Fetch the complete documentation index at: https://docs.botshield.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Link a human

> Run the one-time link between your agent and a human, and store the opaque ID you will propose actions to.

Before your agent can propose an action to someone, that person links to your agent once. Your agent asks BotShield for a short code, the person enters or scans it in the BotShield app and confirms with their device biometric, and your agent receives an **`opaque_id`**. You store that id against your own user record and use it for every later proposal.

The `opaque_id` looks like `OP_QDhs65484684`. It is pairwise: it identifies this human **to your agent only**. It is not a global user id, it carries no personal data, and it does not work with any other agent, including another agent of your own.

```mermaid theme={null}
sequenceDiagram
    participant A as Your agent
    participant B as BotShield API
    participant U as User
    participant P as User's phone (BotShield app)

    A->>B: POST /agent/bind-session
    B-->>A: code, claim_url, expires_at
    A->>U: Show the 6-character code and the link (or a QR of claim_url)
    U->>P: Enter or scan the code
    P->>B: Confirm the link with biometric
    loop until bound or the code expires
        A->>B: GET /agent/check-binding?code=…&wait_seconds=25
        B-->>A: status: pending
    end
    B-->>A: status: bound, opaque_id: OP_…
    A->>A: Store opaque_id against your user record
```

<Note>
  These two operations are not part of the generated API reference or the TypeScript SDK. Call them over HTTP as shown here. Both authenticate with your [agent key](/agents-ask/register-an-agent#the-agent-key), and both use the standard envelope: HTTP 200 with the payload at `data.data`, or a handler error at `data.error`.
</Note>

## 1. Start the link

`POST https://api.botshield.ai/operations/agent/bind-session`

<ParamField body="display_name" type="string">
  Optional. How your agent is named on the user's link screen, 1–80 characters. Defaults to the agent name you registered.
</ParamField>

<ParamField body="platform_user_ref" type="string">
  Optional, 1–256 characters. Your own reference for this person, the same value you send as `partner_user_id` to BotShield Gate. If that person has verified with you before, BotShield can also notify their phone about the link request. The response is identical whether or not a match exists.
</ParamField>

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.botshield.ai/operations/agent/bind-session \
    -H "Authorization: Bearer $BOTSHIELD_AGENT_KEY" \
    -H "Content-Type: application/json" \
    -d '{"display_name": "Meridian Airlines Concierge"}'
  ```

  ```typescript TypeScript theme={null}
  const BASE = "https://api.botshield.ai/operations";

  const res = await fetch(`${BASE}/agent/bind-session`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.BOTSHIELD_AGENT_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ display_name: "Meridian Airlines Concierge" }),
  });

  const body = await res.json();
  if (body.data?.error) throw new Error(body.data.error.message);

  const { code, claim_url, expires_at } = body.data.data;
  ```
</CodeGroup>

```json Response theme={null}
{
  "data": {
    "data": {
      "status": "pending",
      "code": "7K3QWD",
      "claim_url": "https://app.botshield.ai/bind?code=7K3QWD",
      "expires_at": "2026-09-21T17:42:10.000Z",
      "display_name": "Meridian Airlines Concierge"
    }
  }
}
```

<ResponseField name="status" type="string">
  Always `pending`.
</ResponseField>

<ResponseField name="code" type="string">
  Six characters from digits and uppercase letters. The letters I, L, O, and U are never used, so a code read aloud cannot be mistaken for another.
</ResponseField>

<ResponseField name="claim_url" type="string">
  A link that opens the BotShield app on the link screen with the code filled in. Show it as a link on mobile, or render it as a QR code on desktop. BotShield returns the URL, not an image, so you control the presentation.
</ResponseField>

<ResponseField name="expires_at" type="string">
  ISO 8601 time, 10 minutes after the request.
</ResponseField>

<ResponseField name="display_name" type="string">
  The name the user will see.
</ResponseField>

Every call issues a new code. BotShield does not know who is on the other end of your conversation until they claim the code, so it cannot tell you "already linked" at this step.

## 2. Show the code to the user

Show **both** the code and the `claim_url`. The person opens the BotShield app, goes to **Account → BotShield Agent → Link with an agent**, enters the code (or opens the link, or scans your QR), and confirms with their biometric.

<Warning>
  Do not describe the code as a password or a one-time passcode. Holding the code proves nothing: a link is created only when the person confirms in the BotShield app with their biometric. A code seen by someone else is useless to them.
</Warning>

| Limit | Value |
| - | - |
| Code lifetime | 10 minutes |
| Claim attempts per code | 5, after which the code is cancelled |
| Who can poll a code | Only the agent that created it |

## 3. Wait for the link

`GET https://api.botshield.ai/operations/agent/check-binding`

<ParamField query="code" type="string" required>
  The code returned by `bind-session`.
</ParamField>

<ParamField query="wait_seconds" type="number">
  Optional long-poll hold, 0–25. With a value above 0, BotShield re-checks every 2 seconds and answers as soon as the status leaves `pending`, or when the hold ends. Omit it, or send 0, for a single immediate check.
</ParamField>

<CodeGroup>
  ```bash curl theme={null}
  curl --max-time 35 \
    "https://api.botshield.ai/operations/agent/check-binding?code=7K3QWD&wait_seconds=25" \
    -H "Authorization: Bearer $BOTSHIELD_AGENT_KEY"
  ```

  ```typescript TypeScript theme={null}
  const BASE = "https://api.botshield.ai/operations";

  async function waitForLink(code: string): Promise<string> {
    for (;;) {
      const res = await fetch(
        `${BASE}/agent/check-binding?code=${code}&wait_seconds=25`,
        {
          headers: { Authorization: `Bearer ${process.env.BOTSHIELD_AGENT_KEY}` },
          signal: AbortSignal.timeout(35_000), // must be longer than wait_seconds
        },
      );
      const body = await res.json();
      if (body.data?.error) throw new Error(body.data.error.message);

      const { status, opaque_id } = body.data.data;
      if (status === "bound") return opaque_id; // store this
      if (status !== "pending") throw new Error(`Link ended: ${status}`);
    }
  }
  ```
</CodeGroup>

```json Response (linked) theme={null}
{
  "data": {
    "data": {
      "status": "bound",
      "opaque_id": "OP_QDhs65484684"
    }
  }
}
```

```json Response (still waiting) theme={null}
{
  "data": {
    "data": {
      "status": "pending"
    }
  }
}
```

### Statuses

| `status` | Meaning | What to do |
| - | - | - |
| `pending` | The person has not confirmed yet. | Poll again. |
| `bound` | Linked. `opaque_id` is present. | Store the `opaque_id`. Stop polling. |
| `expired` | The 10 minutes passed without a claim. | Call `bind-session` again for a new code. |
| `cancelled` | The code was cancelled, for example after 5 claim attempts. | Call `bind-session` again. |
| `not_found` | No such code for this agent. Waiting will not change it. | Check the code and the agent key. |
| `revoked` | The person linked and then disconnected your agent before you polled. | Ask them to link again. |

`opaque_id` is returned only with `bound`.

### Long-poll guidance

* Set your HTTP client timeout **above 25 seconds**; 30–35 seconds works well. A shorter timeout cuts off a healthy long-poll.
* One call with `wait_seconds=25` covers most people picking up their phone. If it returns `pending`, call again.
* Prefer long-polling to tight loops. Without `wait_seconds`, leave a few seconds between checks.
* Stop when `expires_at` has passed.

## Link once, then remember

Linking happens once per human and agent, not once per conversation.

* **Store the `opaque_id` on your own user record** and reuse it for every proposal. Remembering the link is your job: `bind-session` always issues a fresh code and has no "already linked" answer.
* If a person who is already linked completes the ceremony again with the same agent, you get their existing active `opaque_id` back. A second link never creates a second id for the same pair.
* Each of your agents has its own links. A development agent and a production agent are different agents, so link separately in each.
* Links belong to the agent, not to its key. When you [rotate the agent's key](/agents-ask/register-an-agent#rotate-a-key), every stored `opaque_id` keeps working with the new key.

## When a person disconnects your agent

People manage their links in the BotShield app under **Linked Agents**, where **Disconnect** ends the link with your agent. You are not notified. The next time you [propose an action](/agents-ask/propose-an-action) to that `opaque_id`, the call returns:

```json theme={null}
{
  "data": {
    "error": {
      "message": "No active binding for that opaque_id.",
      "statusCode": 404
    }
  }
}
```

The same error is returned for an id that never existed or that belongs to another agent. Treat it as "not linked": clear the stored `opaque_id` and run the link again. A disconnected person is free to link again later, and doing so requires a fresh biometric confirmation.

## Errors

| Where | `statusCode` | `message` | Cause |
| - | - | - | - |
| `data.error` | 401 | `Authorization: Bearer <bs_agent_*> header required.` and other key messages | Missing, malformed, wrong-environment, or revoked agent key, or a key that was replaced with **Rotate key** in the Console. |
| `data.error` | 500 | `Could not start the binding.` | Temporary failure. Retry. |
| HTTP 400 | — | Input validation error | For example a `code` shorter than 4 characters or `wait_seconds` above 25. |

Always check `data.error` before you read `data.data`. See [Errors](/api-reference/errors).

## Next steps

<CardGroup cols={2}>
  <Card title="Propose an action" icon="paper-plane" href="/agents-ask/propose-an-action">
    Use the `opaque_id` to ask for a Confirm or Deny.
  </Card>

  <Card title="Hosted MCP server" icon="plug" href="/agents-ask/mcp-server">
    The same ceremony as the `bind_session` and `check_binding` tools, with the same long-poll wait.
  </Card>

  <Card title="Privacy boundary" icon="user-shield" href="/concepts/privacy-boundary">
    Why you receive an opaque id and never an identity.
  </Card>

  <Card title="Test it in the Sandbox" icon="flask" href="/agents-ask/register-an-agent#test-in-the-sandbox">
    Run the link from the Console with a development agent.
  </Card>
</CardGroup>
