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

# TypeScript SDK

> Install and use botshield-sdk 2.0.1 to call the BotShield API from Node.js, Bun, or Deno with typed methods.

`botshield-sdk` is the typed TypeScript client for the BotShield API. Use it on your server to confirm BotShield Gate results, and in your agent to ask a human to Confirm or Deny an action. It covers the same operations as the HTTP API and adds types, optional retries, timeouts, and a built-in MCP server. This page describes version 2.0.1.

## Install

<CodeGroup>
  ```bash npm theme={null}
  npm add botshield-sdk
  ```

  ```bash pnpm theme={null}
  pnpm add botshield-sdk
  ```

  ```bash bun theme={null}
  bun add botshield-sdk
  ```

  ```bash yarn theme={null}
  yarn add botshield-sdk
  ```
</CodeGroup>

## Requirements

* A runtime with ECMAScript 2020, the Fetch API, and Web Streams: Node.js 18 or 20 LTS, Bun 1 or later, Deno 1.39, or an evergreen browser. The built-in MCP server needs Node.js 20 or later.
* The package ships ESM and CommonJS builds. Its runtime dependencies are `zod` and `@modelcontextprotocol/sdk`.
* For TypeScript, use `"target": "es2020"` or higher and include `"dom"` in `lib` so the `fetch` types resolve.

<Warning>
  Use the SDK with secret keys on your server only. Never ship an API key or an agent key in browser code. In the browser, use the [web component](/gate/web-component) with a site key.
</Warning>

## Create the client

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

const botshield = new BotShield({
  serverURL: "https://api.botshield.ai/operations", // required
  timeoutMs: 10_000,
});

const result = await botshield.census.createSession(
  { apiKeyAuth: `Bearer ${process.env.BOTSHIELD_API_KEY}` },
  { partnerUserId: "user_8842" },
);

if (result.data.error) {
  throw new Error(`${result.data.error.statusCode}: ${result.data.error.message}`);
}
console.log(result.data.data?.anchorGrantToken);
```

<Warning>
  **Always set `serverURL`.** The package's default server is a local development address (`http://localhost:9991/operations`). A client created with `new BotShield()` and no options sends your requests to localhost and fails to connect. Pass `serverURL: "https://api.botshield.ai/operations"`, or `serverIdx: 1`, which selects the same URL.
</Warning>

Client options:

