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

# Propose an action

> Send an action to a linked human for a biometric Confirm or Deny, wait for the decision, and cancel a proposal you no longer need.

Your agent proposes an action with `POST /agentlink/inquire`, waits for the decision with `GET /agentlink/check-status`, and can withdraw it with `POST /agentlink/cancel`. All three authenticate with your [agent key](/agents-ask/register-an-agent#the-agent-key) and are available in the TypeScript SDK as `client.actions.*`. You need the human's `opaque_id` from [Link a human](/agents-ask/link-a-human).

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

    A->>B: POST /agentlink/inquire (request_id, opaque_id, action)
    B-->>A: status: queued, card_id, ttl_at
    B->>P: Card with your action
    A->>B: GET /agentlink/check-status?wait_seconds=25
    alt User confirms
        P->>B: Confirm with biometric
        B-->>A: status: approved, verdict: approve, resolution_jwt
    else User denies
        P->>B: Deny with biometric
        B-->>A: status: denied, verdict: denied, resolution_jwt
    else Nobody answers before ttl_at
        B-->>A: status: expired, no resolution_jwt
    else Your agent withdraws it
        A->>B: POST /agentlink/cancel
        B-->>A: status: cancelled, no resolution_jwt
    end
```

## Set up the client

<CodeGroup>
  ```bash curl theme={null}
  export BOTSHIELD_AGENT_KEY="bs_agent_MeridianConcierge__…"
  ```

  ```typescript TypeScript SDK theme={null}
  import { BotShield } from "botshield-sdk";

  const client = new BotShield({
    // Required: the package does not default to the production API.
    serverURL: "https://api.botshield.ai/operations",
    security: {
      // Sent verbatim as the Authorization header, so include "Bearer ".
      agentKeyAuth: `Bearer ${process.env.BOTSHIELD_AGENT_KEY}`,
    },
  });
  ```
</CodeGroup>

The SDK uses camelCase field names (`requestId`, `opaqueId`, `summaryTitle`) and returns the API envelope untouched: check `result.data.error` first, then read `result.data.data`.

## Propose the action

`POST https://api.botshield.ai/operations/agentlink/inquire`

<ParamField body="request_id" type="string (UUID)" required>
  A UUID you generate. It is the idempotency key, the id you poll and cancel with, and the `jti` of the resulting proof. Sending the same `request_id` again does not create a second card; see [Idempotency](#idempotency).
</ParamField>

<ParamField body="opaque_id" type="string" required>
  The `OP_…` id your agent holds for this human. It is resolved against your agent's own links only.
</ParamField>

<ParamField body="action" type="object" required>
  What the human is asked to decide.

  | Field | Type | Limits | Shown to the user |
  | - | - | - | - |
  | `summary_title` | string, required | 1–120 characters | The card's headline. |
  | `summary_detail.label` | string | 1–40 characters | Label of the one detail row, for example `TOTAL`. |
  | `summary_detail.value` | string | 1–80 characters | Value of that row, for example `$168.45`. |
  | `category` | string, required | 1–80 characters | Not shown. Must be one of the agent's [allowed action categories](/agents-ask/register-an-agent#action-categories). |
  | `trusted_account_id` | string (UUID) | — | Optional. Echoed into the proof's `action` claim. |

  `summary_detail` is optional, but if you send it, both `label` and `value` are required.
</ParamField>

<ParamField body="ttl_seconds" type="integer" default="600">
  How long the human has to answer. Minimum 60, maximum 86400 (24 hours). Values outside the range are rejected; they are not clamped.
</ParamField>

<ParamField body="adaptive_card_payload" type="object">
  Optional. An Adaptive Card shown when the human expands the card for more detail. See [Add detail with an Adaptive Card](#add-detail-with-an-adaptive-card).
</ParamField>

<Note>
  `user_email` is deprecated and will be removed. Identify the human with `opaque_id`.
</Note>

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.botshield.ai/operations/agentlink/inquire \
    -H "Authorization: Bearer $BOTSHIELD_AGENT_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "request_id": "3f1b2c4d-5e6a-4b7c-8d9e-0f1a2b3c4d5e",
      "opaque_id": "OP_QDhs65484684",
      "action": {
        "summary_title": "Refund booking MA-48213 to your Visa ending 4242",
        "summary_detail": { "label": "TOTAL", "value": "$168.45" },
        "category": "travel.refund"
      },
      "ttl_seconds": 600
    }'
  ```

  ```typescript TypeScript SDK theme={null}
  import { randomUUID } from "node:crypto";

  const requestId = randomUUID(); // persist this with your pending refund

  const result = await client.actions.proposeAction({
    requestId,
    opaqueId: "OP_QDhs65484684",
    action: {
      summaryTitle: "Refund booking MA-48213 to your Visa ending 4242",
      summaryDetail: { label: "TOTAL", value: "$168.45" },
      category: "travel.refund",
    },
    ttlSeconds: 600,
  });

  if (result.data.error) {
    throw new Error(`${result.data.error.statusCode}: ${result.data.error.message}`);
  }
  const { cardId, ttlAt } = result.data.data!;
  ```
</CodeGroup>

```json Response theme={null}
{
  "data": {
    "data": {
      "status": "queued",
      "card_id": "0b9d6c0e-7a57-4c55-9a53-0a1f4c1d2e3f",
      "ttl_at": "2026-09-21T17:52:10.000Z"
    }
  }
}
```

<ResponseField name="status" type="string">
  `queued` for a new proposal. On an idempotent replay, the card's current status.
</ResponseField>

<ResponseField name="card_id" type="string">
  BotShield's id for the card. It also appears in webhook payloads.
</ResponseField>

<ResponseField name="ttl_at" type="string">
  ISO 8601 time at which the proposal expires. Present on a new proposal only.
</ResponseField>

<ResponseField name="idempotent_replay" type="boolean">
  `true` when this `request_id` was already proposed by your agent. No new card was created.
</ResponseField>

The call returns as soon as the card is queued. The decision arrives later, so poll `check-status` or listen for [webhooks](/webhooks/events). BotShield also emits `agents_ask.card.proposed` at this point.

### Idempotency

`request_id` is unique per agent. If a network error leaves you unsure whether a proposal landed, send the same body again with the same `request_id`. You get back the existing card:

```json theme={null}
{
  "data": {
    "data": {
      "status": "queued",
      "card_id": "0b9d6c0e-7a57-4c55-9a53-0a1f4c1d2e3f",
      "idempotent_replay": true
    }
  }
}
```

Use a new UUID for every new action, including a re-ask after a Deny or an expiry.

## What the user sees

The human gets a push notification and a card in the BotShield app showing:

* your agent's **display name**, as registered in the Console;
* the **`summary_title`** and, if sent, the one **detail row**;
* the expanded Adaptive Card body, if you sent one, when they tap the card;
* **Confirm** and **Deny** controls, followed by a passkey confirmation with their device biometric.

Write the title so the decision is clear without the chat context: say what happens, to what, and for how much. A person can decide several waiting cards in one biometric confirmation; those results share a `ceremony_id`.

## Add detail with an Adaptive Card

`adaptive_card_payload` takes [Adaptive Card](https://adaptivecards.io) JSON. BotShield validates and screens it before anything reaches the user's phone, and rejects the whole proposal if it fails.

```json theme={null}
{
  "type": "AdaptiveCard",
  "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
  "version": "1.5",
  "body": [
    {
      "type": "FactSet",
      "facts": [
        { "title": "BOOKING", "value": "MA-48213 · Denver (DEN) → Miami (MIA)" },
        { "title": "REASON", "value": "Flight cancelled by airline" },
        { "title": "REFUND TO", "value": "Visa ending 4242" }
      ]
    }
  ]
}
```

| Rule | Accepted |
| - | - |
| Root | `"type": "AdaptiveCard"` with `version` `"1.0"` through `"1.5"` |
| Elements | `TextBlock`, `FactSet`, `ColumnSet` with `Column`, `Container`, `Table` with `TableRow` and `TableCell`, `Image` |
| Images | Remote image URLs are rejected. Leave `Image` out. |
| Actions | Leave `actions` out; the app supplies Confirm and Deny. If present, only `Action.Submit` with the id `botshield.approve` or `botshield.deny` is accepted, each at most once. |
| Never accepted | `selectAction`, `refresh`, `authentication`, `backgroundImage`, `data`, `associatedInputs`, and any element or property not on the list |
| Size | 51,200 bytes of JSON, 200 elements, 30 facts per `FactSet`, 4,000 characters per text value |
| Content | Screened for card numbers, government ID numbers, embedded links, prompt-injection and social-engineering text, and misleading pricing. Keep the body factual and free of URLs. |
| Identity rows | A fact titled `COMPANY`, `ORGANIZATION`, `FROM`, or `BRAND` should match your agent's registered name or display name. |

## Errors

Handler errors arrive as **HTTP 200** with `data.error`. Check it before reading `data.data`.

| `statusCode` | `code` | `message` | Cause and fix |
| - | - | - | - |
| 401 | — | For example `Authorization: Bearer <bs_agent_*> header required.` | Missing or malformed key, a revoked agent, a wrong secret (including a key that was replaced with **Rotate key** in the Console), or a key from the other environment. |
| 403 | — | `Action category "travel.refund" is not in agent's allowed_action_categories.` | Add the category to the agent in the Console, or send an allowed one. |
| 400 | — | `Provide opaque_id (preferred), or one of user_email / botshield_user_id.` | No human identified. Send `opaque_id`. |
| 404 | — | `No active binding for that opaque_id.` | The id is unknown to this agent or the person disconnected it. [Link again](/agents-ask/link-a-human). |
| 422 | — | `TRUST Layer rejected the Adaptive Card payload.` | The Adaptive Card failed validation or screening. See `violations[]` below. |
| 400 | `ttl_below_floor` | `ttl_seconds (30) is below the floor (60)…` | Includes `min_ttl_seconds: 60`. |
| 400 | `ttl_above_ceiling` | `ttl_seconds (90000) exceeds the ceiling (86400)…` | Includes `max_ttl_seconds: 86400`. |
| 500 | — | `Failed to queue card: …` | Temporary failure. Retry with the same `request_id`. |

A schema problem in the request itself, such as a `request_id` that is not a UUID or a 130-character title, returns **HTTP 400** with an input validation body instead of the envelope. See [Errors](/api-reference/errors).

<CodeGroup>
  ```json Category not allowed theme={null}
  {
    "data": {
      "error": {
        "message": "Action category \"payment.authorize\" is not in agent's allowed_action_categories.",
        "statusCode": 403
      }
    }
  }
  ```

  ```json Adaptive Card rejected theme={null}
  {
    "data": {
      "error": {
        "message": "TRUST Layer rejected the Adaptive Card payload.",
        "statusCode": 422,
        "violations": [
          { "type": "unknown_element_type", "path": "$.body[1]", "severity": "block" },
          { "type": "embedded_url", "path": "$.body[0].facts[2].value", "severity": "block" }
        ]
      }
    }
  }
  ```

  ```json TTL out of range theme={null}
  {
    "data": {
      "error": {
        "message": "ttl_seconds (30) is below the floor (60). Below the floor defeats the architectural purpose — the user has not had a real chance to engage.",
        "statusCode": 400,
        "code": "ttl_below_floor",
        "min_ttl_seconds": 60
      }
    }
  }
  ```
</CodeGroup>

Each entry in `violations[]` has a `type`, a JSONPath `path` into your payload, and a `severity` of `block` or `warn`. Only `block` entries cause the rejection.

<Accordion title="Violation types">
  **Structure:** `payload_too_large`, `payload_not_object`, `wrong_root_type`, `unsupported_version`, `unknown_element_type`, `disallowed_property`, `globally_disallowed_property`, `disallowed_action_type`, `reserved_action_id_required`, `reserved_action_id_conflict`, `image_remote_url_disallowed`, `fact_too_long`, `too_many_facts`, `too_many_elements`, `malformed_fact`, `malformed_element`.

  **Content:** `credit_card`, `ssn`, `bank_account`, `embedded_url`, `email_unlabeled`, `phone_unlabeled`, `api_key_or_token`, `toxic_language`, `prompt_injection`, `social_engineering`, `off_topic_content`, `deceptive_pricing`.

  **Identity:** `identity_mismatch`.
</Accordion>

## Wait for the decision

`GET https://api.botshield.ai/operations/agentlink/check-status`

<ParamField query="request_id" type="string (UUID)" required>
  The `request_id` you proposed with.
</ParamField>

<ParamField query="wait_seconds" type="number">
  Optional long-poll hold, 0–25. While the card is `queued`, BotShield re-checks every 2 seconds and answers as soon as it reaches a final status, 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/agentlink/check-status?request_id=3f1b2c4d-5e6a-4b7c-8d9e-0f1a2b3c4d5e&wait_seconds=25" \
    -H "Authorization: Bearer $BOTSHIELD_AGENT_KEY"
  ```

  ```typescript TypeScript SDK theme={null}
  async function waitForDecision(requestId: string) {
    for (;;) {
      const result = await client.actions.checkActionStatus(
        { requestId, waitSeconds: 25 },
        { timeoutMs: 35_000 }, // must be longer than waitSeconds
      );
      if (result.data.error) throw new Error(result.data.error.message);

      const state = result.data.data!;
      if (state.status !== "queued") return state; // approved | denied | expired | cancelled
    }
  }

  const state = await waitForDecision(requestId);
  if (state.status === "approved" && state.resolutionJwt) {
    // Verify the proof before you execute. See Proof of Resolution.
  }
  ```
</CodeGroup>

```json Response (confirmed) theme={null}
{
  "data": {
    "data": {
      "status": "approved",
      "ttl_at": "2026-09-21T17:52:10.000Z",
      "resolved_at": "2026-09-21T17:43:31.412Z",
      "ceremony_id": "e4d3c2b1-0f9e-4d8c-b7a6-5f4e3d2c1b0a",
      "resolution_jwt": "eyJhbGciOiJFUzI1NiIsImtpZCI6InYyIn0…",
      "verdict": "approve",
      "delivered": false,
      "callback_attempts": 0
    }
  }
}
```

| `status` | State | `verdict` | `resolution_jwt` | Meaning |
| - | - | - | - | - |
| `queued` | Pending | `null` | `null` | Waiting for the human. Keep polling until `ttl_at`. |
| `approved` | Confirmed | `approve` | Signed JWT | The human confirmed with their biometric. |
| `denied` | Denied | `denied` | Signed JWT | The human explicitly refused. The refusal is signed too. |
| `expired` | Expired | `null` | `null` | Nobody answered before `ttl_at`. No proof exists. Stand down. |
| `cancelled` | Cancelled | `null` | `null` | Your agent withdrew the proposal. |

Other fields: `resolved_at` is the time of the final status (`null` while queued). `ceremony_id` identifies the biometric confirmation and is shared by cards decided together. `delivered` and `callback_attempts` are reserved; ignore them.

<Warning>
  A status of `approved` is a signal to go and verify, not permission to act. Execute only after your own code has verified `resolution_jwt` and checked `verdict === "approve"`. See [Proof of Resolution](/agents-ask/proof-of-resolution).
</Warning>

A `request_id` your agent never proposed returns `data.error` with `statusCode` 404 and the message `request_id not found for this agent.`

**Long-poll guidance.** Set your HTTP timeout above 25 seconds; 30–35 seconds works well. One held call replaces a dozen short polls, which matters in agent hosts that run one turn at a time. If you would rather not hold a connection, subscribe to the `agents_ask.resolution.*` [webhooks](/webhooks/events), which carry the same signed proof.

## Cancel a proposal

`POST https://api.botshield.ai/operations/agentlink/cancel`

Withdraw a proposal when the user changes their mind in the conversation or the action no longer applies. A cancelled proposal can no longer be confirmed.

<ParamField body="request_id" type="string (UUID)" required>
  The proposal to withdraw.
</ParamField>

<ParamField body="reason" type="string">
  Optional, up to 200 characters. Recorded for your audit trail.
</ParamField>

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.botshield.ai/operations/agentlink/cancel \
    -H "Authorization: Bearer $BOTSHIELD_AGENT_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "request_id": "3f1b2c4d-5e6a-4b7c-8d9e-0f1a2b3c4d5e",
      "reason": "Customer rebooked instead"
    }'
  ```

  ```typescript TypeScript SDK theme={null}
  const result = await client.actions.cancelAction({
    requestId,
    reason: "Customer rebooked instead",
  });

  if (result.data.error) throw new Error(result.data.error.message);
  if (result.data.data!.alreadyResolved) {
    // Too late to cancel: the card is already approved, denied, expired, or cancelled.
  }
  ```
</CodeGroup>

<CodeGroup>
  ```json Cancelled theme={null}
  {
    "data": {
      "data": {
        "status": "cancelled",
        "card_id": "0b9d6c0e-7a57-4c55-9a53-0a1f4c1d2e3f",
        "cancelled_at": "2026-09-21T17:44:02.118Z"
      }
    }
  }
  ```

  ```json Already decided theme={null}
  {
    "data": {
      "data": {
        "status": "approved",
        "card_id": "0b9d6c0e-7a57-4c55-9a53-0a1f4c1d2e3f",
        "already_resolved": true
      }
    }
  }
  ```
</CodeGroup>

Only a `queued` proposal can be cancelled. Cancel is safe to repeat: if the card already has a final status, nothing changes and you get that status back with `already_resolved: true`. If it says `approved`, a valid proof exists, so decide deliberately whether to honor it. An unknown `request_id` returns a 404 error in `data.error`.

## Next steps

<CardGroup cols={2}>
  <Card title="Proof of Resolution" icon="file-signature" href="/agents-ask/proof-of-resolution">
    Verify the signed result before you execute.
  </Card>

  <Card title="Webhook events" icon="bell" href="/webhooks/events">
    Receive `agents_ask.*` events instead of polling.
  </Card>

  <Card title="TypeScript SDK" icon="js" href="/sdk/typescript">
    Install and configure `botshield-sdk`.
  </Card>

  <Card title="Hosted MCP server" icon="plug" href="/agents-ask/mcp-server">
    Offer the same calls to an agent as tools, with `ttl_seconds` and the long-poll wait built in.
  </Card>
</CardGroup>
