# Question types the AI understands and controls

> How to build a custom question type that the chat advisor (and every headless client — the runtime API, MCP-driven tooling, tests) can understand and operate…

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

---
How to build a custom question type that the **chat advisor** (and every headless client — the
runtime API, MCP-driven tooling, tests) can understand and operate as fluently as a stock type.
This is the companion to `com_configbox_custom_question_types.md`, which covers the mechanics of
adding a type; this guide covers the **contract that makes a type self-describing**.

The problem it solves: to a model, a question type is just a string in the catalog. For the twelve
stock types, generic guidance suffices ("choice questions take an answer id, text questions take
the value"). A custom type — especially one whose selection is a JSON object — is a black box: the
model guesses `"2000x1000"` or `{"width": …}`, the store rejects it, and the conversation dies.
The framework below lets the type *state* everything a client must otherwise guess.

All methods live on your `ConfigboxQuestion<Type>` subclass (`classes/ConfigboxQuestion.php` has
the base implementations and full docblocks). Everything here flows automatically into:

- the runtime API's question projection (`ConfigboxConfiguratorApiHelper::projectQuestion()`),
- the chat advisor's **catalog digest** (the product knowledge the model reads), and
- the advisor's tool guidance (`set_selection` / `preview_selection` tell the model to obey
  `selectionFormat`/`selectionSchema` exactly).

No advisor or API change is needed when a new type ships — the type describes itself.

---

## 1. Design the selection shape first

The selection is ONE string per question, stored, transmitted and diffed as such. Everything else
follows from its design, so decide it before writing code:

- **Answer-backed types** (each option is an answer record): the selection is the answer id.
  You inherit almost everything — projection, validation, cascades, prices per answer. Prefer
  this whenever options are enumerable and priced individually (the `cards` and `cableoutlet`
  types work this way).