| Option | Type | Description |
| - | - | - |
| `serverURL` | string | The API base URL. Set it to `https://api.botshield.ai/operations`. |
| `serverIdx` | number | Alternative to `serverURL`. `0` is local development (the default), `1` is production. |
| `security` | object or async function | Client-level credentials. Used for the agent key. See [Authentication](#authentication). |
| `timeoutMs` | number | Default request timeout in milliseconds. No timeout when unset. |
| `retryConfig` | object | Default retry policy. No retries when unset. See [Retries and timeouts](#retries-and-timeouts). |
| `httpClient` | `HTTPClient` | Custom fetch wrapper, for proxies or request hooks. |
| `debugLogger` | `console`-like | Logs requests and responses. Do not enable it in production, because it prints headers. |
| `userAgent` | string | Overrides the default user agent. |

## Authentication

<Warning>
  **Include the `Bearer ` prefix yourself.** The SDK sends the value you give it as the `Authorization` header exactly as written, and the API reads credentials only from `Authorization: Bearer <credential>`. Pass `` `Bearer ${key}` ``, not the bare key. A bare key returns `data.error` with `statusCode` 401.
</Warning>

The two products authenticate differently.

**BotShield Gate: per call.** Gate methods take a security object as their first argument. This lets one client use an API key for `createSession` and then the grant token for `createVerificationLink`.

| Method | First argument |
| - | - |
| `census.createSession`, `census.revokeVerification`, `census.logout` | `{ apiKeyAuth: "Bearer bs_production_…" }` (or `bs_dev_…`) |
| `census.createVerificationLink` | `{ grantTokenAuth: "Bearer bss_…" }`: the grant token from `createSession`. This method has its own field name. It does not accept `apiKeyAuth`. |
| `census.verifyToken`, `verification.getStatus` | `{}`: these operations need no credential, but the argument is still required |

**Agents Ask: on the client.** Set the agent key once and the `actions` methods use it.

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

const botshield = new BotShield({
  serverURL: "https://api.botshield.ai/operations",
  security: { agentKeyAuth: `Bearer ${process.env.BOTSHIELD_AGENT_KEY}` }, // bs_agent_…
});
```

The client `security` type also lists `agentKeyAuth1`, and the security types of `verifyToken`, `getStatus` and `getPartnerConfig` list `apiKeyAuth1`. They are duplicates produced by the code generator. Ignore them.

## Namespaces and methods

(`census` is the SDK's name for BotShield Gate.)

| Method | API operation | Purpose |
| - | - | - |
| `census.createSession(security, request)` | `POST /sdk/create-session` | Open a five-minute, single-use grant. Returns `anchorGrantToken` (`bss_…`). |
| `census.createVerificationLink(security, request)` | `POST /sdk/create-verification-link` | Create a verification request for a gate. `scope` is the gate key. |
| `verification.getStatus(security, request)` | `GET /verification/status` | Read a request's status. |
| `census.verifyToken(security, request)` | `POST /sdk/verify-token` | Validate an attestation token and read its claims. |
| `census.revokeVerification(security, request)` | `POST /sdk/revoke-verification` | Clear a pending request for a gate and user. |
| `census.logout(security, request)` | `POST /sdk/logout` | Revoke an unused grant token. |
| `actions.proposeAction(request)` | `POST /agentlink/inquire` | Propose an action for the human to Confirm or Deny. |
| `actions.checkActionStatus(request)` | `GET /agentlink/check-status` | Read the outcome. Long-polls up to 25 seconds with `waitSeconds`. |
| `actions.cancelAction(request)` | `POST /agentlink/cancel` | Withdraw a proposal that is still waiting. |
| `census.storeSignal`, `census.validateSignal`, `census.getPartnerConfig` | | Used by the BotShield widget. You do not call these. |

Every method also accepts a final `options` argument for per-call `timeoutMs`, `retries`, `retryCodes`, `serverURL`, and standard `fetch` options such as `signal`.

Not in 2.0.1: the link operations (`POST /agent/bind-session`, `GET /agent/check-binding`) and the Age Gate response fields (`gate_type`, `age_threshold`, `age_verdict`, `age_source`). The SDK drops fields it has no type for, so call the HTTP API directly for those. See [Link a human](/agents-ask/link-a-human) and [Age Gate](/gate/age-gate).

## The response envelope

The SDK returns the API's envelope as it is. It does not unwrap it and it does not throw on handler errors.

* Request and response fields are **camelCase** in the SDK (`anchorGrantToken`, `requestId`). The SDK converts them to and from the API's snake\_case. Timestamps arrive as `Date` objects.
* Read the result from `result.data.data`. For `verification.getStatus` and `census.logout`, read it from `result.data`.
* Check `result.data.error` first. A rejected request resolves normally with `result.data.error` set to `{ message, statusCode, code? }`.

```typescript theme={null}
const result = await botshield.verification.getStatus({}, { requestId });

if (result.data.error) {
  // Rejected: result.data.error.statusCode, result.data.error.message
} else {
  console.log(result.data.status); // not result.data.data.status
}
```

Only real HTTP errors throw. See [Errors](#errors).

## BotShield Gate: confirm a widget result

Most integrations place the [web component](/gate/web-component) on the page and confirm the result on the server. Your page sends the `request_id` and `token` from the widget's success event to your server, and your server runs this check before it unlocks the action.

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

const botshield = new BotShield({
  serverURL: "https://api.botshield.ai/operations",
});

// Copy it from the Console: Settings → Developer Tools → API Keys → Organization ID.
const ORGANIZATION_ID = process.env.BOTSHIELD_ORGANIZATION_ID!;

/**
 * Call this from the route your page posts to after the widget reports success.
 * `token` is null when the widget reports via: "precheck".
 */
export async function confirmGateResult(requestId: string, token: string | null): Promise<boolean> {
  if (token) {
    // The human confirmed on their phone. Validate the attestation token (it lives 120 seconds).
    const check = await botshield.census.verifyToken({}, { token });
    if (check.data.error) {
      throw new Error(check.data.error.message);
    }
    const result = check.data.data;
    return (
      result?.valid === true &&
      result.claims?.requestId === requestId &&
      result.claims?.organizationId === ORGANIZATION_ID
    );
  }

  // No token: a Recent Presence pass. Ask BotShield for the status straight away.
  const res = await botshield.verification.getStatus({}, { requestId });
  const status = res.data; // this operation is not nested under data.data
  if (status.error) {
    throw new Error(status.error.message);
  }
  if (status.organizationId !== ORGANIZATION_ID) {
    return false; // not one of your requests
  }

  // "pass" reads "expired" 60 seconds after the check, so do not defer this call.
  const state: string | undefined = status.status;
  return state === "pass" || state === "completed";
}
```

<Note>
  `POST /sdk/verify-token` is open and the token has no audience claim, so the comparison of `organizationId` and `requestId` is what ties the token to your request. Do both.
</Note>

## BotShield Gate: create a request from your server

Use this flow when your server, not the widget, starts the verification, for example in a native app or a server-rendered step. The status can be `completed` or `pass`. Accept both.

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

const botshield = new BotShield({
  serverURL: "https://api.botshield.ai/operations",
});

// The SDK sends this value as the Authorization header verbatim, so include "Bearer ".
const apiKey = `Bearer ${process.env.BOTSHIELD_API_KEY}`; // bs_production_…

export async function startVerification(partnerUserId: string, bookingRef: string) {
  // 1. Open a five-minute, single-use grant.
  const session = await botshield.census.createSession(
    { apiKeyAuth: apiKey },
    { partnerUserId, metadata: { booking_ref: bookingRef } },
  );
  if (session.data.error) {
    throw new Error(`${session.data.error.statusCode}: ${session.data.error.message}`);
  }
  const grant = session.data.data!.anchorGrantToken; // bss_…

  // 2. The grant token, not the API key, authorizes the verification request.
  const createLink = () =>
    botshield.census.createVerificationLink(
      { grantTokenAuth: `Bearer ${grant}` },
      { scope: "checkout", partnerUserRef: partnerUserId },
    );

  let link = await createLink();
  if (link.data.error?.statusCode === 409) {
    // This user still has a pending, unexpired request for this gate.
    // End it, then create the new one. A rejected call does not use up the grant.
    const revoked = await botshield.census.revokeVerification(
      { apiKeyAuth: apiKey },
      { scope: "checkout", partnerUserId },
    );
    console.log("Pending requests ended:", revoked.data.data?.revokedCount);
    link = await createLink();
  }
  if (link.data.error) {
    throw new Error(`${link.data.error.statusCode}: ${link.data.error.message}`);
  }

  // 3. Send the user to webUrl (or deepLink on mobile), then poll the status.
  const { requestId, webUrl, deepLink, expiresAt } = link.data.data!;
  return { requestId, webUrl, deepLink, expiresAt };
}

export async function waitForResult(requestId: string, expiresAt: Date): Promise<boolean> {
  while (Date.now() < expiresAt.getTime()) {
    const res = await botshield.verification.getStatus({}, { requestId });
    const state: string | undefined = res.data.status;

    if (state === "completed" || state === "pass") return true;
    if (state === "failed" || state === "expired" || state === "not_found") return false;

    await new Promise((resolve) => setTimeout(resolve, 3000));
  }
  return false;
}
```

A 409 comes back only while the same user (`partnerUserRef`, or the `partnerUserId` from `createSession` when you send no reference) has a pending, unexpired request for the same gate. A completed or failed request never blocks a new one. `revokeVerification` ends exactly those pending requests and reports the number in `revokedCount`.

`expiresAt` is the request's own expiry, five minutes after it was created. It is the same instant `verification.getStatus` returns as `expiresAt`, so it is the right deadline for your polling loop.

The gate is looked up in your API key's environment: a `bs_dev_…` key reaches the Development gate named `checkout`, and a `bs_production_…` key reaches the Production one. Requests you create this way count in the gate's Overview and in Analytics in the Console, the same as requests the widget starts.

Do not send `userEmail`. It is deprecated and ignored. `webhookUrl` is deprecated and has no effect, because webhooks are configured in the Console. See [Webhooks overview](/webhooks/overview). In the response, `qrCodeUrl` is deprecated and does not resolve to an image. Draw your own QR code from `webUrl`.

## Agents Ask: propose an action and wait

```typescript theme={null}
import { randomUUID } from "node:crypto";
import { BotShield } from "botshield-sdk";

const botshield = new BotShield({
  serverURL: "https://api.botshield.ai/operations",
  security: { agentKeyAuth: `Bearer ${process.env.BOTSHIELD_AGENT_KEY}` }, // bs_agent_…
  timeoutMs: 35_000, // above the 25-second long-poll
});

export async function askHuman(opaqueId: string) {
  const requestId = randomUUID(); // your idempotency key

  const proposed = await botshield.actions.proposeAction({
    requestId,
    opaqueId,
    action: {
      summaryTitle: "Rebook to MA 482, departing 18:05",
      summaryDetail: { label: "FARE DIFFERENCE", value: "$42.00" },
      category: "travel.rebook",
    },
    ttlSeconds: 300,
  });
  if (proposed.data.error) {
    const { statusCode, code, message } = proposed.data.error;
    throw new Error(`${statusCode} ${code ?? ""} ${message}`);
  }

  // Long-poll until the human decides or the proposal expires.
  for (;;) {
    const res = await botshield.actions.checkActionStatus({ requestId, waitSeconds: 25 });
    if (res.data.error) throw new Error(res.data.error.message);

    const card = res.data.data!;
    if (card.status === "queued") continue;

    if (card.status === "approved" && card.resolutionJwt) {
      return { confirmed: true, proof: card.resolutionJwt };
    }
    return { confirmed: false, status: card.status };
  }
}

export async function standDown(requestId: string) {
  const res = await botshield.actions.cancelAction({ requestId, reason: "Fare no longer available" });
  return res.data.data?.status;
}
```

Set the client `timeoutMs` above the long-poll window, or pass `{ timeoutMs: 35_000 }` as the options argument on `checkActionStatus`. Verify `resolutionJwt` before your agent acts on it. See [Proof of Resolution](/agents-ask/proof-of-resolution).

## Standalone functions

Every method is also exported as a standalone function that takes a `BotShieldCore` client. Bundlers can tree-shake everything you do not import, which matters in serverless and edge bundles. Standalone functions return a `Result` instead of throwing.

```typescript theme={null}
import { BotShieldCore } from "botshield-sdk/core.js";
import { censusCreateSession } from "botshield-sdk/funcs/census-create-session.js";

const botshield = new BotShieldCore({
  serverURL: "https://api.botshield.ai/operations",
});

const res = await censusCreateSession(
  botshield,
  { apiKeyAuth: `Bearer ${process.env.BOTSHIELD_API_KEY}` },
  {},
);

if (!res.ok) {
  // Transport, validation, and HTTP 4xx/5xx errors arrive here as values.
  console.error(res.error);
} else if (res.value.data.error) {
  console.error(res.value.data.error.message);
} else {
  console.log(res.value.data.data?.anchorGrantToken);
}
```

The function names follow the pattern `<namespace><Method>`: `censusCreateSession`, `censusCreateVerificationLink`, `censusVerifyToken`, `censusRevokeVerification`, `censusLogout`, `verificationGetStatus`, `actionsProposeAction`, `actionsCheckActionStatus`, `actionsCancelAction`. Each lives in `botshield-sdk/funcs/<kebab-case-name>.js`.

## Errors

There are two kinds of failure, and you need to handle both.

**Handler errors do not throw.** They arrive as `result.data.error` with HTTP 200. This covers 401, 403, 404, 409, 422 and the other codes listed in [Errors](/api-reference/errors).

**HTTP and transport errors throw.** Import the classes from `botshield-sdk/models/errors`:

| Class | When |
| - | - |
| `BotShieldError` | Base class for every HTTP error response. Has `statusCode`, `body` (string), `headers`, `contentType`, `rawResponse`. |
| `InvalidInputError` | HTTP 400. The request failed the API's input validation. `error.data$.errors` lists the fields. |
| `ErrorResponse` | HTTP 500 with a structured body. |
| `BotShieldDefaultError` | Any other 4xx or 5xx. A rate-limited call arrives here with `statusCode` 403. |
| `ResponseValidationError` | The response did not match the SDK's types. `error.rawValue` holds the raw body. |
| `SDKValidationError` | Your request object failed the SDK's own validation before anything was sent. |
| `ConnectionError` | The server could not be reached. This is what you see when `serverURL` is missing. |
| `RequestTimeoutError` | The request exceeded `timeoutMs`. |
| `RequestAbortedError` | You aborted the request with an `AbortSignal`. |
| `InvalidRequestError`, `UnexpectedClientError` | The request could not be built, or an unexpected client-side failure. |

```typescript theme={null}
import { BotShield } from "botshield-sdk";
import * as errors from "botshield-sdk/models/errors";

const botshield = new BotShield({
  serverURL: "https://api.botshield.ai/operations",
  retryConfig: {
    strategy: "backoff",
    backoff: { initialInterval: 500, maxInterval: 8_000, exponent: 2, maxElapsedTime: 30_000 },
    retryConnectionErrors: true,
  },
});

try {
  const result = await botshield.census.createSession(
    { apiKeyAuth: `Bearer ${process.env.BOTSHIELD_API_KEY}` },
    {},
    { timeoutMs: 10_000, retryCodes: ["500", "502", "503", "504"] },
  );
  if (result.data.error) {
    // Handler error: HTTP 200 with data.error. Not thrown.
    console.error(result.data.error.statusCode, result.data.error.message);
  }
} catch (error) {
  if (error instanceof errors.InvalidInputError) {
    console.error(error.data$.errors); // HTTP 400: which fields failed
  } else if (error instanceof errors.BotShieldError && error.statusCode === 403) {
    const body = JSON.parse(error.body) as { errors?: { code?: string }[] };
    if (body.errors?.[0]?.code === "RATE_LIMITED") {
      // Back off, then retry. See /api-reference/rate-limits.
    }
  } else if (error instanceof errors.RequestTimeoutError) {
    console.error("Timed out");
  } else if (error instanceof errors.ConnectionError) {
    console.error("Network failure");
  } else {
    throw error;
  }
}
```

## Retries and timeouts

Retries are **off by default**. Turn them on for the whole client with `retryConfig`, or for one call with the `retries` option. Intervals are in milliseconds.

| Setting | Description |
| - | - |
| `strategy` | `"backoff"` or `"none"`. |
| `backoff.initialInterval` | First delay. SDK default 500. |
| `backoff.maxInterval` | Longest delay. SDK default 60000. |
| `backoff.exponent` | Multiplier between attempts. SDK default 1.5. |
| `backoff.maxElapsedTime` | Stop retrying after this long. SDK default 3600000 (one hour). Set it lower. |
| `retryConnectionErrors` | Also retry when the connection fails. |
| `retryCodes` (per call) | Status codes to retry. Default `["429", "500", "502", "503", "504"]`. |

The example in [Errors](#errors) shows both forms. Two things to know:

* The retry codes do not include 403, so a rate-limited call is not retried. Catch it and wait as described in [Rate limits](/api-reference/rate-limits).
* Retries act on HTTP status only. A handler error inside an HTTP 200 response is never retried.

`timeoutMs` works the same way: set it on the client, or per call in the options argument. A call that times out throws `RequestTimeoutError`.

## Run the SDK as an MCP server

The package includes a Model Context Protocol server that exposes the Agents Ask methods as tools for an MCP-capable agent host. It needs Node.js 20 or later.

```json theme={null}
{
  "mcpServers": {
    "botshield": {
      "command": "npx",
      "args": [
        "-y", "--package", "botshield-sdk", "--",
        "mcp", "start",
        "--server-url", "https://api.botshield.ai/operations",
        "--agent-key-auth", "Bearer bs_agent_…"
      ]
    }
  }
}
```

| Tool | SDK method |
| - | - |
| `propose_resolution` | `actions.proposeAction` |
| `check_resolution_status` | `actions.checkActionStatus` |
| `cancel_resolution` | `actions.cancelAction` |

Pass `--server-url`. Without it the server targets the local development address, the same default as the client. The `--agent-key-auth` value needs the `Bearer ` prefix. Run `npx -y --package botshield-sdk -- mcp start --help` for all flags. The default transport is `stdio`.

BotShield also hosts an MCP server that needs no local process and adds the link tools. See [MCP server](/agents-ask/mcp-server).

## Error codes

`result.data.error.code` is a string, present when the API sets one. For the operations this SDK calls, the documented codes are `ttl_below_floor` and `ttl_above_ceiling` on `actions.proposeAction`. The error object then also carries `minTtlSeconds` or `maxTtlSeconds`.

`gate_not_active` and `gate_not_found` are reported by the widget, as the `reason` of its `botshield:failure` event. See [Web component](/gate/web-component#botshieldfailure). The SDK's gate methods report a missing or inactive gate through `message` and `statusCode`, so branch on `statusCode`.

## Coming in SDK 2.1

Version 2.0.1 is the current release. These changes arrive with BotShield 3.0 and are not in 2.0.1. Until 2.1 is published, the SDK drops fields it has no type for, so call the HTTP API directly when you need one of them.

| Change | Detail |
| - | - |
| Trust claims | `trusted`, `trustedSince` and `lastPassAt` on the claims that `census.verifyToken` returns, and `trusted` and `firstTime` on `verification.getStatus`. See [Trusted Accounts](/trusted-accounts/token-and-webhooks). |
| `gate` parameter | `gate` becomes the name of the gate key on requests. `scope` stays accepted during the alias window. |
| Deprecation markers | Old names are marked deprecated in the types. See [Alias window](/changelog/alias-window). |
| `notarize` | A request field on `census.createVerificationLink` that can skip the Trusted Accounts offer for one request. |

## Call the API without the SDK

The SDK is a thin layer over plain JSON operations. For other languages, or for the operations and fields the SDK does not cover, call the HTTP API directly. [API overview](/api-reference/overview) has the base URL, the credentials, and the envelope, and [Errors](/api-reference/errors) has a small typed `fetch` helper.

## Upgrading

### 2.0.1

Released 25 September 2026, regenerated from the September API specification.

| Change | What to do |
| - | - |
| `census.createVerificationLink` takes `{ grantTokenAuth }` as its security argument. In 2.0.0 the field was `apiKeyAuth`. | Rename the field in your call. The value is unchanged: `Bearer ` followed by the `bss_` grant token. |
| `verification.getStatus` types the Recent Presence status `pass`. | Accept `pass` as well as `completed`. |
| `webhookUrl` on the request and `qrCodeUrl` on the response of `createVerificationLink` are marked deprecated. | Stop sending `webhookUrl`. Draw your own QR code from `webUrl`. |
| `verifyToken`, `getStatus` and `getPartnerConfig` need no credential. | Pass `{}` as the security argument. |
| The generator's duplicate fields `apiKeyAuth2` and `agentKeyAuth2` are gone. | Nothing, unless you referenced them. |

### From 1.x

Version 2.0.x is a different generated client from 1.x. The API operations are the same. The calling conventions are not.

| | 1.x | 2.0.x |
| - | - | - |
| Import | `import BotShield from "botshield-sdk"` (default export) | `import { BotShield } from "botshield-sdk"` (named export) |
| Server | Set for you | Pass `serverURL: "https://api.botshield.ai/operations"` |
| Gate namespace | `client.sdk.*` | `client.census.*` |
| Status | | `client.verification.getStatus` |
| Agents Ask | | `client.actions.*` |
| Credentials | `apiKey` option on the client | Per call: `{ apiKeyAuth: "Bearer …" }` as the first argument. Agent key: `security.agentKeyAuth` on the client. |
| Field names | snake\_case | camelCase |
| Result | The operation's payload | The envelope. Read `result.data.data` and check `result.data.error`. |
| Errors | `BotShield.APIError` and subclasses | `BotShieldError` and the classes in `botshield-sdk/models/errors` |
| Retries | On by default (`maxRetries`) | Off by default (`retryConfig`) |

## Next steps

<CardGroup cols={2}>
  <Card title="Verify on your server" icon="server" href="/gate/verify-on-your-server">
    The full server-side check for a gate result.
  </Card>

  <Card title="Propose an action" icon="paper-plane" href="/agents-ask/propose-an-action">
    Request fields and outcomes for Agents Ask.
  </Card>

  <Card title="Errors" icon="triangle-exclamation" href="/api-reference/errors">
    Status codes for every operation.
  </Card>

  <Card title="Keys and environments" icon="key" href="/console/keys-and-environments">
    Create the keys this page uses.
  </Card>
</CardGroup>
