Building a custom shopping assistant
Introduction
Section titled “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.
For the conceptual model without code (state shape, rendering rules, back/reset, error handling), see Shopping Assistant implementation flow.
What you’ll learn
Section titled “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
stepsafter 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
Section titled “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
Section titled “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.
Step-by-step
Section titled “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
Section titled “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
Section titled “Endpoint”POST https://live.luigisbox.com/v1/assistant
Required parameters
Section titled “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-1for the latest published version. Must be an integer, notnullor a quoted value.steps(body, array): Empty[]for the first request; the full history thereafter.
Optional parameters (recommended)
Section titled “Optional parameters (recommended)”hit_fields(body, string): Comma-separated list of product attributes to return, for exampletitle,price_amount,image_link,brand. Keeps the response fast and small.
Example
Section titled “Example”// --- 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 optionshits: the products currently matching the selection
Step 2: Handle answers with a safe commit pattern
Section titled “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
Section titled “Example”// 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
stepsarray, 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
Section titled “Completion and empty states”- If
questionisnull, the flow is finished andhitsis the final recommendation set. Render those and offer a way to restart or go back. - If
hitsis 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
Section titled “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:
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
Section titled “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
Section titled “Example”{ "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_fieldsfilters only theattributesobject. Top-level hit fields likeurl(the object identity) are always returned regardless.- If your catalog has image size variants, requesting
image_linkalso returnsimage_link_s,image_link_m, andimage_link_l. Use the smallest variant that fits your card.
Step 4: Swap runtime fields with field_overrides
Section titled “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
Section titled “Example”{ "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_7instead ofprice_amount. - Option
hits_countand other assistant facet calculations use the overridden field. - The main assistant configuration can stay generic while the actual field is chosen at runtime.
Step 5: Track clicks and add-to-cart
Section titled “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 if your storefront already emits GA4-style ecommerce events, or the Events API for direct HTTP events from a backend or custom frontend. In both cases, use the top-level hit.url (the object identity) as the product identifier so Luigi’s Box can pair the event back to the correct product.
Product click
Section titled “Product click”function trackAssistantProductClick(productId) { window.dataLayer = window.dataLayer || []; window.dataLayer.push({ event: "select_item", ecommerce: { items: [{ item_id: productId }], }, });}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
Section titled “Add-to-cart”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 }], }, });}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.
Best practices
Section titled “Best practices”- Commit state after success. Build a candidate
stepsarray, call the API, and assignstate.steps = candidateStepsonly 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_idstable. Use the sameuser_idacross every assistant request for one shopper so analytics attribute the flow correctly. - Request only what you render. Use
hit_fieldsto limit the payload to attributes your card actually displays. - Separate state, transport, and rendering. One module owns
state.stepsandresponseHistory, one builds the request (includinghit_fieldsand overrides), one renders the currentquestionandhits. This pays off as soon as you add back/reset or runtime overrides.
Next steps
Section titled “Next steps”- Learn the concepts: Shopping Assistant implementation flow covers the state model, loop, and edge cases without code.
- Set up measurement: Shopping Assistant analytics shows how to track product outcomes and which metrics matter.
- Full field reference: Assistant API reference documents every request and response field.
Was this page helpful?
Thanks.