---
title: "Building a custom shopping assistant"
description: "Learn how to build a custom Shopping Assistant UI with the Assistant API — commit-after-success state, lean hit payloads, runtime field overrides, and analytics tracking."
slug: quickstart/shopping-assistant/building-custom-ui
docKind: guide
hub: quickstart
---

## Introduction

The Shopping Assistant leads a shopper to the right product through a short guided dialogue. The API returns the next question and the products matching the shopper's answers so far; your UI renders both surfaces and sends the next selection back.

This guide walks through building a custom Shopping Assistant UI in JavaScript with the [Assistant API](/shopping-assistant/api/assistant/).

For the conceptual model without code (state shape, rendering rules, back/reset, error handling), see [Shopping Assistant implementation flow](/shopping-assistant/guides/implementation-flow/).

:::caution
This is a demonstration guide, not production-ready storefront code. The HTML samples insert API response data directly into the DOM without sanitization. For production use, call the assistant through your own backend or edge layer and sanitize all rendered output.
:::

### What you'll learn

- How to make the first assistant request and render the returned question and products.
- How to keep client state consistent with the server by committing `steps` after success, not before.
- How to keep responses small with `hit_fields`.
- How to swap catalog fields at runtime with `field_overrides`.
- How to track downstream clicks and add-to-cart actions after the assistant shows products.

### Who is this guide for

- Developers replacing a filter-only product grid with a guided product finder.
- Developers who want full control over rendering, state, and analytics instead of an embedded widget.
- Teams that already send analytics through either the Events API or a GA4-style DataLayer.

### Prerequisites

- Your Luigi's Box `tracker_id`.
- An assistant already configured and published in the Luigi's Box app.
- A synchronized catalog with the attributes used by your assistant questions and filters.
- A stable user identifier to send as `user_id`.

:::note[Runnable example HTML]

- **Events API analytics:** [assistant-api.html](/examples/shopping-assistant/assistant-api.html)
- **DataLayer analytics:** [assistant-api-datalayer.html](/examples/shopping-assistant/assistant-api-datalayer.html)

Both pages run the same assistant flow with two analytics transports. Use them as implementation references, not as production storefront templates.
:::

## Step-by-step

The assistant is stateless — your frontend owns the answer history in a `steps` array and resends it on every request. The steps below cover the first request, answering questions with a safe commit pattern, trimming the payload, runtime field overrides, and product-outcome analytics.

### Step 1: Make the first request

At the start, the shopper hasn't answered anything, so `steps` is empty. You only need enough information for the assistant to identify itself and return the first question.

#### Endpoint

`POST` `https://live.luigisbox.com/v1/assistant`

#### Required parameters

- **`tracker_id`** (query): Your Luigi's Box site identifier.
- **`assistant_handle`** (query): The handle of the published assistant to run.
- **`user_id`** (query): Stable identifier for the current shopper. Keep it consistent across every assistant request for one shopper so analytics attribute the flow correctly.
- **`assistant_version`** (body, integer): Use `-1` for the latest published version. Must be an integer, not `null` or a quoted value.
- **`steps`** (body, array): Empty `[]` for the first request; the full history thereafter.

#### Optional parameters (recommended)

- **`hit_fields`** (body, string): Comma-separated list of product attributes to return, for example `title,price_amount,image_link,brand`. Keeps the response fast and small.

#### Example

```javascript
// --- CONFIGURATION ---
const API_ENDPOINT = "https://live.luigisbox.com/v1/assistant";
const TRACKER_ID = "YOUR_TRACKER_ID";
const ASSISTANT_HANDLE = "shoe_finder";
const USER_ID = "customer-42";
const ASSISTANT_VERSION = -1;

// --- STATE ---
// The assistant is stateless. The frontend owns the answer history.
const state = {
  steps: [],
  responseHistory: {}, // optional cache, used by back/reset to skip re-fetches
};

// Bootstrap the flow with an empty steps array.
fetchAssistant(state.steps);
```

The response returns two surfaces to render:

- `question`: the next question and its available options
- `hits`: the products currently matching the selection

