# 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:** mock eSIM supplier, mock payments, nothing is charged and no eSIM works on a real phone.

- 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-01T00: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.
- `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 top-up links, 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 disputes.
- `regenerate_activation`: For one of your own delivered orders whose activation code (QR / LPA) does not install: asks the supplier for a fresh code and returns the new delivery payload.
- `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, 3 to 128 chars of `A-Z a-z 0-9 . _ : -` |
| `principal_id` | `X-Principal-Id` | Who you act for; must match registration |
| `agent_key` | `X-Agent-Key` | Secret returned once by `register_agent`. 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` |

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 fails, the hold is released and nothing is debited. 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`.

9. **`order_status`** with `order_id`. Shows the order, the eSIM's activation state and data used, receipts and disputes.

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

10. **`verify_receipt`** with a receipt object. 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, or for `refund_rule: none`. |
| `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% if activated with under 10% of the data used. Escalated otherwise. |
| `duplicate_purchase` | `dispute` | Full refund if you bought the same plan within 24 hours and this copy is unactivated. |

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). Refunds are credited to the principal's wallet immediately (`refund_destination: "wallet"`), and refunded amounts free up mandate budget.

## 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-01.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` | 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. |
| `SUPPLIER_FAILURE` with `retryable: false` | Pick a different plan. |
| `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` | 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, 30 `register_agent` / `rotate_principal_key` calls per IP per hour. |
| `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 tool call, decision, order change, wallet hold, debit and credit, top-up notification, supplier call, refund and receipt is appended to a hash-chained ledger. 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`.

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>`).
- `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.
