Agnic
Agentic Commerce

Checkout tools

The seven MCP tools that find a merchant, price an order, and place it — with the rules that decide when each one is safe to call.

Checkout tools

Seven tools do the whole job: find a merchant, look at a product, price an order, place it, and follow it to the end. They are exposed by the Agnic MCP server at https://mcp.agnic.ai/sse.

This page describes the tools. There are HTTP routes underneath them, listed in the API reference, and they are not simply the same thing with a different envelope. One difference decides how you write your code, so it is worth reading before anything else.

Confirmation tokens are a tool-layer idea. The HTTP routes do not have them.

preview_order mints a token bound to a hash of the order — items, amount, currency, destination, spending caps — and place_order refuses if any of it changed. That is what stops a delivery address or a spending limit moving between the moment a person said yes and the moment money moves.

POST /api/autofill/dispatch never sees a token. It rebuilds the cart itself and refuses on its own terms: 409 if a spending cap is breached, price_changed if the total came out above the amount you authorised. Both are pre-charge and both are safe — but the guarantee is different, and a REST integration that assumes token binding is assuming something that is not there.

Everything else on this page is true of both.

Two ideas run through all of them, and if you only remember two things, remember these.

Preview is a promise; place is the only thing that spends money. preview_order never launches a browser and never charges. It returns a confirmation_token bound to the exact order it priced. place_order will not run without that token, and the token stops matching the moment anything about the order changes.

Processing is not failure. A real checkout takes about a minute — longer than a tool call politely waits. place_order returns processing and an order_id. Poll get_order_status. Never call place_order twice for the same order; that is how a customer gets charged twice.


Finding something to buy

find_merchants takes an optional query and returns merchants already in the network, each with a merchant_id, a rail, and an is_test flag. The rail matters more than it looks: shopify merchants quote live prices and real shipping options, while worker merchants price from a cached catalogue. Ship-to only works on the Shopify rail.

discover_merchant takes a merchant_url and a goal in plain language, and onboards a store Agnic has never seen. A model drives the live checkout read-only, stops at the payment step, charges nothing, and records what an order there takes. Use it when the merchant you want is not in find_merchants yet. It is the slow one — allow a couple of minutes.

get_product_details takes a merchant_id and a sku and returns the live product. Prices here are browse-time. The preview is the money truth.

When the user names a thing, not a shop

search_products takes a query and a country and searches the whole vetted network at once. It is the tool for "a beeswax candle" where find_merchants is the tool for "that candle shop in Ealing".

Every result carries a sku you can hand straight to preview_order. Some carry an onboard.merchant_url instead of a merchant_id, which means Agnic has never bought from that shop. That is normal and it is most of the network: call discover_merchant with that URL first, then preview. A result without onboard is ready immediately.

Markets are US, GB, CA and AU — the four with a hand-vetted shop pool. Anything else is an answer, not a retryable error.

An empty result does not mean the product does not exist. This searches vetted shops, not the web.

lookup_product takes a url and is what you reach for the moment someone pastes a link. Send the URL exactly as they copied it. The ?variant= parameter is which size or colour they chose; strip it and you are guessing on their behalf. Without one, the first available option is used and the response says so, which is your cue to ask rather than assume.

It is Shopify-only and it never onboards anything by itself. A GET that quietly created a merchant record would be a side effect nobody asked for.


Pricing an order

preview_order takes the merchant, the items, and what you believe the amount is. You do not need to be right about the amount, and you do not need to know the exact SKU — pass your best guess and read what comes back.

It returns one of three things.

Blockers. A list, each with a code and a next_action written for a model to follow. unknown_sku carries the nearest real products. setup_required lists every missing prerequisite at once, each with a deep link, so the user completes setup in one pass rather than three round trips. A blocker is a workflow step, not an error.

Blockers are a tool-layer idea too. setup_required in particular is preview_order's own poka-yoke: it refuses to hand you a token until a card, a profile and a signed spending mandate all exist. The HTTP route does not enforce that. POST /dispatch will place an order for a caller with no mandate on file, recording that fact on the order rather than refusing. If you are integrating over HTTP and you want the mandate to be mandatory, check for it yourself.

A question. When a merchant offers more than one fulfilment option and you have not chosen, requires_fulfillment_choice is true and no token is issued. Show the options, ask, and call preview again with fulfillment_option_id.

A token. ready_to_place is true, and preview_summary_for_user is a sentence written to be shown verbatim. Show it, get an affirmative reply, and pass that reply into place_order.

