Skip to content

Building a custom shopping assistant

View MD

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.

  • 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.
  • 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.
  • 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.

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.

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.

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

  • 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.
  • 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.
// --- 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

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.

// 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.
  • 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 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.

{
"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) 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

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.

{
"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.

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.

function trackAssistantProductClick(productId) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: "select_item",
ecommerce: {
items: [{ item_id: productId }],
},
});
}
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 }],
},
});
}

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.

  • 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.