### Step 2: Handle answers with a safe commit pattern

Do not mutate `state.steps` before the API confirms the new answer. If the request fails, the rendered UI would still show the old question while your local state has already advanced, so the two drift apart and the next click compounds the inconsistency.

The pattern is: build a **candidate** `steps` array, send it, and only assign it to `state.steps` once the response succeeds.

#### Example

```javascript
// Assumes the config constants and `state` from Step 1.

async function fetchAssistant(stepsToSend, nextQuestionHandle) {
  try {
    const response = await axios.post(
      API_ENDPOINT,
      {
        assistant_version: ASSISTANT_VERSION,
        steps: stepsToSend,
        next_question_handle: nextQuestionHandle,
        hit_fields: "title,price_amount,image_link,brand",
      },
      {
        params: {
          tracker_id: TRACKER_ID,
          assistant_handle: ASSISTANT_HANDLE,
          user_id: USER_ID,
        },
      },
    );

    // Commit client state only after the request succeeds.
    state.steps = stepsToSend;
    state.responseHistory[state.steps.length] = response.data;
    renderAssistant(response.data);
    return true;
  } catch (error) {
    console.error("Assistant request failed:", error);
    return false;
  }
}

// Option click handler — build a candidate, don't mutate in place.
async function onOptionSelected(
  questionHandle,
  optionHandle,
  nextQuestionHandle,
) {
  const candidateSteps = [
    ...state.steps,
    { question_handle: questionHandle, option_handles: [optionHandle] },
  ];

  const ok = await fetchAssistant(candidateSteps, nextQuestionHandle);
  if (ok) {
    // User forked onto a new branch — invalidate stale forward snapshots.
    pruneHistorySnapshots();
  }
}

function pruneHistorySnapshots() {
  Object.keys(state.responseHistory).forEach((key) => {
    if (Number(key) > state.steps.length) {
      delete state.responseHistory[key];
    }
  });
}
```

Three things to note:

- Always send the full `steps` array, not only the latest selection. The API needs the complete history to decide the next question and matching products.
- For multi-choice questions, collect all selected options into the same step: `option_handles: [a, b, c]`.
- If the selected option returns `next_question_handle`, pass it in the next request. Some assistant flows rely on this explicit hint to advance reliably.

#### Completion and empty states

- If `question` is `null`, the flow is finished and `hits` is the final recommendation set. Render those and offer a way to restart or go back.
- If `hits` is empty while questions are still active, the current combination of user selections has filtered out all available products. Show an empty state and allow the shopper to go back and adjust their answers.

#### Back, reset, and jump

Back and reset are frontend operations on `state.steps`. Caching responses per index in `responseHistory` lets a jump back to a previous state render from cache without re-fetching:

```javascript
async function goToHistoryIndex(historyIndex) {
  const candidateSteps = state.steps.slice(0, historyIndex);
  const cachedResponse = state.responseHistory[historyIndex];

  if (cachedResponse) {
    state.steps = candidateSteps;
    renderAssistant(cachedResponse);
    return;
  }

  await fetchAssistant(candidateSteps);
}
```

### Step 3: Limit hit payloads with `hit_fields`

Each hit can carry a large attribute set by default. If your card only renders title, image, brand, and price, request only those fields.

#### Example

```json
{
  "assistant_version": -1,
  "steps": [],
  "hit_fields": "title,price_amount,image_link,brand"
}
```

Three details that matter:

- The value is a comma-separated string, not an array.
- `hit_fields` filters only the `attributes` object. Top-level hit fields like `url` (the [object identity](/platform-foundations/identity/)) are always returned regardless.
- If your catalog has image size variants, requesting `image_link` also returns `image_link_s`, `image_link_m`, and `image_link_l`. Use the smallest variant that fits your card.

### Step 4: Swap runtime fields with `field_overrides`

`field_overrides` keeps the assistant configuration generic while replacing fields used by assistant filters and facets at request time — for example, switching `price_amount` to `price_amount_7` for a logged-in user.

#### Example

```json
{
  "assistant_version": -1,
  "steps": [{ "question_handle": "budget", "option_handles": ["budget-low"] }],
  "field_overrides": {
    "price_amount": "price_amount_7"
  }
}
```

