Installing the widgets

The widgets in @meisei/widgets render ratings, reviews, wishlists and the loyalty program on any storefront. If your storefront is a React app, the same widgets are React components you can import and compose: see React widgets. Every endpoint they call is in the Widget API reference.

The script tag

Drop one script tag on the page and mark up the elements you want filled in:

<script
  src="https://cdn.example.com/meisei/embed.js"
  data-meisei-base-url="https://your-store.meisei.app/api"
  data-meisei-publishable-key="pk_live_..."
></script>

<div data-meisei-rating="classic-tee"></div>
<div data-meisei-reviews="classic-tee"></div>
<div data-meisei-reviews-carousel></div>
<div data-meisei-media-grid></div>
<div data-meisei-add-to-wishlist="classic-tee"></div>
<div data-meisei-wishlists></div>
<div data-meisei-wishlists-modal></div>
<div data-meisei-wishlist-banner="classic-tee"></div>
<div data-meisei-loyalty></div>
<div data-meisei-loyalty-launcher></div>
<div data-meisei-loyalty-points data-meisei-price="49.00"></div>

data-meisei-base-url is your store's own address with /api on the end. The snippet the dashboard hands you on Settings → Widget installation already carries it, and Settings → General prints it under Address. No other address serves your store's data.

The value of each product attribute is the product slug. To name the product by its ID on your store instead, add data-meisei-product-id beside it. With several sites that share an ID, also put data-meisei-site on the script tag so Meisei knows which one you mean:

<div data-meisei-reviews="classic-tee" data-meisei-product-id="7788"></div>

An ID keeps working when the product's slug changes. The Shopify app's theme blocks send the Shopify product ID this way, so renaming a handle never disconnects a product's reviews.

Optional script tag attributes:

AttributeWhat it does
data-meisei-siteWhich of your sites this storefront is. Without it, your oldest site's appearance is used.
data-meisei-currencyISO currency code for money in the loyalty panel. Defaults to USD.
data-meisei-theme-versionPins the theme stylesheet to a version so it can be cached indefinitely.
data-meisei-agent-toolsSet to true to offer the widgets' tools to an AI agent. See AI agents.
data-meisei-customer-email, data-meisei-timestamp, data-meisei-signatureIdentify the signed-in shopper. See Identifying the customer.

The review and rating widgets read public data, so the publishable key is all they need. The wishlist widgets need a signed-in shopper for their real work and keep a guest's saves in the browser until then. The loyalty widget renders for everybody and gains the member's own state once signed in.

The script loads only the widgets your page uses, from the same address the script came from, so a page with star ratings alone downloads none of the review list or the loyalty panel. It adds the widgets' stylesheet too, so the page needs no <link> of its own, and it leaves a stylesheet alone if the page already links one. Load the script from the address the dashboard gives you rather than a copy on your own server, since a copy can't find the files it loads. If your site sets a Content-Security-Policy, allow that address in script-src and style-src.

The properties you can set

Everything the widgets draw resolves through these CSS custom properties. The appearance editor sets them, and you can set any of them from your own stylesheet.

PropertyWhat it colorsDefault
--meisei-fontThe font stack.inherit
--meisei-textBody text.inherit
--meisei-muted-textDates, counts, help lines.65% of the text color
--meisei-surfaceThe ground behind cards and panels.transparent
--meisei-surface-mutedRating bar tracks, thumbnails, the summary panel.A faint gray
--meisei-borderEvery hairline between rows, cards and controls.A light gray
--meisei-accentFilled buttons, the selected tab, progress fills.CanvasText
--meisei-accent-textText and glyphs on top of the accent.Canvas
--meisei-starA filled star, and the rating breakdown bars.#facc15
--meisei-dangerError messages, and a negative review theme.#b42318
--meisei-radiusCorner radius on cards, panels and controls.8px

Two more are derived rather than chosen, and are set from custom CSS only: --meisei-control-radius rounds buttons and inputs on their own (follows --meisei-radius), and --meisei-floating-surface is the opaque ground under the loyalty panel and the wishlist dialog.

How the theme reaches your storefront

The embed script asks for the stylesheet in the same request as everything else the page's widgets need, and puts it in the page before any widget draws, so the page never flashes default colors. The stylesheet is also its own URL, for a page that links it from <head>:

