# Quartermaster

An agent-only store selling prepaid travel eSIM data plans to AI agents over MCP, paid from the principal's prepaid wallet. **This deployment is the sandbox:** it may buy test plans from a real wholesaler, so simulated payments are disabled and the operator funds test wallets.

- MCP endpoint (Streamable HTTP): `https://api.quartermasterhq.ai/mcp`
- Legacy HTTP+SSE endpoint: `https://api.quartermasterhq.ai/sse`
- Policies (machine-readable JSON): [https://api.quartermasterhq.ai/policies](https://api.quartermasterhq.ai/policies)
- Discovery: [/.well-known/mcp.json](https://api.quartermasterhq.ai/.well-known/mcp.json), [/llms.txt](https://api.quartermasterhq.ai/llms.txt), [/openapi.json](https://api.quartermasterhq.ai/openapi.json)
- Claude skill: [https://api.quartermasterhq.ai/docs/SKILL.md](https://api.quartermasterhq.ai/docs/SKILL.md)

## Quickstart with curl

Stateless calls need no session. Register once, keep `agent_key` and `principal_key` from the result, then search.

```sh
MCP=https://api.quartermasterhq.ai/mcp
H=(-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream')
curl -s "$MCP" "${H[@]}" -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"register_agent","arguments":{"agent_id":"my-agent-1","principal_id":"principal:me","mandate":{"max_per_tx":5000,"max_per_day":10000,"max_per_month":30000,"expiry":"2027-02-05T00:00:00Z"}}}}'
# From the result: agent_key (every call) and principal_key (give it to your principal).
ID=(-H 'X-Agent-Id: my-agent-1' -H 'X-Principal-Id: principal:me' -H 'X-Agent-Key: ak_PASTE_YOURS')
curl -s "$MCP" "${H[@]}" "${ID[@]}" -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_wallet","arguments":{}}}'
curl -s "$MCP" "${H[@]}" "${ID[@]}" -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_plans","arguments":{"country":"JP","min_data_gb":5}}}'
curl -s "$MCP" "${H[@]}" "${ID[@]}" -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"get_quote","arguments":{"product_id":"PASTE_A_PRODUCT_ID"}}}'
```

Pick your own `agent_id` and `principal_id`: if the principal already exists, `register_agent` needs its `principal_key` and fails with `PRINCIPAL_KEY_REQUIRED` otherwise. MCP clients that keep a session (initialize, then `Mcp-Session-Id`) work too.

## Claude Code

```sh
claude mcp add --transport http quartermaster https://api.quartermasterhq.ai/mcp
```

Then ask Claude to call `register_agent`. Once you have an `agent_key`, re-add the server with identity headers so every call carries them:

```sh
claude mcp remove quartermaster
claude mcp add --transport http quartermaster https://api.quartermasterhq.ai/mcp --header "X-Agent-Id: my-agent-1" --header "X-Principal-Id: principal:me" --header "X-Agent-Key: ak_..."
```

## Claude Desktop

Claude Desktop starts local (stdio) servers from `claude_desktop_config.json`, so bridge to the remote endpoint with `mcp-remote` (needs Node.js):

```json
{
  "mcpServers": {
    "quartermaster": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://api.quartermasterhq.ai/mcp", "--header", "X-Agent-Id:${AGENT_ID}", "--header", "X-Principal-Id:${PRINCIPAL_ID}", "--header", "X-Agent-Key:${AGENT_KEY}"],
      "env": { "AGENT_ID": "my-agent-1", "PRINCIPAL_ID": "principal:me", "AGENT_KEY": "ak_..." }
    }
  }
}
```

Before the first registration, drop the three `--header` pairs and let Claude pass identity as tool arguments.

## Install the skill

The skill teaches an agent the buying flow, the wallet and the failure handling. For Claude Code:

```sh
mkdir -p ~/.claude/skills/quartermaster-esim
curl -s https://api.quartermasterhq.ai/docs/SKILL.md -o ~/.claude/skills/quartermaster-esim/SKILL.md
```

Use `.claude/skills/quartermaster-esim/SKILL.md` inside a repository instead to share it with a project. In apps that accept uploaded skills, upload the `quartermaster-esim` folder (zipped) containing that `SKILL.md`.

## Tools

- `register_agent`: Create an agent identity bound to a principal, with a spending mandate (limits in minor units).
- `rotate_principal_key`: Replace your principal's principal_key.
- `list_agents`: Every agent registered for your principal: agent_id, created_at, principal_key_version, status (active, expired or revoked), mandate_expiry and limits, plus active_agents against max_agents_per_principal.
- `revoke_agent`: Revoke an agent of your principal for good: it can no longer authenticate (AGENT_REVOKED) and no longer holds a slot under max_agents_per_principal.
- `renew_mandate`: Re-sign the mandate of an existing agent of your principal (you, unless target_agent_id names another) with new limits and expiry, under the same registration policy caps as register_agent.
- `get_policies`: Returns every policy as machine-readable JSON (mandate, refund, dispute, supplier trust, registration), the store terms and the product/agent JSON Schemas, so you can reason about terms before buying.
- `search_plans`: Search eSIM data plans from trusted suppliers.
- `get_quote`: Lock the final price for one product for a short time (quote_ttl_seconds in store terms).
- `purchase`: Buy the product in a quote.
- `get_wallet`: Balance, available funds (balance minus pending holds), open (unpaid) top-up links, top-ups awaiting confirmation, recent top-ups and recent ledger entries of the wallet of the principal you act for.
- `get_topup_link`: Create a hosted payment URL to hand to your human principal.
- `order_status`: Current order state, eSIM delivery payload, activation and data usage from the supplier, receipts, and the latest disputes (disputes_total counts all).
- `regenerate_activation`: For a delivered order of your principal (bought by any of its agents) whose activation code (QR / LPA) does not install: asks the supplier for a fresh code and returns the new delivery payload.
- `troubleshoot`: Ordered steps to relay to the traveler when an order's eSIM does not install (cant_install_qr) or does not work (no_data, no_signal, slow), worded for the phone (device.os ios or android, device.model if known): eSIM compatibility and carrier lock, install by QR or manual entry (SM-DP+ address and activation code), data roaming on the eSIM line, the eSIM as the data line, the APN when the plan has one, airplane-mode toggle and restart, then the escalation (regenerate_activation, request_refund with reason not_working and the evidence to include, or order_status for a code not delivered yet).
- `request_refund`: Reasons "unused" and "changed_plans" are voluntary refunds under the refund policy.
- `verify_receipt`: Check a receipt's signature and that it matches its hash-chained ledger entry.
- `sandbox_simulate_topup_payment`: Sandbox only.
- `sandbox_simulate_activation`: Sandbox only.

Read the policies before buying: [https://api.quartermasterhq.ai/policies](https://api.quartermasterhq.ai/policies).

---

## Agent guide

Quartermaster sells travel digital goods to AI agents acting for a principal (the person or company that pays). Today it sells one category, `esim`: prepaid mobile data plans for travelers, delivered as an eSIM activation code. There is no human storefront. Everything happens over MCP.

Purchases are paid from the principal's **prepaid wallet**, kept in USD. The principal funds it by paying a hosted payment link (PayPlus, an Israeli card acquirer, charged in ILS) that you request and pass on. You never see or handle card data.

## Connect

| Transport | How |
|---|---|
| stdio | Run `npm run start:stdio` (or `node --import tsx src/server.ts`) as an MCP server process. |
| Streamable HTTP | `POST <store-url>/mcp` (public: `https://api.quartermasterhq.ai/mcp`). Send `initialize` without `Mcp-Session-Id` to open a session (the response carries `Mcp-Session-Id`; send it on later requests, `DELETE /mcp` to end it), or skip `initialize` and POST single requests statelessly (plain JSON responses). An unknown or expired session id answers 404: initialize again. |
| Legacy HTTP+SSE | `GET <store-url>/sse`, then `POST <store-url>/messages?sessionId=...` |

Read-only HTTP helpers: `GET /policies` (all policy JSON), `GET /schema/product.schema.json`, `GET /schema/agent.schema.json`, `GET /healthz`, `GET /readyz`. Discovery: `GET /.well-known/mcp.json`, `GET /llms.txt`, `GET /openapi.json`. `POST /webhooks/payplus` is for the payment provider only.

MCP request bodies must be `Content-Type: application/json`, valid UTF-8 JSON objects or arrays, at most 256 KB.

## Identity

Every call carries three values, as tool arguments or HTTP headers:

| Argument | Header | Notes |
|---|---|---|
| `agent_id` | `X-Agent-Id` | Your stable id: `[A-Za-z0-9._:-]{3,128}` |
| `principal_id` | `X-Principal-Id` | Who you act for; must match registration: `[A-Za-z0-9._:@-]{3,128}` |
| `agent_key` | `X-Agent-Key` | Secret returned once by `register_agent`, at most 256 chars. Not needed for `register_agent` and `get_policies` |
| `principal_key` | `X-Principal-Key` | Only for `register_agent` on an existing principal and for `rotate_principal_key`; at most 256 chars |

A malformed or over-long value, as an argument or a header, fails with `IDENTITY_INVALID` (the value is not echoed). If an argument and a header are both present and differ, the call fails with `IDENTITY_CONFLICT`.

### Principal key

The principal owns the wallet. The **first** `register_agent` for a new `principal_id` creates the principal and returns `principal_key` once, next to `agent_key`. Hand `principal_key` to your principal (the human or company you act for) and keep it out of logs. Every later `register_agent` for that `principal_id`, by you or another agent, must present it; without it the call fails with `PRINCIPAL_KEY_REQUIRED`, with a wrong one `PRINCIPAL_KEY_INVALID`. This stops anyone from registering an agent under someone else's principal and spending their wallet. `get_wallet` shows the key's hint (`pk_...` plus its last four characters), version and dates, never the key.

`rotate_principal_key` (your agent credentials plus the current `principal_key`) issues a new key and voids the old one; agents already registered keep working. Choose a `principal_id` that is unlikely to collide with someone else's, such as `principal:<your-domain>:<customer-id>`.

## The buying flow

In short: `register_agent` -> `get_wallet` -> (short?) `get_topup_link` -> principal pays -> `get_wallet` shows the balance -> `search_plans` -> `get_quote` -> `purchase`.

1. **`register_agent`** (once). Send your mandate: the limits your principal granted you.

   ```json
   {
     "agent_id": "trip-planner-7",
     "principal_id": "principal:acme-travel",
     "mandate": {
       "currency": "USD",
       "max_per_tx": 5000,
       "max_per_day": 10000,
       "max_per_month": 30000,
       "allowed_categories": ["esim"],
       "expiry": "2027-02-01T00:00:00Z"
     }
   }
   ```

   Amounts are minor units (5000 = 50.00 USD). The store signs the mandate and returns `agent_key` (and, for a new principal, `principal_key`; see [Principal key](#principal-key)). Store them; they are not shown again. To add an agent to an existing principal, include `"principal_key"`. New agents are capped at 250.00 per transaction and 1,000.00 per month, and a mandate may last at most 365 days (policy `registration`).

2. **`get_policies`**. Returns every policy as JSON, the store terms and the JSON Schemas. Read the `refund` and `dispute` policies before buying if refundability matters to your principal, and `topup` for top-up limits. Rules are evaluated top to bottom and the first match decides, so you can predict any decision from the facts.

3. **`get_wallet`**. Shows the principal's wallet: `balances[]` with `balance_minor`, `held_minor` (reserved for purchases in flight) and `available_minor`, plus open top-up links, recent top-ups and recent entries (`topup`, `debit`, `refund`). The wallet belongs to the principal, so every agent registered for the same `principal_id` sees and spends the same balance, each within its own mandate.

4. **`get_topup_link`** when the balance is short. Arguments: `amount_minor` and `currency`:
   - `"USD"` (default): the wallet credit you want; the ILS charge is computed at the current rate and rounded up to the agora.
   - `"ILS"`: the exact amount the principal will be charged.

   ```json
   { "amount_minor": 5000, "currency": "USD" }
   ```

   Response: `url` (hand this to your principal), `topup_id`, `expires_at` (30 minutes), `charge` (e.g. 185.00 ILS), `expected_credit` (e.g. 50.00 USD) and `fx` (rate, source, time). The principal pays on the provider's page. The wallet is credited in USD **at the rate in force when the payment is confirmed**, so the final credit can differ slightly from `expected_credit` if the rate changed. Limits (policy `topup`): 5.00 to 500.00 USD per link, at most 5 open links per principal, and a 1,000.00 USD cap on the wallet balance.

5. **Wait for the money.** Poll `get_wallet` (every 10 to 30 seconds is plenty) until `available_minor` covers your purchase, or the top-up shows `status: "failed"` (card declined: ask the principal to try again with a new link). Each credited top-up has a signed receipt (`kind: "topup"`) recording the ILS charged, the USD credited and the rate; its id is on the wallet entry (`ref.receipt_id`) and the receipts of recent top-ups are in `get_wallet.topup_receipts`.

6. **`search_plans`**. Filters: `country` (ISO 3166-1 alpha-2, e.g. `JP`), `region` (`EUROPE`, `ASIA`, `MIDDLE_EAST`, `NORTH_AMERICA`, `LATIN_AMERICA`, `OCEANIA`, `AFRICA`, `GLOBAL`), `min_data_gb` (unlimited plans always qualify), `unlimited_only`, `min_validity_days`, `max_validity_days`, `max_price_minor`, `include_out_of_stock`, `sort` (`price` default, `price_per_gb`, `validity`), `limit` (1 to 50).

   Regional plans appear in country searches when they cover that country. Suppliers below the trust threshold are left out; `excluded_suppliers` tells you which and why.

   Each result is a normalized product (see `schema/product.schema.json`). The fields that matter most:
   - `price_final.amount_minor`: what you will pay, taxes included. No fees are added later.
   - `refund_rule`: `{"type":"unactivated_within_days","days":7}` or `{"type":"none"}`.
   - `constraints`: unlocked device needed, hotspot, KYC, network types, activation window.
   - `delivery.method`: `qr` (you get an LPA activation string to render as a QR code) or `activation_link`.

7. **`get_quote`** with `product_id`. Locks the price for 10 minutes (`store_terms.quote_ttl_seconds`). The response includes `mandate_precheck` and `wallet_precheck`, the same decisions `purchase` will make if nothing changes, plus a plain-language `refund_terms` line.

8. **`purchase`** with `quote_id` and a fresh `idempotency_key` (a UUID is ideal). The store checks the mandate first, then the wallet, holds the amount, orders from the supplier and then debits the wallet. If the supplier definitely failed, the hold is released, nothing is debited and you get `SUPPLIER_FAILURE`. On success you get:
   - `order` with `status: "fulfilled"` and `delivery` (ICCID, `lpa_string` or `activation_url`, install instructions for the traveler),
   - `receipt`, signed by the store and bound to a ledger entry,
   - `decision`, the mandate decision that authorized it,
   - `wallet`, the balances after the debit.

   If the wallet is short, `purchase` fails with `WALLET_INSUFFICIENT_FUNDS`. `error.message` already contains a top-up link for the shortfall (at least the 5.00 USD minimum), and `error.decision.effects` has it structured: `topup_url`, `topup_id`, `shortfall_minor`, `suggested_topup_minor`. Give the link to your principal, wait for `get_wallet` to show the funds, then call `purchase` again with the same quote (if it has not expired) and a new `idempotency_key`. Repeated attempts reuse the same open link.

   Calling `purchase` again with the same key returns the same order (`idempotent_replay: true`) and never charges twice. Reusing a key with a different quote fails with `IDEMPOTENCY_KEY_REUSED`.

   **Supplier outcome unknown.** If the supplier's answer is lost (timeout, network error, server error), `purchase` returns the order with `status: "processing"`, `supplier_outcome: "unknown"` and a `next_step`. The order may still be delivered. The price is held in the wallet, not debited. The store then asks the supplier for the order by its reference:
   - Poll `order_status` (every 30 to 60 seconds is plenty) until the order is `fulfilled` (delivery and receipt as above), or `failed` with failure code `SUPPLIER_ORDER_NOT_PLACED` (the hold is released, nothing was debited; get a new quote to buy again).
   - Never buy again for the same need with a new `idempotency_key` while the order is `processing`: that could buy two eSIMs. Replaying `purchase` with the same key is safe and returns the same order.
   - Only `SUPPLIER_FAILURE` with `retryable: true` calls for a new quote and a new key.
   - Some outcomes need a human at the store to check with the supplier, so settling can take longer than a few minutes.

9. **`order_status`** with `order_id`. Shows the order, the eSIM's activation state and data used (`esim`), receipts and disputes. `esim.activated` and `esim.data_used_mb` are absent when the supplier does not report them, which means unknown, not "never activated" or 0 MB. A `note` explains an order still awaiting its supplier, or a fully refunded one. The order's `supplier_attempt` field records when the supplier was called and, if its answer was unknown, the error. After a full refund the order no longer carries its activation code (`delivery`).

   If the traveler's phone rejects the activation code, call **`regenerate_activation`** with the `order_id` (your own orders only). It returns a fresh `delivery` payload for the same eSIM; the previous code may stop working. Suppliers that cannot reissue codes answer `NOT_SUPPORTED_BY_SUPPLIER`: then open a dispute with reason `not_working`. A fully refunded order gets no new code (`ORDER_NOT_ELIGIBLE`).

10. **`verify_receipt`** with a receipt object (at most 16 KiB of JSON). Checks the signature and that it matches its ledger entry.

## Refunds and disputes: `request_refund`

| `reason` | Policy | Typical outcome |
|---|---|---|
| `unused`, `changed_plans` | `refund` | Full refund if the eSIM was never activated and the request is within the product's window. Denied once activated, after the window, for `refund_rule: none`, or when the supplier does not report the activation state (`REFUND_ESIM_STATUS_UNKNOWN`). |
| `not_delivered` | `dispute` | Full refund if no eSIM was delivered 15 minutes after payment. Rejected if the supplier shows it activated. Escalated if delivered but unused. |
| `not_working` | `dispute` | Full refund if never activated (within 30 days). 50% proposed if activated with under 10% of the data used. Escalated otherwise, and when the supplier does not report activation or usage (`DISPUTE_USAGE_UNKNOWN`). |
| `duplicate_purchase` | `dispute` | Full refund if you bought the same plan within 24 hours and this copy is unactivated. Escalated when the supplier does not report activation (`DISPUTE_DUPLICATE_ACTIVATION_UNKNOWN`). |

**Delivered eSIMs.** Once an activation code was handed over, a refund is credited automatically only when the supplier confirms it cancelled the eSIM. Otherwise:
- a voluntary refund is refused (`SUPPLIER_REFUND_REFUSED`);
- a dispute is escalated with `outcome: "escalate"` and `decision.reason_code` `DISPUTE_SUPPLIER_CANCEL_UNCONFIRMED`, and a human reviews it.

Partial refunds of a delivered eSIM (the 50% case) are always reviewed by a human, because they do not cancel the eSIM. Some suppliers cannot cancel eSIMs at all, so every dispute about their delivered eSIMs is escalated. Non-delivery refunds stay automatic.

An agent with 3 or more disputes in 90 days is escalated to human review automatically. Escalations answer within `store_terms.dispute_sla_hours` (48); poll `order_status` for the outcome. Refunds are credited to the principal's wallet immediately (`refund_destination: "wallet"`), and refunded amounts free up mandate budget. After a full refund, `order_status` and a purchase replay no longer return the activation code, and `regenerate_activation` is refused.

`request_refund` also fails with:
- `ORDER_AWAITING_SUPPLIER` while the supplier has not confirmed the order: nothing is charged yet. Poll `order_status`.
- `ORDER_NOT_CHARGED` (or the dispute decision `DISPUTE_ORDER_NOT_CHARGED`) when nothing was charged for the order.
- `ORDER_CHANGED` when the order changed while the request was decided. Call `request_refund` again.

## Troubleshooting an eSIM: `troubleshoot`

Call this before opening a dispute when a delivered eSIM does not work. Input: `order_id`, `device` (`os`: `ios` or `android`, `model` if known) and `symptom` (`no_data`, `cant_install_qr`, `no_signal` or `slow`). It is read-only and returns the order's status, the plan's facts (APN, networks, network types) and ordered steps to relay to your human: check device eSIM compatibility and carrier lock, install by QR or manual entry, turn on data roaming for the eSIM line, set it as the data line, set the APN if the plan needs one, toggle airplane mode or restart, then the escalation (`regenerate_activation`, or `request_refund` with reason `not_working` and the evidence listed). Any agent of the order's principal may call it; other principals get `ORDER_NOT_FOUND`. Refunded or failed orders get no steps.

## Other tools

- `list_agents`: the agents of your principal. `revoke_agent`: revoke one (its key stops working: `AGENT_REVOKED`). `renew_mandate`: extend or change an existing agent's mandate, keeping its key. `rotate_principal_key` takes `keep_agent_ids`; agents it does not keep are revoked.
- `register_agent` accepts `partner_id` and `partner_key` (both or neither) if a partner of the store brought you; it changes nothing about prices or limits.
- Any agent of the principal that placed an order may read and act on it.

## Decisions and errors

Every policy decision has the same shape:

```json
{
  "allowed": false,
  "reason_code": "REFUND_ESIM_ACTIVATED",
  "human_readable": "The eSIM was already installed and activated on a device, so it can no longer be refunded as unused.",
  "policy_id": "refund",
  "policy_version": "2026-11-04.1",
  "rule_id": "esim_activated"
}
```

A failed call returns `isError: true` with `{"error": {"code": "...", "message": "...", ...}}`. When a policy denied the action, `error.code` equals the decision's `reason_code` and `error.decision` holds the full decision.

### Handling failures

| Code | What to do |
|---|---|
| `WALLET_INSUFFICIENT_FUNDS` | Give your principal `error.decision.effects.topup_url` (also in the message), poll `get_wallet`, then purchase again. Do not split the order to fit the balance. |
| `TOPUP_BELOW_MINIMUM`, `TOPUP_ABOVE_MAXIMUM`, `TOPUP_BALANCE_CAP`, `TOPUP_TOO_MANY_OPEN_LINKS` | Adjust the amount, or reuse an open link from `get_wallet.open_topups`. |
| `TOPUP_PROVIDER_UNAVAILABLE` | The payment provider is unreachable. Retry `get_topup_link` in a minute. |
| `SUPPLIER_FAILURE` with `retryable: true` | The supplier did not place the order and nothing was debited (the wallet hold was released). Call `get_quote` again and `purchase` with a **new** `idempotency_key`. Back off a few seconds between attempts; stop after 3 or 4. This is the only case that calls for a new quote and key. |
| `SUPPLIER_FAILURE` with `retryable: false` | The supplier did not place the order and nothing was debited, but retrying will not help. Pick a different plan. |
| `purchase` result with `supplier_outcome: "unknown"` (order `processing`) | Not an error: the price is held, not debited, and the order may still be delivered. Poll `order_status`; never buy again with a new `idempotency_key` meanwhile (the same key is safe). |
| `SUPPLIER_ORDER_NOT_PLACED` (failure code of an order, or of a purchase replay) | The store confirmed the supplier never placed the order; the hold was released and nothing was debited. Get a new quote to buy again. |
| `ORDER_AWAITING_SUPPLIER` | The supplier has not confirmed the order yet. Poll `order_status` until it is `fulfilled` or `failed`. |
| `ORDER_NOT_CHARGED`, `DISPUTE_ORDER_NOT_CHARGED` | Nothing was charged for this order, so there is nothing to refund. |
| `ORDER_CHANGED` | Retry `request_refund`; it decides on the order's current state. |
| `REFUND_ESIM_STATUS_UNKNOWN` | The supplier does not report whether the eSIM was activated. Open a dispute if the eSIM does not work. |
| `SUPPLIER_REFUND_REFUSED` | The supplier did not confirm cancelling the delivered eSIM. Open a dispute if the eSIM is unusable; a human reviews it. |
| `QUOTE_PRICE_CHANGED` | The plan's price changed since the quote. Get a new quote. |
| `ORDER_IN_PROGRESS` | The same `idempotency_key` is still being placed. Poll `order_status` or retry the same call shortly. |
| `PRINCIPAL_FROZEN` | A top-up was reversed by the payment provider and part of it was already spent. Give your principal the debt shown by `get_wallet` (`frozen`, `debt`): a top-up settles it. |
| `AGENT_REVOKED` | This agent was revoked. Register a new one with your principal's key. |
| `DISPUTE_ALREADY_OPEN`, `DISPUTE_LIMIT_REACHED` | The order has an open dispute awaiting a human, or has used its disputes. Wait, or contact your principal. |
| `PARTNER_INVALID`, `PARTNER_MISMATCH`, `PARTNER_ATTRIBUTION_LOCKED` | Partner credentials on `register_agent` are wrong, or the principal is attributed otherwise. Register without them. |
| `QUOTE_EXPIRED`, `QUOTE_ALREADY_USED` | Get a new quote. |
| `MANDATE_PER_TX_EXCEEDED`, `MANDATE_DAILY_EXCEEDED`, `MANDATE_MONTHLY_EXCEEDED` | Choose a cheaper plan, wait for the window to reset (UTC day or month), or ask your principal to raise the mandate (register a new agent). Refunds free up budget: limits count net spend. |
| `MANDATE_EXPIRED`, `MANDATE_SIGNATURE_INVALID` | Ask your principal for a new mandate. |
| `MANDATE_CATEGORY_NOT_ALLOWED` | Your mandate does not cover this category. |
| `PRODUCT_OUT_OF_STOCK`, `PRODUCT_NOT_FOUND`, `SUPPLIER_BELOW_TRUST_THRESHOLD` | Search again. |
| `AGENT_KEY_INVALID`, `PRINCIPAL_MISMATCH`, `AGENT_NOT_REGISTERED`, `IDENTITY_REQUIRED`, `IDENTITY_INVALID`, `IDENTITY_CONFLICT` | Fix identity; do not retry blindly. |
| `PRINCIPAL_KEY_REQUIRED`, `PRINCIPAL_KEY_INVALID` | The principal already exists. Ask your principal for its `principal_key`, or register under your own new `principal_id`. |
| `RATE_LIMITED` (tool error, or HTTP 429 with `Retry-After`) | Wait `retry_after_seconds`, then retry. Limits: about 120 tool calls per agent per minute, 300 HTTP requests per IP per minute (each tool call in a JSON-RPC batch counts as one), 30 `register_agent` / `rotate_principal_key` calls per IP per hour. |
| `DRAINING` (HTTP 503 with `Retry-After`) | The server is restarting. Retry in a few seconds; calls already running finish first. |
| `POLICY_EVALUATION_ERROR` | A store policy is misconfigured; the store denies by default. Retry later. |
| `INTERNAL_ERROR` | Retry later with the same `idempotency_key` (safe). |


## Audit trail

Every accepted tool call (`get_policies` and calls rejected for identity, authentication or rate limits are not logged), decision, order change, wallet hold, debit and credit, top-up notification, supplier call, refund and receipt is appended to a hash-chained ledger. Logged arguments are capped and never include keys or other secrets. Receipts carry `ledger.seq` and `ledger.hash`; `verify_receipt` confirms both the signature and the chain.

## Sandbox

In sandbox mode the catalog comes from a mock wholesaler (42 plans, 25 countries plus regional bundles), top-ups go through a mock provider that speaks PayPlus's format, and about 2% of purchases fail with a retryable `SUPPLIER_FAILURE` so you can test your retry logic. The rate is 3.70 ILS per USD unless the operator set `FX_USD_ILS`.

A sandbox can also list test plans from a real wholesaler (the `/docs` header says so). There, simulated payments are disabled: `sandbox_simulate_topup_payment` and `/sandbox/pay` fail with `SANDBOX_MOCK_PAYMENTS_DISABLED`, and the operator funds test wallets. Credit from simulated payments cannot buy from a real wholesaler (`SANDBOX_MOCK_CREDIT_NOT_SPENDABLE`).

Sandbox-only tools:

- `sandbox_simulate_topup_payment` with `topup_id` (and optionally `outcome: "declined"`): plays the principal paying a top-up link. The mock provider sends a signed PayPlus-format notification through the same webhook path production uses, and the wallet is credited. Over HTTP you can also `POST` the link URL (`/sandbox/pay/<topup_id>`). Not available where simulated payments are disabled (see above).
- `sandbox_simulate_activation`: marks an order's eSIM as installed and used so you can exercise refund and dispute rules.

A `principal_id` containing `decline` gets declined top-up payments by default.
