---
name: miles-wallet
description: >
  Keep the owner's Miles wallet record correct over the MCP connection at
  askmiles.ai — adding cards, closing them, product changes, airline-fee
  selections, retention offers. Use when writing to the wallet, and when a
  read has to be right before a write.
homepage: https://askmiles.ai/connect?utm_source=skill&utm_medium=assistant
---

# Miles wallet

*Last updated 2026-08-27 — the latest version of this file lives at
https://askmiles.ai/skills/miles-wallet.md; re-fetch it to upgrade.*

Miles keeps a record of the cards the owner holds: what they opened and when,
what each one is worth to them, which airline the $200 credit is locked to
this year, what the issuer offered to keep them. This skill is how your agent
writes to that record without damaging it.

**This is not the `ask-miles` skill.** That one is a chat endpoint — one
question, one answer, read only. This is the wallet record over MCP: tools,
writes, and the rules that bind them together. Install both if you use both;
they do not overlap.

The tools carry their own descriptions and those descriptions are worth
reading. What follows is what no single tool description can tell you: the
rules that only show up when two tools meet.

## Connect

```
claude mcp add --transport http Miles https://mcp.askmiles.ai/mcp
```

Other clients, and the OAuth details, are at https://askmiles.ai/connect?utm_source=skill&utm_medium=assistant.
Read access is the default. **Writing needs `miles:write`**, a separate
checkbox on the approval screen — if a write comes back saying you granted
read-only access, that is a consent the owner has to re-give, not a call to
retry.

## The tools that keep the record

| Tool | What it is for |
| --- | --- |
| `get_wallet_cards` | The wallet, one row per card. Your read. |
| `get_card_info` | One card PRODUCT in full — the catalog, not the holding. |
| `add_card` | One card, or a batch. |
| `update_card` | Every correction, every status change, product changes. |
| `remove_card` | Delete a holding outright. Rarely what you want — see below. |
| `get_product_changes` | Which upgrades and downgrades a held card can take. |
| `get_needs_attention` | What is due right now. Poll this; do not compute it. |
| `get_reconciliation_needs` | Open questions a transactions agent could answer. |

## Reading before you write

**`summary` is a projection, not the schema.** `get_wallet_cards` defaults to
`format="summary"`, roughly fifteen fields chosen to save tokens. `"full"`
returns everything; `"with_multipliers"` sits between. Two different things
make a field absent from a summary row, and neither means the wallet lacks it:

- The format projected it away. Every response names exactly which fields
  those were, in `wallet_fields_omitted_by_format` — read that list before
  concluding anything is missing.
- Its stored value was null. Null-valued keys are dropped from a row rather
  than sent as `null`.

So an absent field is never evidence of an absent value. Call `format="full"`
before you conclude, and never report "you have not recorded X" off a summary.

**Closed cards are hidden by default.** Pass `include_closed=true` for them.
Every response carries `closed_cards_excluded` — zero cards shown with
`closed_cards_excluded: 4` means four closed cards exist, not an empty wallet.

**`get_card_info` takes either a product name or a wallet id.** `cc_47`,
`cc_82` — the ids `get_wallet_cards` keys its rows by — resolve to the
product that entry holds. Prefer the id: it is stable, while a display name
moves with every product change. An id that is not in the wallet is refused by
id rather than matched onto some other card.

What comes back either way is the **product**: fees, earning rates, credits,
benefits, transfer partners. The owner's own fields on that entry — nickname,
notes, `benefit_selections`, `date_opened` — are not on it. Those live on
`get_wallet_cards`.

**The id is spelled two ways, and you will use both.** A read hands it back
as `wallet_id`; every write takes it as `card_id`. Same value, and it is on
every row including the summary — so `update_card(card_id=<the wallet_id you
just read>)` is the identification you should reach for by default.
`lookup_card_name` exists for when you have only a name, and it can fail two
ways the id cannot.

**`updated_at` is only on `format="full"` and on a write's echo.** It is not
in the summary projection, and it is not in that projection's
`wallet_fields_omitted_by_format` list either. You need it for the
precondition below, so read `full` or keep the token from your last write.

**`disposition`, `review_by` and `fee_posts_on` are `format="full"` only**
— none of the three are in the summary projection. `disposition_set_at`,
the timestamp Miles stamps the moment `disposition` is written, is on
neither format: it is not writable and it is not readable through
`get_wallet_cards` at all. The only place you will ever see it is the write
echo of your own `update_card`/`add_card` call. So do not tell a member
when they last recorded a decision unless you just wrote it yourself — the
date is shown to them on the card's own page, which is where it belongs.

