# The configurator chat advisor

> > Scope: the Claude-backed chat assistant on the configurator and cart — concept, architecture, > the tool surface, page driving, the knowledge model, the wi…

Source: CBX documentation, version 3.x (released). Canonical page: https://docs.configbox.at/docs/technical/chat_advisor. Last updated 2026-08-25.

---
> **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()`):

1. **Ground rules** — persona, the behavioral contract (see §6), house rules appended last so
   they win.
2. **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 with `cache_control` — Anthropic's
   prompt caching pays for it once per conversation instead of once per message.
3. **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:

1. **`ai_may_edit_cart`** — the store's own permission (migration 3.8.11, defaults on). Its own
   setting rather than a reading of `ai_may_add_to_cart`: agreeing that the assistant may complete
   a purchase is not agreeing that it may empty the basket.
2. **`editOrder`** — the order's status, via `ConfigboxPermissionHelper::isPermittedAction()`. A
   cart that has been checked out, ordered or paid is not a basket, and the tasks behind these
   tools refuse it with `409 CART_NOT_EDITABLE`. Asked here so the assistant never offers what the
   store will refuse.
3. **`ai_may_drive_page`**, for `copy_cart_line` and `edit_configuration` only — 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 the `configurator.noScroll` flag —
  **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).
- `switchPage` invokes the callback only once the view carries `view-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 the `window.stickyHeaderHeight` global, 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 `formatted` strings 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`](https://docs.configbox.at/docs/technical/cli_commands) — 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:

- **`CbSettings` cannot read the AI settings — fixed 2026-08-09.** They are `storeExternally`
  fields in `#__configbox_config_ai`, while `CbSettings` does a plain
  `SELECT * FROM #__configbox_config` — a table with no `ai_` columns — so `get('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 legacy `chatadvisor.json` supplied the key and flipped `enabled` on.
  `ConfigboxChatAdvisorHelper::getConfigRow()` now reads that table directly and `getSettings()`
  resolves from it — deliberately not via `ConfigboxModelConfig::getRecord()` (which builds every
  property definition in the store settings, on a path that runs per chat message) and deliberately
  not by teaching `CbSettings` about external tables (it is constructed during bootstrap, where its
  own `KText` comment already warns about circular init). `ConfigboxChatOutreachHelper` reads 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 loses `data-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:

1. **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-pane` is writable for this reason): a content manager's assistant closes a
   `content` wishlist 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.
2. **The telemetry loop.** `feature-request` wishlist 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.
3. **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 behind `CB_CHAT_LIVE=1` because 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
  through `testautomation/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.py` in 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 provoke `content` wishlist items.