GET /widgets/theme.css?apiPublishableKey=pk_live_...&site=main-store

That URL is cached for five minutes. The appearance page also shows a versioned URL, which is immutable until you next change something. Put the version on the script tag as data-meisei-theme-version and the embed links that URL instead, which the browser can cache indefinitely, or link the versioned URL from your own <head> if you load the embed script late.

One request per page

However many widgets a page has, the embed script asks Meisei for their first view in one request, GET /widgets/boot, and every star rating on the page shares one read inside it. For a shopper who is not signed in, that request is the same for everyone looking at the page, so it is answered from the edge cache. After the first view each widget asks its own endpoint, for the next page of reviews or a different sort. The React widgets do the same for every widget inside one MeiseiProvider.

Custom CSS rules

The class names below are a supported API and will not be renamed without notice. Anything else in the markup is internal. A modifier is always a -- suffix on its block (.meisei-button--primary, .meisei-loyalty-tier--current).

WidgetClasses
Shared.meisei (on every widget's outermost element), .meisei-button, .meisei-button--primary, .meisei-button--link, .meisei-input, .meisei-muted, .meisei-heading, .meisei-notice
Rating.meisei-rating, .meisei-rating-stars, .meisei-rating-count
Reviews.meisei-reviews, .meisei-reviews-summary, .meisei-reviews-overall, .meisei-reviews-average, .meisei-reviews-breakdown, .meisei-reviews-breakdown-row, .meisei-reviews-breakdown-bar, .meisei-reviews-breakdown-fill, .meisei-reviews-about, .meisei-reviews-about-link, .meisei-reviews-about-dialog, .meisei-reviews-about-body, .meisei-reviews-toolbar, .meisei-reviews-list, .meisei-reviews-item, .meisei-reviews-item-header, .meisei-reviews-item-name, .meisei-reviews-item-title, .meisei-reviews-item-body, .meisei-reviews-item-media, .meisei-reviews-item-response, .meisei-reviews-empty, .meisei-reviews-ai-summary, .meisei-reviews-write, .meisei-reviews-write-toggle, .meisei-reviews-form, .meisei-reviews-form-field, .meisei-reviews-form-label, .meisei-reviews-form-stars, .meisei-reviews-form-star, .meisei-reviews-form-media, .meisei-reviews-form-thumb, .meisei-reviews-form-actions, .meisei-reviews-form-status, .meisei-reviews-question-summary, .meisei-reviews-question-summary-item, .meisei-reviews-question-summary-header, .meisei-reviews-question-summary-value, .meisei-reviews-question-track, .meisei-reviews-question-marker, .meisei-reviews-question-ends, .meisei-reviews-question-options, .meisei-reviews-question-option, .meisei-reviews-question-option-bar, .meisei-reviews-question-option-fill, .meisei-reviews-item-answers, .meisei-reviews-item-answer, .meisei-reviews-item-answer-label, .meisei-reviews-item-answer-value, .meisei-reviews-question, .meisei-reviews-question-scale, .meisei-reviews-question-step, .meisei-reviews-question-choices, .meisei-reviews-question-choice
Reviews carousel.meisei-reviews-carousel, .meisei-reviews-carousel-header, .meisei-reviews-carousel-nav, .meisei-reviews-carousel-arrow, .meisei-reviews-carousel-track, .meisei-reviews-carousel-card, .meisei-reviews-carousel-card-title, .meisei-reviews-carousel-card-body, .meisei-reviews-carousel-card-footer, .meisei-reviews-carousel-name, .meisei-reviews-carousel-verified, .meisei-reviews-carousel-product, .meisei-reviews-carousel-thumb, .meisei-reviews-carousel-empty
Media grid.meisei-media-grid, .meisei-media-grid-tiles, .meisei-media-grid-tile, .meisei-media-grid-tile-button, .meisei-media-grid-image, .meisei-media-grid-play, .meisei-media-grid-load-more, .meisei-media-grid-empty, .meisei-media-grid-caption, .meisei-media-grid-caption-title, .meisei-media-grid-caption-meta, .meisei-media-grid-verified, .meisei-media-grid-product-link
Loyalty.meisei-loyalty, .meisei-loyalty-header, .meisei-loyalty-balance, .meisei-loyalty-section, .meisei-loyalty-list, .meisei-loyalty-row, .meisei-loyalty-row-name, .meisei-loyalty-row-progress, .meisei-loyalty-tiers, .meisei-loyalty-tier, .meisei-loyalty-progress, .meisei-loyalty-dots, .meisei-loyalty-dot, .meisei-loyalty-dot--on, .meisei-loyalty-code, .meisei-loyalty-launcher, .meisei-loyalty-launcher-button, .meisei-loyalty-launcher-panel, .meisei-loyalty-points
Wishlists.meisei-wishlists, .meisei-wishlists-header, .meisei-wishlists-row, .meisei-wishlists-items, .meisei-wishlists-item, .meisei-wishlists-modal, .meisei-add-to-wishlist, .meisei-add-to-wishlist-popover, .meisei-wishlist-banner, .meisei-wishlist-banner-image, .meisei-wishlist-banner-attribution, .meisei-wishlist-banner-note, .meisei-wishlist-banner-link

Custom CSS is sanitized on save, and the sanitized version is what is stored:

  • Every rule is scoped under .meisei. A rule for body or :root is kept but can never match.
  • Only @media, @supports and @container survive. @import, @font-face and the rest are removed. Load a webfont from your own theme and point --meisei-font at it.
  • url() may only carry a data: URI. A declaration with any other url() is dropped.
  • Nested rules are dropped. Write each selector out in full.
  • 20,000 characters is the limit. Past that the save is refused.

CSS the assistant writes goes through the same sanitizer and replaces the custom CSS in full rather than appending to it. Every version is kept under Earlier versions, so you can put back what it replaced.

Reviews widget options

The write form asks for a rating, title, review, name and email, up to five photos or videos, and any review questions. A signed-in shopper's email is filled in and locked, and their review is marked as a verified buyer when they have an order for the product that wasn't canceled. A guest's review never is. When the store only accepts storefront reviews from verified buyers, a guest is asked to sign in and a signed-in shopper with no order is told why, in place of the form, so sign shoppers in (see Identifying the customer) if you turn that on. Turn the form off to collect reviews only from the requests you send:

<div data-meisei-reviews="classic-tee" data-meisei-review-form="false"></div>

Helpful votes need no sign-in: the widget keeps a random token in local storage so a visitor can change their vote rather than vote twice. Votes are anonymous and only published reviews accept them.

Each review also carries a small Report link beside the votes. It opens a dialog with five reasons (looks fake or not from a real customer, offensive or abusive, shares someone's private information, not about this product, something else) and an optional note of up to 1,000 characters, and sends them to POST /widgets/reviews/{id}/reports. A report flags the review for the store's staff and never unpublishes it; reporting the same review again from the same IP address replaces the earlier report. With React, pass your own onReport to ProductReviews or leave it out to hide the link.

Review questions

Questions set up on Settings → Features → Reviews appear in three places: in the write form (scales as a row of steps, multiple choice as radios or checkboxes, required ones block sending until answered), above the list as a summary of published answers (a scale as a track with a marker at the average, a multiple choice as one bar per option; shares can exceed 100% where several options are allowed), and on each review card as that reviewer's answers. A question nobody has answered is left out. Archiving keeps existing answers; hiding a question or option removes it from the storefront while the answers stay in reports.

To keep the answers on the cards but drop the summary above the list:

<div data-meisei-reviews="classic-tee" data-meisei-question-summary="false"></div>

Review summaries

Once a product has at least five published reviews, the reviews widget shows a generated "What reviewers say" summary with up to five themes, refreshed at most once a day per product. It is kept out of the structured data. Regenerate, hide or revert it from the product page in the dashboard, or turn the feature off with "Summarize what reviewers say" on Settings → Features → Reviews. See Review summaries.

Instant answers

With instant answers on, the reviews widget shows a "Looking for specific info?" box. The answer is generated from the product's published reviews, its description as Meisei has it, and what the page says: the widget reads the page title, its meta descriptions and any schema.org Product the page already carries. Two attributes shape that:

AttributeWhat it does
data-meisei-contextNotes for the model, up to 2,000 characters: facts the reviews and the page don't cover (shipping, returns, sizing advice) and how you want questions answered.
data-meisei-page-contextWhat the page says about the product, up to 4,000 characters, instead of letting the widget read the page. false sends nothing beyond the product's stored description.
<div
  data-meisei-reviews="classic-tee"
  data-meisei-context="We ship worldwide. Returns are free within 30 days. Sizing runs true, but suggest sizing up for a relaxed fit."
></div>

Changing the notes regenerates stored answers the next time each question is asked. On Shopify, the Product reviews block passes the product's title and description as the page context and has a Notes for instant answers setting for the rest.

<div data-meisei-reviews-carousel></div>
<div data-meisei-reviews-carousel="classic-tee"></div>

Store-wide is the default; each card then names and links to its product when Meisei knows the URL. A product slug narrows it to one product.

AttributeWhat it does
data-meisei-sortThe order of the cards: newest (the default), highest, lowest or helpful.
data-meisei-min-ratingLowest rating to show. There is no floor by default, so every published review counts.
data-meisei-max-reviewsHow many reviews to load. Defaults to 10, and the maximum is 50.
data-meisei-headingThe heading above the cards. Defaults to "What shoppers say".

Cards are ordered newest first unless data-meisei-sort says otherwise, and slide with the arrow buttons, by dragging, or with the arrow keys once focused. Nothing advances on its own. A carousel set to highest or given a lowest rating shows a selection of your reviews, so label it as one ("Some of our favorite reviews") rather than as what customers think overall. The carousel emits no structured data. On Shopify it is the Reviews carousel block, with the same settings (its Order offers newest or highest rated first) plus an "Only this product" checkbox.

Media grid

<div data-meisei-media-grid></div>
<div data-meisei-media-grid="classic-tee"></div>
AttributeWhat it does
data-meisei-min-ratingLowest rating to show. There is no floor by default, so every published review counts.
data-meisei-max-itemsHow many tiles a page holds. Defaults to 12, and the maximum is 48.
data-meisei-headingThe heading above the grid. Defaults to "From our customers".

Tiles are ordered newest review first and a review's own photos stay together, so a page can come back a little over the number asked for. "Load more photos" appends the next page. As with the carousel, a lowest rating makes the grid a selection, and its heading should say so. Store-wide, the lightbox carries a "View product" link. Arrow keys walk the grid and Escape closes it. The grid emits no structured data. On Shopify it is the Review photos and videos block.

Structured data

Both review widgets can emit schema.org JSON-LD, which produces the star snippets in Google results: a Product node with an AggregateRating, and from the reviews widget the individual Review records too.

{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "Classic T-Shirt",
  "url": "https://shop.example.com/products/classic-tee",
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": 4.67,
    "reviewCount": 3,
    "bestRating": 5,
    "worstRating": 1
  },
  "review": [
    {
      "@type": "Review",
      "name": "Great shirt",
      "reviewBody": "Fits perfectly and the material is soft.",
      "datePublished": "2026-01-02",
      "author": { "@type": "Person", "name": "Alice J." },
      "reviewRating": {
        "@type": "Rating",
        "ratingValue": 5,
        "bestRating": 5,
        "worstRating": 1
      }
    }
  ]
}

The generated instant answer is never part of the markup.

WidgetEmits markup by defaultWhy
data-meisei-reviewsYesIt belongs on a product page, once per page.
data-meisei-ratingNoIt usually repeats across a collection or grid.

Override either with data-meisei-structured-data (or ="false"). In React the same controls are the structuredData and productUrl props on <ProductRatingWidget> and <ProductReviewsWidget>.

Placement rules:

  • Put the marked-up widget on the product's own page. Markup on a home page or blog post is ignored or flagged in Search Console.
  • One marked-up widget per page. With both widgets on a product page, let the reviews widget emit it.
  • Leave it off in collection and search grids.
  • If your theme already publishes an aggregateRating from another review app, turn Meisei's off rather than shipping two contradictory ratings.

The product URL comes from <link rel="canonical">, falling back to the current path without query or fragment; set data-meisei-product-url when neither is right (a quick-view drawer, for example). Nothing is emitted until the product has one published review. Only published reviews are included, reviewers appear as first name and last initial ("Anonymous" if unnamed), and reviews loaded through "Load more" are added as they appear.

Verify with Google's Rich Results Test using the URL tab: the markup only exists after the page's JavaScript runs.

Identifying the customer

Anything that belongs to one shopper (wishlists, loyalty balance) needs the storefront to prove who is signed in with a signature made on your server. Add three attributes to the script tag:

<script
  src="https://cdn.example.com/meisei/embed.js"
  data-meisei-base-url="https://your-store.meisei.app/api"
  data-meisei-publishable-key="pk_live_..."
  data-meisei-customer-email="ada@example.com"
  data-meisei-timestamp="1735689600"
  data-meisei-signature="48eb876e883be4490d05d06ea8b9fe870b481c1098cc7ef88553998690a1dd31"
></script>
AttributeValue
data-meisei-customer-emailThe signed-in customer's email address.
data-meisei-timestampUnix time in seconds when you made the signature.
data-meisei-signatureHex HMAC-SHA256 of the email and timestamp, keyed with your secret.

All three go together. For a signed-out shopper leave all three off: the wishlist widgets keep guest lists in the browser that are claimed on sign-in (see Before signing in), and the loyalty widget shows the signed-out catalog. A page with some but not all of them is refused by the wishlist widgets and treated as signed out by the loyalty widget. The reviews widget sends the signature too, which is how a review is matched to the shopper's orders.

The signing recipe

  1. Take the email exactly as sent in data-meisei-customer-email, including its case.
  2. Take the current Unix time in seconds, as a string.
  3. Concatenate the two, in that order, with nothing between them.
  4. HMAC-SHA256 the result, keyed with your widget signing secret, as lowercase hex.

A signature is good for 24 hours; sign on every page render anyway. The secret is under Settings → API keys in the widget signing secret section. There is one per organization, shared by every site. Keep it on your server and out of theme files, JavaScript bundles and version control: anyone holding it can sign as any of your customers.

Rotate secret in the same section takes effect immediately with no grace period: every storefront still signing with the old value fails until the new one is deployed. Rotations are recorded in the audit trail.

Node
import { createHmac } from "node:crypto"

const signingSecret = process.env.MEISEI_SIGNING_SECRET

export function meiseiCustomerAuth(email) {
  const timestamp = String(Math.floor(Date.now() / 1000))
  const signature = createHmac("sha256", signingSecret).update(`${email}${timestamp}`).digest("hex")

  return { email, timestamp, signature }
}

With the Python client, sign_customer(email, signing_secret=secret) does the same.

Test vector: secret wsk_demo_signing_secret, email test@example.com, timestamp 1735689600 gives 48eb876e883be4490d05d06ea8b9fe870b481c1098cc7ef88553998690a1dd31.

On Shopify there is nothing to do. The Meisei embed asks Meisei for the signature of whoever is signed in, on your store's own domain, and the secret never leaves Meisei. Do not sign in Liquid: the secret would have to live in a theme setting or metafield, both readable by anyone who can edit your theme. See Widgets in your theme.

Every widget call carrying a customer answers 401 with one of:

MessageWhat happened
Unknown publishable keyThe publishable key is wrong, or belongs to another organization.
Invalid timestampThe timestamp isn't a number. It has to be Unix seconds, not an ISO date.
Signature expiredThe timestamp is more than 24 hours from now. Check your server clock.
Invalid signatureThe digest doesn't match. Usually the email and timestamp were joined with a separator, the digest is base64 rather than hex, or the secret is stale.
Missing emailThe email was blank.
Missing signatureThe signature was blank.

Calling the widget API from a server

A storefront that renders on the server (Next.js, Remix, a custom app) can call the same endpoints with a secret key instead of the publishable key. Send it as a bearer token and leave apiPublishableKey off; the key names your organization, and the response is the same:

curl "https://your-store.meisei.app/api/widgets/reviews?productSlug=classic-tee" \
  -H "Authorization: Bearer sk_live_..."

Prefer this over the publishable key on a server. The publishable key is rate limited per address, so one server calling for every shopper spends a single shopper's allowance; a secret key draws on your organization's API allowance instead.

To read one shopper's wishlists or loyalty balance, ask Meisei for their signature rather than keeping the widget signing secret in your own code:

curl -X POST "https://your-store.meisei.app/api/widget-signing-secrets/sign" \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"email": "ada@example.com"}'

