<!-- Generated from Btab's docs/api/AGENTIC_COMMERCE.md. The same guide as a web page: https://btab.app/developers/agentic-commerce -->

# Agentic commerce — how an AI shopping agent reads BTAB

Every BTAB storefront is legible to an AI shopping/answer agent without executing JavaScript.
All of it is **read-only, public catalogue data** — the same data the HTML already serves —
so nothing here needs a credential. (The *operator* side — driving your own store from an
agent — is the separate, authenticated [Vendor MCP](/developers/vendor-mcp.md).)

**Scope decision (2026-09-08):** discovery aggregates at `btab.app`; the transaction stays on
the vendor's own storefront. The vendor remains seller of record, so attribution and the
platform-fee split are unchanged whether a buyer arrived by browser or by agent. Automated
checkout is **not** offered anywhere (`agentic_checkout: false`) until a transaction rail exists.

## Per-store surfaces (any `<slug>.btab.app` or live custom domain)

Every surface is generated from the request host — there is no hand-written per-vendor file.

| Surface | What it is |
|---|---|
| `/agents.md` | Generated instruction sheet: store identity, catalogue entry points, prices are AUD, published policies, enquiry contact, purchasing posture. |
| `/.well-known/ucp` | Minimal, honest UCP merchant profile. `capabilities: []` and `transaction.agentic_checkout: false` — we declare only what we serve. |
| `/sitemap.xml` | Every product at its canonical slug URL + content pages. |
| `/robots.txt` | Default-allow for search + AI crawlers; junk routes disallowed. |
| `/products/<name>-<id>` | The PDP. Embeds schema.org `Product` + `Offer` (+ `BreadcrumbList`) JSON-LD. Numeric or stale-slug URLs 308 to the canonical slug. |
| `/products/<name>-<id>.json` | **The same Product + Offer JSON-LD as a first-class document** (Shopify convention). See below. |

### `GET /products/{slug}.json`

The PDP's structured data as a document, plus one non-schema block, `purchase`, that says
honestly what an agent may do next.

```
$ curl -s https://<store>/products/leather-sofa-42.json
{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "Leather Sofa",
  "sku": "SOFA-1",
  "productID": "42",
  "url": "https://<store>/products/leather-sofa-42",
  "offers": {
    "@type": "Offer",
    "url": "https://<store>/products/leather-sofa-42",
    "price": "1299.00",
    "priceCurrency": "AUD",
    "availability": "https://schema.org/InStock",
    "itemCondition": "https://schema.org/NewCondition"
  },
  "image": ["https://…/sofa-1.jpg"],
  "description": "A comfy leather sofa",
  "brand": { "@type": "Brand", "name": "ComfyCo" },
  "category": "Furniture",
  "purchase": {
    "enquiry_only": false,
    "agentic_checkout": false,
    "url": "https://<store>/products/leather-sofa-42"
  }
}
```

- **The same data as the product page's JSON-LD**, so the two can never disagree. Price is AUD, the binding currency. Description is plain text (HTML stripped).
- **Same visibility as the product page.** A product the store's own page does not show (not
  active, not approved, not selected by the store, or unpriced) is not here either.
- **`purchase.enquiry_only: true`** — the vendor sells this product by enquiry, not cart. The
  Offer still carries the displayed price (the PDP shows it), but do not treat the product as
  add-to-cart-able; direct the buyer to `purchase.url`, which deep-links the enquiry form.
- **`purchase.agentic_checkout`** is `false` everywhere. Never attempt to buy programmatically;
  send the buyer to `purchase.url`.
- `<slug>` may be the canonical `name-<id>` or a bare numeric id; a numeric id or stale slug
  **308**s to the canonical `<slug>.json`. Unknown product or malformed ref → `404
  {"error":"product_not_found"}`; unknown host → `404 {"error":"store_not_found"}`.
- `Content-Type: application/json`, `Cache-Control: public, max-age=60`,
  `Access-Control-Allow-Origin: *`. `HEAD` works.

The public address is the `.json` suffix; any other path the store answers it from is an
implementation detail and may change.

## Platform front door (`btab.app`)

An agent shopping "Australia" should not have to find sixty storefronts one by one. `btab.app`
aggregates **discovery** only — it never re-serves a catalogue and never transacts; every
pointer leads to a store's own origin, where the transaction and the seller of record live.
Only **published + discoverable** stores are listed (the same predicate as
`GET /api/public/stores`).