The amount is either a total or a ceiling

amount_is_final decides which, and telling a user a ceiling is the total is the mistake this field exists to prevent. When it is true, amount_minor is what will be charged. When it is false, the merchant adds tax at checkout: the real charge is read off the checkout page and is never above the figure you were given. State it as "subtotal plus tax, never more than X".

Delivering to someone else

Pass ship_to when the buyer is not the person receiving the parcel — a gift, a registry, a customer of yours. It is order data: it does not touch the cardholder's saved address, and the next order goes back to the cardholder unless you say otherwise.

{
  "name": "Dana Okonkwo",
  "street_address": "Unit 18, Dickens Yard",
  "address_locality": "Ealing",
  "postal_code": "W5 2TG",
  "address_country": "GB"
}

address_country is an ISO-3166-1 alpha-2 code. A province or state is required for Canada, the United States and Australia, because those checkouts will not complete without one. Everything else in the object is required; a half-address is not an address, and the refusal names the field that is missing rather than making you guess.

The preview prices shipping in the destination's market, so the options and totals you see are the ones that apply to where the parcel is actually going. The merchant's confirmation email still goes to the cardholder. That is deliberate, and it is what someone buying on behalf of a customer wants.

Ship-to works on Shopify-rail merchants, and it cannot be combined with in-store pickup. Both refusals come back as blockers with a next action.

Refusing before the card

Pass constraints when the user has stated a limit.

{ "max_total_minor": 6000, "max_shipping_minor": 1200 }

Both are in minor units of the order currency, the same unit as amount_minor. They are checked at preview and checked again against a freshly built cart before the card is used.

Through the tools they are also bound into the confirmation token, so they cannot change between approving and placing. Over HTTP that binding does not exist — the second check still happens, and a breach is still a 409 before any charge, but nothing stops the caller sending different caps the second time. They are the caller's own caps either way; the binding matters when a model is choosing them on a user's behalf.

When a limit is breached you get a blocker carrying both figures — what it costs and what the limit was — so you can state the gap rather than describe it. Tell the user and ask. Do not raise a limit yourself; it is an instruction, not a suggestion.

On a merchant whose rail cannot price shipping before checkout, a shipping limit returns constraint_unverifiable. That is deliberate. It says we cannot check, which is not the same as saying it passed.


Placing it

place_order needs the confirmation_token, the user's literal reply, and a timestamp. If you passed ship_to or constraints to preview, pass exactly the same values here.

That last rule is not bureaucracy. The token is bound to a hash of the order, destination and limits included, so a corrected typo or a rounded cap makes the token stop matching and nothing is charged. It is what stops an address or a spending limit changing between the moment a person said yes and the moment the money moves. If the user genuinely wants something different, preview again and get a fresh approval.

place_order can come back as approval_required. The user is being asked to approve this specific purchase with their passkey, because it falls outside the standing policy they signed. Show the approval URL, poll check_approval_status, then call place_order again with the approval_token. It is a step in the flow, not a failure.

Over HTTP the same thing arrives as a 202 from POST /dispatch, carrying approval_required, approval_token, approval_url, expires_in and reason. Handle it: open the URL so the user can approve with their passkey, then dispatch again with the approval_token. An integration that treats a 202 as success will report an order placed that never was.


Knowing what happened

get_order_status takes an order_id. Poll it every few seconds while an order is processing.

Every finished order carries two fields that exist so you never have to pattern-match an error string.

retryable is true when placing the order again is safe, and it only says that when the evidence proves the card was never submitted. false means a retry fails the same way, or it already succeeded. null means nobody knows and money may have moved — stop, and tell the user to check their statement.

retry_action is the single next call: re_preview when the quote is dead, poll when it is not finished, handoff when a person has to act at the live URL, contact_support when nothing should be automated past this point, and none when it is settled.

Both are computed server-side from the status, the error code and the charge evidence together, which is why they are worth more than the error code alone. The help string beside them is the sentence to say to a person; these two are what your code branches on.

A handoff means a human step appeared mid-order — a captcha, a bank's 3-D Secure check, or a merchant sign-in. Show the live URL and keep polling. Do not re-place.


Timings, and one honest caveat

A confirmation token lives five minutes. A preview is free and idempotent, so re-preview rather than nursing a token.

A discover_merchant call can take two minutes. A place_order typically settles in sixty to seventy seconds. Both of those are longer than a comfortable tool call, which is why both hand you an id and expect you to poll.

There are no webhooks yet. Polling is the documented path, not a workaround.