## The write echo IS your confirmation

`add_card`, `update_card` and the product-change path all return
`wallet_card`: the stored entry as it now stands, carrying every field just
written. Read it. There is no follow-up call to make, and a whole-wallet
`format="full"` fetch after every write is a cost you do not owe.

Its boundary, so this has a stated limit rather than an assumed one. The echo
is the **raw stored entry**, not the merged card a read builds:

- Catalog data is not on it — `benefits`, `network`, transfer partners,
  earning rates. A write never touches the catalog.
- Computed read-merge fields are not on it. `benefit_selections_current` is
  the one that matters: it is derived from the credit's own frequency and the
  card's anniversary, neither of which a write sees. `cardholder_name` is the
  other.
- Three keys are spelled differently on the raw entry than on a read: `id`
  (which reads back as `wallet_id`), `notes` (as `user_notes`), and
  `sign_up_bonus` (as `user_sign_up_bonus`). A field you wrote is on the
  echo under the write-side name.
- **`sign_up_bonus` is the one to be careful with**, because it is the only
  spelling that collides. The name exists on both shapes and means different
  things: on the echo it is the member's own value; on a merged read it is
  the CATALOG's current offer for that product, and the member's is at
  `user_sign_up_bonus`. `id` and `notes` simply go missing, which you
  notice. This one hands you a plausible wrong value. Confirm a written
  `sign_up_bonus` from the echo, then re-read to build your next write, and
  you will write the catalog's offer over what the member told you.

For catalog data and read-merge fields, read back through
`get_wallet_cards`. For a renamed key, just read the echo under the
write-side name — the value is there.

## Replace, merge, and the race between them

This is the rule that costs real data when it is missed.

**Three fields REPLACE the whole stored value:**

- `benefit_selections`
- `retention_offers`
- `product_history`

Sending one entry does not append it. It deletes everything else under that
key. To add one entry you must read the current list, append to it, and send
the whole thing back.

**`card_settings` MERGES.** Send only the keys that changed. A key whose
value is `null` inside the dict is deleted from the stored settings; other
keys are left alone.

Everything else is a scalar you set outright.

**The read-modify-write on those three fields races, and a stale COMPLETE
list is exactly as destructive as a partial one.** Between your read and your
write, the owner may pick an airline on askmiles.ai, or a second agent session
may write. Your write then removes what they did. Nothing fails. The list you
read back afterwards looks entirely valid, so the loss is invisible on every
surface — including to the owner.

**`expected_updated_at` closes that.** Pass the card's `updated_at` as you
last read it:

1. Read the card (`get_wallet_cards` at `format="full"`, or keep the
   `wallet_card` echo from your last write). Keep its `updated_at`.
2. Append your new entry to the list you just read.
3. `update_card(card_id=..., benefit_selections=<the whole map>,
   expected_updated_at=<the token>)`.

If the stored card moved in between, nothing is written and you get back
`error: "stale_precondition"`, naming both the token you sent and the token
now stored. That is retryable and it is the whole point: re-read, re-apply
your change to the **current** values, retry with the new `updated_at`. Never
retry the same body with the same stale token, and never strip the
precondition to force the write through — the refusal is a member's data
you are about to delete.

Omit it and behavior is what it always was: last writer wins, silently. It is
optional so that clients written before it keep working, not because skipping
it is safe. **Send it on every read-modify-write of those three fields.**

It guards the product-change path too.

Two more things you cannot express here: `update_card` drops null arguments
before they reach storage, so **passing `null` does not clear a field** — the
call reports success and changes nothing. And there is no append tool; the
read-modify-write above is the only way to add one entry.

`disposition` is the field where that silently substitutes a different
fact, not just a no-op. An absent `disposition` means "nobody has decided
anything about this holding"; `undecided` means "the member looked and
could not make the call" — two different, meaningful states. Sending
`disposition: null` to get back to the first one from the second reports
success and leaves `undecided` stored, because the null is dropped before
storage ever sees it. There is no way to clear `disposition`, `review_by`
or `fee_posts_on` back to absent from here today. If a member wants a
disposition record removed rather than changed to another value, say so and
point them to /wallet — do not report the clear as done.

## The current pick is not the last entry