- **Scalar types**: the value itself (`textbox`, `slider`). The core min/max machinery applies.
- **Composite types**: a JSON **object** serialized into the string (`dimensions`:
  `{"w":"2000","h":"1000","q":"1"}`). Rules of thumb, learned the hard way:
  - **Short, stable keys** (`w`, not `width`) — they are your API forever; renaming keys orphans
    stored configurations, saved rules and formulas.
  - **String values** even for numbers — the store passes strings around; mixed types make
    byte-stable serialization impossible.
  - **Empty means `''`**, not `{}` — an all-empty widget must store the empty string so the
    required-check machinery sees "not answered" (or override `isEmptySelection()`).
  - **Optional members are absent**, not empty — a member that only sometimes applies (the
    dimensions type's custom-division maxima) is dropped from the JSON when unused.

## 2. Normalize storage — `getStorableValue()`

Override it to produce ONE canonical serialization: known keys only, fixed key order, trimmed
values, all-empty → `''`. Two reasons:

1. Rules, calc terms and any consumer comparing selections meet a single byte representation no
   matter how a client ordered or padded the JSON.
2. The chat advisor **writes, then reads back** every selection and reports failure when the
   read-back does not match. It compares through `isSameSelection()` (below), whose default
   normalizes the candidate via `getStorableValue()` — so your normalization must be
   **idempotent**: `getStorableValue(getStorableValue($x)) === getStorableValue($x)`.

## 3. The comparison contract — `isSameSelection()`, `isEmptySelection()`

The framework routes every "is this the same selection?" decision through the type:

- `isSameSelection($candidate, $stored)` — default: normalize the candidate with
  `getStorableValue()` and compare strings. The advisor uses it for its write-then-read-back
  `stuck` verification, for matching a two-turn confirmation, and for the "matches the preset
  default" marker. **Never** let a consumer compare selection strings with `===`; a normalizing
  type turns every such comparison into a lie (a successful change reported as failed).
- `isEmptySelection($selection)` — default: `''`/null. What "answered" means, in one place: the
  projection's `answered` flag, the selections list and the open-questions logic all consult it.

## 4. Describe the value — the AI descriptors

These four feed the projection and the advisor's catalog digest. Implement the ones that apply:

| Method | For | What to return |
|---|---|---|
| `getSelectionFormatHint()` | any non-obvious value | One or two sentences stating the EXACT format, the live bounds, and one example. Prose — this is what the model reads first. |
| `getSelectionSchema()` | structured values | JSON Schema of the stored value, a description on **every** property, live per-question bounds in the descriptions, one example. The machine-readable companion; also what validating clients use. |
| `getChoiceList()` | enumerable values without answer rows | The selectable strings (generalizes the stock `choices` type's newline list). Lands in the projection's `choices`. |
| `getConstraintHints()` | scalar constraints beyond min/max | Keys merged into the projection's `constraints` (generalizes the slider's `steps`, the upload's `extensions`). Prefer the schema for structured bounds. |
| `selectionIsAnswerId()` | composite selections that REFERENCE answers | Return `false` when the selection is not a bare answer id (a quantity map, a multi-pick JSON) even though the question has answer rows. The API's answer-membership guards (UNKNOWN_ANSWER, applies/availability) apply only while this is true — without the override the storefront works but every headless/AI write is rejected. Your `isValidValue()` then owns answer membership. |

Build the hint and the schema from the **same** bound-resolution code (a shared private method)
so the two can never disagree — a model that reads contradicting bounds picks the wrong one.

Worked example: the `dimensions` type in the cbx-joomla site customization
(`customization/question_types/ConfigboxQuestionDimensions.php`) implements the full contract —
format hint and schema with live bounds, canonical storage, comparable value, sub-value
delegation.

## 5. Output for humans — `getOutputValue()`

De-facto required for composite types, because its output goes EVERYWHERE a human looks: cart,
order confirmation, e-mails, the advisor's live-state block — and the advisor **reads it to the
visitor** as `valueNow` after every change. Skip it and `{"w":1200,"h":800}` is what your
customer hears. Cover the empty case ("No dimensions entered") — the advisor also surfaces it.

## 6. Rules and pricing — delegate the semantics

A composite type almost always wants a custom rule condition and calc term (so admins can build
"width ≥ X" rules and "area × unit price" formulas). Keep the selection semantics in the TYPE:

- Put a static `getSubValueKeys()` (key → description) and `getSubValue($selection, $key)` on the
  question class — including derived values (the dimensions type derives `area` and `perimeter`).
- The condition and the term **delegate** to those, with a defensive `require_once` of the type's
  file first (customization classes do not autoload each other; the engine may evaluate your
  condition on a page where the type was never instantiated).
- Also override `getComparableValue()` to the best scalar reduction (dimensions → area), so the
  STOCK "question value" condition/term does something sensible instead of `floatval(json) = 0`.
- Unanswered semantics: a condition on an unanswered question returns **false**; a term returns
  **0** — unless the type has a documented default (the stabilizer compares its recommendation).

See `com_configbox_custom_rule_conditions.md` / `com_configbox_custom_calc_term_types.md` for the
class mechanics; the sub-value delegation pattern is in the cbx-joomla customization's
`CustomConditionDimensions` / `CustomCalcTermDimensions`.

### The cart-quantity contract — when the selection carries "how many"

A type whose selection includes a piece count (the dimensions type's `q`) can OWN the cart line's
quantity instead of merely multiplying inside its formula. Override:

- `providesCartQuantity()` — the switch. Usually per question (an admin toggle on the record),
  not per type: existing questions keep their formula-multiplied quantity untouched.
- `getCartQuantity($selection)` — the piece count of a stored selection (≥ 1, or null).
- `applyCartQuantity($selection, $quantity)` — the selection with the count replaced; return it
  UNCHANGED when there is nothing to apply it to (an empty selection), and the cart refuses the
  edit with a pointer to the configurator.

The base system does the rest: `ConfigboxConfiguration::setSelection()` mirrors the count onto
the position row on every store, the cart's quantity edit routes through the selection (the
type's validation, the rules, a recalculation — a quantity that feeds a formula must never
bypass the engine), the projection advertises `drivesCartQuantity` so headless clients and the
advisor change the QUESTION when asked for "3 of these", and `configurator.getCartQuantity()`
hands the widget the line quantity to seed from while unanswered (1 on a fresh configuration,
the line's value when editing from the cart).

**Price per piece.** The cart multiplies the position price by the line quantity — a formula
that also multiplies by the selection's own count would count it twice. Flipping the switch on
an existing question therefore always pairs with removing the count factor from its formula.

Worked example: the dimensions type (`dim_drives_cart_qty`), pinned by
`tests/specs/api/cart-quantity-question.spec.ts` in cbx-joomla.

## 7. The frontend JS contract

Register the type or the page dies: a question whose type is unregistered **throws inside
`configurator.initQuestions()`** and stops every question after it (the page never reaches
`questions-init-done`). The registry requires nine methods; use the helper that supplies the
boilerplate:

```js
// assets/javascript/questiontypes/mytype.js  (AMD id: configbox/custom/questiontypes/mytype)
define(['cbj', 'configbox/configurator'], function ($, configurator) {
	'use strict';

	return configurator.defineQuestionType('mytype', {

		initEach: function () {
			// bind widgets; guard against double-init with a marker class
		},

		onSystemSelectionChange: function (event, questionId, selection) {
			// re-seed the widget from an authoritative selection (rule change, restore)
		}

	});
});
```

`defineQuestionType(type, methods)` fills every unspecified handler with a no-op and gives the
two validation-message handlers the standard implementation (mark `.form-group`, fill/clear
`.validation-message-target`, guarded to your type). Override any subset.

Have `custom_questions.js` (the auto-loaded aggregator) list your module as a dependency.

Widget rules that keep AI control working:

- **Mirror `data('selection')` on store.** Core sets it from the makeSelection *response*; write
  it yourself at send time so same-tick readers (cross-question overlays, flow modules) see the
  in-progress value.
- **Send raw, let the server validate.** No client-side clamping — the server's `isValidValue()`
  message comes back through `onValidationMessageShown`, which is the SAME path an AI-rejected
  value reports through. One validation story for humans and models.
- **No per-type work is needed for AI-applied changes.** The advisor re-renders the page through
  `configurator.switchPage()`, so a type that renders correctly for a human renders correctly
  after an AI turn. If your widget caches state outside the DOM, re-seed it in
  `onSystemSelectionChange` — that is the hook that fires.

### Custom page flows: the reveal contract

A page flow that collapses or hides questions (an accordion/wizard layout, tabs, …) breaks the
advisor's ability to POINT at a question: its `scroll_to_question` tool scrolls to
`#question-<id>`, which may be `display:none`. The framework closes this with one cancelable
event pair, so any flow can cooperate without the advisor knowing the template:

1. `configurator.requestQuestionReveal(questionId, context)` fires **`cbQuestionRevealRequest`**
   (on the question element, bubbling; on the document when the element is absent). The advisor
   calls this before its own scroll; so can any future deep-link code.
2. A flow that presents this question its own way **claims** the request with
   `event.preventDefault()`, then owns revealing it (expand the step, switch the tab) and
   bringing it into view — through the same path the customer's own edit affordance uses.
3. When done, the flow announces **`cbQuestionRevealed`** on the element it revealed, with the
   question id. Callers decorate THAT element (the advisor puts its highlight there).
4. An **unclaimed** request falls back to the caller's default — the plain scroll, exactly the
   pre-flow behaviour. A flow therefore only claims questions it actually manages: unengaged
   pages and rule-hidden questions stay unclaimed.

The shipped accordion flow (`customization/assets/javascript/accordionflow.js` in cbx-joomla)
is the worked example: a dozen lines, delegated on the document so they survive re-renders,
reusing its public `editQuestion()` — the pencil-edit path — for the reveal.

## 8. Test the contract, not the model

Model-in-the-loop tests cost tokens and flake; the projection is deterministic. Pin, in a plain
API spec (the pattern of `tests/specs/api/pricing-visibility.spec.ts` in cbx-joomla):

1. `getStructure` emits your `selectionFormat` and `selectionSchema` for a question of your type.
2. `setSelection` with a valid canonical value → `accepted`, and `getConfiguration` returns your
   canonical serialization and your `outputValue`.
3. `setSelection` with the SAME value serialized differently (key order, padding) → accepted and
   stored canonically (this is what keeps the advisor's `stuck` verification true).
4. An invalid value → `422 SELECTION_REJECTED` carrying your message.
5. A rule and a formula through your condition/term sub-values evaluate correctly.

An opt-in live test (like `CB_CHAT_LIVE`) may then drive the advisor once over the real API —
as a smoke check, never as the regression net.

## 9. Checklist — a well-made, AI-ready question type

**PHP class**: `getStorableValue` (canonical, idempotent) · `isValidValue` (localized messages)
· `getOutputValue` (incl. empty case) · `getComparableValue` (best scalar) ·
`getSelectionFormatHint` + `getSelectionSchema` (shared bounds code, one example) ·
`getInitialValue` if the type has defaults · `isEmptySelection`/`isSameSelection` only when the
defaults don't fit · static `getSubValueKeys`/`getSubValue` when rules/pricing need parts.

**View**: `views/question_<type>/view.html.php` (+`metadata.xml` with `calc-matrix-axis`) +
`tmpl/default.php` emitting the standard wrapper (`data-question-type` comes with it) and a
`.validation-message-target`.

**JS**: one AMD module per type, `defineQuestionType`, `data('selection')` mirroring, listed in
`custom_questions.js`.

**Admin**: per-type fields via `model_property_customization/questions.php` gated with
`appliesWhen`; columns in a side table via a customization update (value columns **nullable** —
every save writes the whole side-table row, including columns of types that don't apply);
`apiTitle`/`apiDescription` on every stored property, then
`php cli/joomla.php configbox:generate-types` so the schemas, record stubs and TS types carry
the fields.

**Docs & i18n**: language keys in `language_overrides/` (remember `KText::sprintf()` has no
fallback parameter); an admin-guide page if operators will configure it.

**Verify**: the payload assertions of §8, plus one manual advisor conversation watching it
select your type.

## See also

- `com_configbox_custom_question_types.md` — folder layout, template chain, admin fields
- `technical/com_configbox_chat_advisor.md` — the advisor's projections and tool loop
- `technical/com_configbox_runtime_api.md` — the endpoints every assertion above talks to
