The configurator chat advisor
- Version
- 4.0 preview
- Updated
Scope: the Claude-backed chat assistant on the configurator and cart — concept, architecture, the tool surface, page driving, the knowledge model, the wishlist, the journal and its analysis layers, and how it relates to the MCP server · Last reviewed: 2026-08-02
A store visitor configuring a product gets a floating chat panel. The assistant knows the product (structure, answers, prices, descriptions, detail panes), talks in the visitor's language, and can act: make selections, explain why something is unavailable, run what-ifs, drive the visitor's page, and put the finished configuration in the cart. Everything it does is journaled; everything it couldn't do is filed on a categorized wishlist for the teams that can fix it.
All paths below are relative to the component root.
1. The concept — one state, two clients
The design rests on a single decision: the assistant is a second client of the same runtime API
the page itself uses. Its tools are thin wrappers over the configuratorpage controller tasks
(setSelection, getConfiguration, previewSelection, explainQuestion, addConfigurationToCart),
executed in-process in the visitor's own request and session.
That answers every "how do web clicks and assistant actions reconcile" question at once:
- There is only ONE configuration state — the session's cart position.
- One validation path — the same ownership, product, selectable and needsConfirmation guards that protect the page protect the assistant (and every other headless client).
- One refresh mechanism — after the assistant acts, the panel calls the configurator's own
switchPage(), which re-renders from the server and re-seeds the client store, so the assistant's work looks exactly like the visitor's own clicks.
The assistant can therefore never do anything the visitor could not do themselves. There is no admin surface, no catalog write, no other user's data behind any of its tools.
visitor message
└─ panel (assets/javascript/chatadvisor.js)
└─ POST cb-api chatadvisor::sendMessage (visitor's own session)
└─ ConfigboxChatAdvisorHelper::handleMessage
└─ Anthropic Messages API ⇄ tools (server-side loop, ≤ 8 rounds)
get_configuration ─┐
set_selection ─┤
preview_selection ─┤ ConfigboxCliHelper::runTask()
explain_question ─┤ → ConfigboxControllerConfiguratorpage
add_to_cart ─┤
edit_configuration ─┘ (cart surface only)
show_page / scroll_to_question → queued as uiCommands
add_wishlist_item / suggest_replies → turn state
┌─ reply + stateChanged/addedToCart/openedForEditing flags + uiCommands + quickReplies
panel renders the reply (markdown), applies page effects in ONE re-render, follows redirects
2. Configuration
Settings live in the store settings' AI Configurator Assistant group (models/config.php,
ai_* fields): API key, chat model, analysis model, persona, greeting, house rules, the three
permissions (may add to cart, may drive the page, may change the cart), rate limit, journal
switches and retention.
ConfigboxChatAdvisorHelper::getSettings() resolves them; a legacy gitignored
data/store/private/settings/chatadvisor.json is imported once by migration 3.7.6 and still
works as an override source for the fields it carries.
Without a key (or with the feature off) there is no panel markup, no assets, and the endpoint
refuses. The panel is one view — views/chatadvisor/ — rendered by whichever page wants it
(ConfigboxControllerConfiguratorpage::display(), ConfigboxControllerCart::display(), and
ConfigboxControllerM2configurator for Magento's injected fragment). What differs between the
surfaces is piped in through the view's setters: setSurface(), and on the cart the binding below.
It used to be a template inside each host view, and the two copies of the shell were byte-identical
— kept in step by hand, and already drifting. Overriding it is now
data/customization/templates/chatadvisor/default.php, once, instead of one file per host view.
The binding — what grounds a conversation
Every message the panel posts carries a cart position and a product. The position is what
every tool acts on and what ownership is checked against; the product is whose catalog digest the
assistant is briefed on. Without both, ConfigboxControllerChatadvisor::sendMessage() refuses.
| Surface | Where the ids come from |
|---|---|
| Configurator | The view's own attributes (data-cart-position-id, data-product-id). A configurator page is a position, so they are always there. |
| Cart | A stored conversation when one is still valid; otherwise the binding the page advertises — ConfigboxChatAdvisorHelper::getPanelBinding(), rendered onto the panel as data-chatadvisor-cart-position-id / -product-id / -lines. |
The cart binding exists because there is no configurator view to read: the panel used to take the
ids from sessionStorage alone, which meant it only worked for a visitor who had talked to the
advisor on the configurator and travelled here. A cold arrival — back the next day, cart opened from
the menu, storage cleared — got a chat window that answered everything with "Missing
cartPositionId". The page binds to its first line, the row at the top of what the visitor is
looking at; that decides which product the assistant knows in depth, not what it can do, because
the cart tools take a line id and list_cart_lines shows them all.
data-chatadvisor-lines is the cart's position→product map, and it does two jobs: a stored
conversation whose line is no longer in the cart is dropped rather than posted and refused, and a
valid one is preferred over the page default so a conversation that travelled here still continues.
An empty cart renders no panel at all — nothing to bind to, and a chat window that cannot answer
is worse than none.
3. The knowledge model — catalog, prose, live state
The system prompt is built per turn from three blocks (buildSystemBlocks()):
- Ground rules — persona, the behavioral contract (see §6), house rules appended last so they win.
- The catalog digest (
buildCatalogDigest()) — pages, questions, answers, prices, descriptions, plus the product's prose:getProductKnowledge()folds in the product's description, long description and all detail panes (tags stripped). This block contains deliberately NO live state, which makes it safe to mark withcache_control— Anthropic's prompt caching pays for it once per conversation instead of once per message. - The live state block — what is selected right now, what applies, what cannot currently be picked, what is still open. Small, never cached, re-read every message.
Two hard-won details:
- The live state also rides WITH the visitor's message, not only in the system block. Measured: with the facts only in a system block above a long transcript, the model kept answering from what it had said earlier while the visitor had clicked something else. Recency wins.
- Selections equal to the product's declared defaults are marked
[matches the preset default]in every snapshot. The selection store keeps no provenance — a default the engine applied on first render is stored exactly like a click — so equality with the declared default is the honest claim, and the prompt forbids crediting such selections to the visitor.
The prose enrichment is advisor-local on purpose: the public runtime API's projectProduct()
stays untouched (id/title/sku/price), so its schema does not change because the advisor wants to
read panes.
4. The tool surface
Custom question types describe their selection format themselves. A custom type whose
selection is structured (the JSON multi-value types) is a black box to the model — the type
name alone says nothing about the value shape, and a model that has to guess guesses wrong
(observed in the journal: "2000x1000", {"width": ...} against a {"w","h","q"} type,
four failed attempts, then a bug-report wishlist item). The seam is
ConfigboxQuestion::getSelectionFormatHint() (base: null): a custom type class overrides it
with one or two sentences stating the exact format, bounds and an example.
ConfigboxConfiguratorApiHelper::projectQuestion() emits it as selectionFormat, the
catalog digest carries it, and the set_selection tool description tells the model to follow
it exactly. With the hint in place the same requests succeed on the first attempt. The
machine-readable companion is ConfigboxQuestion::getSelectionSchema() (base: null): a
JSON Schema of the stored value with a description on every property (live bounds
included), emitted as selectionSchema beside selectionFormat — for the model, and for
any tooling that wants to validate a selection before writing it.
Defined in getToolDefinitions($surface, $cartPositionId), executed by executeTool().
| Tool | What it does | Notes |
|---|---|---|
get_configuration | Full live state + total | |
set_selection | Select / enter / clear one question's value | Write-then-verify: after an accepted write it re-reads the store and returns a verified block — valueNow, stuck, open questions, total, and nowAvailable (what the cascade just unlocked, with the allowed options). The assistant may only claim what verified says. |
preview_selection | What-if without committing | The prompt routes every hypothetical here; set_selection is only for actual instructions. |
explain_question | Why something is unavailable and what would unlock it | |
show_page | Switch the visitor's page | Queued as a uiCommand; requires a reason (journaled). Gated by the drive permission. |
scroll_to_question | Bring one question into view (switching page first if needed) | Queued; requires a reason. Gated by the drive permission. |
add_to_cart | Finish the configuration | Configurator surface only, gated by the add-to-cart permission; requires a reason. |
list_cart_lines | Every line in the cart, with its cartPositionId | Cart surface only, and NOT behind the edit permission — reading the basket is not changing it. It is also where the model learns the ids: a line it has not been told about cannot be acted on. |
ask_which_cart_line | One tappable button per line, to ask which one is meant | Cart surface only. Labels are built from the cart's own lines, never from anything the model wrote, so it cannot offer a line the cart does not have. Colliding labels are qualified by price, and by the cart's row number when the prices match too — never by cartPositionId, which is a database key a shopper cannot read (and which the assistant started reciting when the labels contained it). It must NAME the lines it is choosing between, at least two of them (minItems, and refused server-side): naming none used to mean "offer everything", which made asking free — no thought, no commitment — and a model that cannot name a second line has, by its own reckoning, found one. |
set_cart_quantity | How many of this line the visitor wants | Cart surface only (see the gates below). The number replaces the quantity, never adds to it — the description says so, because "make it three" and "three more" are one word apart. |
remove_cart_line | Take the line out of the cart | Cart surface only, and two-turn: called without confirmed it changes nothing and hands back the sentence to put to the visitor; only a confirmed in a LATER turn removes anything (same guard as a confirm-selection, see below). In a cart of several lines the answer also carries the line's own label, worded exactly as the chooser's buttons would word it, and says to use it — a confirmation reading "this line" over a row of lookalikes asks the visitor to agree to something they cannot see. No undo — the selections go with the line. Withheld when the journal is off, since that is where the guard reads the previous turn from. |
copy_cart_line | Duplicate the line so the visitor can vary it | Cart surface only, needs the drive permission too: the copy comes back unfinished and out of the cart, so the visitor is handed to the configurator with it. The original is untouched; a second identical one is a quantity change, which the description says in as many words. |
edit_configuration | Reopen the cart line item in the configurator | Cart surface only, needs the drive permission too; requires a reason. Goes through the cart::editPosition task — so ownership and the order's status are checked while the assistant can still say so — and navigates to the configuratorUrl it answers with, optionally landing on a named question's page with a scroll handed off through the stored conversation. (It used to drive the legacy editCartPosition redirect, which reopened the line as a side effect of the page it landed on, checked no status at all, and logged a legacy call on every use.) |
suggest_replies | 2–3 one-tap answers rendered as buttons | Labels are sent verbatim as the visitor's next message, so transcript, model context and journal all read as if typed. Last call wins. |
add_wishlist_item | File one categorized gap — see §7 | Telemetry, invisible to the visitor. |
The confirmation guard: a question set to behavior_on_changes = confirm must be confirmed by
the CUSTOMER. The confirmed flag in set_selection's input is only forwarded when that exact
question + selection was presented for confirmation in an EARLIER turn of the same conversation —
the only way the visitor can actually have been asked. An over-eager same-turn confirmation is
dropped and journaled as a dropped_confirmation event. remove_cart_line uses the action-level
twin of the same lookup (hasPendingActionConfirmation()), for the same reason: it cannot be
taken back. Both read the previous turn out of #__configbox_chat_events, so both depend on the
journal being on — which is why the removal tool is withheld when it is off rather than left to
refuse forever.
Surface differences: on the cart, the tools that would change the configuration's SELECTIONS
(set_selection, show_page, scroll_to_question) and add_to_cart are withheld entirely — a
tool the model can see is a tool it will eventually offer. What the cart gets instead is the
basket: quantity, remove, copy and reopening the line.
Which line they act on. Every cart tool takes an optional cart_position_id. In a cart holding
ONE line, leaving it out means that line; in a cart of several it means the chooser, not the bound
line (below). A named line has to be in the SAME cart — a tighter rule than ownership (the tasks
already refuse somebody else's position), so the assistant cannot reach into an old cart of the same
customer that the visitor is not looking at.
Ambiguity is refused by the server, not discouraged in the prompt. In a cart with more than one
line, a mutating tool that does not name a line changes nothing: it answers needsLineChoice
with the choices and puts the buttons on screen. This is a mechanism because wording was measured
and found insufficient — on a two-line cart, "take the fixture product out of my basket" went
straight to remove_cart_line on the bound line about half the time even with both lines listed in
the message and the rule stated beside them. Same finding the confirmation guard rests on: wording
moves the odds, a mechanism settles them, and here a guess deletes a line the visitor never pointed
at. A model that genuinely knows says so explicitly and is not slowed down at all — which is why the
lines, with their ids, ride with every cart-surface message.
The prompt also has to say the mechanism exists. The first version described only the danger — "do not guess", three times over — beside a sentence claiming an omitted id "means this one", which the guard had already made false. So the note now states the guard as the reassurance it is (an omitted id cannot hit the wrong line, so call the tool) and keeps exactly one warning, the one the guard does not cover: an id the model supplies is acted on as given, so it must name a line only when the visitor's own words single one out.
That did not fix the over-asking. It was accepted on 2026-08-11 and that decision was REVERSED on 2026-08-17, when the matching layer it had ruled out was built and measured. The original reasoning and its measurement are kept below, because they are still the best account of what the model is doing; the reversal and its numbers follow at the end of the section.
The reported symptom — "make the espresso machine three", on a cart holding exactly one espresso
machine, answered with a question and no tool call — occurred in roughly two runs in three. It is
measured by chat-advisor-cart.spec.ts, which keeps the case behind CB_CHAT_TUNING=1 precisely
so it documents the behaviour instead of reddening the suite.
What the measuring turned up is worth keeping, because it contradicts the obvious reading:
- The note is not the problem. It was dumped and read back: both lines listed with their product names, the bound one marked, the decision rule stated twice. The model has everything it needs.
- It is not confused about the line. The journal has it naming the right one every time — "I can see you've got an Espresso Machine Product in your cart (line #4530)" — and then hesitating over what three means: "change that one to a quantity of three, or did you mean to add three of…?". Sometimes it re-asks which line on top. At least two behaviours, and only one is about the chooser.
- Wording was measured three ways and moved none of it: correcting the contradiction, adding the "reading what they told you is not guessing" rule, and reframing the note's OPENER, which used to cast the model as an explainer ("you explain it, answer questions about it") and then forbid prose several hundred words later. Each landed inside the noise of three runs.
Which puts this on the wrong side of the maxim above. There is no mechanism to reach for here: the failing turns call no tool at all, so the server never sees them and has nothing to refuse. Every guarantee that matters — nothing removed without an informed yes, no line acted on that the visitor did not point at — is settled by the server and holds regardless of how this turn goes.
Accepted, 2026-08-11. What the assistant actually does in the failing runs is ask "change that one to a quantity of three, or did you mean to add three more?" — which is a fair question about a genuinely ambiguous instruction, asked of the right line. It costs the visitor one turn and cannot cost them anything else. Against that: wording has been measured three ways and moves it none, and the only mechanism that could settle it would have to resolve the visitor's words server-side — a matching layer between what someone typed and the cart's own lines, with its own wrong-line failure mode, built to save one clarifying question. That trade is not worth taking.
Reversed, 2026-08-17. Two of the premises above did not survive being measured again.
The first is "there is no mechanism to reach for here". True of refusing — the failing turns call
no tool, so the server never sees them — but the server does not have to refuse. It can assert.
Whether a message quotes one line's product title and no other's is text matching, not judgement,
and the file already had the pattern: getOfferedCartChoices() resolves a tapped chooser label to
its id and states that id in the note as a fact. getLineNamedInMessage() now does the same for a
product name.
The second is "built to save one clarifying question". The A/B says otherwise — one site, one model (claude-haiku-4-5), six runs an arm, nothing else changed:
| acted on the right line | |
|---|---|
| note without the resolved line | 0 / 6 |
| note with it | 5 / 6 (6 / 7 with the validation run) |
The baseline's six turns each produced a turn row, a reply and zero events — the model declining to act, not a swallowed click. And the failure this section calls "not accepted" was in there: alongside the fair "do you want ... three" question, two runs answered "I need to clarify which item you mean. Your basket has two different products". So the accepted reading — that it always knows the line and only hesitates over the quantity — was not what six clean runs showed.
With the mechanism, every acting run called set_cart_quantity with an explicit cart_position_id,
and no run re-asked which line. The one failure named the right line and asked only about the
quantity, which is precisely the behaviour that was accepted.
The wrong-line failure mode it was rejected for is bounded by strictness. The match is the longest run of consecutive title words quoted as a phrase in the message, the winner must beat every other line outright, a one-word run only counts when it is the whole title, and any tie resolves to "say nothing" — two of the same product, one title inside another, two titles sharing the quoted phrase. When it says nothing the note is exactly what it was before. It also states only WHICH line, never that the model should act, because the same paragraph is reached on a read-only cart.
The spec stays gated behind CB_CHAT_TUNING=1 — it is a live measurement of a stochastic thing, not
a guarantee — but it is no longer expected to be red. Reopen it if the shape changes: a turn that
re-asks WHICH LINE when the visitor named the product is the bug this closed, and one that asks what
"three" means is the question that was accepted.
The three gates on the cart operations, all answered when the toolset is built and none of them at call time:
ai_may_edit_cart— the store's own permission (migration 3.8.11, defaults on). Its own setting rather than a reading ofai_may_add_to_cart: agreeing that the assistant may complete a purchase is not agreeing that it may empty the basket.editOrder— the order's status, viaConfigboxPermissionHelper::isPermittedAction(). A cart that has been checked out, ordered or paid is not a basket, and the tasks behind these tools refuse it with409 CART_NOT_EDITABLE. Asked here so the assistant never offers what the store will refuse.ai_may_drive_page, forcopy_cart_lineandedit_configurationonly — both hand the visitor to the configurator, and a copy nobody is taken to configure is an unfinished line the visitor never asked for and cannot see.
The cart-surface note that rides with the visitor's message is built from the same two settings and the same editable check as the toolset, so the assistant is never handed a tool the prose forbids (or told it can do something the toolset withheld).
The model can name a line of THIS cart, and nothing else. It never supplies a bare position id that is taken on trust: an id it names is checked against the cart's own lines first, and the tools go through the cart controller's runtime tasks, which refuse a position belonging to somebody else. So "remove the other one" works, and reaching outside the basket in front of the visitor does not. The tools go through the cart controller's runtime tasks rather than the position model, which is where ownership and the status check live.
5. Driving the page
Server side, show_page / scroll_to_question queue uiCommands; the turn's response carries
them together with stateChanged. When the assistant changed a selection but asked for no
navigation itself, the server adds a scroll to the last changed question — a selection the
visitor cannot SEE is a selection they do not believe.
Client side, chatadvisor.applyPageEffects() unifies everything into at most one re-render:
- A page the assistant asked to show wins over staying put; a scroll to a question on another page implies that page.
- The refresh is the configurator's own
switchPage(pageId, callback). Its built-in scroll-to-view is suppressed for the advisor's refresh via theconfigurator.noScrollflag — set before the call and deleted in the callback (the flag is global configurator state; left set, every later visitor-initiated page switch silently loses its scroll). switchPageinvokes the callback only once the view carriesview-init-done— its init calls have run, but layout may still settle (images, panes), so the scroll re-checks its target twice during the highlight pulse and corrects without animation if it drifted.
The scroll itself (chatadvisor.scrollToQuestion()) follows the configurator's own viewport
convention — the question's top lands below the sticky header with padding — with two rules
learned in production:
- The header height is MEASURED per call (
configurator.getStickyHeaderHeight()): templates ship sticky headers without ever setting thewindow.stickyHeaderHeightglobal, and an offset of 0 scrolled targets squarely behind a 220px header. The helper honours the global when a template declares it, else measures pinned sticky/fixed top bars. - Scroll targets are clamped at 0: for elements near the page top the header offset makes the computed target negative, and a smooth scroll to a negative position rubber-bands on macOS — the page briefly shows blank space above the document.
The cart-to-configurator handoff: edit_configuration responds with openedForEditing +
redirectUrl (+ scrollQuestionId). The panel stashes the pending scroll in the stored
conversation (sessionStorage — nothing else survives the navigation), navigates, and the
configurator-side panel init consumes it once, pointing at the question. copy_cart_line takes the
same road, with the COPY's configurator URL.
Coming back open is one rule, not a list of arrivals. A panel the visitor left open reopens on
load, whatever moved the page — added to the cart, a line reopened, a copy, or their own click
through. It used to be spelled out per arrival, and the arrivals nobody spelled out stayed shut:
copy_cart_line and an edit_configuration naming no question both land in the configurator with
no pending scroll, so the visitor got a new page, no explanation, and a conversation to go looking
for. wasOpen is written by setOpen() as well as storeConversation() — without that it only
ever recorded the state at the last message, and a panel the visitor had deliberately CLOSED would
be forced open again on the next page.
A changed cart is a page reload, not a re-render. set_cart_quantity and remove_cart_line
set cartChanged, which the panel answers with window.location.reload() — applyPageEffects()
is not an option there, because its refresh is the configurator's switchPage() and there is no
configurator on the cart page to re-render. It is also what the cart's own Remove button does, and
the transcript survives it (restored from session storage, panel reopened).
The one exception is a removal: it also sets positionRemoved, and the panel drops the stored
conversation before reloading. Every turn is grounded in a cart position id, and the transcript
carries the one that has just been deleted — kept, it would give the visitor a panel whose next
message can only answer "cart position does not exist". The conversation ends with the line it was
about.
Reply rendering: assistant bubbles render a deliberately small markdown subset — bold,
italic, inline code, bullet/numbered lists, paragraphs — escape-first, so model output can never
inject HTML; anything unrecognized stays visible as typed. Visitor bubbles stay verbatim plain
text. The admin journal renders model text through renderModelText() (same idea, PHP side).
6. The behavioral contract (prompt guardrails)
The ground rules encode the failure modes the journal actually caught:
- Never claim an unverified change. Report
verified.valueNow, never the requested value; a selection that did not stick is a failed selection whatever the engine said. - Answer every part of a compound message ("add it to the cart, but double-check X") — silently dropping either half is a serious failure; accuracy challenges are addressed head-on and filed on the wishlist when the data cannot settle them.
- Questions are answered from live state; hypotheticals go to
preview_selection; only an actual instruction changes anything. - Live state outranks conversation history — the visitor clicks between messages.
- Preset defaults are not the visitor's choices (see §3).
- Pricing language follows the visitor's customer group. B2C visitors are told prices are
final and VAT is never brought up unless asked; B2B visitors get the mixed reality described
precisely — the
formattedstrings are display values, and no figure is called net or gross unless the data's own fields say so for THAT figure. - The wishlist hard rule: saying "I can't" or "I don't have that information" without having filed a wishlist item in the same turn is forbidden.
7. The wishlist
One telemetry tool (add_wishlist_item) replaces the earlier
report_unfulfilled_request / report_missing_capability pair — two overlapping tools made the
model pick; one tool with categories makes it file. Each item carries:
- category —
content(missing product information → content managers),feature-request(missing tool/API ability → the vendor),bug-report(observed misbehavior),translation,ux-friction,other; - title — one actionable line;
- details — what is missing/wrong, why it matters, what it would have enabled;
- visitor_request — the triggering ask, near-verbatim, when there was one.
An item WITH a visitor request counts as an unfulfilled request (the journal's demand-signal
counter and filter); one without is a gap the assistant noticed itself. Persistence: one wish
event per item — category in category, title in detail, the structure in payload — plus
wish_count/unfulfilled_count on the conversation. The journal UI renders category chip +
title with details and the visitor's words behind an expander; the flat log writes
WISHLIST [category]: lines.
The long-term purpose: this is the diagnostic data an installation can eventually ship to the vendor, so content gaps, feature demand and field bugs aggregate across stores.
8. The journal
Enabled per settings; gated stricter than the admin area (currentUserMayViewJournal() — Joomla
Super Users, WordPress administrators, Magento 2 admin-area users), because it shows visitors'
words verbatim.
Three tables (created by migrations, pruned by retention):
#__configbox_chat_conversations— one row per conversation: reference (CADV-…), product, cart position, user, model/persona, aggregate counters (turns, tokens, selections, refusals, wishes, unfulfilled, errors),page_url(always the configurator URL — cart-surface turns never overwrite it), satisfaction + lessons columns.#__configbox_chat_turns— per turn: visitor message, reply, before/after selection snapshots, token/duration accounting, error.#__configbox_chat_events— the queryable record of everything inside a turn, one row each:
| event_type | What | Category / detail |
|---|---|---|
reasoning | The model's pre-action rationale | |
tool_call | Every tool invocation with input payload and outcome | |
set_selection | An attempted selection with previous value, acceptance, outcome | category configuration |
show_page, scroll_to_question, edit_configuration | Page actions | category navigation, the model's reason in detail |
cart | Add-to-cart | category cart, reason in detail |
wish | A wishlist item | see §7 |
dropped_confirmation | A confirmed-flag the guard refused | |
error | A failed turn |
This taxonomy makes questions like "every refused selection", "all content wishes", or "every
page action and why" plain SQL — for the admin journal and for analysis agents reading through
the MCP server alike (chat-conversation, chat-turn, chat-event entities, read-only).
9. The analysis layers
Lessons (per conversation) — analyzeConversation(): one call on the analysis model over the
full transcript (bubbles, reasoning, tool calls, wishlist items, before/after states). Output: a
tone verdict (stored separately for filtering) and a compact review — what worked, what went
wrong, every wishlist item judged in context, and gaps the runtime tagging missed (the
best-effort filing is audited by the reviewer). Runs from the scheduled sweep
(configbox:chatadvisor:analyze — which also enforces journal
retention, unconditionally) or per conversation from its journal page.
The recommendation backlog (over the journal) — ConfigboxChatReconcilerHelper: the corpus
analysis maintains a durable backlog instead of writing snapshot reports. One row per distinct
problem (chat_recommendations), owned by one stakeholder, with a status lifecycle
(open → sent → done | dismissed | regressed); the conversations backing it are
chat_recommendation_evidence rows carrying the CADV reference and the visitor's quote captured
at reconcile time, so the justification survives retention pruning. The reconciler runs after the
lessons sweep, over ONLY the conversations analysed since its last visit (reconciled_at
watermark), against a compact summary of the existing backlog, and emits validated mutations:
attach evidence / open (refused without evidence) / rescore. New evidence on a done
recommendation flips it to regressed — the fix did not hold; dismissed rows accumulate
evidence silently but stay dismissed. Emails sent from the Recommendations screen are recorded in
chat_outreach (+ join) with the exact approved text, and covered items flip to sent.
Migration 3.8.9 created the tables and seeded the backlog once from the newest snapshot report.
The old ConfigboxChatInsightsHelper::generateReport() pipeline (one call over up to 300
conversations, chat_insight_reports/_items) is gone — helper, report partial, JS module and
the analyze tasks with it. Migration 3.8.9 seeded the backlog from the newest report first, so
nothing was lost; keeping a picker onto frozen duplicates of a self-maintaining list only invited
reading a stale copy. Conversation Insights is the digest (actionable counts per stakeholder,
30-day movement, your own assignment count) plus the live demand ledger (below). Migration
3.8.10 drops the two chat_insight_* tables — safe only because of the ordering: 3.8.9 seeds
the backlog from the newest report, and update scripts run in version order, so every install gets
its seed before the source is removed.
The demand ledger (on Conversation Insights) — the raw feed under the curated backlog: every
wish event whose outcome carries the visitor's request, aggregated live by category and wish
title over a selectable window (30/90 days, all time). Plain SQL over chat_events ×
chat_turns × chat_conversations — no analysis run, no reconciler, no tokens — so it is
current the moment a visitor asks for something the shop cannot do, where the backlog only knows
after the lessons sweep AND the reconciler have both run. The demand-signal distinction from §7
is load-bearing here: an item filed without a visitor request is a gap the assistant noticed
itself and deliberately stays out of the ledger — the ledger's claim is measured demand, in the
visitors' own words, each group expandable to its quotes and journal references. The time window
filters on the TURN's created_at (events carry no timestamp of their own).
The journal timeline shows ONE card per action. A tool call and what it meant are two rows in
chat_events by design (raw call for debugging, semantic row for querying), and rendering both put
every wishlist item and every selection on screen twice — once as unreadable JSON, once as a card.
ConfigboxViewAdminchatconversation::buildTimeline() pairs them back through TOOL_EVENT_PAIRS:
the semantic row supplies the human reading, the tool row supplies the raw input behind the
expander. Unpaired tool calls (get_configuration, explain_question, preview_selection,
suggest_replies) stand alone as lookup cards, and a semantic row whose tool row is missing —
pre-pairing history — still renders. Question and answer ids are resolved to labels, so a selection
reads Battery capacity: 750 Wh (was 625 Wh) rather than three ids.
Recommendations (per stakeholder) — ConfigboxChatOutreachHelper: the same findings, seen one
audience at a time, with a way out of the admin. Each of the five sections was already written FOR a
named reader; STAKEHOLDERS maps reader → section (catalog→product-explanation,
product-owner→unfulfilled, developers→bug, vendor→proposal, team→frustration), each with two
settings (ai_contact_<suffix>_name / _email, migration 3.8.8.1). The screen
(adminchatrecommendations) renders every audience's panel server-side and switches with a class
toggle; composeEmail() has the analysis model write a short persuasive email from that audience's
findings (verbatim, so it frames and prioritises but cannot invent), and sendToContact() sends
what the sender approved, not a re-composition. Composing and sending are two tasks on purpose —
nothing leaves without a human having read it.
The team layer (migration 3.8.12) — the backlog stopped assuming one reader. A recommendation
can be assigned (assigned_to_id + assigned_to_name, the name denormalized so "who owned
this" survives account deletion the way evidence quotes survive retention pruning) and carries a
note thread (chat_recommendation_notes — the team's discussion on the record next to the
evidence; status_note stays what it was, the reason attached to one status change). Assignable
users are the journal-gate circle (getAssignableUsers(): enumerable on Joomla — active accounts
filtered through the same super-user check the gate uses; other platforms degrade to "take it
yourself"). The screen gets an assignee select and note form per card (ajax:
assignAjax/noteAjax), an "Only mine" filter per panel, and Insights shows the personal count.
Assignment is workflow, not access control — anyone who can see the backlog can assign, like a
shared kanban board. MCP/entity surface: the new fields on chat-recommendation and the
chat-recommendation-note entity — an analysis agent should read the thread before re-raising an
item, because a note often says why it sits.
The vendor relay (migration 3.8.13) — the automated way the vendor items reach the
software vendor. ConfigboxChatVendorRelayHelper::relayPending() runs after the reconciler in
the analysis sweep (and by hand via configbox:chatadvisor:relay, whose --dry-run prints the
exact envelope without sending): every vendor recommendation that is new or changed since its
last transmission (relayed_at watermark) is POSTed to the configured endpoint
(ai_vendor_relay_url, default the CBX Insights Hub) as a versioned envelope —
schemaVersion, install identity (a HASH of the licence key, never the key), and the
recommendations with their evidence quotes. Strictly opt-in (ai_vendor_relay, default
off): the payload quotes visitor conversations, and that never leaves an install without the
operator's say-so. Fail-soft: a down endpoint costs a log line, and the next sweep re-offers
everything still unsent. The receiving side — ingest upsert semantics, the read API, the MCP
tools a vendor-side agent triages with — is the cbx-insights-hub project's docs/API.md;
its schema version moves in lockstep with ConfigboxChatVendorRelayHelper::SCHEMA_VERSION.
Impact sizing (migration 3.8.14) — two additive envelope fields answer "how big is this":
each recommendation carries impactedVisitors (DISTINCT users across its evidencing
conversations — guests have user rows, so this is people; null when retention pruned the
evidence first, the reader falls back to occurrences), and the install carries usage — the
local usage statistics. Those are #__configbox_usage_stats: one row per day of RUNNING TOTALS
(users, cart positions, orders, conversations, turns, products), written by
ConfigboxUsageStatsHelper::snapshotToday() in the analysis sweep regardless of any relay
opt-in — the store's own record of how much it is used. The envelope sends the current totals
plus the closest snapshot from ~30 days back: two points, one trend, a recommendation's reach
sized against real store activity. Totals rather than per-day deltas on purpose — a delta
between any two snapshots is always computable, the reverse is not, and a missed cron day
costs nothing.
Two traps this hit, worth not re-learning:
CbSettingscannot read the AI settings — fixed 2026-08-09. They arestoreExternallyfields in#__configbox_config_ai, whileCbSettingsdoes a plainSELECT * FROM #__configbox_config— a table with noai_columns — soget('ai_…')silently returned the NULL fallback for every one of them. Everything the admin saved in this group was ignored at runtime (key, models, journal switches, retention); dev machines never noticed because the legacychatadvisor.jsonsupplied the key and flippedenabledon.ConfigboxChatAdvisorHelper::getConfigRow()now reads that table directly andgetSettings()resolves from it — deliberately not viaConfigboxModelConfig::getRecord()(which builds every property definition in the store settings, on a path that runs per chat message) and deliberately not by teachingCbSettingsabout external tables (it is constructed during bootstrap, where its ownKTextcomment already warns about circular init).ConfigboxChatOutreachHelperreads the same row, so the two can never disagree about where the AI settings live.- The view template must emit
$this->getViewAttributes()on its root element. A hand-written class list losesdata-init-calls-once, and the AMD module never loads — here that showed up as audience tabs that render perfectly and do nothing.
The Insights screen runs the analysis over XHR: analyzeAjax (controller adminchatinsights)
generates the report and answers with the rendered report partial (tmpl/report.php via
getReportHtml()), which configbox/adminChatInsights swaps into the page while showing a live
in-flight state — no reload; the plain analyze form post is the no-JS fallback. The module
goes through configbox/server like every other admin mutation.
A budget lesson that applies to both: the Claude 5 family thinks by default, and
max_tokens caps thinking PLUS visible text. The analysis call runs with generous headroom and
marks a review INCOMPLETE if it still hits the cap; the chat call raises its budget and pins
effort low when the configured model is a 5-family model. Truncated-at-1000-tokens reviews (33
characters of visible text after a long hidden reasoning pass) were a real production failure.
10. Security
- The API key never leaves the server — it lives in the settings and is used in the server-to-Anthropic call only.
- The assistant can only do what the visitor could. Every tool runs as the session's user
against the visitor's own cart position; no impersonation, no admin surface, no catalog writes.
The cart operations are the sharpest case and they hold to it exactly: the position id is
injected server-side, and the tools call the same controller tasks the cart page's own buttons
call — which resolve the position through
requireOwnedPosition()and refuse a cart whose order has moved on. A tool is not a second way in; it is the same door. - Prompt injection is bounded by the same fact. Visitor text goes into messages, never the system prompt; a hostile conversation can at worst call the visitor-scope tools above.
- The per-session rate limit caps API spend; it is a lid, not a security boundary.
- The journal is privilege-gated (see §8) and retention-pruned (see §9).
11. Where the MCP server docks in
The MCP server (docs/technical/com_configbox_mcp_server.md) and the chat advisor are two AI
surfaces over the same store with deliberately different trust models:
| Chat advisor | MCP server | |
|---|---|---|
| Who drives it | An anonymous store visitor | An operator's AI assistant (shell access) |
| Scope | The visitor's own cart position, runtime API only | The catalog: describe/list/get over every entity, create/update/delete over the authoring subset |
| Writes | Selections on the visitor's configuration, and that line in their cart | Products, pages, questions, answers, lists, calculations, detail panes |
The MCP server has no runtime tools — no configuring, no cart. That is not an oversight: it runs on stdio with no session and no acting customer, so "does this visitor own this cart position?" has no answer there. Cart work belongs to the surface that has a visitor.
They meet in three places:
- The content loop. The advisor's product knowledge is the catalog's own prose —
descriptions and detail panes. Those are exactly what the MCP server can author
(
product-detail-paneis writable for this reason): a content manager's assistant closes acontentwishlist item by writing the missing pane, and the advisor knows it from the next conversation on. Gap found by the visitor-facing AI, fixed through the authoring-facing AI. - The telemetry loop.
feature-requestwishlist items are field evidence for what the MCP/runtime API should grow next; the journal tables are themselves readable through the MCP server (chat-conversation,chat-turn,chat-event), so an analysis agent can mine them with real queries. - One entity registry. The MCP server's entity names, the REST surface and the generated
schemas come from the same registry (
ConfigboxEntityApiHelper); the advisor's runtime tools sit beside them on the same controller tasks the page uses. There is no second description of the store anywhere in the AI stack.
What the advisor does NOT use the MCP server for: its own tools are in-process runtime calls in the visitor's session, not MCP calls — the MCP server is a separate stdio process with operator privileges and must never be reachable from a visitor request.
12. Testing
tests/specs/frontend/chat-advisor.spec.ts— panel presence/toggle, token-free; skips when unconfigured. The real-conversation specs are opt-in behindCB_CHAT_LIVE=1because they spend tokens.tests/specs/frontend/chat-advisor-cascade.spec.ts— live conversations against the E2E-CASCADE product: rule refusals, driving the whole cascade with engine-verified prices, the confirmation guard, explain-without-changing.tests/specs/api/cart-guards.spec.ts— the cart operations, from both ends and token-free: the ownership and status guards on the tasks behind the tools, and the advertised toolset itself throughtestautomation/getAdvisorTools. Because the advisor withholds rather than refuses, what it is HOLDING is the thing to assert — and it is computed without an API key, so this runs on any store.- The AI Challenge Lab product list (built by
tests/tools/build-ai-challenge-catalog.py+enhance-ai-challenge-catalog.pyin the cbx-joomla repo) is a standing manual test bed: products spanning complexity (multi-level cascades, interval matrices, chained formulas, code calcs, calculated min/max) and information depth — including one deliberately information-starved product to provokecontentwishlist items.