`benefit_selections` is a **history**, one entry per benefit window, sorted
by the window each pick governs. The last entry of the list is not the pick in
force. An owner who chose Delta for 2025 and has not chosen for 2026 is not on
Delta — they have an unspent credit and a deadline, which is a different
sentence and a more useful one.

Read `benefit_selections_current` instead. It is on the summary projection —
you do not need `format="full"` for it — and it resolves the question
properly, keyed by credit:

- `choices`, `period`, `selected_on`, `source` — the pick as stored.
- `current_period` — the window open today, from the credit's own frequency
  and this card's anniversary.
- `is_current` — whether the pick shown is FOR the open window. `false`
  means the most recent pick on record predates it: unchosen, not chosen.

Each entry you write carries `choices` (a list, even for a one-pick credit),
`period` (the window the pick GOVERNS), `selected_on` (when it was made), and
`source` (your evidence — a confirmation email id, "amex.com", whatever you
relied on). `period` and `selected_on` are different facts: a pick made on
2025-12-28 for the coming year has `selected_on: "2025-12-28"` and
`period: "2026"`. Omit `period` and Miles derives it from `selected_on`.

Record a change by ADDING an entry, never by editing the one there. Two
entries in one period are accepted — an issuer's phone rep will move a
selection mid-year — as long as each has its own `selected_on` to order them
by. The keys are the credit's own ids from the card's benefits
(`airline_fees`, `splurge`); an id the card does not have is refused by name,
and the refusal lists the ids it does have.

## Closing a card is an event, not a deletion

A closed card is still part of the owner's history: it still counts for 5/24,
it still carries the product changes and the sign-up bonus it earned.

- **To close:** `update_card(card_id=..., status="closed", closed_on="YYYY-MM-DD")`.
- **`status="closed"` on its own stamps TODAY as `closed_on`.** If the owner
  told you when it actually closed, pass `closed_on` — otherwise Miles
  records a closure date that is fiction. Passing `closed_on` by itself
  corrects the date on a card already closed.
- Reopening (`status="active"`) clears `closed_on`, so a later re-close
  stamps a fresh date.

**`remove_card` is not "close".** It deletes the holding outright — the
entry, its `wallet_id`, its product history, its benefit selections, its
retention offers, its open date. There is no undo and no soft delete. Use it
for something that was never true (a card added by mistake, a duplicate), and
for nothing else.

## Product changes

An upgrade or downgrade that is happening NOW is
`update_card(card_id=..., product_change=true, new_card_name=...,
changed_date="YYYY-MM-DD")`. Miles does the rest server-side. **Do not send
`card_name`, `original_card_name` or `product_history` in that call** — it
writes all three itself: it moves the card's name, sets
`original_card_name` if this is the first change, and APPENDS the history
row.

Two effects worth knowing before you call it:

- **The sign-up bonus fields are cleared.** `sign_up_bonus`,
  `signup_bonus_status` and `signup_bonus_received_on` are wiped on every
  agent-driven product change. The result names what it cleared in
  `cleared_fields`; relay that rather than discovering it later.
- **`network_override` is cleared too**, always — it is an answer about one
  product and is wrong about the card the entry becomes.

`changed_date` left out means Miles stamps today, which forward-dates a real
event. Pass the date you were given. To replay several changes, call once per
change in chronological order.

Sending `product_history` or `original_card_name` on a plain `update_card`
is the repair path for a **different** problem: fixing a row already recorded
wrong. It replaces the whole list — see Replace above, and send
`expected_updated_at` with it.

## Adding cards

`add_card` with a `card_name`. What comes back is not simply success or
failure:

- Adding a card the wallet already holds is **not an error**. It returns
  `success: true` with `already_present: true` and echoes the existing card,
  because the wallet is in the state that was asked for. Do not retry it into
  a real duplicate. If the owner genuinely holds a second copy, and only
  then, call again with `intentional_duplicate=true`.
- **`entry_key` is an idempotency key** of your own choosing, up to 64
  characters. Repeat the same key and the add is an exact no-op returning the
  existing card. Use one whenever a lost response would leave you unsure
  whether a card landed.
- **The batch form is different.** Pass `cards`, a list using the same
  fields. The response carries one row per input entry, each with a
  `status`: `applied`, `already_present`, `ambiguous` (no confident match
  for that name — retry that one entry with a name from its `close_matches`
  or from `search_cards`), or `rejected` (with a `reason`). Partial success
  is normal for a real migration and a failed entry never blocks the rest.
  Batches are capped per call; a batch over the cap is refused whole, with
  the cap named and nothing applied.

