MCP Usage Guide
How to run a multi-turn shopping conversation on top of aon_search_offers:
act on the server's follow-up suggestions, keep the session continuous, retry
safely, and understand what happens when arguments are imperfect.
The recommended flow
- Vague intent? Call
aon_get_category_schema(oraon_resolve_categoryfirst if you have no category id) and ask the user one clarifying question from its decision factors. - Search. Call
aon_search_offerswith one natural sentence inintent.content. Addconstraints.category_idsonly with ids you got from the other two tools — never invent them. - Present offers. Show the offer title and description from
offer_info, the merchant or provider fromentity.name, and theaction.payload.urllink verbatim.match_reasontells you why each offer matched — use it in your presentation. - Offer the follow-ups. If the response has
data.engagement, itsrefinementsandfollowup_topicsare pre-validated, inventory-backed next questions: the server only suggests moves that have inventory behind them.
The guidance loop
Each entry in engagement.refinements[] and engagement.followup_topics[]
carries a label (show it to the user) and a query_helper:
{
"label": "Under $200",
"query_helper": {
"request_patch": { "intent": { "signals": { "budget": { "max": 200, "currency": "USD" } } } },
"origin": [{ "kind": "query_helper", "id": "qh_..." }]
}
}Budget is an intent.signals value, so every budget helper includes both a
numeric max and an uppercase ISO 4217 currency. The server may only emit
one when the current request has a known currency; it must not infer a
currency from the user's language or region.
Category ids remain canonical filters under constraints, not natural-language
intent. A category-complement helper therefore patches a different branch of
the next request:
{
"label": "Hiking socks",
"query_helper": {
"request_patch": { "constraints": { "category_ids": ["cat_hiking_socks"] } },
"origin": [{ "kind": "query_helper", "id": "qh_..." }]
}
}When the user picks a suggestion, three things go into your next
aon_search_offers call:
- Merge the patch.
request_patchis an RFC 7386 merge-patch whose keys are protocol field paths — merge it straight into your previous request object. It can patchintentorconstraints; do not move canonical category ids intointentjust to make the JSON shape uniform. - Echo the origin. Copy
query_helper.originintointent.origin[]. This is how adoption is attributed; skip it and the loop's analytics — and your ranking benefit — are lost. - Chain the round. Pass the previous response's
data.request_idascontext.session.previous_request_idso offers the user already saw are not repeated.
Session continuity
context.session_id— send a stable, unguessable id per conversation (a fresh UUID). With a user identity, cross-round offer de-duplication engages when both are present. In anonymous mode (no user identity) de-duplication and intent memory are keyed by the session id alone, so never use a guessable value such assession-1— it would mix your history with other callers'.- User identity — hosts that proxy many end users should send the
x-aon-user-pseudo-idHTTP header per user. Thecontext.user_profile.user_pseudo_idfield is a fallback for clients that cannot set headers; the header always wins. Single-user MCP clients (Claude Code, Cursor, a personal desktop) can skip this entirely. context.session.recent_topics[]— up to 10 short phrases of recent conversation context; helps the server rank follow-up topics.
request_id idempotency
request_id is an idempotency key with a 10-minute window. Two
obligations:
- Retry = same id, new query = new id. Reusing an old id within the window returns the previous full response without an error — a changed question with a stale id silently gets the stale answer.
- Never share ids across end users. Under one placement (or the shared anonymous application), two users reusing one id would receive each other's responses — including each other's tracking links, which mis-attributes clicks and conversions.
Omit request_id and every call is treated as new (the server generates one
for the response; the generated id does not join the idempotency window).
In anonymous mode (no placement) request_id is echoed but never replayed:
every call is computed fresh, and a retry recomputes. To keep multi-turn
memory for an anonymous user, pass context.session_id or the
x-aon-user-pseudo-id header.
Tolerant degradation
The search arm is forgiving on purpose: a value-level problem never fails
the call. A wrong enum value, an out-of-range number, a malformed field —
each is treated as "not sent" and reported in extra.hints[] with a
machine-readable code and the closest valid option. If you see hints, read
them: they tell you exactly how to fix the next call.
Only four things hard-fail:
- A malformed, unknown or inactive placement (a missing placement runs in
anonymous mode instead), or wrong
X-AON-Testusage - Invalid JSON
- Missing
intent.content - Oversized request body
Two more behaviors worth knowing:
- Unknown category ids in
constraints.category_idscome back as an in-band correction listing valid candidates — resolve ids throughaon_resolve_categoryto avoid the round-trip. - An empty
offerslist is a real answer. Checkdata.empty_reason, relay it honestly, and offer the engagement follow-ups instead of retrying the same query.
Paging through recommendations
Both aon_search_offers and the hosted REST Offer Query API
accept nested pagination. Send limit (1 to 50; the default is 3, or 20 for
Flight queries; larger values are clamped to 50) on the first request. When the
request includes pagination, the response returns has_more and, while more
offers are available, next_cursor. Neither field is returned without
pagination.
To get the next page, repeat the same intent and constraints and pass
next_cursor back unchanged as pagination.cursor. Send next_cursor back
exactly as received: the cursor is opaque, so do not parse, build, or edit it.
Do not send offset: for ordinary recommendation queries a
pagination.offset or top-level offset greater than 0 fails with
BAD_REQUEST (HTTP 400), and offset: 0 is treated as the first page. Offer
Wall placements still accept a deprecated offset (see
Offer Wall paging); offset together with cursor
fails with BAD_REQUEST everywhere.
INVALID_CURSOR(HTTP 400): the cursor is malformed, has an unrecognized version, does not match this kind of query, is out of range, or belongs to a different query. Fix the request; do not retry it unchanged.PAGINATION_EXPIRED(HTTP 410): an ordinary-recommendation round lasts 30 minutes, or the cursor's handle is invalid. Run the query again from the first page.
An ordinary-recommendation round returns at most 100 offers in total, then
has_more is false. Offer Wall, Flight, and Hotel cursors do not expire.
Sending the same cursor again returns the same batch of offers and records
impressions again, with no effect on billing; a live partner offer is treated
as no longer available when replayed.
Use a new request_id for each page and reuse that id only when retrying the
same page. Offer Wall pages use the same has_more / next_cursor fields,
described in the Offer Wall guide.
Do / don't
| Do | Don't |
|---|---|
Surface action.payload.url links verbatim | Rewrite, shorten, or proxy tracking links |
Echo query_helper.origin when a suggestion is adopted | Apply a patch but drop its origin |
| Send one natural sentence as intent | Stuff keyword lists into intent.content |
Use ids from aon_resolve_category | Guess or invent taxonomy ids |
| Show an honest "nothing relevant" on empty results | Pad with unrelated offers |