Skip to main content
Version: 3.x

Question types the AI understands and controls

Version
3.x
Updated
View markdown

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:

MethodForWhat to return
getSelectionFormatHint()any non-obvious valueOne or two sentences stating the EXACT format, the live bounds, and one example. Prose — this is what the model reads first.
getSelectionSchema()structured valuesJSON 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 rowsThe selectable strings (generalizes the stock choices type's newline list). Lands in the projection's choices.
getConstraintHints()scalar constraints beyond min/maxKeys 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 answersReturn 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:

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