The answer is the email, timestamp and signature trio above, good for 24 hours. Pass all three on the widget request as usual. The key needs the customers:write permission, because holding the trio is the power to act as that shopper.

Keep secret keys on your server. A secret key in browser JavaScript can read and write everything your organization has.

From Ruby, the meisei gem does all of this for you, signing included. See Ruby client.

Wishlists

<div data-meisei-add-to-wishlist="classic-tee"></div>
<div data-meisei-wishlists></div>
<div data-meisei-wishlists-modal></div>
<div data-meisei-wishlist-banner="classic-tee"></div>

A save button for a product page, the list manager for an account page, and the same manager behind a button that opens it in a sheet from the right edge of the page. All three need the customer attributes above. See Wishlists for sharing, purchase tracking and the Shopify markup.

The last one is the banner a product page opens with when somebody arrives from a shared wishlist link (?meiseiWishlist=...&meiseiItem=...). It needs no customer attributes, reads the link's parameters itself, and renders nothing at all on an ordinary visit, so it is safe to place on every product page. In React it is <WishlistBannerWidget productSlug="..." />.

Loyalty

<div data-meisei-loyalty></div>
<div data-meisei-loyalty-launcher="Rewards" data-meisei-loyalty-position="bottom-left"></div>
AttributeWhat it renders
data-meisei-loyaltyThe panel, inline, for a rewards or account page. Takes no value.
data-meisei-loyalty-launcherA floating pill (bottom right by default) that opens the panel. Its value is the label.
data-meisei-loyalty-positionbottom-left moves the launcher.