| Surface | What it is |
|---|---|
| `https://btab.app/agents.md` | Platform instruction sheet: what Btab is, how to discover stores (directory HTML + JSON, agentic sitemap), what every store serves, AUD pricing, purchasing posture, and the live store list. |
| `https://btab.app/.well-known/ucp` | Platform profile — `role: marketplace`, `capabilities: []`, `transaction.agentic_checkout: false`, `seller_of_record: store`, `discovery.*` pointers, and `stores[]` each with its own `agents` + `ucp` URL. |
| `https://btab.app/sitemap-agentic-discovery.xml` | Sitemap of agent surfaces: the platform's `agents.md` + `ucp`, then every store's `agents.md`, `.well-known/ucp` and `sitemap.xml`. Listed in `robots.txt` beside `sitemap.xml`. |
| `https://btab.app/llms.txt` | Btab's own index for agents and developer tools (llmstxt.org form): every developer guide as Markdown, the API contract, the agent files above, the Help Centre and the blog. Per store, the storefront's `/llms.txt` is that store's own. |
| `https://api.btab.app/api/public/stores` | The store directory as JSON (`{ stores: [{ slug, name, tagline, category, url, logo_url, updated_at }] }`). Public, 60/min/IP, cached 5 min. |

All three `btab.app` surfaces are generated per request from the store directory; the directory
fetch is cached for 10 minutes and never throws, so an
unreachable API yields an honest surface with an empty store list rather than an error.

## Query the catalogue: the buyer-side MCP (Phase 3)

`POST https://api.btab.app/api/public/catalog/mcp` — [Model Context Protocol](https://modelcontextprotocol.io)
over streamable HTTP (JSON-RPC 2.0). **No authentication** (public catalogue data only), per-IP
rate limit 60/min, CORS-open. `GET` the same URL for a plain description. This is a separate server
from the authenticated, operator-side [Vendor MCP](/developers/vendor-mcp.md); it shares the same JSON-RPC /
registry and fencing plumbing and serves `btab-catalog`.

UCP capability ids served — and the only ones declared on any `/.well-known/ucp`:
`dev.ucp.shopping.catalog.search`, `dev.ucp.shopping.catalog.lookup`.

| Tool | Arguments | Returns |
|---|---|---|
| `catalog_search` | `query` (required), `store?` (slug, host or custom domain), `limit?` 1–48 (default 12) | Relevance-ranked products across every public store (or one store), each with its **offers**. A query that matches nothing returns an empty list — never the whole catalogue. |
| `catalog_lookup` | `product_id` **or** `url` (a storefront product URL, `.json` accepted), `store?` | One product with every public store's offer for it — or that store's only when scoped. Unknown → tool error `NOT_FOUND`. |

Both are `read` tools (`readOnlyHint: true`). Every result is fenced as quoted data (`content[0].text`)
and also returned raw as `structuredContent`.

**The result shape — a product with one OFFER per store** (the "discovery aggregates, transaction
stays on the store" decision made concrete):

```json
{
  "product_id": 42, "name": "Standing Desk", "sku": "DESK-42", "brand": "DeskCo", "category": "Desks",
  "description": "A standing desk.", "image_url": "https://…/medium.jpg",
  "offers": [
    { "store": "Example Store", "store_slug": "example-store", "store_url": "https://example-store.btab.app/",
      "url": "https://example-store.btab.app/products/standing-desk-42",
      "product_json": "https://example-store.btab.app/products/standing-desk-42.json",
      "price_cents": 89900, "price": "899.00", "currency": "AUD", "in_stock": true,
      "enquiry_only": false, "purchase_url": "https://example-store.btab.app/products/standing-desk-42" },
    { "store": "Another Store", "store_slug": "another-store", "…": "…", "price": "949.00", "enquiry_only": true,
      "purchase_url": "https://another-store.btab.app/products/standing-desk-42#enquire",
      "note": "Enquiry-only at this store: not add-to-cart-able — send the buyer to purchase_url to enquire." }
  ],
  "note": "Prices are AUD (GST-inclusive) and belong to the store shown. Automated checkout is not available: send the buyer to purchase_url on the store, which is the seller of record."
}
```

- **Visibility is the storefront's.** A product appears when the store shows it (approved, active,
  priced, with images), and only for stores that are **published and discoverable** (the same rule
  as `GET /api/public/stores`).
- **`enquiry_only` is the effective value** — the store's own setting first, else the product's or its
  supplier's default — the same rule checkout enforces.
- **Relevance** is the storefront search's own ranking, so a search across stores orders products
  the way a store's own search box would.
- A live custom domain wins for store/product URLs (same rule as the directory).

```
$ curl -s -X POST https://api.btab.app/api/public/catalog/mcp -H 'Content-Type: application/json' \
    -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"catalog_search","arguments":{"query":"standing desk","limit":3}}}'
```
