# @unboxai/widgets

BehaviorGPT on your own site: a search box that suggests as visitors type, search results, category pages, recommendation shelves, a home page of titled shelves, an item's keywords, a search by picture and a shopping assistant, each ranked for the visitor. Web components that work on any page, a client for drawing your own interface, and React bindings.

The portal's **Add to site** page (`/add-to-site/`) previews every widget live on your catalog and gives you the code with your key filled in.

## On any page

```html
<script async src="https://portal.unboxai.com/widgets/v1.js"
        data-key="ubx_pub_..." data-catalog="cat_..."
        data-search-url="/search" data-item-url="/products/{id}"></script>

<bgpt-search placeholder="Search the shop"></bgpt-search>        <!-- in your header -->
<bgpt-results></bgpt-results>                                     <!-- on /search -->
<bgpt-results category="Sofas"></bgpt-results>                    <!-- a category page -->
<bgpt-sections></bgpt-sections>                                   <!-- the home page's shelves -->
<bgpt-recommendations kind="similar" item="sku-123"></bgpt-recommendations>
<bgpt-keywords item="sku-123"></bgpt-keywords>                    <!-- on a product page -->
<bgpt-recommendations></bgpt-recommendations>                     <!-- for the visitor, anywhere -->
<bgpt-recommendations kind="after-purchase" items="sku-1,sku-9"></bgpt-recommendations> <!-- order confirmation -->
<bgpt-visual-search></bgpt-visual-search>                         <!-- a camera: search with a picture -->
<bgpt-assistant></bgpt-assistant>                                 <!-- a shopping assistant, once per page -->
```

`data-key` is a publishable key from the portal that lists your site. It is meant to be seen: it only reads rankings and the items they show, and only from the sites it lists. Never put your secret API key in a page.

| Script setting | |
| --- | --- |
| `data-key`, `data-catalog` | Required, unless every widget names its own `key` and `catalog`. |
| `data-search-url` | Your search page. A search goes there as `?q=`; unset, results show on the same page. |
| `data-search-param` | The query parameter, `q` by default. |
| `data-item-url` | An item's page when the catalog gives none, with `{id}`. |
| `data-currency`, `data-locale` | For prices that carry no currency, and number formats. |
| `data-market` | The visitor's market, such as `SE`. |
| `data-track="off"` | Don't pass searches made in a widget to the tracking script. |

## The widgets

**`<bgpt-search>`** suggests from what BehaviorGPT ranks for the visitor with the text so far, so the same letters suggest different things after different visits. Arrow keys, Enter, Escape and the mouse work; with an empty box it suggests what comes next. Attributes: `placeholder`, `action` (the search page), `param`, `limit` (6), `label`, `button` (`text`, `icon`, `none`), `button-text`, `value`, `suggest="off"`.

**`<bgpt-results>`** shows the search in the page's address (`?q=`) or its `query`, with **Show more**, and follows searches started on the same page. Attributes: `query`, `param`, `limit` (12 per page), `item-url`, `empty-text` (`{query}` is the search), `more-text`, and the filters below. Without a query but with a filter it is a **category page**: `<bgpt-results category="Sofas">` lists the sofas, best for this visitor first (a visitor nothing is known about gets the category's best for anyone), and a search made on the page searches within it.