Without a customer the panel shows the catalog: tiers, ways to earn, offers and what points buy. With the customer attributes it adds the member's balance, tier progress, progress toward any punch card rule and held rewards with their codes. Joining, claiming and canceling a reward need a signed-in shopper.

Which sections appear is decided in the dashboard, always in the order tiers, ways to earn, offers, rewards. Ways to earn and offers follow the Sections switches on Settings → Features → Loyalty; tiers appears whenever the program has any; rewards appears once the program's points are named. A switched-off section's rules are never sent to the browser.

Check-ins. Loading a page earns a shopper nothing on its own. To reward a store visit or a check-in, have your own systems report it as a custom event and give the rule a limit of once a day. See Custom loyalty events.

Points on the product page.

<div data-meisei-loyalty-points data-meisei-price="49.00"></div>
AttributeWhat it does
data-meisei-loyalty-pointsThe badge. Takes no value.
data-meisei-priceWhat the shopper would spend, in whole currency units rather than cents. Required.
data-meisei-currencyThe currency the price is in. Falls back to the script tag's currency.

The price is read once when the badge mounts, so re-render it on a variant change (or use the React component's price prop). A member reads "Earn 250 points with this purchase." with their tier multiplier applied; everyone else reads "Join to earn 250 points with this purchase." The badge renders nothing when there is no program, no live purchase rule, a paused program, no named points, or a price too small to earn; product-restricted rules are left out. On a page with the inline embed, the badge links to it. On Shopify it is the Points on product page block.

Links from loyalty emails go to your storefront: the Loyalty page section on Settings → URLs & domains takes a loyalty page URL per site, defaulting to the site's base URL. Every link carries #meisei-rewards; the launcher opens its panel on arrival and the inline embed scrolls into view.

Every endpoint behind these embeds is in the Widget API reference.

AI agents

With data-meisei-agent-tools="true" on the script tag (agentTools on MeiseiProvider in React), the widgets register WebMCP tools that an agent in the shopper's browser can call. A browser without WebMCP gets nothing, and the tools are registered on document.modelContext, or on navigator.modelContext in the earlier drafts. What a merchant sees is on AI agents on your storefront.

ToolOffered byWhat it does
meisei_get_product_reviewsThe reviews widgetThe rating, breakdown, summary and reviews. Takes rating, sort, limit (up to 20) and cursor.
meisei_ask_about_productThe reviews widgetAn instant answer to question, with the reviews it cites. Only when instant answers are on.
meisei_get_loyalty_statusThe loyalty panel and the launcherThe program's ways to earn and rewards, plus the signed-in member's balance, tier and reward codes.
meisei_add_to_wishlistThe wishlist buttonSaves the product to the shopper's default list, or to the browser's list for a guest. Never removes it.

Each tool is registered once per page, however many widgets offer it. The product tools take a productSlug, listed as an enum of the products the page's widgets show and optional when there is only one. They read with the same publishable key and signed shopper as the widgets, so an agent can see nothing the shopper couldn't.