A retired product — a program that has been shut down — is accepted with
`status="closed"`, because history is what it is, and refused as an active
holding.

## Household attribution

`cardholder` takes a member **name**, as it appears in the owner's Household
Profiles. A name matching no member is refused rather than guessed.

Read the names off an existing card's `cardholder_name` or from
`get_eligibility`. The two spellings are worth knowing: the summary
projection gives you `cardholder_name` (the name — what you write back), and
`format="full"` gives you both that and `cardholder` (the internal member
id). Write the name; never write the id back.

Household Profiles is a Membership feature. For an owner without it the card
is still added or updated — minus the attribution — and the result carries a
`note` saying so. Check for that note rather than reporting an attribution
that did not happen.

## What to refuse to guess

The rules above are enforced by the server. These are not, and they are the
ones that decide whether the owner's record is worth trusting.

**Never substitute today for a date you were not given.** Not for
`date_opened`, not for `changed_date`, not for `selected_on`, not for
`closed_on`. Every date field here is optional precisely so a partial record
can be honest. "This card was product changed, date unknown" is true; a row
saying it happened today is false, and no later reader can tell which one they
are looking at.

**Never derive `anniversary_date` from `date_opened`.** For many issuers they
differ, and every free-night and credit-cycle figure Miles computes reads that
field. If the owner did not say, leave it unset.

**Never choose the airline.** `benefit_selections` records a decision that
locks real money to one vendor for a year. Record only what the owner said, or
what an issuer confirmation they showed you says. If they have not chosen,
`is_current: false` is the correct state and telling them the window is open
is the useful answer.

**`sub_target` is what the bonus requires, not what they have spent.** Miles
holds no transactions and there is no field for progress. Do not read a
deadline off the card's marketing terms either — only off what the owner told
you.

**Ask before you do something they cannot undo.** `remove_card` deletes a
holding permanently. `update_card(status="closed")` changes what the wallet
says about a real account. Neither has a confirmation step in the tool
surface, and nothing in a schema can add one. Say what you are about to do and
get a yes first. Every other write here is correctable; these two are the ones
worth the extra turn.

**Leave a reconciliation question open rather than answering it wrong.**
`get_reconciliation_needs` returns questions that default to unanswered. Only
write back a confirmation when you found an actual matching transaction — the
right merchant or card, in the stated window, roughly the stated amount. An
unconfirmed question costs nothing; a false confirmation writes fiction into a
permanent ledger.

**Never record a decision the member did not state.** `disposition` is what
the MEMBER decided to do with a holding, never Miles' own arithmetic.
`analyze_keep_cancel`'s `recommendation` is that arithmetic — copying it
into `disposition` fabricates a choice the member never made and then nags
them about a decision they did not take. "Should I cancel this?" is a
question, asking for analysis; "I'm cancelling this" is a decision, asking
you to record one — they are different sentences, and only the second
writes `disposition`. Leave the field out entirely when nobody has said
anything: absent means "nobody has decided," a different fact from
`undecided`, which means the member looked and could not make the call.
When `analyze_keep_cancel`'s `member_decision` and `recommendation`
disagree, that disagreement is the useful thing to tell the member — say
so, and leave the record alone rather than writing over either one.

## When a call comes back wrong

- **`stale_precondition`** — expected. Re-read, re-apply, retry. See above.
- **Read-only access** — the owner granted `miles:read`. They have to
  disconnect and reconnect with wallet changes checked. Not retryable.
- **Connections paused** — the owner turned MCP access off in Settings.
  Theirs to undo.
- **A Membership refusal** — some tools (custom valuations, the watchlist,
  credit and certificate tracking, elite status) are Membership features.
  `add_card`, `update_card` and `remove_card` are not among them; they are
  free. A refusal here is a fixed answer, not a ceiling that resets.
- **A daily ceiling** — call, write and mutation ceilings exist as a
  runaway-loop guard. A successful call carries an `advisory` as you approach
  one, and the refusal names when it resets. Slow down; do not retry in a
  loop.
- **`multiple_matches` or `not_found`** on `lookup_card_name` — two copies
  of one product, or a nickname that matches several. Pass `card_id`
  instead, using the `wallet_id` off the row you read. It is exact, and it
  is the failure neither of these can produce.

An error, a timeout or a refusal is never Miles saying the card does not
exist. Say the write did not land and what it was, rather than reporting a
record you did not make.