**Filters.** `<bgpt-results>`, `<bgpt-recommendations>` and `<bgpt-sections>` take `category`, `brand`, `min-price` and `max-price`, which keep them to part of the catalog: any of the categories (at any level of an item's category path), any of the brands, within the prices. Names are matched exactly as the catalog has them, and several are separated by `|`, since names may hold commas: `category="Sofas|Armchairs"`. So `<bgpt-recommendations category="Floor lamps" max-price="200">` is the floor lamps under 200 this visitor most likely wants.

**`<bgpt-recommendations>`** is a shelf. `kind` is `for-you` (the default: what the visitor likely wants next, which follows their searches on the page), `similar` (what people view after `item`), `bought-together` (what goes in the same purchase as `item`: one product of each kind bought with it, each with its kind's name under it, as "Floor lamps", where the catalog is grouped into kinds), `cart` (what goes with `items`, ids separated by commas) or `after-purchase` (what the visitor wants next now that they bought `items`, for the order confirmation page: one product of each kind, each with its kind's name, other than the kind they bought). No shelf shows what the visitor viewed or bought, or the same product (by name) twice. Attributes: `item`, `items`, `limit` (8), `heading` (empty for none), `layout` (`rail` or `grid`), `item-url`, and the filters. A shelf with nothing to show hides itself.

**`<bgpt-sections>`** is a home page made for the visitor: titled shelves, best first, such as "Linen sofas", "Floor lamps", "Throw pillows". BehaviorGPT ranks what the visitor likely wants next and groups it by kind of product; it follows their searches on the page. With `query`, that search's results are grouped instead. The shelves come from the catalog's keywords, so a catalog without them shows none (generate them on the portal's Catalogs page). Attributes: `query`, `sections` (4, at most 10), `limit` (8 per shelf), `layout` (`rail` or `grid`), `item-url`, and the filters. Cards announce `bgpt-item` with `widget` naming the shelf (`sections:Floor lamps`), and `--bgpt-section-gap` sets the space between shelves. With nothing to show it hides itself.

**`<bgpt-suggestions for="search">`** adds suggestions to a search box you already have, keeping its look, its form and your code. Put it inside the box's positioned wrapper and it hangs under the input with that id (or the first text input beside it). Picking a suggestion fills the input, as typing would, so a framework that owns the input hears it. It then announces `bgpt-search` and submits the input's form, as Enter would; call `preventDefault()` to search yourself. Without a form it goes to the search page. Searches submitted in the box are remembered for the next suggestions. It is rendered in your page rather than a shadow root, so the input's `aria-controls` and `aria-activedescendant` reach it. Its styles are at `:where()` strength, so any rule of yours wins (classes `bgpt-suggestions-list`, `-option`, `-head`). Attributes: `for`, `limit` (6), `heading` (over suggestions for an empty box), `disabled` (shows nothing, as while your box offers something of its own), `action`, `param`, `track="off"`.

**`<bgpt-visual-search>`** is a camera button that opens a panel where the visitor drops, pastes or uploads a picture, takes a photo (on a phone), or pastes a link to a picture or to any shop's product page, and sees the products that look like it. BehaviorGPT reads the picture as it reads the catalog's products: the picture becomes an item of a catalog of pictures, and the catalog is ranked with it. A new picture takes BehaviorGPT a little while (the panel says what it is doing); the same picture is instant afterwards, and a link to one of the catalog's own pictures needs no wait at all. Pictures are made at most 1024 px in the browser before they are sent, and their location and other photo data never leave the page. Attributes: `kind` (`look`, the products nearest the picture, or `for-you`, what this visitor would view next after it, from their own history), `limit` (12), `heading`, `button` (`icon`, `text` or `none`, to call `open()` yourself), `button-text`, `item-url`. `<bgpt-search visual>` puts the same camera in the search box (`visual-kind` sets its `kind`). It fires `bgpt-visual` {picture, items} when it shows results. Only where the portal runs Visual search: elsewhere it says it is not set up.

**`<bgpt-assistant>`** is a shopping assistant behind a chat button in the corner of the page. Shoppers say what they need ("a gift for my dad who grills, under $100"); Claude searches the catalog, shows product cards, compares, asks when it has to and suggests what goes with what, and BehaviorGPT ranks every search and recommendation for the visitor, from their recorded history and what `configure({ history, who })` gives. It is the shopping assistant's own script (`/shopping/widget/v1.js` on the portal), loaded when the element is on the page; `configure({ assistant })` points it elsewhere. Attributes: `name`, `greeting`, `subtitle`, `starters` (prompts on its welcome, separated by `|`), `button-text`, `accent`, `accent-text`, `font` (a CSS font-family your page loads), `corners` (`sharp`, `soft`, `round`), `launcher` (`pill` with text, `icon`, or `bar`, a field that asks like a search box), `panel` (`card`, or `sheet` the window's height), `position` (`right`, `left`), `inset-x` and `inset-y` (px from the window's edges, to sit in the corner of your content area), `theme` (`light`, `dark`, `auto`), `open`, `inline` (drawn in place, open, instead of behind a button), `cart-url`. A change to any of the look's attributes, or to the element's `look` property (the same as an object: `{ name, starters, launcherText, font, corners, launcher, panel, inset: { x, y } ... }`), restyles it in place. One per page. The page hears what the shopper does as events on `window`: `shopping-assistant:add-to-cart` {item, quantity}, `shopping-assistant:product-click` {item} (`preventDefault()` to open your own page), `shopping-assistant:remove-from-cart`, `save`, `open`, `close`. The element's `open(text?)` opens it, asking `text` if given.

**`<bgpt-keywords>`** shows an item's search terms, each a link to your search page for it. Attributes: `item`, `limit` (6), `heading`, `action`, `param`.

Every widget also takes `key` and `catalog`, so one page can show two catalogs.

## Personal results

Put the [tracking script](../track/README.md) on the same pages. The widgets name the visitor it records (its `unbox_uid` cookie, or your own id after `unbox("identify", ...)`), so every ranking reads that visitor's recorded history. Searches made in a widget are passed to it, so they are recorded too. Without it, rankings still follow what the visitor did on the page.

## Single-page apps

A widget announces what it is about to do, and you can take over:

```js
document.addEventListener("bgpt-search", (e) => {   // { query, source, href }
  e.preventDefault();
  router.push("/search?q=" + encodeURIComponent(e.detail.query));
});
document.addEventListener("bgpt-item", (e) => {     // { item, position, widget, query }
  e.preventDefault();
  router.push("/products/" + e.detail.item.id);
});
```

The script also exposes `window.BehaviorGPT`: `configure({...})` sets what the script tag would (`itemUrl`, `currency`, `userId`...), and `client(key, catalog)` is the client below.

An app that keeps the visitor's history itself gives it to every widget: `configure({ history: () => [{ type: "search", query: "sofa" }, { type: "view", item: "sku-1" }] })`, oldest first, read at each call in place of what the widgets saw on the page. `who` can be a function too (`() => ({ gender, ageGroup, market })`). The storefront demo does this with its decision tree's branch.

## Look

The widgets take your page's font and text colour. Everything else is a CSS variable, set on the widget or any parent: `--bgpt-accent`, `--bgpt-on-accent`, `--bgpt-bg`, `--bgpt-border`, `--bgpt-muted`, `--bgpt-radius`, `--bgpt-card-radius`, `--bgpt-image-bg`, `--bgpt-image-aspect` (`1 / 1`, `3 / 4`), `--bgpt-image-fit` (`contain`, `cover`), `--bgpt-columns`, `--bgpt-card-width`, `--bgpt-gap`, `--bgpt-height` and `--bgpt-font`. For anything else, every piece has a `part` for `::part()` rules: `form`, `input`, `button`, `list`, `option`, `heading`, `grid`, `shelf`, `card`, `media`, `image`, `info`, `brand`, `name`, `price`, `tag`, `more`, `message`, `chips`, `chip`, and for the picture search `dialog`, `pick`, `action`, `preview`, `close` and `visual-button`. A widget's state is on it as `data-state` (`loading`, `ready`, `empty`, `error`).

If your site has a Content Security Policy, allow the portal in `script-src` and `connect-src`, and product images in `img-src`.

## React

```sh
npm install https://portal.unboxai.com/widgets/unboxai-widgets-1.5.0.tgz
```

```jsx
import { Assistant, BehaviorGPTProvider, Keywords, Recommendations, SearchBox, SearchResults, Sections, Suggestions, VisualSearch } from "@unboxai/widgets/react";

<BehaviorGPTProvider publishableKey="ubx_pub_..." catalog="cat_..." searchUrl="/search" itemUrl="/products/{id}">
  <SearchBox visual onSearch={({ query }) => (router.push(`/search?q=${encodeURIComponent(query)}`), false)} />
  <VisualSearch kind="look" onItem={({ item }) => (router.push(`/products/${item.id}`), false)} />
  <Assistant name="Ava" cartUrl="/cart" />
  <Recommendations kind="similar" item={product.id} onItem={({ item }) => (router.push(`/products/${item.id}`), false)} />
  <SearchResults category={["Sofas", "Armchairs"]} maxPrice={900} />
  <Sections sections={4} />
</BehaviorGPTProvider>
```

`<Suggestions input="search" onSearch={...} />` is `<bgpt-suggestions for="search">`. The components render the elements above with the same props in camelCase (`buttonText`, `itemUrl`, `emptyText`, `minPrice`); `category` and `brand` take a list too. A handler that returns `false`, or calls the event's `preventDefault()`, keeps the widget from navigating. They render on a server too.

To draw your own interface, the hooks give you the data: `useSuggestions(text)`, `useSearch(query)` (with `loadMore`), `useBrowse(only)` (a category page, with `loadMore`), `useRecommendations({ kind, item, items, only })`, `useSections({ query, sections, limit, only })`, `useItem(id)`, `useVisualSearch({ kind, limit })` (`search(file or link)`, then `phase` while BehaviorGPT reads it, and `answer`), `useRemember()` and `useBehaviorGPT()` for the client. Each returns `{ loading, error }` with its data.

## Your own interface

```js
import { createClient } from "@unboxai/widgets";

const bgpt = createClient({ key: "ubx_pub_...", catalog: "cat_..." });
await bgpt.suggest("lin");                          // [{ text, source, item }]
await bgpt.search("linen sofa", { limit: 24 });     // { items, offset, more }
await bgpt.recommend({ kind: "similar", item: "sku-123" });
await bgpt.recommend({ kind: "for_you", only: { categories: ["Sofas"], maxPrice: 900 } });
await bgpt.browse({ categories: ["Sofas"] }, { limit: 24 }); // a category page: { items, offset, more }
await bgpt.sections({ sections: 4 });               // [{ title: "Linen sofas", items }]
await bgpt.item("sku-123");                         // with keywords
await bgpt.visual(file, { onProgress: console.log }); // { status, picture, items }: a File, Blob or link
bgpt.remember({ type: "view", item: "sku-123" });   // read by the calls after it
```

`only` is `{ categories, brands, minPrice, maxPrice }`, each optional, as the filters above. An item is `{ id, name, brand, image, price, currency, url }`; `visual()` marks the item a link was the very picture of with `exact: true`. Answers are kept for a minute, the same call made twice at once is sent once, and a refusal is a `BehaviorGPTError` whose message says what to fix ("This key is not for https://shop.example.com...").

## In a phone app

A phone app makes the same calls itself, with a publishable key that lists the app. In the portal, add the app's bundle id (`com.example.shop`, the iOS bundle id or the Android package name) to a key under **Events → Publishable keys**. The app names itself in the `X-Unbox-App` header, which the client sends when given `app`; it counts only where there is no `Origin`, so a web page cannot pass for the app. Like a page, an app can be taken apart, so the key is as public as the app: it only reads rankings and records events, and limits bound it. Never ship your API key in an app.

**React Native.** The provider and the hooks work as on the web; the components render web elements, so draw with your own:

```jsx
import { BehaviorGPTProvider, useRecommendations, useSearch, useVisualSearch } from "@unboxai/widgets/react";

<BehaviorGPTProvider publishableKey="ubx_pub_..." catalog="cat_..." app="com.example.shop" userId={shopperId}>
  <App />
</BehaviorGPTProvider>

function Shelf() {
  const { items } = useRecommendations({ kind: "for_you" });
  return <FlatList horizontal data={items} renderItem={({ item }) => <ProductCard item={item} />} />;
}
```

`userId` is the id the app records the shopper's events under (below), so every ranking reads their history. `createClient({ key, catalog, app, userId })` is the same without React. A search by picture takes the picker's photo as a `data:` URL: ask the picker for base64 at most 1024 pixels a side, and pass `` `data:image/jpeg;base64,${photo.base64}` `` to `visual()` or the hook's `search()`.

**Recording events.** `@unboxai/track` has a tracker for apps, which keeps no cookie: the app keeps the shopper's id between launches and hands it back.

```sh
npm install https://portal.unboxai.com/track/unboxai-track-1.1.0.tgz
```

```js
import { createAppTracker } from "@unboxai/track";

const unbox = createAppTracker({
  key: "ubx_pub_...", catalog: "cat_...", app: "com.example.shop",
  userId: await AsyncStorage.getItem("unbox_uid"),   // null on the first launch: a new id is made
  onUserId: (id) => (id ? AsyncStorage.setItem("unbox_uid", id) : AsyncStorage.removeItem("unbox_uid")),
});
unbox.view("sku-123");
unbox.search("linen sofa");
unbox.order(["sku-123", { item: "sku-9", quantity: 2 }]);
unbox.identify(customer.id);   // after sign-in; unbox.reset() after sign-out
```

It takes the browser tracker's commands (`profile`, `consent`, `userId`...) and `waitForConsent`. Item pages come from your catalog's file or your site's tracking script, never from the app.

**Swift, Kotlin and the rest.** The calls are JSON over HTTPS: `POST https://portal.unboxai.com/api/widgets/v1/<call>` with the body below (`key`, `catalog`, and the call's fields), as `application/json`, and the app's id in `X-Unbox-App`. The calls and their answers are described in [OpenAPI](https://portal.unboxai.com/api/v1/shop/openapi.json), for generating a client: that document is the server's (`/api/v1/shop/`, with an API key); an app sends the same bodies to `/api/widgets/v1/` with `key`. Events go to `POST https://behaviorgpt-northeurope.api.unboxai.com/v1/events` with `x-api-key: ubx_pub_...` and the same `X-Unbox-App`, one at a time, as the tracker sends them: `{"user_id": "...", "store_id": "cat_...", "event": {"type": "viewItem", "product": "sku-123"}, "domains": {"market": "SE"}, "timezone": "Europe/Stockholm", "timestamp": "2026-10-29T12:00:00Z"}`, where `event` is also `{"type": "search", "query": "..."}` or `{"type": "order", "cart_id": "...", "items": [{"product": "sku-123", "quantity": 1, "unit_selling_price": 0}]}`.

## From a server

A server that renders pages, sends email or answers its own app asks with the account's API key instead: `POST https://portal.unboxai.com/api/v1/shop/<call>`, `Authorization: Bearer ubx_...`, the same bodies without `key`, and `user` your id for the shopper. The Python SDK wraps them as `client.shop` (`behaviorgpt` 0.8): `client.shop.search("linen sofa", user_id=uid)`. They are the widgets' own answers from the same code, so a shelf is the same everywhere.

## In an app of your own

An app that ranks with its own API key, such as the storefront demo, asks the portal for its account's demo key: `GET /api/v1/keys/demo` with `Authorization: Bearer <api key>` answers `{key, allowed_origins}`. That is the publishable key whose one site is the storefront demo's (`DEMO_URL`), the same one the portal's launch links carry as `p`, made on first ask.

The storefront demo's visits with no account of their own use the portal's house account instead: `GET /api/widgets/v1/house-key` answers its demo key, with no key needed to ask.

## How it works

The widgets call the portal at `/api/widgets/v1/`, which ranks with BehaviorGPT for the key's account and records nothing. A server makes the same calls at `/api/v1/shop/` with its API key, and gets the same answers ([OpenAPI](https://portal.unboxai.com/api/v1/shop/openapi.json)). A category page is `/browse` (the visitor's ranking within the catalog filters), the sections `/sections` (titled sections over the visitor's ranking), and an order confirmation's shelf is the visit followed by the order. A picture goes to `/visual`, which reads it as an item, then ranks the catalog with it; while BehaviorGPT reads a new picture the answer says `pending` or `embedding`, and the client asks `/visual/status` every 1.5 seconds until it is `ready`. Pictures have a limit of their own: 600 a minute per key, 20 per visitor. Calls are JSON sent as `text/plain`, so the browser makes no CORS preflight before each keystroke. A call is answered when it comes from a site the key lists (its `Origin`), or with no `Origin` from an app the key lists (`X-Unbox-App`), the key and its account's API key are live, and the catalog is the account's own or a shared one it may read. Each key has a limit per minute, and so does each visitor's address.

