# CasperAI Integrator API

This document describes the HTTP surface CasperAI exposes to third-party integrators: creating
an API key, streaming chat answers, handling billing (`402` / deposits), checking usage, health,
the rate limits that apply, paying per query with x402, and the MCP server. Every shape below is
read directly from the current source — see the "Source" note under each section if you want to
verify a field yourself.

All examples use a placeholder host and placeholder credentials. Replace
`https://your-casperai-host` with your deployment's actual base URL. No token or key shown below
is real — `<your-token>` and `cai_example...` strings are placeholders, never real secrets.

**Contents:** [1. Authentication](#1-authentication) · [2. `POST /v1/chat`](#2-post-v1chat) ·
[3. Insufficient balance — `402`](#3-insufficient-balance--402) ·
[4. `GET /v1/usage`](#4-get-v1usage) · [5. `GET /v1/health`](#5-get-v1health) ·
[6. Rate limiting](#6-rate-limiting) · [7. End-to-end curl walkthrough](#7-end-to-end-curl-walkthrough) ·
[8. x402 pay-per-query](#8-x402-pay-per-query-agents-without-an-api-key) ·
[9. MCP server](#9-mcp-server--the-casper-brain-as-tools)

---

## 1. Authentication

Not every `/v1` route needs a credential:

| Route | Credential |
|---|---|
| `POST /v1/chat` | None required (`[AllowAnonymous]`). Every request goes through the billing gate instead (§2): a `cai_` API key is billed to its prepaid balance (§3), a signed-in web session has no free allowance by default (below), and a request with no credential pays per query with x402 (§8) when that rail is enabled. |
| `GET /v1/usage` | A `cai_` API key (§4). |
| `GET /v1/health` | None (§5). |
| `POST /v1/mcp` | None to connect: `casper_ask` is paid per call with an API key or x402, and `casper_command_build` is free (§9). |

When a request does carry a credential, CasperAI recognizes two kinds, selected per request by
`Billing.ApiKeyForwarding.SelectScheme` (`src/CasperAI.Domain/Billing/CasperApiKeyDefaults.cs`):

- **OpenIddict bearer / cookie** — the normal portal login (Angular app, Swagger UI). Works on
  every route.
- **CasperAI API key (`cai_...`)** — works **only** under `/v1` (segment match: `/v1` or
  `/v1/...`; `/v1abc` does **not** qualify and falls through to OpenIddict). A `cai_` token
  presented anywhere outside `/v1` (e.g. `/api/app/...`) is not treated as an API key at all — it
  is forwarded to OpenIddict validation, where it is not a valid bearer token, and the request is
  rejected.

An `Authorization` header that is empty, or is the bare word `Bearer` with no token after it,
carries no credential and is treated exactly like a missing header
(`AuthorizationHeader.CarriesCredential`, `src/CasperAI.Domain.Shared/Billing/AuthorizationHeader.cs`).

### 1.1 Creating an API key

This endpoint needs a CasperAI account, which anyone can create on the site (Create account).
Without one, pay per query with x402 (§8, no account needed) or ask on the web chat at the site
itself, which is free up to a daily limit.

With an account, sign in to the CasperAI portal and create a key for your integration:

```
POST /api/app/my-billing/key
Authorization: Bearer <your-portal-session-token>
Content-Type: application/json

{ "label": "my-integration" }
```

Response (`CreateApiKeyResultDto`):

```json
{
  "secret": "cai_example1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ",
  "key": {
    "id": "3fae1e0e-6f2b-4a9a-9e0e-2f2b6f2b4a9a",
    "prefix": "cai_example1",
    "label": "my-integration",
    "isActive": true,
    "lastUsedAt": null,
    "creationTime": "2026-08-14T12:00:00Z"
  }
}
```

`secret` is returned **exactly once** — CasperAI stores only its SHA-256 hash
(`ApiKeySecrets.Hash`, `src/CasperAI.Domain/Billing/ApiKeySecrets.cs`). If you lose it, revoke the
key (`POST /api/app/my-billing/{id}/revoke-key`) and create a new one.

Key format: `cai_` followed by 43 URL-safe base64 characters (32 random bytes). The first 12
characters (`cai_` + 8) are stored as `prefix` for display/audit — never enough to reconstruct
the secret.

*Source: `CreateApiKeyResultDto`/`ApiKeyDto` — `src/CasperAI.Application.Contracts/Billing/BillingDtos.cs`;
route — `angular/src/app/proxy/billing/my-billing.service.ts` (ABP conventional controller over
`MyBillingAppService`, which is `[Authorize]` with no extra policy — any signed-in user manages
only their own keys, via `BillingAccountManager.GetOrCreateForUserAsync(CurrentUser.GetId())`).*

### 1.2 Using the key

```
Authorization: Bearer cai_example1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ
```

Send it on the `/v1` requests you want billed to your prepaid balance. `GET /v1/usage` requires
it (§4). `POST /v1/chat` accepts it but does not require it (§2), and the MCP endpoint's
`casper_ask` takes it the same way (§9); `/v1/health` is anonymous (§5). A key that fails to
authenticate is rejected — `401` on `/v1/chat` and `/v1/usage`, an `invalid_api_key` tool error on
MCP — and is never downgraded to a keyless request, so it is never offered x402.

---

## 2. `POST /v1/chat`

Streams an answer over Server-Sent Events. The action is `[AllowAnonymous]`: no credential is
required to call it. Before any stream opens, `ChatController.Chat` does two things:

1. A request whose `Authorization` header carries a credential that failed to authenticate is
   refused with a `401` challenge. A bad credential is never treated as a keyless request.
2. Every other request goes through the billing gate, `IBillingGateAppService.GateAsync`
   (`src/CasperAI.Application/Billing/BillingGateAppService.cs`), which decides how the turn is
   paid for:

| Caller | Outcome |
|---|---|
| `cai_` API key | Prepaid: if the balance covers one query, the answer streams and is charged only if delivered; otherwise `402` with an `InsufficientBalanceDto` body (§3). |
| Signed-in web session (OpenIddict bearer or cookie, no key) | No free allowance by default: the stream carries a single `quota_exceeded` event with `quotaLimit` 0 (below), never a `402`. An operator can set a daily allowance, which then streams unbilled up to it. A turn paid with x402 is not counted against it. Use a `cai_` key or x402 to pay per query. |
| No credential, x402 rail enabled | `402` with an x402 payment challenge; pay per query with a `PAYMENT-SIGNATURE` header (§8). |
| No credential, x402 rail disabled | The authentication challenge, as before the x402 rail existed — no `402`. A plain HTTP client gets a `302` redirect to `/Account/Login`; a request sent with `X-Requested-With: XMLHttpRequest` gets a `401`. |

While the x402 rail is enabled, a request carrying a `PAYMENT-SIGNATURE` header takes the x402 path
ahead of the rows above (the bad-credential check in step 1 still runs first), so an underfunded
key holder can pay per query instead of topping up (§8).

### Request

```json
{
  "sessionId": null,
  "message": "What is the minimum delegation amount on Casper?"
}
```

`ChatRequestDto` (`src/CasperAI.Application.Contracts/Chat/ChatRequestDto.cs`):

| Field | Type | Notes |
|---|---|---|
| `sessionId` | `Guid?` | Omit or `null` to start a new session; pass a prior `done` event's `sessionId` to continue it. A continued session replays up to the last ten messages: answered exchanges, and questions that were refused for any reason other than scope or a secret (with a short note in place of the answer). |
| `message` | `string` | Required, max 4000 chars (`ChatConsts.MaxQuestionLength`). |

### Response

`200 OK`, `Content-Type: text/event-stream`, `X-Accel-Buffering: no` (disables proxy buffering —
set explicitly in `ChatController.Chat`). Each SSE frame's `event:` line is the event's
`eventType`, and `data:` is a JSON-serialized `ChatStreamEventDto`
(`src/CasperAI.Application.Contracts/Chat/ChatStreamEventDto.cs`) — **one shared, flat DTO for
all 8 event types**. Only the fields relevant to a given `eventType` are populated; every other
field is present in the payload as `null` (it's a single C# class serialized as-is, not a
per-event polymorphic type). JSON property names are camelCase (ASP.NET Core's default
`System.Text.Json` web options). The examples below show only the populated fields for
readability — expect the rest of the DTO's fields alongside them, set to `null`.

Event types (`ChatConsts` + the DTO's own field comment): `status`, `token`, `citation`,
`command`, `refusal`, `usage`, `done`, `quota_exceeded`.

#### `status`

Emitted as the agent enters a new stage. `status` is one of `ChatConsts.StatusStages`:
`searching_docs`, `querying_chain`, `building_command`, `verifying`.

```
event: status
data: {"eventType":"status","status":"searching_docs"}
```

#### `token`

Streamed answer text, in ~400-character chunks (`AgentEngine.TokenReplayChunkChars`) — emitted
only once the full answer has passed verification; nothing streams before that.

Answers are written in the language of the message they answer. Commands, code, URLs and
citations stay exactly as the sources give them; curated registry answers and the refusal
messages below are English; a refusal that quotes the model's own reason is English because the
model is asked to write that reason in English.

```
event: token
data: {"eventType":"token","text":"The minimum delegation amount on Casper is 500 CSPR [1]."}
```

#### `citation` — `kind: "doc"`

A knowledge-base source backing a `[n]` marker in the answer text.

```
event: citation
data: {
  "eventType": "citation",
  "index": 1,
  "kind": "doc",
  "url": "https://docs.casper.network/staking/",
  "headingPath": "Staking > Delegation > Minimums",
  "tier": "Official",
  "lastVerified": "2026-08-10T03:00:00Z",
  "snippet": "The minimum amount to delegate is 500 CSPR..."
}
```

`tier` is one of `Official`, `Ecosystem`, `CommunityCurated` (`SourceTier`). `lastVerified` is the
owning source's `LastFetchedAt` — the freshness guard.

`index` matches a `[n]` marker in the answer text. The delivered citations of one answer are
normally numbered **contiguously from 1** across both kinds: an answer with three citations emits
`index` 1, 2 and 3, whatever the agent's internal evidence indices were. Two rules bound that, and
both exist to protect the answer's code:

- A bracketed number inside a fenced block or an inline code span is never a marker and never gets
  a citation event — `args[2]` in a sample is not a citation, and nothing rewrites it.
- An answer whose prose indexes a word with a number that could also be a citation (`deploys[2]`,
  or a marker the model wrote with no space in front of it) is delivered with the agent's own
  numbers instead, because renumbering around a bracket we cannot read would make a marker name the
  wrong source. `index` can then be gapped: `1, 3`.

So key the chips off `index` itself rather than off their position in the stream, and treat a gap
as normal rather than as a dropped event.

#### `citation` — `kind: "live"`

A live tool call backing a `[n]` marker — a chain read through the MCP `chain_*` tools, or a
CSPR.name lookup through `cspr_name_lookup`.

```
event: citation
data: {
  "eventType": "citation",
  "index": 2,
  "kind": "live",
  "toolName": "chain_get_account_balance",
  "retrievedAt": "2026-08-14T12:00:03Z",
  "network": "mainnet"
}
```

`toolName` is the live tool actually called: either the `chain_`-prefixed MCP tool name (one of the
tools allowlisted in `CasperAI:Mcp:AllowedTools`) or `cspr_name_lookup`, the CSPR.name lookup,
which appears only on deployments where `CasperAI:CsprName:Enabled` is true and is always
`mainnet` (CSPR.name runs on no other network). `network` is a display label
(`McpOptions.Network`, default `"mainnet"`) — **not** the `casper`/`casper-test` chain name used in
casper-client commands (see `command` below). Doc-kind fields (`url`, `headingPath`, `tier`,
`lastVerified`, `snippet`) are `null` for live citations, and vice versa.

#### `command`

A ready-to-run casper-client command the agent built via its `command_build` tool — emitted only
for commands whose exact text actually appears in the delivered answer.

```
event: command
data: {
  "eventType": "command",
  "commandText": "casper-client transfer --chain-name casper --target-account 01a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1 --amount 25000000000 --transfer-id 1 --payment-amount 100000000 --secret-key <PATH-TO-YOUR-SECRET-KEY> --node-address <NODE-ADDRESS>",
  "actionId": "transfer.native",
  "verifiedAgainst": "5.0.1",
  "network": "casper",
  "notice": "Verify every parameter before running this command. It moves real funds and cannot be undone."
}
```

`verifiedAgainst` is the pinned casper-client release every template was checked against
(`CommandConsts.CasperClientVersion`). `--transfer-id` is always present — the parameter is
**required** (deposit matching depends on it; see the `transfer.native` template comment).
Genuinely optional params (`block.get`'s identifier, `keys.generate`'s algorithm, and the
`reservedSlots` of `bid.add` and `bid.change-rate`) are skipped entirely by `CommandRenderer` when
unset rather than emitting an empty flag. A parameter sent empty or as JSON `null` is unset; that
holds only for a name the action takes, since any other name is refused ("Parameter names are
exact", below). `network` is the command's `--chain-name`: the network the user named or,
since 2026-09-30, the one `command_build` inferred from the chain ("Chain state", below).
**Never run a command without verifying every parameter yourself** —
`<PATH-TO-YOUR-SECRET-KEY>`, `<NODE-ADDRESS>` and `<PATH-TO-KEYS-DIR>` are always literal
placeholders CasperAI cannot fill in; substitute your own before running.

The full action catalog (15 ids; the authoritative list is `unknown_action`'s `available[]`, and
its `actions[]` gives each one's purpose and parameters):
`transfer.native`, `delegate`, `undelegate`, `redelegate`, `bid.add`, `bid.activate`,
`bid.withdraw`, `bid.withdraw-all`, `account.get`, `balance.query`, and — since Phase 7 —
`keys.generate` (offline key pair), `account.address` (offline account-hash derivation),
`transaction.get` (look up a transaction by hash; casper-client sends it as a TransactionV1 hash,
so a legacy deploy such as a `transfer.native` transfer is not found), `block.get` (look up a
block by height/hash, or the latest when omitted), and — since 2026-09-29 — `bid.change-rate`
(change an existing validator's commission, its delegation rate: casper-client has no dedicated
subcommand, so it re-submits add-bid with the new rate and a fixed 1 CSPR that is added to the
validator's own stake). The `notice` text matches what the command actually does:
transactions carry the "moves real funds" warning; offline/read-only commands carry
"It does not move funds." A transaction's notice always starts with that warning, and
`bid.add`'s and `bid.change-rate`'s continue with what can fail the transaction and still burn
its payment: "Sign it with the validator's own key. Without --reserved-slots, the bid's reserved
delegator slots are set to 0; if the validator has made delegator reservations, the transaction
then fails and its payment is still burned, so pass its current count with --reserved-slots."
Since 2026-09-30, when the build read the chain ("Chain state", below) and filled in a value or has
a warning, the notice then ends with them, in plain words: "Filled in from the chain: " and each
value `state.applied` names with its reason, then every warning of `state.warnings`, whole. For a
rate change whose network was inferred it ends: "Filled in from the chain: the network casper
(mainnet), because the validator's public key has a validator bid on casper (mainnet) and none on
casper-test (testnet), per CSPR.cloud; the reserved delegator slots 0, the validator's current
count on casper, per CSPR.cloud." With no read, or nothing filled in and no warning, the notice is
the action's own. The result's `notice`, the stream's `command` event, `casper_ask`'s `commands[]`
and a reloaded chat carry the same text. Show the whole notice with the command.

**Reserved slots.** `bid.add` and `bid.change-rate` take an optional `reservedSlots`, a whole
number from 0 to 1200 (the chainspec's `max_delegators_per_validator`), rendered as
`--reserved-slots` only when given. When it is omitted, the node sets an existing bid's reserved
delegator slots to 0. A validator with reserved slots must pass its current count: a lower value
cuts them, and one below the number of delegator reservations it has made (`add-reservations`),
used or not, fails the transaction, whose payment is still burned. A value above the delegator
slots not already taken by delegators without a reservation fails too
(`ExceededReservationSlotsLimit`), and its payment is burned as well; the current count is always
accepted. Neither action renders
`--minimum-delegation-amount` or `--maximum-delegation-amount`, so an existing bid keeps its own.
When `command_build` reads the chain ("Chain state", below), it fills an omitted `reservedSlots`
with the bid's current count and says so in `state.applied`.

**`command_build` results.** The agent's `command_build` tool, and MCP's `casper_command_build`
(§9), return one of three flat JSON objects:

| `status` | Fields |
|---|---|
| `ok` | `command`, `actionId`, `network`, `verifiedAgainst`, `notice`, and `purpose`: what the action does (its catalog description). Also `state`, when the build read the chain (below). |
| `needs_input` | `missing[]`: every required parameter that is absent and every given parameter that is invalid, optional ones included, as `{parameter, constraint}` in the action's parameter order, then every name the action does not take, in the order sent. If the arguments are not a JSON object, `missing[]` holds a single `arguments` entry instead. `parameter` and `constraint` repeat the first entry. `ask` (below) follows `missing[]` when there is something to ask the user. Also `purpose`, and `state` when the build read the chain (below). |
| `unknown_action` | `available[]`: every action id, in ordinal order. `actions[]`: the same actions in the same order, each `{actionId, description, parameters: [{name, kind, required}]}`. `kind` is a stable lowercase word: `public_key`, `amount_cspr`, `chain_name`, `delegation_rate`, `transfer_id`, `hash64`, `block_identifier`, `key_algorithm` or `reserved_slots`. |

**`ask`.** Since 2026-09-30 a `needs_input` result carries `ask`: one plain-language question for
every entry of `missing[]` the user can give, each named in plain words with the values it takes,
for example "Which network is it, casper (mainnet) or casper-test (testnet), and what new rate do you
want, as a whole percent from 0 to 100?". A missing value and an invalid one are asked for, and so
is a `rate` the chain read refused as the current one; a name the action does not take is not (it
is the caller's to rename or drop). When the read refused a key (no bid, no delegation, a bond too
small; its reason says why), there is no `ask` at all: no other value makes that build succeed for
that key, so asking for the rest would only lead to the same refusal. With nothing to ask, there is
no `ask` key. Like a constraint, each value it states is what the build accepts, so an agent may end
its reply with it. CasperAI's own agent does: a reply to a `needs_input` that does not end by
asking for every parameter the ask asks for is sent back once, and then gets the ask as its last
paragraph.

`purpose` and `description` are the same text. Each of its sentences is checked against the
pinned casper-client release and mainnet's protocol version, so an agent may repeat it to a user.
A transaction's (an action whose `notice` says it moves real funds) ends with one sentence on how
it is signed: "casper-client 5.0.1 signs this transaction with a secret key file (--secret-key)
and has no Ledger support, so a key held only on a Ledger device cannot sign it." A read-only or
offline action signs nothing, and its text has no such sentence.

**Chain state (`state`).** Since 2026-09-30 a bid or delegation action (`bid.add`,
`bid.change-rate`, `bid.withdraw`, `bid.withdraw-all`, `bid.activate`, `delegate`, `undelegate`,
`redelegate`) may read the chain before it builds, on a deployment with
`CasperAI:ChainState:Enabled`: the validator's bid by public key in CSPR.cloud's active auction (its
bidder row for the current era plus 2, which follows the bid as the node holds it) and, for a
delegation action, the delegator's delegations, from CSPR.cloud's REST API. It reads only with a
syntactically valid validator key, and only the named network, or both when `chain` is omitted. The
whole read has 2.5 s, and a failed or slow read never blocks the build. `ok` and `needs_input` then
end with a `state` object. With no read (another action, the feature off, no valid key, or a
`casper_command_build` call on a deployment whose reads have no token of their own, §9) there is no
`state` key, and the result is byte for byte what it was before.

```json
"state": {
  "status": "ok", "network": "casper", "source": "cspr.cloud",
  "retrievedAt": "2026-09-30T10:00:00Z", "eraId": 24023,
  "validator": {"found": true, "active": true, "rate": 10, "reservedSlots": 0,
                "stakeCspr": "72803.842090994", "minDelegationCspr": "500",
                "maxDelegationCspr": "1000000000", "delegators": 74},
  "applied": [
    {"parameter": "chain", "value": "casper",
     "reason": "The validator's public key has a validator bid on casper (mainnet) and none on casper-test (testnet), per CSPR.cloud."},
    {"parameter": "reservedSlots", "value": "0",
     "reason": "The validator's current reserved delegator slots on casper, per CSPR.cloud. …"}],
  "warnings": []
}
```

| Field | Meaning |
|---|---|
| `status` | `ok`, or `unavailable` when the read failed or gave nothing the build can use (a timeout, a `400`, `401`, `403` or `429`, a `5xx`, an open circuit breaker, a row without a value the build uses, or a key whose bid CSPR.cloud shows in the auction before the active one only), or when this deployment's per-minute budget of CSPR.cloud requests is spent (`CasperAI:ChainState:MaxReadsPerMinute`; for `casper_command_build`, its share of it, `CasperAI:ChainState:MaxFreeToolReadsPerMinute`, §9). That last cause is no outage: it is the one a caller can bring on itself, and it clears within a minute once calls slow down. The build is then exactly what it is without a read, plus this `state`. `ok` with `network` `null` means the read succeeded and the build could not settle on a network (below). |
| `network` | The network the state describes: the `chain` given, the one inferred, or `null` when the build could not settle on one (the chain omitted and the key's bid on both networks, on neither, or a read unavailable). |
| `source` | `cspr.cloud`. |
| `retrievedAt` | UTC time of the oldest data used (a validator's bid is kept for up to 60 s, except that `bid.add` and `bid.change-rate` read a found bid at build time; a delegator's delegations for 30 s); `null` when unavailable, or when `network` is `null`. |
| `eraId` | The era of the CSPR.cloud bidder row that was read: its active auction, the current era plus 2 (the era a bid made now would first validate in). Absent when no bid was found, and absent when `network` is `null`. |
| `validator` | The action's validator: the operator's own key for the `bid.*` actions. `{"found": false}` when it has no bid on that network, else every member. `newValidator` is `redelegate`'s target, in the same shape. Both are absent when `network` is `null`. |
| `delegation` | `{exists, stakeCspr}`: the delegator's stake with the validator. `newDelegation` is its stake with `redelegate`'s target. Both are absent when `network` is `null`. |
| `applied[]` | `{parameter, value, reason}` for each value the build filled in: `chain` (inferred from the one network where the key has a bid), `reservedSlots` (the current count) and `rate` (a `bid.add` top-up's current rate). An `ok` result's `notice` ends with each, in plain words (above). |
| `warnings[]` | Sentences on what the command will do: a given `reservedSlots` that differs from the current count, a full validator, an undelegation that unbonds the whole stake, a redelegation that moves it all. An `ok` result's `notice` ends with each, whole. |

Amounts are CSPR as decimal strings. A read changes the build as the node would judge the
transaction. The network is inferred when it is omitted. The 500 CSPR floor binds only a new bid or
a new delegation: a top-up needs a positive amount that keeps an existing bid at 500 CSPR or more,
or an existing delegation within the validator's maximum, and a new delegation needs the validator's
own minimum. Amounts get the ceilings the chain sets: an undelegation or a redelegation at most the
stake with the validator, a partial withdrawal at most the stake less 500 CSPR, and a delegation at
most the validator's maximum. A key with no bid, or a delegator with no delegation, comes back in
`missing[]` with the reason: the transaction would fail and still burn its payment, or, for
`bid.withdraw` and `bid.withdraw-all`, casper-client would refuse to send it. So does a
`bid.activate` for a bid below 500 CSPR, which the node refuses to activate. So does a
`bid.change-rate` whose `rate` is the bid's current one, when the read shows the bid active and the
reserved slots it passes are the current count: add-bid writes the rate it is given, so the
transaction would change nothing but the stake, and `missing[]` holds `rate`: "The rate given, 10%,
is the validator's current commission (delegation rate) on casper, per CSPR.cloud. add-bid writes
the rate it is given, so the commission would stay 10% while the transaction still adds 1 CSPR to
the validator's own stake and its payment is still burned. A rate change needs the new rate the user
wants." The `applied` reasons and the warnings name no parameter, so a user can read them as they
are. A `missing[]` entry's reason, like any constraint, may name the parameter it refuses
("validator has no validator bid on casper, per CSPR.cloud: …"), so an agent says it in its own
words. Each `reason`, constraint and warning is checked against casper-node v2.2.2 or casper-client
5.0.1, so an agent may repeat it, and should tell its user what `applied` filled in and every
warning.

**Parameter names are exact.** Every key of the parameters object must be one of the action's
parameter names, exactly as cased: `reservedSlots`, not `reserved_slots`, `reserved-slots` or
`ReservedSlots`. Any other key is refused whatever its value, `null` and empty included. It comes
back in `missing[]`, after the action's own entries, as
`{"parameter": "<name>", "constraint": "<name> is not a parameter of <actionId>. Its parameters are: <its parameter names, in order>."}`.
For example, `bid.change-rate` with a `reserved_slots` key gets this constraint:
"reserved_slots is not a parameter of bid.change-rate. Its parameters are: chain, publicKey, rate, reservedSlots."
**This tightened the contract on 2026-09-29, for MCP clients too:** until then an unknown key
was dropped without a word, and the call could still return `ok`. A `reserved_slots` then built
an add-bid without `--reserved-slots`, which sets the bid's reserved slots to 0 ("Reserved
slots", above). A client that sends extra keys now gets `needs_input` and must rename or drop
them.

*Source: `src/CasperAI.Domain/Commands/CommandBuilder.cs` (the three shapes, `ParamKindNames` in
`CommandTemplate.cs`); the catalog and each action's description, with its source comments —
`CommandTemplates.cs` beside it; the chain read — `CommandBuildService.cs` and `Commands/Chain/`
(`CsprCloudChainStateReader`, and `CommandChainEffects` with every sentence's source).*

#### `refusal`

`reason` is one of `ChatConsts.RefusalReasons`: `out_of_scope`, `secret_detected`,
`insufficient_sources`, `model_refused`, `verification_failed`, `live_data_unavailable`.

```
event: refusal
data: {
  "eventType": "refusal",
  "reason": "insufficient_sources",
  "message": "I don't have verified information on that in my Casper knowledge base yet. This question has been logged so a curator can add coverage."
}
```

A refusal never charges (§3) — nothing was delivered, so nothing is metered. What follows the
`refusal` event depends on which reason it is: the two reasons decided before the agent runs —
`secret_detected`, by a pattern check before any model or embedding provider sees the question,
and `out_of_scope`, the guard's verdict, which comes after the registry lookup — never reach the
agent model, so they stream `refusal` → `done` directly, with **no `usage` event** — there are
no token counts to report. The other four reasons (`model_refused`, `verification_failed`,
`insufficient_sources`, `live_data_unavailable`) are all raised after at least one LLM call, so
they stream `refusal` → `usage` → `done`.

Curated registry answers short-circuit the full pipeline: they stream `token` event(s) → `citation` event(s) → `done` directly, with **no `status` and no `usage` event**. A delivered answer with no refusal is charged normally per §3, so do not assume every successful turn carries a `usage` event before `done`.

#### `usage`

Anthropic/OpenAI token counts for the turn.

```
event: usage
data: {"eventType":"usage","inputTokens":842,"outputTokens":57}
```

#### `done`

Always the last event of a stream. Carries the session/message ids — pass `sessionId` back in on
your next `ChatRequestDto` to continue the conversation.

```
event: done
data: {"eventType":"done","sessionId":"a1b2c3d4-1111-4a4a-9a9a-1a2b3c4d5e6f","messageId":"b2c3d4e5-2222-4b4b-9b9b-2b3c4d5e6f7a"}
```

#### `quota_exceeded`

**Only ever emitted on the free web-session rail** (OpenIddict auth, no API key) — API-key
integrators never see this; they get a `402` before the stream even opens instead (§3). When it
fires, it is the **only** event in the stream (`ChatAppService.StreamAsync` yields it and
returns immediately).

```
event: quota_exceeded
data: {"eventType":"quota_exceeded","quotaLimit":0,"quotaResetUtc":"2026-08-15T00:00:00Z"}
```

`quotaLimit` is `BillingOptions.FreeWebTurnsPerDay`, counted by UTC calendar day. It ships at **0**:
a signed-in account is not a chat allowance, because with self-registration open anyone could
create accounts to multiply one. An operator may raise it on a deployment with registration
closed. `quotaResetUtc` is midnight UTC of the following day.

---

## 3. Insufficient balance — `402`

Before opening the stream, `ChatController` runs `IBillingGateAppService.GateAsync()`. On the
API-key rail, if `account.BalanceUsd < priceUsd`, the request returns `402 Payment Required` with
an `InsufficientBalanceDto` body **instead of** opening the SSE stream — a normal JSON error
response, no `Content-Type: text/event-stream`.

```json
{
  "error": "insufficient_balance",
  "balanceUsd": 0.00,
  "priceUsd": 0.02,
  "treasuryPublicKey": "01b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
  "depositId": 48213,
  "instructions": "Send CSPR to the treasury address with your depositId as the transfer-id; credits appear within ~2 minutes."
}
```

| Field | Meaning |
|---|---|
| `error` | Always `"insufficient_balance"`. |
| `balanceUsd` | Your account's current balance. |
| `priceUsd` | The price of one query right now (`PriceProvider`; default $0.02, admin-settable). |
| `treasuryPublicKey` | Where to send CSPR. |
| `depositId` | **Your** account's deposit id — use it as the transfer's `transfer-id`. At most 2^53 − 1 (16 digits), so a JavaScript client and a wallet built on casper-js-sdk read it exactly. It never changes, with one exception: ids used to run to 19 digits, and each one above 2^53 − 1 was replaced once by a shorter one — the old id still credits the same account. A JavaScript client that stored the old value as a number stored it rounded, and the rounded value never credited: read `depositId` again. |
| `instructions` | Human-readable restatement of the above. |

### Depositing

Send CSPR to `treasuryPublicKey` with `depositId` set as the transaction's `transfer-id`. CasperAI
polls `api.cspr.cloud` for transfers into the treasury account (`DepositProcessor`, every
`BillingOptions.PollSeconds`, default 60s) and credits your balance once a USD rate is available
for that block. If the rate is slow to appear, the transfer is retried on every later poll rather
than being passed over — a delayed rate costs you time, never the deposit. Using casper-client
(pinned against v5.0.1, matching `verifiedAgainst` in §2):

```
casper-client transfer \
  --chain-name casper \
  --target-account 01b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2 \
  --amount 100000000000 \
  --transfer-id 48213 \
  --payment-amount 100000000 \
  --secret-key <PATH-TO-YOUR-SECRET-KEY> \
  --node-address <NODE-ADDRESS>
```

(This is deliberately the classic `transfer` command rather than `put-transaction transfer`:
casper-client v5.0.1 — the latest release — crashes with a parser panic whenever
`put-transaction transfer` is given `--transfer-id`, and the deposit id must travel in that field.
`--amount` is in motes: here, 100 CSPR × 1,000,000,000 motes/CSPR. For an account with a verified
email, the portal's `GET /api/app/my-billing` response — `MyBillingDto.depositCommandExample` —
renders exactly this command, pre-filled with your real `depositId`, via the same
`CommandBuilder`/`CommandRenderer` pipeline the agent's `command_build` tool uses. Until the email is
verified it gives no deposit instructions: `depositId` is 0 and the command and treasury key are
empty.)

**If you omit `--transfer-id` or use the wrong one**, the deposit is not lost — it's queued as an
`UnmatchedDeposit` for an admin to assign to your account manually (`BillingAdminAppService`). It
just won't show up automatically. Always set `--transfer-id` to your `depositId`.

*Source: `InsufficientBalanceDto` — `src/CasperAI.Application.Contracts/Billing/InsufficientBalanceDto.cs`;
gate logic — `src/CasperAI.Application/Billing/BillingGateAppService.cs`; deposit example
rendering — `src/CasperAI.Application/Billing/MyBillingAppService.cs` (`BuildDepositExample`);
transfer template — `src/CasperAI.Domain/Commands/CommandTemplates.cs` (`transfer.native`);
unmatched-deposit path — `src/CasperAI.Domain/Billing/DepositProcessor.cs`.*

---

## 4. `GET /v1/usage`

`[Authorize]`, **API-key rail only** — throws `AbpAuthorizationException("api key required")` if
called without a `cai_` key (e.g. with only an OpenIddict token — there is no `ApiKeyId` claim to
resolve an account from).

```
GET /v1/usage?skipCount=0&maxResultCount=20
Authorization: Bearer cai_example1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ
```

```json
{
  "balanceUsd": 4.86,
  "priceUsd": 0.02,
  "key": {
    "prefix": "cai_example1",
    "label": "my-integration",
    "lastUsedAt": "2026-08-14T12:00:03Z",
    "rateLimitPerMinute": 20
  },
  "entries": [
    { "type": 1, "amountUsd": -0.02, "reference": "b2c3d4e5-2222-4b4b-9b9b-2b3c4d5e6f7a", "creationTime": "2026-08-14T12:00:04Z" },
    { "type": 0, "amountUsd": 5.00, "reference": "9c2a1f0e...deployhash:0", "creationTime": "2026-08-14T11:58:10Z" }
  ]
}
```

`skipCount`/`maxResultCount` are optional (default `0`/`20`, clamped to a max of 100).
`entries` is your ledger, newest first. `type` is `LedgerEntryType`: `0 = Deposit`, `1 = Usage`,
`2 = AdminAdjustment`. `reference` is the charged message id for `Usage` entries,
`deployHash:transferIndex` for `Deposit` entries, and always `null` for `AdminAdjustment` — admin
notes are internal, never exposed to the customer (`LedgerEntryProjection`). `key.rateLimitPerMinute`
is your **effective** limit — your key's per-key override if one is set, else
`BillingOptions.DefaultKeyRateLimitPerMinute`.

*Source: `UsageDto`/`UsageKeyDto`/`LedgerEntryDto` — `src/CasperAI.Application.Contracts/Billing/UsageDtos.cs`;
`src/CasperAI.Application/Billing/UsageAppService.cs`; `LedgerEntryType` —
`src/CasperAI.Domain.Shared/Billing/LedgerEntryType.cs`; `LedgerEntryProjection` —
`src/CasperAI.Application/Billing/LedgerEntryProjection.cs`.*

---

## 5. `GET /v1/health`

Anonymous (`[AllowAnonymous]`), no key needed — still subject to rate limiting (§6, anonymous IP
tier), since it lives under `/v1`.

```
GET /v1/health
```

```json
{
  "status": "ok",
  "kbFreshness": "2026-08-14T03:00:00Z",
  "depositWatcher": { "lastSuccessfulPollAt": "2026-08-14T12:01:00Z" },
  "kbIngestion": {
    "lastSuccessfulSweepAt": "2026-08-20T11:00:00Z",
    "staleSources": 0,
    "failingSources": 0
  },
  "telegram": { "enabled": false }
}
```

`kbFreshness` is the most recent `LastFetchedAt` across all knowledge sources (`null` if none
exist). `depositWatcher.lastSuccessfulPollAt` is the last time the CSPR.cloud deposit poll cycle
completed without throwing (`null` before the first successful cycle). `status` is currently
always `"ok"` — there is no failure branch in `HealthAppService.GetAsync`.

`kbIngestion` reports the knowledge refresh worker (opt-in via `CasperAI:Knowledge:RefreshEnabled`):
`lastSuccessfulSweepAt` advances only when a sweep completes cleanly (per-source failures do not
block it — they surface in the counts instead; `null` until the first sweep ever runs, and
frozen at its last value if the worker is later disabled); `staleSources` counts enabled sources
currently due per the refresh policy
(never fetched, or `LastFetchedAt` older than the source's `StaleAfterDays`; `StaleAfterDays <= 0`
means manual-only and never counts); `failingSources` counts enabled sources whose latest refresh
attempts failed consecutively.

`telegram.enabled` mirrors `CasperAI:Telegram:Enabled` — whether this deployment registers
Telegram bots at all (`false` in the committed configuration) — and is the only Telegram fact
this anonymous endpoint carries; queue depth, webhook errors and dispatcher state are shown to
each bot's owner on `/telegram`, never here.

*Source: `HealthDto`/`DepositWatcherHealthDto`/`KbIngestionHealthDto`/`TelegramHealthDto` —
`src/CasperAI.Application.Contracts/Billing/UsageDtos.cs`;
`src/CasperAI.Application/Billing/HealthAppService.cs`.*

---

## 6. Rate limiting

`/v1/*` traffic (and only `/v1/*` — the same segment-match boundary as §1) is partitioned and
rate limited (`RateLimitPartitioner.Resolve` + `AddRateLimiter`,
`src/CasperAI.HttpApi.Host/CasperAIHttpApiHostModule.cs`). Limiting runs after authentication (so
the partitioner can read your API-key claims) and before authorization.

Exceeding your limit returns `429 Too Many Requests` with a `Retry-After` header (whole seconds
until the fixed window resets):

```
HTTP/1.1 429 Too Many Requests
Retry-After: 41
```

| Rail | Partition key | Default limit | Source |
|---|---|---|---|
| API key (`cai_`) | `key:<apiKeyId>` | 20/min, overridable per key (`ApiKey.RateLimitPerMinute`) | `BillingOptions.DefaultKeyRateLimitPerMinute` |
| Authenticated web session, no key | `user:<userId>` | 10/min | `BillingOptions.WebUserRateLimitPerMinute` |
| Anonymous | `ip:<remoteIp>` (or `ip:unknown` if unresolvable) | 30/min | `BillingOptions.AnonymousIpRateLimitPerMinute` |
| Non-`/v1` traffic | `none` | unlimited | n/a — `RateLimitPartitioner.Resolve` returns `PermitLimitPerMinute: null` |

Fixed 1-minute windows, no queueing (`QueueLimit: 0` — over-limit requests are rejected
immediately, never queued).

**This is separate from the `quota_exceeded` daily web quota (§2).** That's a per-*day* cap on
free web-session chat turns enforced inside `ChatAppService`, not the per-*minute* HTTP rate
limiter described here. API-key integrators are never subject to the daily quota; they're
governed only by the table above and by their account balance (§3).

*Source: `src/CasperAI.Domain/Billing/RateLimitPartitioner.cs`,
`src/CasperAI.Domain/Billing/BillingOptions.cs`,
`src/CasperAI.HttpApi.Host/CasperAIHttpApiHostModule.cs` (`ConfigureRateLimiting`,
`OnApplicationInitialization`'s `app.UseRateLimiter()` placement).*

---

## 7. End-to-end curl walkthrough

Replace `https://your-casperai-host` and every placeholder token/key below with your real values.
None of the tokens shown here are real.

**1. Create an API key** (while signed in to the portal):

```bash
curl -s -X POST https://your-casperai-host/api/app/my-billing/key \
  -H "Authorization: Bearer <your-portal-session-token>" \
  -H "Content-Type: application/json" \
  -d '{"label":"my-integration"}'
# => {"secret":"cai_example...","key":{...}}
export CAI_KEY="cai_example..."   # save the secret — it's shown once
```

**2. Try to chat with a fresh (zero-balance) account — get `402`:**

```bash
curl -s -i -X POST https://your-casperai-host/v1/chat \
  -H "Authorization: Bearer $CAI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"message":"What is the minimum delegation amount?"}'
# HTTP/1.1 402 Payment Required
# {"error":"insufficient_balance","balanceUsd":0.00,"priceUsd":0.02,
#  "treasuryPublicKey":"01b2b2...","depositId":48213,"instructions":"..."}
```

**3. Deposit CSPR, using the `depositId` from the `402` body as `--transfer-id`:**

```bash
casper-client transfer \
  --chain-name casper \
  --target-account 01b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2 \
  --amount 100000000000 \
  --transfer-id 48213 \
  --payment-amount 100000000 \
  --secret-key <PATH-TO-YOUR-SECRET-KEY> \
  --node-address <NODE-ADDRESS>
```

Wait for the deposit watcher's next poll cycle (default: within `PollSeconds` = 60s, plus however
long CSPR.cloud takes to index the transfer and attach a USD rate — the `402` body's `instructions`
field estimates ~2 minutes end to end).

**4. Confirm the balance landed:**

```bash
curl -s https://your-casperai-host/v1/usage \
  -H "Authorization: Bearer $CAI_KEY"
# => {"balanceUsd":5.00,"priceUsd":0.02,"key":{...},"entries":[{"type":0,"amountUsd":5.00,...}]}
```

**5. Chat again — this time it streams:**

```bash
curl -s -N -X POST https://your-casperai-host/v1/chat \
  -H "Authorization: Bearer $CAI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"message":"What is the minimum delegation amount?"}'
# event: status
# data: {"eventType":"status","status":"searching_docs"}
#
# event: token
# data: {"eventType":"token","text":"The minimum delegation amount on Casper is 500 CSPR [1]."}
#
# event: citation
# data: {"eventType":"citation","index":1,"kind":"doc","url":"https://docs.casper.network/staking/",...}
#
# event: usage
# data: {"eventType":"usage","inputTokens":842,"outputTokens":57}
#
# event: done
# data: {"eventType":"done","sessionId":"a1b2c3d4-...","messageId":"b2c3d4e5-..."}
```

Continue the conversation by passing the `sessionId` from `done` back in as `sessionId` on your
next `/v1/chat` request. `-N` disables curl's own output buffering so you see events as they
arrive (the server already sends `X-Accel-Buffering: no` to defeat proxy buffering upstream).

---

## 8. x402 pay-per-query (agents without an API key)

An [x402 v2](https://www.x402.org/) rail for autonomous agents: no account, no API key — each
answer is paid for individually with a CEP-3009-signed token authorization, verified and
settled through the [CSPR.cloud x402 Facilitator](https://docs.cspr.cloud/x402-facilitator-api/reference).

**Off by default** in the shipped configuration (`CasperAI:X402:Enabled` is `false` in
`appsettings.json`), so a self-hosted deployment has no x402 rail until its operator enables and
configures one. While disabled, keyless requests get the ordinary authentication challenge (§2)
and every `PAYMENT-SIGNATURE` header is ignored.

**The hosted instance, `https://casperai.ekolsoft.com`, runs it enabled**
(`etc/prod/docker-compose.yml`): network `casper:casper` (Casper mainnet), paid in **Wrapped
CSPR** (WCSPR) — `asset` is the contract package hash
`8df5d26790e18cf0404502c62ce5dc9025800ad6975c97466e20506c39c505b6`, `extra.name` is
`"Wrapped CSPR"` — with 9 decimals, so `amount` is in units of 10⁻⁹ WCSPR (see Pricing below).
Build your payment from the live `PAYMENT-REQUIRED` challenge you receive, never from the example
below: the example is illustrative (a testnet network and placeholder values), and the price is
an admin setting read on every request, so `amount` can change without a deploy.

### The challenge

A keyless `POST /v1/chat` (or an underfunded API key's request) returns `402 Payment Required`
with the header `PAYMENT-REQUIRED: base64(PaymentRequired)`:

```json
{
  "x402Version": 2,
  "resource": { "url": "https://<host>/v1/chat", "description": "CasperAI verified answer",
                "mimeType": "text/event-stream", "serviceName": "CasperAI" },
  "accepts": [{
    "scheme": "exact",
    "network": "casper:casper-test",
    "amount": "25000",
    "asset": "<CEP-3009-capable CEP-18 contract package hash>",
    "payTo": "<33-byte tagged address, hex>",
    "maxTimeoutSeconds": 300,
    "extra": { "name": "<token name>", "version": "<signing domain version>" }
  }]
}
```

`extra.name`/`extra.version` are the CEP-3009 EIP-712 signing-domain fields — your client needs
them to construct the digest. The JSON **body** stays application data: keyless callers get
`{ "error": "payment_required", "priceUsd": 0.025, "reason": "<set when a payment failed>",
"instructions": "..." }`; API-key holders keep the §3 `InsufficientBalanceDto` (an underfunded
key holder may pay per-query instead — the payment takes precedence over the balance gate).

### The paid request

Retry with `PAYMENT-SIGNATURE: base64(PaymentPayload)` per the Casper exact scheme: echo the
chosen `accepts` entry as `accepted` **field-for-field** (`authorization.value` must equal
`amount` exactly — no overpaying), sign the CEP-3009 EIP-712 digest, and leave at least 240
seconds of validity when CasperAI receives it (`validBefore` ≥ now + 240,
`X402Protocol.MinRemainingValiditySeconds`) to cover streaming and settlement — less is rejected
as `authorization_expired`. A payment minted with `validBefore` = now + the advertised
`maxTimeoutSeconds` (300 by default) and sent promptly clears it. The `make-software/casper-x402` JS/Go
clients produce this header shape.

**The x402 rail is stateless:** a `sessionId` in the request body is rejected with `400`
(`CasperAI:X402:SessionIdNotAllowed`). Carry your own conversation context.

### The response — settlement in the SSE stream

CasperAI verifies with the facilitator **before** streaming (invalid → fresh 402 with `reason`;
facilitator unreachable → `503`, never an unpaid answer) and settles **after** the answer is
fully streamed — only if the answer was delivered. A refused answer (see §2 `refusal`) never
settles; your authorization simply expires unused.

Because response headers are sent when streaming begins, the settlement result arrives as a
final SSE event before `done` instead of the transport's `PAYMENT-RESPONSE` header (the v2
HTTP transport does not address streaming; no normative requirement is violated):

```
event: payment
data: {"eventType":"payment","status":"settled","transaction":"<deploy-hash>","network":"casper:casper-test"}
```

`status` is `"settled"` or `"settle_failed"` (a settle failure after delivery is CasperAI's
loss — the answer is never clawed back; the payment row is flagged for manual reconciliation).

### Gate order (exact semantics)

| Situation | Response |
|---|---|
| Rail disabled, anonymous (payment header or not) | Authentication challenge (§2) — pre-x402 behavior |
| `Authorization` header carrying a credential that fails to authenticate | 401 — a bad credential is never an x402 opportunity (an empty header or a bare `Bearer` carries no credential and counts as anonymous, §1) |
| Keyless, no payment, enabled | 402 + challenge |
| Malformed / mismatched / expired payload | 402 + fresh challenge |
| `sessionId` supplied with a payment | 400 `CasperAI:X402:SessionIdNotAllowed` |
| Facilitator verify: invalid | 402 + `reason` |
| Facilitator unreachable | 503 |
| Replayed authorization (same from+nonce) | 402 "authorization_already_used" |

### Pricing

One flat price per query, `CasperAI.Billing.X402PricePerQueryUsd`, settable on the admin
Customers page alongside the prepaid price (both are written together; either being
non-positive rejects the save and writes neither). Default `0.025` — chosen as slightly above
the prepaid rail's $0.02, covering settlement overhead with zero commitment.

**The setting is not denominated in dollars, despite its name.** `amount` is
`ceil(price × 10^AssetDecimals)` in the configured token's own atomic units, and there is no
exchange-rate step anywhere in the rail. That reads as dollars only when the token is
USD-pegged. The shipped production stack pins **WCSPR** (`etc/prod/docker-compose.yml`,
`AssetDecimals: 9`), so the default charges 0.025 WCSPR per query — not 2.5 cents. An operator
running a non-pegged asset must set this to the asset amount they intend and revisit it as the
asset moves.

*Source: gate matrix — `src/CasperAI.Application/Billing/BillingGateAppService.cs` (`GateAsync`)
and `src/CasperAI.HttpApi/Chat/ChatController.cs`; protocol shapes —
`src/CasperAI.Domain/Billing/X402/X402Protocol.cs`; settlement —
`src/CasperAI.Domain/Billing/X402/X402PaymentGate.cs` and `ChatAppService.StreamAsync`'s
payment block; bookkeeping — `AppPaymentAuthorizations` (admin page: x402 Payments).*

---

## 9. MCP server — the Casper brain as tools

Any MCP-capable agent framework can mount CasperAI directly:

- **Endpoint:** `https://<host>/v1/mcp` (MCP Streamable HTTP, **stateless** — no `Mcp-Session-Id`;
  every tool call is an independent POST; `GET`/`DELETE` answer `405` per the spec — there is no
  server-initiated SSE stream and no client-terminable session).
- **Tools:** `casper_ask` (paid) and `casper_command_build` (free).

### `casper_ask` (paid per call)

Input: `{ "question": string }` (≤4000 chars, enforced server-side). Two ways to pay, per call:

1. **API key:** send `Authorization: Bearer cai_...` on the POST — the prepaid ledger is
   charged exactly as `/v1/chat` (§1/§3 semantics, delivered answers only).
2. **x402:** put the v2 `PaymentPayload` at `params._meta["x402/payment"]` — as a raw JSON
   object (per the x402 MCP transport spec) or a base64 JSON string (Vercel/Cloudflare style);
   both are accepted.

A call that must pay gets a **tool result with `isError: true`** whose `structuredContent` is a
valid x402 v2 `PaymentRequired` (same `accepts` as §8, `resource.url` = `mcp://tool/casper_ask`)
— and `content[0].text` is always the JSON of `structuredContent`. Retry the same `tools/call`
with the signed payment in `_meta`.

Success returns the answer as structured content
(`{answer, refusal, citations[], commands[], sessionId, messageId}` — the §2 citation vocabulary;
each `commands[]` entry is `{commandText, actionId, network, verifiedAgainst, notice}`, the §2
`command` event's fields, for a command the answer delivers, and a refusal has none) plus, for
x402 payments, the settlement receipt at `result._meta["x402/payment-response"]` =
`{success, transaction, network, payer}`.

**Settlement timing differs from §8:** MCP is non-streaming, so CasperAI settles BEFORE
returning content. A settlement failure returns a fresh payment challenge with
`error:"settlement_failed"` and **withholds the answer** (per the x402 MCP transport spec).
Refusals return as normal content and are never settled or charged.

**Non-payable errors** are `isError` results WITHOUT x402 framing (no `x402Version`/`accepts`):
`payment_rail_disabled`, `facilitator_unavailable`, `invalid_api_key` (bad credential — never
an x402 invitation; an empty `Authorization` header or a bare `Bearer` with no token is not a
credential, so that call is handled as keyless), `use_api_key_or_x402` (web/OIDC tokens have no
rail here),
`question_too_long`, and `insufficient_balance` (underfunded key while the x402 rail is
disabled; carries the deposit remedy at `extensions["casperai/prepaid"]`). Underfunded keys
with x402 ENABLED get a full challenge with the same `extensions` remedy.

### `casper_command_build` (free)

Input: `{ "actionId": string, "parameters": object }` — identical contract to the engine's
`command_build` tool (§2). Output structured content (advertised as the tool's `outputSchema`)
is the flat command contract: `status`
(`ok | needs_input | unknown_action`) with `command`/`actionId`/`network`/`verifiedAgainst`/
`notice`/`purpose` on `ok`, `parameter`+`constraint`, `missing[]`, `ask` and `purpose` on
`needs_input`, `available[]` and `actions[]` on `unknown_action`, and `state` on `ok` and
`needs_input` when the build read the chain (§2's "`command_build` results" and "Chain state"
have every field). The tool's description names every action with a few words; call it with an
unknown `actionId` to get the full catalog with each action's purpose and parameters.
Parameter names are exact and case-sensitive: since 2026-09-29 a key the action does not take is
refused in `missing[]` rather than ignored (§2, "Parameter names are exact"), so a client that
sent extra keys now gets `needs_input`.
`casper_command_build` reads the chain only on a deployment that gives the reads a CSPR.cloud token
of their own (`CasperAI:ChainState:CsprCloudToken`), never on the token the deployment credits
deposits with; elsewhere its results carry no `state`. Its requests take only a share of the
deployment's per-minute budget of CSPR.cloud requests
(`CasperAI:ChainState:MaxFreeToolReadsPerMinute`, at most 40 of the 120 by default), so free calls
never use up the reads that chat and `casper_ask` answers need. A spent share makes `state.status`
`unavailable` until the calls slow down.
No key, no payment, no LLM cost.

### Example

```bash
curl -s -X POST https://<host>/v1/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
        "name":"casper_command_build",
        "arguments":{"actionId":"delegate","parameters":{"chain":"casper",
          "delegator":"01aa…","validator":"01bb…","amount":"600"}}}}'
```

A call that is missing parameters gets all of them back at once. `bid.change-rate` with only
`{"chain":"casper"}` returns (purpose shortened here):

```json
{"status":"needs_input","parameter":"publicKey",
 "constraint":"publicKey is required: an account public key (hex, starting 01 or 02).",
 "missing":[
   {"parameter":"publicKey","constraint":"publicKey is required: an account public key (hex, starting 01 or 02)."},
   {"parameter":"rate","constraint":"rate is required: the delegation rate (the commission), a whole percent from 0 to 100."}],
 "ask":"What is the validator's public key, in hex starting 01 or 02, and what new rate do you want, as a whole percent from 0 to 100?",
 "purpose":"Change an existing validator's commission: its delegation rate, …"}
```

*(Local-dev note, for a Host running on `https://localhost:44341` only: its development
certificate is untrusted until you run `dotnet dev-certs https --trust`, so until then curl needs
`-k` against localhost. Never use `-k` against a real deployment — it switches off TLS
certificate verification. The same untrusted certificate makes the HealthChecks-UI collector log
`UntrustedRoot` when it probes itself; trusting the certificate silences that too.)*

*Source: tool surface — `src/CasperAI.HttpApi.Host/Mcp/CasperAiMcpTools.cs` (shim) and
`src/CasperAI.Application/Mcp/McpAskService.cs` (logic); wire shapes —
`src/CasperAI.Application/Mcp/McpAskResultMapper.cs`; mount —
`CasperAIHttpApiHostModule.ConfigureMcpServer` + `MapMcp("/v1/mcp")`.*
