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

# Proof of Resolution

> Verify the signed JWT that records a human's Confirm or Deny before your code executes the action.

A **Proof of Resolution** is the signed result of a decided action. It is a JWT signed by BotShield with ES256, issued when a human gives a **Confirm** or a **Deny** with their device biometric. You verify it locally against BotShield's public keys, check that it belongs to your agent and your request, and only then execute.

<Info>
  Both decisions are signed: a Confirm carries `verdict: "approve"` and a Deny carries `verdict: "denied"`. An **Expired** or cancelled proposal produces **no proof at all**. Absence of a proof is never permission.
</Info>

## Token format

Header:

```json theme={null}
{
  "alg": "ES256",
  "kid": "v2",
  "typ": "JWT"
}
```

Payload:

```json theme={null}
{
  "iss": "https://api.botshield.ai",
  "sub": "OP_QDhs65484684",
  "aud": "a9f2c6d4-1b7e-4f3a-9c58-2e6d0b4a7f11",
  "iat": 1790012611,
  "exp": 1790099011,
  "jti": "3f1b2c4d-5e6a-4b7c-8d9e-0f1a2b3c4d5e",
  "verdict": "approve",
  "action": {
    "category": "travel.refund",
    "description": "Refund booking MA-48213 to your Visa ending 4242",
    "trusted_account_id": null
  },
  "kid": "v2",
  "ceremony_id": "e4d3c2b1-0f9e-4d8c-b7a6-5f4e3d2c1b0a"
}
```

| Claim | Meaning | What to check |
| - | - | - |
| `iss` | Always `https://api.botshield.ai`. | Equals that value. |
| `sub` | The pairwise `opaque_id` (`OP_…`) your agent holds for the human who decided. Never a global user id, a name, or an email. | Equals the `opaque_id` you proposed to. |
| `aud` | Your agent's **Agent ID** (a UUID). The proof is issued to one agent. | Equals the Agent ID you copied from the Console. See [Find your agent's id](#find-your-agents-id). |
| `jti` | The `request_id` you sent to `POST /agentlink/inquire`. | Equals the request you are about to execute, and has not been executed before. |
| `verdict` | `approve` for a Confirm, `denied` for a Deny. | Is exactly `approve` before you execute. |
| `action.category` | The category you proposed. | Matches the action you are about to run. |
| `action.description` | The `summary_title` the human saw. | Optional: compare with what you sent. |
| `action.trusted_account_id` | The value you sent, or `null`. | — |
| `iat` | When the human decided, in Unix seconds. | — |
| `exp` | `iat` plus 24 hours. | Not in the past. Redeem the proof within a day. |
| `kid` | The signing key id, also in the header. Currently `v2`. | Let your JWT library select the key by `kid`. Do not hard-code it; keys rotate. |
| `ceremony_id` | Identifies the biometric confirmation. When a person decides several cards at once, those proofs share a `ceremony_id` and an identical `iat`. | — |

The proof carries no personal data and does not include the Adaptive Card body.

## Public keys

```text theme={null}
https://api.botshield.ai/.well-known/jwks.json
```

The JWK Set holds EC P-256 public keys with `alg: "ES256"`, `use: "sig"`, and a `kid`. It is served with a 5-minute cache lifetime, so cache it for 5 minutes and refetch when you meet an unknown `kid`. The same key set signs BotShield Gate attestation tokens.

## Verify before you execute

Verification is local. There is no BotShield endpoint to call and nothing to trust except the signature.

```mermaid theme={null}
sequenceDiagram
    participant S as Your server
    participant J as BotShield JWKS

    S->>S: Receive resolution_jwt (poll) or proof_token (webhook)
    S->>J: GET /.well-known/jwks.json (cached 5 min)
    J-->>S: Public keys
    S->>S: Verify ES256 signature, iss, aud, exp
    S->>S: Check jti = your request_id and sub = your opaque_id
    alt verdict is approve and jti not yet executed
        S->>S: Mark jti executed, then run the action
    else anything else
        S->>S: Do not execute
    end
```

Install [`jose`](https://github.com/panva/jose) and verify:

```bash theme={null}
npm install jose
```

```typescript theme={null}
import { createRemoteJWKSet, jwtVerify } from "jose";

const ISSUER = "https://api.botshield.ai";

// Fetches and caches the key set; refetches on an unknown kid.
const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`), {
  cacheMaxAge: 5 * 60 * 1000,
});

