Question types the AI understands and controls
- Version
- 4.0 preview
- Updated
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_selectiontell the model to obeyselectionFormat/selectionSchemaexactly).
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
cardsandcableoutlettypes 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, notwidth) — 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 overrideisEmptySelection()). - 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.
- Short, stable keys (
2. Normalize storage — getStorableValue()
Override it to produce ONE canonical serialization: known keys only, fixed key order, trimmed
values, all-empty → ''. Two reasons:
- Rules, calc terms and any consumer comparing selections meet a single byte representation no matter how a client ordered or padded the JSON.
- 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 viagetStorableValue()— 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 withgetStorableValue()and compare strings. The advisor uses it for its write-then-read-backstuckverification, 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'sansweredflag, 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, and the field
declaration rules and formulas read (§6).
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 — declare the fields
A composite type almost always wants rules ("width ≥ X") and formulas ("area × unit price") on its
parts. You do not write a condition class or a term class for that: the question type declares
fields, and the fields ARE the delegation. The generic question condition and calc term, both
editors, the runtime API's rule projection and the MCP authoring tools all read the declaration
(com_configbox_custom_question_types.md §2.6 "Rules and formulas — the type declares its fields").
getRuleFields()lists what a condition can compare,getCalcFields()what a term can use as a number, andgetFieldValue($key, $selection, $answerId, $selections)reads either from the GIVEN selection. Each field is aConfigboxQuestionField(key, label with%sfor the question title, kindchoice/number/date/text, group, description). Derived values (the dimensions type'sareaandperimeter) are fields like any other; a per-answer figure is declared once per answer withanswerId.- A composite has no single entry: drop the base
selectedfield (withoutField()) and declare the members instead. Still overridegetComparableValue()to the best scalar reduction (dimensions → area), since min/max validation compares through it. - Unanswered means null.
getFieldValue()returns null when nothing is there; a condition then reads "not answered" and a term uses its fallback. Never turn it into 0 in the type. A type with a documented default (the stabilizer compares its recommendation) returns that default instead, and a field whose value depends on other questions reads them off the$selectionsthe engine passes. - Write a
descriptionon every field.cbx_describe_rules/cbx_describe_calculationslist each question's fields fromConfigboxQuestionField::describe()(field, title, kind, operators, choices, answerId, description), so an assistant writes{"question": 42, "field": "w", …}without knowing the type. The generic{"condition": …}/{"term": …}forms are not needed for a question type; they exist for condition and term types that are not about a question. - The static
getSubValueKeys()/getSubValue()helpers the dimensions and lengthofrun types keep are their own decoding helpers, called bygetFieldValue()and by their views. Nothing in the engine reads them.
Worked examples in the cbx-joomla customization: ConfigboxQuestionDimensions (members and derived
measures, no selected), ConfigboxQuestionQuantityanswers (a quantity field per answer, calc-only
pricedtotal / weighttotal), ConfigboxQuestionCableoutlet (a rule-only slot choice on top of
the base answer fields). com_configbox_custom_rule_conditions.md / com_configbox_custom_calc_term_types.md
cover the classes you write for sources that are not a question.
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:
// 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 throughonValidationMessageShown, 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 inonSystemSelectionChange— 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:
configurator.requestQuestionReveal(questionId, context)firescbQuestionRevealRequest(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.- 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. - When done, the flow announces
cbQuestionRevealedon the element it revealed, with the question id. Callers decorate THAT element (the advisor puts its highlight there). - 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.
Scrolling, once you have revealed it. Claiming the request means you own bringing the question
into view, so use the core's own measurements rather than scrollIntoView or a CSS
scroll-margin guess — a fixed guess is wrong on every template but the one it was tuned on:
const scrollNow = function (behavior) {
const rect = el.getBoundingClientRect();
// ALL the pinned chrome, not just the header: templates stack bars, and the one that hides
// questions is usually the second — a floating visualization pinned under a slim header.
const headerHeight = configurator.getTopObstructionHeight();
// Clamped at 0: a smooth scroll to a negative target rubber-bands on macOS.
window.scrollTo({top: Math.max(0, window.scrollY + rect.top - headerHeight - 20), behavior: behavior});
};
Three traps, each of which cost a real bug:
- Measure after two animation frames. A rect taken in the same tick as the collapse/expand still describes the PRE-collapse geometry, so the page scrolls to where the step used to be.
- Correct with
'instant', never'auto'.'auto'means whatever CSSscroll-behaviorsays, and templates set it tosmooth— so a correction meant as a jump animates, and can be overtaken by the next re-render. - The bottom bar hides things too. For "is it already visible, leave it alone", compare against
window.innerHeight - configurator.getBottomObstructionHeight(); most configurator templates pin a running total there, andinnerHeightcounts a step behind it as visible.
(getTopObstructionHeight() was getStickyHeaderHeight() until 2026-09-07; the old name still
works as a deprecated alias. See the breaking changes log.)
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):
getStructureemits yourselectionFormatandselectionSchemafor a question of your type.setSelectionwith a valid canonical value →accepted, andgetConfigurationreturns your canonical serialization and youroutputValue.setSelectionwith the SAME value serialized differently (key order, padding) → accepted and stored canonically (this is what keeps the advisor'sstuckverification true).- An invalid value →
422 SELECTION_REJECTEDcarrying your message. - A rule and a formula on your declared fields evaluate correctly, and
cbx_describe_rules/cbx_describe_calculationslist the fields for a question of your type.
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 · getRuleFields/getCalcFields/getFieldValue (a description on every
field) when rules or pricing need parts of the selection.
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 fieldstechnical/com_configbox_chat_advisor.md— the advisor's projections and tool looptechnical/com_configbox_runtime_api.md— the endpoints every assertion above talks to