Skill 01 · Storefront Best Practices
Subchapter 1.1
reference/agent-friendly.mdMarkdown24 KBView on GitHub
AI agents now browse, compare and buy on behalf of shoppers: AI search (ChatGPT search, Claude, Perplexity), user-initiated fetchers (ChatGPT-User, Claude-User), and browser agents (ChatGPT agent, Claude in Chrome, Gemini). They work very differently from humans:
curl output, they don’t see it.<div> or an unnamed icon button is invisible or ambiguous to them.Golden rule: after implementing any page, run curl <url> (no JS) and confirm the product name, visible price with currency, stock status, add-to-cart form and links are in the response.
Load this file when: building product pages, cart/add-to-cart, search, footer, robots.txt, sitemap, structured data, middleware/redirects, or when the user asks for an “agent-ready”, “AI-ready”, “AI shopping” or “agentic commerce” storefront.
Work in this order. Category weight reflects how much each area matters for agent-readiness; within a category, H items matter most.
| Category | Weight | Items (H = high, M = medium, L = low) |
|---|---|---|
| Access | 25% | H: agent UAs not blocked · H: robots.txt allows AI search + user agents · H: no CAPTCHA on home/PDP/cart · M: no age/consent/geo gate hiding content |
| Product data | 25% | H: name/price/availability in server HTML · H: complete Product JSON-LD matching visible price · M: variants as ProductGroup · M: return + shipping structured data · L: OpenGraph product tags |
| Operability | 20% | H: add-to-cart is a named <button> in a <form> · H: variant selectors are real controls · H: accessible names/labels · H: GET search URL · M: policy pages linked sitewide · L: landmarks, one h1, lang, alt |
| Discovery | 15% | H: sitemap in robots.txt with live product URLs · M: canonical + real 404s · M: fast TTFB, light HTML · L: Markdown alternative · L: /llms.txt |
| Agentic commerce | 15% | H: UCP profile · H: live agent endpoint · M: cart/checkout permalinks · M: OAuth discovery · L (bonus): WebMCP, A2A card, ai-catalog, api-catalog, rel="ucp" |
Default scope: Access, Product data, Operability and Discovery are standard storefront work. Implement them by default. Agentic commerce requires backend/API work and is user-dependent - ask before implementing.
ChatGPT-User, Claude-User, Perplexity-User, Google-Agent, Amzn-User, Meta-ExternalFetcherOAI-SearchBot, Claude-SearchBot, PerplexityBot, Googlebot, Bingbot, Applebot, Amzn-SearchBot, Meta-WebIndexer, DuckAssistBotGPTBot, ClaudeBot, Google-Extended, Applebot-Extended, Meta-ExternalAgent) are the merchant’s choice. Blocking them is fine and doesn’t hurt agent shopping./robots.txt as text/plain with HTTP 200. A 5xx or unreachable robots.txt means “disallow everything” to crawlers (RFC 9309)./, product, collection, policy and /search pages. Disallowing /cart, /checkout, /account is normal.User-agent group replaces the * group for that bot. Copy any rules you still need into it.Sitemap: line with an absolute URL.// Next.js App Router: app/robots.ts
import type { MetadataRoute } from "next"
export default function robots(): MetadataRoute.Robots {
const base = process.env.NEXT_PUBLIC_BASE_URL
return {
rules: [
{ userAgent: "*", allow: "/", disallow: ["/cart", "/checkout", "/account"] },
// Optional: opt out of AI training only
{ userAgent: ["GPTBot", "ClaudeBot", "Google-Extended"], disallow: "/" },
],
sitemap: `${base}/sitemap.xml`,
}
}/ (e.g. / → /{countryCode}). Serve a default region at / (or return 200 with region links), offer a visible country selector and hreflang alternates.$49.00, 49,00 €) and an explicit availability string (“In stock”, “Out of stock”, “Pre-order”) must be in the initial HTML.__NEXT_DATA__, hydration payload) counts as partial at best. Render it as text.Render a <script type="application/ld+json"> in the server HTML of every product page (not injected by client JS). Follows Google merchant listing (opens in a new tab) requirements:
name, image, offers with price (> 0, dot decimal) and priceCurrency (ISO 4217)offers.availability (https://schema.org/InStock etc.), sku or gtin/mpn, brand, descriptionoffers.url and itemConditionFor products with options (size, color), use a ProductGroup instead of a single Product (Google product variants (opens in a new tab)):
{
"@context": "https://schema.org",
"@type": "ProductGroup",
"name": "Classic Tee",
"description": "Heavyweight cotton t-shirt",
"image": "https://example.com/tee.jpg",
"brand": { "@type": "Brand", "name": "Acme" },
"productGroupID": "prod_123",
"variesBy": ["https://schema.org/size", "https://schema.org/color"],
"hasVariant": [
{
"@type": "Product",
"name": "Classic Tee - M / Navy",
"sku": "TEE-M-NAVY",
"size": "M",
"color": "Navy",
"image": "https://example.com/tee-navy.jpg",
"offers": {
"@type": "Offer",
"url": "https://example.com/products/classic-tee?variant=variant_456",
"sku": "TEE-M-NAVY",
"price": "29.00",
"priceCurrency": "USD",
"availability": "https://schema.org/InStock",
"itemCondition": "https://schema.org/NewCondition"
}
}
]
}ProductGroup itself must carry name, image, description and brand - agents treat the group as the product, so missing group-level image counts as missing required data.sku/gtin, option values and offers. Repeat sku on the variant’s Offer too.?variant=<id>) that preselects it on the page.hasMerchantReturnPolicy (MerchantReturnPolicy: applicableCountry, returnPolicyCategory, merchantReturnDays, returnMethod, returnFees) - on the Organization in the homepage JSON-LD (preferred) or on each Offer.shippingDetails (OfferShippingDetails: shippingRate, shippingDestination, deliveryTime with handlingTime and transitTime) on each Offer, or an org-level shipping policy.In the product page <head>: og:title, og:description, og:image (absolute), og:url (canonical), og:type = product, plus product:price:amount, product:price:currency (and og:price:amount / og:price:currency), and product:availability.
<button type="submit"> with visible text (“Add to cart”). Never a clickable <div>/<span>.<form method="post" action="..."> with a non-empty action and hidden variant ID + quantity inputs, so it works as a plain form post before/without JavaScript. Enhance with JS afterwards (optimistic UI, cart popup).disabled, change text to “Sold out”.// Form posts to a real route; JS can intercept onSubmit for the cart popup
<form method="post" action="/cart/add" onSubmit={handleAddToCart}>
<input type="hidden" name="variant_id" value={selectedVariant.id} />
<input type="hidden" name="quantity" value="1" />
{/* variant selectors go inside this form */}
<button type="submit" disabled={!inStock}>
{inStock ? "Add to cart" : "Sold out"}
</button>
</form>/cart/add route (e.g. a Next.js route handler, SvelteKit form action, Remix action) adds the line item server-side and 303-redirects back to the product or cart page.<form action={fn}> works without JS, but React typically renders it as action="" in the HTML, so agents and scanners can’t tell where it posts. Prefer a real URL. Always confirm with curl that the rendered <form> has a non-empty action.<fieldset><legend>Size</legend><input type="radio" name="size" id="size-m"><label for="size-m">M</label>...) or a labelled <select>. Style radios as swatches if needed.checked.role="radiogroup" + role="radio" with aria-checked and a text name (“Color: Navy”).disabled + “sold out” in the label.Target: ≥95% of buttons/links named and all form fields labelled.
aria-label (or visually hidden text) on every icon-only button/link: cart, search, menu, wishlist, close, quantity +/-, carousel arrows, social icons.input/select/textarea gets a <label for> or aria-label. Placeholders are not labels.alt./search?q=<terms> that server-renders a results page with links to product pages.<form role="search" method="get" action="/search"><label for="q">Search</label><input id="q" type="search" name="q"></form>. Instant search can enhance it but must not replace it.WebSite + SearchAction JSON-LD on the homepage:{
"@context": "https://schema.org",
"@type": "WebSite",
"url": "https://example.com",
"potentialAction": {
"@type": "SearchAction",
"target": { "@type": "EntryPoint", "urlTemplate": "https://example.com/search?q={search_term_string}" },
"query-input": "required name=search_term_string"
}
}/returns, /shipping, /terms, /privacy, /contact).layouts/static-pages.md and components/footer.md.<main>, at least one <nav>, plus <header>/<footer>.<h1> per page (the product name on PDPs).<html lang="..."> set.alt on every <img>: descriptive for product images (include variant/color), alt="" for decorative ones./sitemap.xml (or a sitemap index) listing every live product URL plus collections/categories, each with <lastmod>.Sitemap: in robots.txt. Generated dynamically from the backend so new/removed products are reflected. See seo.md → Dynamic Sitemaps.<link rel="canonical"> (variant URLs can point to the base product).notFound() when the backend returns no product.OutOfStock availability.Accept: text/markdown, return a Markdown rendering (name, price, availability, options, description, policy links) with Content-Type: text/markdown and Vary: Accept. Never error or redirect for this header.<link rel="alternate" type="text/markdown" href="/products/classic-tee.md">.Serve /llms.txt (optionally mirrored at /agents.md) as text/markdown or text/plain, per llmstxt.org (opens in a new tab):
# Acme Store
> Sustainable basics. Ships to US, CA and EU. Prices in USD/EUR.
## Shop
- [All products](https://example.com/store)
- [T-shirts](https://example.com/categories/t-shirts)
- Search: https://example.com/search?q={query}
## Policies
- [Shipping](https://example.com/shipping)
- [Returns](https://example.com/returns)
- [Contact](https://example.com/contact)
## For agents
- [Sitemap](https://example.com/sitemap.xml)Must have a # (H1) title, links, and mention products/policies/commerce endpoints. Generate collection links from the backend, don’t hardcode them.
These let agents shop without parsing HTML. They need backend/API work, so ask the user before implementing. Never present them as built into a backend unless verified in its docs.
Publish a Universal Commerce Protocol (opens in a new tab) business profile at /.well-known/ucp (application/json):
ucp.version (YYYY-MM-DD), ucp.services (e.g. dev.ucp.shopping with transport and absolute endpoint), ucp.capabilities, ucp.payment_handlers.dev.ucp.shopping.catalog.search / .catalog.lookup, dev.ucp.shopping.cart, dev.ucp.shopping.checkout, dev.ucp.shopping.order.POST {"jsonrpc":"2.0","id":1,"method":"tools/list"} returns tools, or returns 401 with WWW-Authenticate: Bearer resource_metadata="<url>"./buy/{variant_id}:{qty},{variant_id}:{qty} (UCP permalink spec (opens in a new tab))./.well-known/ucp as the dev.ucp.shopping.permalink capability with config.endpoint.For agents that sign shoppers in (orders, saved addresses):
/.well-known/oauth-protected-resource (RFC 9728) with resource and authorization_servers./.well-known/oauth-authorization-server (RFC 8414) or /.well-known/openid-configuration with issuer, authorization_endpoint, token_endpoint, scopes, PKCE methods.Link: </.well-known/ucp>; rel="ucp" response header and <link rel="ucp" href="/.well-known/ucp"> in the <head> (trivial once a UCP profile exists).toolname/tooldescription attributes on search and add-to-cart forms, or document.modelContext.registerTool()./.well-known/agent-card.json, /.well-known/ai-catalog.json, /.well-known/api-catalog (RFC 9727).https://cloud.medusajs.com/mcp. It’s a read-only, merchant-facing server: the merchant signs in with their Cloud account (OAuth) so AI tools can query their orders, products, customers and Store/Admin API data, as well as Cloud data such as deployments, logs, and environment variables. It doesn’t count as the shopper-facing agent endpoint in section 5: it can’t create carts or check out, it only accepts the merchant’s own Cloud login, and it isn’t declared in a store’s /.well-known/ucp. Suggest it when the merchant wants to manage or analyze their store with AI, not for making the storefront shoppable by agents.sitemap.xml, robots.txt or llms.txt → add app/sitemap.ts, app/robots.ts, app/llms.txt/route.ts and JSON-LD in the product page server component.<button> isn’t inside a <form> → wrap it in a <form method="post" action="..."> posting to a real route./ → /{countryCode} → consider serving a default region at /./[countryCode]/search route that queries the Store API products endpoint with q and server-renders results.variant.calculated_price.calculated_amount as-is (don’t divide by 100) with the region’s currency. Fetch products with region_id or prices will be missing.manage_inventory, allow_backorder, inventory_quantity). Verify the exact fields and fields query param via the Medusa MCP/docs before writing code.notFound() when the Store API returns no product.Run these against a running storefront (replace URLs):
# Product facts + JSON-LD + ATC form in raw HTML (no JS)
curl -s https://example.com/products/classic-tee | grep -iE 'application/ld\+json|<form|type="submit"|in stock|\$[0-9]'
# Agent UA gets the same page
curl -s -o /dev/null -w "%{http_code}\n" -A "ChatGPT-User/1.0" https://example.com/products/classic-tee
# robots.txt (200, text/plain, Sitemap line) and sitemap
curl -si https://example.com/robots.txt
curl -s https://example.com/sitemap.xml | head
# Real 404 for unknown products (expect 404)
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/products/does-not-exist-123
# GET search returns product links
curl -s "https://example.com/search?q=tee" | grep -o 'href="[^"]*/products/[^"]*"' | head
# TTFB < 0.8s and HTML size < 1 MB
curl -s -o /dev/null -w "%{time_starttransfer}s %{size_download} bytes\n" https://example.com/products/classic-tee
# Optional: Markdown, llms.txt, UCP
curl -sI -H "Accept: text/markdown" https://example.com/products/classic-tee | grep -i content-type
curl -si https://example.com/llms.txt | head
curl -s https://example.com/.well-known/ucpAlso run Google’s Rich Results Test (opens in a new tab) on a product URL and Lighthouse’s Accessibility / Agentic Browsing audits.
Product JSON-LD for a product with size/color options (use ProductGroup)<div onClick>, or a <button> with no surrounding <form action><div>/<span> with no role, state or labelaria-labelDisallow: /search in robots.txt, or a bot-specific group that silently drops * rulesChatGPT-User/Claude-User/OAI-SearchBot in middleware or WAF (blocking training crawlers is fine)/ to a locale path or “not available” page<h1>s or images without altNearby