What changes:

- Hit filtering uses `price_amount_7` instead of `price_amount`.
- Option `hits_count` and other assistant facet calculations use the overridden field.
- The main assistant configuration can stay generic while the actual field is chosen at runtime.

:::note
The override target must exist in the indexed catalog and must be type-compatible with the source field. Invalid overrides return `400 Bad Request`.

If you only need a different price field for option price ranges (not for filtering), set `price_field` instead.
:::

### Step 5: Track clicks and add-to-cart

Assistant analytics split into two layers. Luigi's Box records the flow automatically from your API calls (the Assistant Listing event — which questions were shown, which options were selected, which products were returned). What it cannot see is what happens after: product clicks and add-to-cart. Your integration sends those.

Pick the transport that matches your stack: the [DataLayer collector](/analytics/collector/) if your storefront already emits GA4-style ecommerce events, or the [Events API](/analytics/api/events/) for direct HTTP events from a backend or custom frontend. In both cases, use the top-level `hit.url` (the [object identity](/platform-foundations/identity/)) as the product identifier so Luigi's Box can pair the event back to the correct product.

#### Product click

```javascript+dataLayer
function trackAssistantProductClick(productId) {
  window.dataLayer = window.dataLayer || [];
  window.dataLayer.push({
    event: "select_item",
    ecommerce: {
      items: [{ item_id: productId }],
    },
  });
}
```

```javascript+API
const ANALYTICS_API_URL = "https://api.luigisbox.com/";

async function sendAnalyticsEvent(payload) {
  await axios.post(ANALYTICS_API_URL, payload);
}

function trackAssistantProductClick(productId) {
  return sendAnalyticsEvent({
    id: crypto.randomUUID(),
    type: "click",
    tracker_id: TRACKER_ID,
    client_id: USER_ID,
    action: { type: "click", resource_identifier: productId },
  });
}
```

#### Add-to-cart

```javascript+dataLayer
function trackAssistantProductAddToCart(productId, productPrice) {
  window.dataLayer = window.dataLayer || [];
  window.dataLayer.push({
    event: "add_to_cart",
    ecommerce: {
      currency: "EUR",
      value: productPrice || 0,
      items: [{ item_id: productId, quantity: 1 }],
    },
  });
}
```

```javascript+API
function trackAssistantProductAddToCart(productId) {
  return sendAnalyticsEvent({
    id: crypto.randomUUID(),
    type: "click",
    tracker_id: TRACKER_ID,
    client_id: USER_ID,
    action: { type: "add-to-cart", resource_identifier: productId },
  });
}
```

In the Events API, add-to-cart is a `click` envelope with `action.type: "add-to-cart"` — not a separate top-level `conversion` event. For the fuller measurement model and which metrics to watch, see [Shopping Assistant analytics](/shopping-assistant/guides/analytics/).

## Best practices

- **Commit state after success.** Build a candidate `steps` array, call the API, and assign `state.steps = candidateSteps` only on a successful response. This keeps client state consistent when requests fail.
- **Disable option buttons while a request is in flight.** A second click before the first response lands can race the commit and produce a corrupted history.
- **Keep `user_id` stable.** Use the same `user_id` across every assistant request for one shopper so analytics attribute the flow correctly.
- **Request only what you render.** Use `hit_fields` to limit the payload to attributes your card actually displays.
- **Separate state, transport, and rendering.** One module owns `state.steps` and `responseHistory`, one builds the request (including `hit_fields` and overrides), one renders the current `question` and `hits`. This pays off as soon as you add back/reset or runtime overrides.

## Next steps

- **Learn the concepts:** [Shopping Assistant implementation flow](/shopping-assistant/guides/implementation-flow/) covers the state model, loop, and edge cases without code.
- **Set up measurement:** [Shopping Assistant analytics](/shopping-assistant/guides/analytics/) shows how to track product outcomes and which metrics matter.
- **Full field reference:** [Assistant API reference](/shopping-assistant/api/assistant/) documents every request and response field.