interface Expected {
  agentId: string;   // your Agent ID, copied from the Console into config
  requestId: string; // the request_id you proposed with
  opaqueId: string;  // the opaque_id you proposed to
  category: string;  // the category you proposed
}

/** Returns true only when a verified human confirmed exactly this request. */
export async function isConfirmed(token: string, expected: Expected): Promise<boolean> {
  let payload;
  try {
    // Verifies the signature and rejects a wrong issuer, a wrong audience,
    // an expired token, or any algorithm other than ES256.
    ({ payload } = await jwtVerify(token, JWKS, {
      algorithms: ["ES256"],
      issuer: ISSUER,
      audience: expected.agentId,
    }));
  } catch {
    return false;
  }

  if (payload.jti !== expected.requestId) return false;
  if (payload.sub !== expected.opaqueId) return false;

  const action = payload.action as { category?: string } | undefined;
  if (action?.category !== expected.category) return false;

  return payload.verdict === "approve";
}
```

Use it as the gate in front of the action:

```typescript theme={null}
const pending = await db.refunds.findByRequestId(requestId); // your own record

const ok = await isConfirmed(resolutionJwt, {
  agentId: process.env.BOTSHIELD_AGENT_ID!,
  requestId: pending.requestId,
  opaqueId: pending.opaqueId,
  category: "travel.refund",
});

if (!ok) return; // denied, expired, tampered, or not yours: do nothing

