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

# agentgateway

> Require a verified human behind sensitive MCP tool calls by checking BotShield-backed JWTs in your own agentgateway.

[agentgateway](https://agentgateway.dev) is an open-source gateway for agent and MCP traffic. If you front your MCP tools with it, you can make the gateway refuse a sensitive tool call unless the caller presents proof that a real person is behind it. The gateway checks a signed JWT against a public key set and evaluates a one-line rule. No BotShield code runs in the gateway, and your MCP server does not change.

<Note>
  You run agentgateway yourself. BotShield does not provide or host a gateway. BotShield supplies the signed proofs, the public keys to check them with, and a generated starting configuration. The configurations on this page were validated against agentgateway **v1.4.1**.
</Note>

## How it works

agentgateway has two policies that do the work:

* `jwtAuth` verifies the bearer token on each MCP request: signature (against a JWKS), issuer, audience, and expiry.
* `mcpAuthorization` is a list of CEL `allow` rules. A rule can read the MCP request (`mcp.tool.name`, `mcp.tool.target`, …) and the claims of the verified token (`jwt.<claim>`).

There are two tokens a rule can rely on. They come from different issuers and carry different claims, so be exact about which one a rule reads.

| | Link JWT (bind JWT) | Proof of Resolution |
| - | - | - |
| **What it proves** | This caller is a human who linked to your agent in the BotShield app with their device biometric. | A linked human confirmed one specific action your agent proposed through Agents Ask. |
| **Signed by** | Your link server (`link-server.js` from the policy pack), with a key pair it generates on first run | BotShield |
| **`iss`** | Your public origin, for example `https://gateway.meridianairlines.example` | `https://api.botshield.ai` |
| **`aud`** | `mcp` | Your Agent ID |
| **`sub`** | The human's opaque id (`OP_…`) | The human's opaque id (`OP_…`) |
| **Other claims** | `opaque_id` (same value as `sub`), `link: "botshield-bind"` | `verdict` (`approve` or `denied`), `jti` (your `request_id`), `action`, `ceremony_id` |
| **Lifetime** | 24 hours | 24 hours |
| **Verify with** | `link-server-jwks.json`, written next to the link server | `https://api.botshield.ai/.well-known/jwks.json` |
| **Typical rule** | `jwt.opaque_id != ""` | `jwt.verdict == "approve"` |

`opaque_id` exists only on the link JWT. `verdict` exists only on the Proof of Resolution. The opaque id is pairwise: it identifies one human to one agent and means nothing anywhere else. Neither token carries a name, an email, or any other personal data.

<Warning>
  The BotShield Gate attestation token (the one `<botshield-verify>` produces) has no `aud` claim. A `jwtAuth` provider that checks `audiences` rejects it. Use the two tokens above at the gateway, and verify gate tokens on your server as described in [Verify on your server](/gate/verify-on-your-server).
</Warning>

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

    G->>B: GET /.well-known/jwks.json
    A->>G: tools/call issue_refund (no proof)
    G-->>A: Denied by mcpAuthorization
    A->>B: POST /agentlink/inquire (request_id, opaque_id, action)
    B->>P: Show the action card
    P->>B: Confirm with device biometric
    A->>B: GET /agentlink/check-status?wait_seconds=25
    B-->>A: status approved + resolution_jwt
    A->>G: tools/call issue_refund with the proof as bearer
    Note over G: Verify signature, iss, aud, exp, then check verdict is approve
    G->>M: tools/call issue_refund (bearer passed through)
    Note over M: Check jti and action, then execute
    M-->>A: Tool result
```

The gateway cannot pause a request while a person decides. The pattern is deny, get the confirmation, retry with the proof.

## Generate a policy pack in the Console

The BotShield Console builds a working configuration for one agent and one MCP server.

<Steps>
  <Step title="Register an agent">
    You need an active agent under **Agents Ask → Trusted Agents**, and its agent key (`bs_agent_…`, shown once when you register the agent or rotate its key). See [Register an agent](/agents-ask/register-an-agent).
  </Step>

  <Step title="Open the policy pack dialog">
    In the [BotShield Console](https://console.botshield.ai), go to **Settings → Integrations**, find the **agentgateway** card, and select **Get policy pack**.
  </Step>

  <Step title="Fill in the form">
    | Field | What to enter |
    | - | - |
    | **Trusted Agent** | The agent the pack is for. The list shows active agents as `name · environment`. After you choose one, the dialog shows its id (`aud …`) and the host its signing keys come from. The pack is per agent because the Proof of Resolution audience is the agent id. |
    | **Your MCP server URL** | The MCP server the gateway fronts, for example `https://mcp.meridianairlines.example/mcp`. |
    | **Target name** | The name of that server inside agentgateway. Defaults to a slug of the agent name. Lowercase letters, digits, and hyphens. |
    | **Tools that require a person** | Comma-separated MCP tool names, for example `hold_seat, issue_refund`. Case-sensitive. Every other tool on the target stays public. |
    | **Public origin** | The HTTPS origin that fronts both the gateway and the link server, for example `https://gateway.meridianairlines.example`. It becomes the `iss` of the link JWT. |
    | **Name on Link page** | The brand name people see on the link page. Defaults to the agent name. |
  </Step>

  <Step title="Download">
    Select **Download policy pack**. The browser saves `botshield-agentgateway-<target>-<environment>.zip`. The pack is built in your browser and holds no secrets.
  </Step>
</Steps>

### What is in the zip

| File | Purpose |
| - | - |
| `agentgateway.yaml` | The gateway configuration: both `jwtAuth` providers, the `mcpAuthorization` rules for the tools you named, and your MCP server as a target. |
| `link-server.js` | A small OAuth authorization server (Node 20.6 or later, no dependencies) for MCP clients that connect with OAuth. Its sign-in step is the BotShield link: the person gets a 6-character code, enters it in the BotShield app under **Link an Agent**, and confirms with their passkey. The access token it returns is the link JWT. It requires PKCE; see [How the link server handles OAuth](#how-the-link-server-handles-oauth). |
| `.env.example` | Settings for the link server, including where your agent key goes. |
| `README.md` | Install, route, connect, and verify steps for your exact values. |

The pack targets a different BotShield API host depending on the agent's environment:

| Agent environment | API base (`BOTSHIELD_API_URL`) | JWKS URL in `agentgateway.yaml` |
| - | - | - |
| Production | `https://api.botshield.ai/operations` | `https://api.botshield.ai/.well-known/jwks.json` |
| Development | `https://wg-staging.botshield.ai/operations` | `https://wg-staging.botshield.ai/.well-known/jwks.json` |

The issuer is `https://api.botshield.ai` in both cases.

### Run it

<Steps>
  <Step title="Install agentgateway">
    ```bash theme={null}
    curl -sSL https://agentgateway.dev/install.sh | bash
    ```

    Or download a release from the [agentgateway releases page](https://github.com/agentgateway/agentgateway/releases).
  </Step>

  <Step title="Start the link server">
    ```bash theme={null}
    cp .env.example .env
    # paste your agent key into BOTSHIELD_AGENT_KEY
    node --env-file=.env link-server.js
    ```

    This is the run command in the pack's own `README.md`. The link server needs **Node 20.6 or later**, where `--env-file` loads `.env`; on an older Node it prints the version it found and exits. Variables that are already set in the process environment take precedence, so a process manager or container can supply them instead of a file.

    `LINK_ORIGIN` and `BOTSHIELD_AGENT_KEY` are required. If either is missing, the server does not start: it prints the list of missing variables with a one-line description of each, repeats the run command, and exits with a non-zero status. The other settings have defaults (`BOTSHIELD_API_URL`, `LINK_AUDIENCE`, `BRAND_NAME`, `MCP_RESOURCE_PATH`, `PORT`), and `.env.example` fills them in for your agent's environment.

    The first run writes `link-server-key.pem` (keep it private) and `link-server-jwks.json`, which `agentgateway.yaml` reads as its second trusted issuer. The server listens on port `9099`.
  </Step>

  <Step title="Start the gateway">
    ```bash theme={null}
    agentgateway -f agentgateway.yaml
    ```

    MCP traffic is served on port `3000`. Run it from the folder that holds `link-server-jwks.json`.
  </Step>

  <Step title="Route your public origin">
    Use any reverse proxy or tunnel in front of both processes:

    | Path | Backend |
    | - | - |
    | `/.well-known/oauth-*` and `/oauth/*` | `link-server.js` on port `9099` |
    | `/mcp` | agentgateway on port `3000` |
  </Step>

  <Step title="Connect a client">
    Add `https://<your public origin>/mcp` as a connector in your MCP client. The OAuth step shows the link page. After the person links, the client holds a link JWT and sends it as the bearer on every call.
  </Step>
</Steps>

With the generated rules, the tools you named run only for a linked human. Your MCP server then asks that human to confirm each sensitive action: call `POST /agentlink/inquire` with your agent key and the `opaque_id` from the token's `sub`, and complete the action after `GET /agentlink/check-status` returns a confirmed result. See [Propose an action](/agents-ask/propose-an-action).

### How the link server handles OAuth

`link-server.js` is an OAuth 2.0 authorization-code server whose only sign-in method is the BotShield link. It enforces the parts of the flow that protect the link JWT:

| Check | Behaviour |
| - | - |
| **PKCE is required** | `/oauth/authorize` rejects a request that has no `code_challenge`, or whose `code_challenge_method` is anything other than `S256`, with HTTP `400` and `invalid_request`. `plain` is not accepted. The discovery document lists `code_challenge_methods_supported: ["S256"]`. |
| **The authorization code is opaque and single-use** | After the person links, the client is redirected with a random code, not a token. The code is valid for 60 seconds and is spent on the first attempt to redeem it, whether or not that attempt succeeds. |
| **The token is released only at `/oauth/token`** | The client exchanges the code with `grant_type=authorization_code` and its `code_verifier`. A missing verifier returns `invalid_request`; a verifier that does not match the challenge returns `invalid_grant`. If the client sends `redirect_uri` or `client_id`, they must match the authorization request. Only then does the server sign and return the link JWT. |
| **Redirect URIs are restricted by scheme** | `redirect_uri` must be `https`, `http` on a loopback host (`localhost`, `127.0.0.1`, `[::1]`), or a private-use app scheme such as the one a desktop MCP client registers. Script-capable and local schemes (`javascript:`, `data:`, `vbscript:`, `file:`, `blob:`, `about:`) are refused. Errors on `/oauth/authorize` are answered on the page, never by redirecting to the supplied URI. |
| **Pending sign-ins expire** | An authorization request waits up to 15 minutes for the person to link. Pending sign-ins are held in memory, so restarting the link server cancels any sign-in in progress; the person starts again. Clients that already hold a link JWT keep working. |

Standard MCP clients send PKCE with `S256` by default, so there is nothing to configure on the client.

<Warning>
  The link server has no login of its own, and its dynamic client registration endpoint (`/oauth/register`) is open: any client can register and start a link. What stops a stranger is the link itself, which needs a person to confirm in the BotShield app with their biometric. If only known users or known clients should be able to start a link, put your own authentication in front of the link server at your reverse proxy.

  PKCE protects the token in transit. It does not tell your tool which action a caller is allowed to run. Keep checking `jti` and the action details in your tool, as described in [Check the proof again in your tool](#check-the-proof-again-in-your-tool).
</Warning>

## Write policies yourself

You do not need the policy pack. Any agentgateway configuration can trust BotShield with one `jwtAuth` provider and a few rules.

### Worked example: Meridian Airlines

Meridian Airlines fronts one MCP server with three tools:

* `search_flights` is open to any caller.
* `hold_seat` requires a linked human.
* `issue_refund` requires a confirmed Proof of Resolution.

```yaml agentgateway.yaml theme={null}
# yaml-language-server: $schema=https://agentgateway.dev/schema/config
mcp:
  port: 3000
  policies:
    cors:
      allowOrigins:
        - "*"
      allowHeaders:
        - "*"
      exposeHeaders:
        - Mcp-Session-Id
      allowMethods:
        - GET
        - POST

    # "optional" lets callers without a bearer reach the tools you leave open.
    # A bearer that is present must still verify.
    jwtAuth:
      mode: optional
      providers:
        # 1. BotShield: Proof of Resolution for THIS agent.
        - issuer: https://api.botshield.ai
          audiences:
            - 3f6c2a1e-8b47-4d0a-9c35-7e1d5b2a9f04   # your agent id
          jwks:
            url: https://api.botshield.ai/.well-known/jwks.json
        # 2. Your link server: the link JWT that carries opaque_id.
        - issuer: https://gateway.meridianairlines.example
          audiences:
            - mcp
          jwks:
            file: ./link-server-jwks.json

    # Allow-list. A call goes through when at least one rule is true.
    mcpAuthorization:
      rules:
        - allow: mcp.tool.target != "meridian"
        - allow: mcp.resource.target != "meridian"
        - allow: mcp.prompt.name != ""
        - allow: mcp.tool.target == "meridian" && !(mcp.tool.name in ["hold_seat", "issue_refund"])
        - allow: mcp.tool.target == "meridian" && mcp.tool.name == "hold_seat" && jwt.opaque_id != ""
        - allow: mcp.tool.target == "meridian" && mcp.tool.name == "issue_refund" && jwt.verdict == "approve"
        - allow: mcp.resource.target == "meridian" && jwt.opaque_id != ""

  targets:
    - name: meridian
      mcp:
        host: https://mcp.meridianairlines.example/mcp
      policies:
        backendAuth:
          passthrough: {}

gateways:
  public:
    port: 8080

ui:
  gateways: public
```

Replace the agent id with your own **Agent ID**, which you copy from the agent's row under **Agents Ask → Trusted Agents** in the Console. If you do not use the link server, remove the second provider and the rules that read `jwt.opaque_id`.

| Rule | What it does |
| - | - |
| `mcp.tool.target != "meridian"` | Tools on any other target pass untouched. It has no effect until you add a second target, and it keeps these rules from locking that target when you do. |
| `mcp.resource.target != "meridian"` | The same, for MCP resources. |
| `mcp.prompt.name != ""` | Every prompt is open. |
| `… && !(mcp.tool.name in ["hold_seat", "issue_refund"])` | Every Meridian tool that is not on the protected list is open. This is what lets `search_flights` through without a token. |
| `… && mcp.tool.name == "hold_seat" && jwt.opaque_id != ""` | `hold_seat` runs only when the bearer is a valid link JWT. A caller with no bearer, or with a token that has no `opaque_id` claim, does not match. |
| `… && mcp.tool.name == "issue_refund" && jwt.verdict == "approve"` | `issue_refund` runs only when the bearer is a valid Proof of Resolution for this agent with a confirmed result. A denied action is also a signed token, with `verdict: "denied"`, so compare the value. Do not test only that the claim exists. |
| `mcp.resource.target == "meridian" && jwt.opaque_id != ""` | Meridian resources are readable only by a linked human. |

`backendAuth: passthrough` forwards the caller's `Authorization` header to your MCP server, so your tool can read the same token the gateway verified.

### Policy cookbook

Rules are an allow-list: anything no rule allows is denied. These variations use only the variables shown above.

**Deny by default for a target.** Allow named tools and nothing else. A new tool on `meridian` stays closed until you add it.

```yaml theme={null}
- allow: mcp.tool.target == "meridian" && mcp.tool.name in ["search_flights", "get_fare_rules"]
```

**Require a linked human for every tool on a target.** Drop the open-tools rule and use one rule for the whole target.

```yaml theme={null}
- allow: mcp.tool.target == "meridian" && jwt.opaque_id != ""
```

**Put several tools behind the same proof.** Use a list instead of one rule per tool.

```yaml theme={null}
- allow: mcp.tool.target == "meridian" && mcp.tool.name in ["hold_seat", "change_seat", "add_bag"] && jwt.opaque_id != ""
- allow: mcp.tool.target == "meridian" && mcp.tool.name in ["issue_refund", "cancel_booking"] && jwt.verdict == "approve"
```

**Gate resources with either proof.** Two rules for the same target act as "or".

```yaml theme={null}
- allow: mcp.resource.target == "meridian" && jwt.opaque_id != ""
- allow: mcp.resource.target == "meridian" && jwt.verdict == "approve"
```

**Accept only link JWTs from the policy pack's link server.** The link JWT carries a `link` claim you can pin.

```yaml theme={null}
- allow: mcp.tool.target == "meridian" && mcp.tool.name == "hold_seat" && jwt.opaque_id != "" && jwt.link == "botshield-bind"
```

## Present the proof

The caller sends the token as a standard bearer on the MCP request:

```text theme={null}
Authorization: Bearer <JWT>
```

One request carries one bearer. That decides which pattern fits your caller:

* **An MCP client that connects with OAuth** holds the link JWT and sends it on every call. It cannot swap in a different token for one call. Gate sensitive tools on `jwt.opaque_id != ""` at the gateway, and run the Agents Ask confirmation inside your tool, as the policy pack does.
* **An agent you build** controls its own headers. It proposes the action, waits for the result, and retries the tool call with the `resolution_jwt` as the bearer. Gate those tools on `jwt.verdict == "approve"`.

### Check the proof again in your tool

A Proof of Resolution lives for 24 hours and is bound to one action: its `jti` is the `request_id` you sent when you proposed that action. The gateway rule does not know which action that was. `jwt.verdict == "approve"` proves only that some confirmed resolution for your agent is on the request. A caller could present the proof from a small refund while asking for a large one, or present the same proof twice.

Before your tool executes, check the rest:

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

const JWKS = createRemoteJWKSet(
  new URL("https://api.botshield.ai/.well-known/jwks.json"),
);

const AGENT_ID = "3f6c2a1e-8b47-4d0a-9c35-7e1d5b2a9f04";

// Use a durable store in production. The set only shows the idea.
const usedProofs = new Set<string>();

export async function assertRefundConfirmed(
  authorization: string | undefined,
  pending: { requestId: string; opaqueId: string },
): Promise<void> {
  const token = authorization?.replace(/^Bearer\s+/i, "");
  if (!token) throw new Error("missing proof");

  // Signature, issuer, audience, and expiry.
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: "https://api.botshield.ai",
    audience: AGENT_ID,
    algorithms: ["ES256"],
  });

  if (payload.verdict !== "approve") throw new Error("not confirmed");

  // The proof must be for THIS action and THIS person.
  if (payload.jti !== pending.requestId) throw new Error("proof is for another action");
  if (payload.sub !== pending.opaqueId) throw new Error("proof is for another person");

  // One proof, one execution.
  if (usedProofs.has(pending.requestId)) throw new Error("proof already used");
  usedProofs.add(pending.requestId);
}
```

`pending` is the record you saved when you proposed the action: the `request_id` you generated, the `opaque_id` you asked, and the parameters (amount, booking reference) you will execute. Execute those saved parameters, not the ones on the retried tool call. See [Proof of Resolution](/agents-ask/proof-of-resolution) for every claim.

## Test it

<Steps>
  <Step title="Validate the file">
    ```bash theme={null}
    agentgateway -f agentgateway.yaml --validate-only
    ```
  </Step>

  <Step title="Confirm both key sets are reachable">
    ```bash theme={null}
    curl -s https://api.botshield.ai/.well-known/jwks.json
    curl -s https://gateway.meridianairlines.example/.well-known/oauth-authorization-server
    ```
  </Step>

  <Step title="Call through the gateway with and without a proof">
    Use your MCP client or the playground in the agentgateway admin UI, and set the `Authorization` header by hand. Expect these results:

    | Bearer | Result |
    | - | - |
    | None | Open tools work. Gated tools are not available to the caller. |
    | Expired token | HTTP `401`. |
    | Valid token that matches a rule | The gated tool is available and the call reaches your MCP server. |
  </Step>
</Steps>

Use a development agent while you test. Development agents are served by the development API host in the table above.

## Troubleshooting

<Accordion title="A valid-looking token is rejected: audience mismatch">
  The Proof of Resolution `aud` is the id of the agent that proposed the action. It must equal an entry in the provider's `audiences`. Decode the token and compare:

  ```bash theme={null}
  node -e 'console.log(JSON.parse(Buffer.from(process.argv[1].split(".")[1], "base64url")))' "$TOKEN"
  ```

  The expected value is the **Agent ID** shown on the agent's row under **Agents Ask → Trusted Agents** in the Console. A pack generated for one agent does not accept proofs for another. Development and production agents have different ids. Rotating an agent's key does not change its Agent ID, so a rotation never requires a new pack; update `BOTSHIELD_AGENT_KEY` in the link server's `.env` and restart it. For the link JWT, `aud` must equal `LINK_AUDIENCE` in the link server's `.env` (default `mcp`), and `iss` must equal `LINK_ORIGIN` exactly, with no trailing slash.
</Accordion>

<Accordion title="HTTP 401: expired token">
  Both tokens expire 24 hours after they are issued. For a link JWT, the person reconnects the client to link again. For a Proof of Resolution, propose the action again with a new `request_id`. An action that expires before the person responds produces no proof at all.
</Accordion>

<Accordion title="The gateway cannot fetch the JWKS">
  The gateway host needs outbound HTTPS to the JWKS URL. Check it from that host with `curl`. If BotShield cannot serve its keys, the endpoint answers HTTP `503` with `{"keys":[],"error":"jwks_unavailable"}`; retry. Point `jwks.url` at the endpoint instead of a saved copy, because tokens name their signing key in the `kid` header and the key set can change. For a development agent, use the development JWKS URL. For the link server, `jwks.file` is relative to the folder you start agentgateway from, and the file exists only after `link-server.js` has run once.
</Accordion>

<Accordion title="Open tools stopped working, or gated tools are open: jwtAuth.mode">
  `mode: optional` lets a request with no bearer continue to the rules, which is what keeps `search_flights` open. `mode: strict` requires a valid bearer on every request, so open tools also need a token. In either mode, only the `mcpAuthorization` rules protect a tool. If a gated tool is reachable without a proof, look for a broader rule that also matches it, such as an open-tools rule whose list is missing the tool name. Tool names are case-sensitive.
</Accordion>

<Accordion title="The link page never completes">
  The link server calls BotShield with your agent key. It does not start without `BOTSHIELD_AGENT_KEY`, so a running server has a key; check that it is the agent's current key (a key that was revoked, or replaced with **Rotate key**, makes the link page report `Could not start: bind_failed`), and that `BOTSHIELD_API_URL` matches the agent's environment. A production key does not work against the development host, and the reverse. Link codes expire after 10 minutes, and the sign-in itself expires after 15 minutes; when it does, the page asks the person to go back to their client and connect again.
</Accordion>

## Next steps

<CardGroup cols={2}>
  <Card title="Register an agent" icon="robot" href="/agents-ask/register-an-agent">
    Create the agent and key the policy pack is built for.
  </Card>

  <Card title="Link a human" icon="link" href="/agents-ask/link-a-human">
    How a person links to your agent and where the opaque id comes from.
  </Card>

  <Card title="Propose an action" icon="paper-plane" href="/agents-ask/propose-an-action">
    Ask a linked human to confirm one action.
  </Card>

  <Card title="Proof of Resolution" icon="file-signature" href="/agents-ask/proof-of-resolution">
    Every claim in the signed result, and how to verify it.
  </Card>
</CardGroup>