// Execute once per jti, even if the proof arrives twice (poll + webhook).
const first = await db.refunds.markExecuted(pending.requestId);
if (first) await issueRefund(pending);
```

<Warning>
  Run this check in deterministic code on your server, at the point where the action executes. A language model cannot verify a signature, and a status string is not proof. Compare against **your own stored** `request_id`, `opaque_id`, and category, not against values the agent hands you alongside the token.
</Warning>

### Find your agent's id

`aud` is the **Agent ID**: the UUID BotShield assigned to your agent, not its name. Copy it from the BotShield Console:

1. Go to **Agents Ask → Trusted Agents** and select the environment with the **Development | Production** toggle.
2. Find the agent's row. The **Agent ID** is shown under the agent name.
3. Click the copy button next to it.

Store the value in configuration next to the agent key (the example above reads it from `BOTSHIELD_AGENT_ID`), and pin `audience` to it. Treat it as configuration, not as a value you read from the token or from the agent at run time: the check is only worth something when the expected audience comes from you.

* A development agent and a production agent have different Agent IDs, so configure each environment with its own.
* The Agent ID stays the same when you [rotate the agent's key](/agents-ask/register-an-agent#rotate-a-key). A proof issued before a rotation still verifies against the same audience.
* An agent you register after revoking an old one has a new Agent ID, even if it reuses the name. Update the audience when you replace an agent.

The same value appears in the webhooks your organization receives, as `agent_id` in every `agents_ask.card.proposed` payload and as `metadata.agent_id` in every `agents_ask.resolution.*` payload. Use it there to tell which agent an event belongs to.

## How the proof reaches you

The same token is delivered two ways. Verify it the same way on both, and make execution idempotent on `jti`, because you may receive it twice.

<Tabs>
  <Tab title="Poll check-status">
    `GET /agentlink/check-status` returns the token as `resolution_jwt` once the status is `approved` or `denied`. It is `null` for `queued`, `expired`, and `cancelled`.

    ```json 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
        }
      }
    }
    ```

    See [Wait for the decision](/agents-ask/propose-an-action#wait-for-the-decision).
  </Tab>

  <Tab title="Webhooks">
    `agents_ask.resolution.confirmed` and `agents_ask.resolution.denied` carry the token as `proof_token`.

    ```json theme={null}
    {
      "type": "agents_ask.resolution.confirmed",
      "request_id": "3f1b2c4d-5e6a-4b7c-8d9e-0f1a2b3c4d5e",
      "resolution_id": "5c0a7e1d-93b2-4f6e-8a44-d1e2f3a4b5c6",
      "outcome": "confirmed",
      "proof_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InYyIn0…",
      "metadata": {
        "card_id": "0b9d6c0e-7a57-4c55-9a53-0a1f4c1d2e3f",
        "ceremony_id": "e4d3c2b1-0f9e-4d8c-b7a6-5f4e3d2c1b0a",
        "agent_id": "a9f2c6d4-1b7e-4f3a-9c58-2e6d0b4a7f11",
        "agent_name": "MeridianConcierge"
      }
    }
    ```

    `agents_ask.resolution.expired` has `outcome: "expired"`, a `card_id`, and **no** `proof_token`:

    ```json theme={null}
    {
      "type": "agents_ask.resolution.expired",
      "request_id": "3f1b2c4d-5e6a-4b7c-8d9e-0f1a2b3c4d5e",
      "card_id": "0b9d6c0e-7a57-4c55-9a53-0a1f4c1d2e3f",
      "outcome": "expired",
      "metadata": {
        "agent_id": "a9f2c6d4-1b7e-4f3a-9c58-2e6d0b4a7f11",
        "agent_name": "MeridianConcierge",
        "summary_title": "Refund booking MA-48213 to your Visa ending 4242"
      }
    }
    ```

    Add endpoints in the BotShield Console under **Settings → Developer Tools → Webhooks**, and verify each delivery's signature before you read the body. See [Webhooks](/webhooks/overview) and [Webhook events](/webhooks/events).
  </Tab>
</Tabs>

<Tip>
  Verify the webhook signature **and** the `proof_token`. The webhook signature proves BotShield sent the delivery to you. The proof proves what the human decided, and it stays verifiable after you store it or pass it to another system.
</Tip>

## Outcomes at a glance

| State | `status` | Proof | Webhook | What your code does |
| - | - | - | - | - |
| **Confirmed** | `approved` | Yes, `verdict: "approve"` | `agents_ask.resolution.confirmed` | Verify, then execute once. |
| **Denied** | `denied` | Yes, `verdict: "denied"` | `agents_ask.resolution.denied` | Do not execute. Keep the proof as evidence of the refusal. |
| **Expired** | `expired` | None | `agents_ask.resolution.expired` | Do not execute. Propose again with a new `request_id` if it still matters. |
| Cancelled by your agent | `cancelled` | None | — | Nothing to do. |

## Keep the proof

Store the token with the record of the action it authorized. It is a self-contained, independently verifiable record that a verified human confirmed that exact request, which is useful for disputes and audits. Verify it when it arrives and record the result and the time next to it: after `exp` the token no longer passes a standard JWT verification.

## Next steps

<CardGroup cols={2}>
  <Card title="Propose an action" icon="paper-plane" href="/agents-ask/propose-an-action">
    Where `request_id`, `verdict`, and `resolution_jwt` come from.
  </Card>

  <Card title="Webhook events" icon="bell" href="/webhooks/events">
    Full payloads for the `agents_ask.*` events.
  </Card>

  <Card title="Privacy boundary" icon="user-shield" href="/concepts/privacy-boundary">
    Why `sub` is an opaque, per-agent id.
  </Card>

  <Card title="agentgateway" icon="tower-broadcast" href="/integrations/agentgateway">
    Use BotShield with agentgateway in front of your agent's tools.
  </Card>
</CardGroup>
