Examplary supports importing and exporting questions in QTI 3.0 format.
By default, custom question types are exported as Portable Custom Interactions (PCI), which bundles your React component inside a QTI-compatible container. This works without any extra configuration.
If your question type maps cleanly to one of the standard QTI interaction types, you can define a native mapping via the export.qti3 field in your question-type.json. This generates standard QTI XML instead of a PCI wrapper, and is more interoperable with other LMS systems that support native QTI interaction types.
Basic structure
The QTI 3.0 configuration lives under the export.qti3 key:
{
"export": {
"qti3": {
"interaction": {
"type": "ChoiceInteraction",
"options": { ... }
}
}
}
}| Field | Required | Description |
|---|---|---|
interaction.type | Yes | The QTI interaction type to generate. Must be one of the supported types (PascalCase). |
interaction.options | No | A record of JSONata expressions whose evaluated results are passed as properties to the QTI interaction constructor. |
When no export.qti3 config is present, the question type is exported as a Portable Custom Interaction (PCI) instead. PCI is fully supported by Examplary's own QTI packages and many third-party LMS systems.
When rendered as a PCI in an external LMS, only a subset of the assessment component's props is passed through: question, answer, saveAnswer, api (always null, see API calls and file uploads), t, and i18n. isPreview is always false. isLoading, reviewMode, and environment are not passed at all, so they'll be undefined — don't rely on them to decide what to render in that context.
JSONata expressions
Values inside interaction.options can be either literals or JSONata expressions:
- Literal value — any string, number, or boolean is passed through as-is:
"shuffle": true - JSONata expression — a string starting with
=is evaluated as a JSONata expression:"shuffle": "=$.question.settings.shuffleOptions"
Expressions are evaluated at export time against a root context object ($) containing:
$.question— the full question object (id, title, description, settings, scoring, etc.)$.questionType— the question type definition from yourquestion-type.json$.exam— the parent exam object
// Example root context (the $.question part):
{
"id": "q_abc123",
"title": "What is the capital of France?",
"description": "Select the correct answer.",
"settings": {
"options": [
{ "text": "London", "correct": false },
{ "text": "Paris", "correct": true },
{ "text": "Berlin", "correct": false }
],
"shuffleOptions": true,
"maxSelections": 1
}
}Access properties using $.question.settings.shuffleOptions, $.question.description, etc.
Common JSONata patterns
The examples below show expression bodies (the part after the = prefix). When placing these inside question-type.json string values, use single quotes for string literals to avoid having to escape double quotes.
// Simple property access
$.question.settings.shuffleOptions // → true
// Conditional (ternary) — use single quotes for string literals inside JSON
$.question.settings.maxSelections = 1 ? 'single' : 'multiple' // → "single" or "multiple"
// String concatenation — single quotes avoid JSON escaping
'choice_' & $string($idx) // → "choice_0", "choice_1", etc.
// Map array to new structure
$map($.question.settings.options, function($opt, $idx) {
{ 'identifier': 'choice_' & $string($idx), 'content': $opt.text }
})
// Filter array by condition
$.question.settings.options[correct = true] // → items where correct is true
// Get identifiers for items matching a condition (position binding)
$.question.settings.options#$i[correct = true].('choice_' & $string($i)) // → ["choice_1"]For comprehensive JSONata documentation, visit jsonata.org. You can also use the JSONata Exerciser to test your expressions interactively.
Export configuration
The interaction.options record defines the properties passed to the QTI interaction element. The property names and their expected values depend on the specific interaction type — refer to the QTI 3.0 specification for the full list of attributes each interaction supports.
Example: multiple choice
{
"export": {
"qti3": {
"interaction": {
"type": "ChoiceInteraction",
"options": {
"shuffle": "=$.question.settings.shuffleOptions",
"maxChoices": "=$.question.settings.maxSelections",
"prompt": "=$.question.description",
"choices": "=$map($.question.settings.options, function($opt, $idx) { { 'identifier': 'choice_' & $string($idx), 'content': $opt.text } })",
"correctResponse": "=$.question.settings.options#$i[correct = true].('choice_' & $string($i))"
}
}
}
}
}Example: essay / extended text
{
"export": {
"qti3": {
"interaction": {
"type": "ExtendedTextInteraction",
"options": {
"prompt": "=$.question.description",
"expectedLength": "=$.question.settings.maxWords * 6"
}
}
}
}
}Example: ordering
This example assumes each item has a correctPosition field (1-based) that indicates its position in the correct order. choices uses source-order indices as identifiers; correctResponse sorts by correctPosition and maps back to those identifiers.
{
"export": {
"qti3": {
"interaction": {
"type": "OrderInteraction",
"options": {
"shuffle": "=$.question.settings.shuffleItems",
"prompt": "=$.question.description",
"choices": "=$map($.question.settings.items, function($item, $idx) { { 'identifier': 'item_' & $string($idx), 'content': $item.text } })",
"correctResponse": "=$sort($.question.settings.items#$i, function($a, $b) { $a.correctPosition < $b.correctPosition }).('item_' & $string($i))"
}
}
}
}
}Text with gaps
For a question type built around a text with blanks (cloze), use export.qti3.gaps instead of interaction. The text is exported as one item with a TextEntryInteraction for each gap, and each gap is scored separately.
{
"export": {
"qti3": {
"gaps": {
"text": "=$.question.settings.text",
"answers": "=$.question.settings.answers",
"caseInsensitive": "=$.question.settings.caseInsensitive"
}
}
}
}| Field | Required | Description |
|---|---|---|
text | Yes | A JSONata expression returning the text with the gaps, as HTML. |
marker | No | The string that marks a gap in the text. Defaults to ___. |
answers | Yes | A JSONata expression returning the correct answer for each gap, in order. |
caseInsensitive | No | A JSONata expression returning whether answers are accepted regardless of letter case. |
anyOrder | No | A JSONata expression returning whether the answers may be given in any order, which accepts every answer in every gap. |
interaction and gaps are alternatives; a question type with gaps is not exported as a PCI.
Supported interaction types
The following QTI 3.0 interaction types are supported. The type value must match exactly (PascalCase):
| Interaction type | Description | Typical use |
|---|---|---|
ChoiceInteraction | Single or multiple choice | Multiple choice questions |
ExtendedTextInteraction | Long text response | Essays, open questions |
TextEntryInteraction | Short text response | Fill-in-the-blank, short answer |
OrderInteraction | Arrange items in order | Sequencing, ranking |
MatchInteraction | Match pairs of items | Matching exercises |
InlineChoiceInteraction | Dropdown within text | Cloze tests |
HotspotInteraction | Click on image regions | Image-based questions |
GapMatchInteraction | Drag items into gaps | Drag-and-drop fill-in |
SliderInteraction | Numeric slider | Numeric estimation |
UploadInteraction | File upload | Document submissions |
DrawingInteraction | Freeform drawing | Diagrams, sketches |
MediaInteraction | Audio/video response | Recording responses |
AssociateInteraction | Associate pairs | Association exercises |
GraphicAssociateInteraction | Associate pairs on image | Image-based association |
GraphicGapMatchInteraction | Gap match on image | Image-based drag-and-drop |
GraphicOrderInteraction | Order items on image | Image-based sequencing |
HottextInteraction | Select text regions | Hottext selection |
PortableCustomInteraction | Portable Custom Interaction | Encapsulates a custom question type as a self-contained portable bundle; use in import.qti3 to match PCIs exported from other systems |
PositionObjectInteraction | Position object on image | Image positioning |
SelectPointInteraction | Select point on image | Point selection on image |
EndAttemptInteraction | End attempt button | Manual submission |
Import configuration
When a QTI file is imported into Examplary, questions with standard interaction types are matched to your question types using the import.qti3 field in your question-type.json. This tells Examplary how to reconstruct your question type's settings from the incoming QTI data.
The import configuration lives under the import.qti3 key:
{
"import": {
"qti3": {
"interaction": {
"type": "ChoiceInteraction"
},
"condition": "=$.interaction.maxChoices = 1",
"settings": {
"shuffleOptions": "=$.interaction.shuffle",
"options": "=$map($.interaction.choices, function($c) { { 'text': $c.content, 'correct': $c.identifier in $.correctResponse } })",
"maxSelections": "=$.interaction.maxChoices"
}
}
}
}| Field | Required | Description |
|---|---|---|
interaction.type | Yes | The QTI interaction type this import mapping applies to. Must be one of the supported types (PascalCase). |
condition | No | A JSONata expression (starting with =) that must evaluate to true for this mapping to be selected. Use this to distinguish between multiple question types that share the same interaction type (e.g. single vs. multiple choice). |
settings | No | A record of JSONata expressions that map QTI data to your question type's settings fields. |
Import context
Expressions in condition and settings are evaluated against a root context object ($) containing:
| Variable | Type | Description |
|---|---|---|
$.interaction | object | The raw QTI interaction element as a plain JavaScript object. |
$.response | object | The interaction's response declaration, for example with an areaMapping for select point. |
$.correctResponse | string[] | Array of correct response identifiers from the QTI correctResponse element. |
$.points | number | The point value assigned to the question in the source QTI file. |
Importing text with gaps
To import QTI items with gaps in a text (several text entries, dropdowns, word bank gaps, or one text entry inside a line of text), set import.qti3.gaps to true instead of interaction. The first question type that is not deprecated and has gaps set handles all such items.
{
"import": {
"qti3": {
"gaps": true,
"settings": {
"text": "=$.text",
"answers": "=$.answers",
"caseInsensitive": "=$.caseInsensitive",
"anyOrder": "=$.anyOrder"
}
}
}
}The settings expressions are evaluated against { text, answers, choices, caseInsensitive, anyOrder, points }, where each gap in text is marked as ___ and answers holds the correct answer per gap, in order. choices lists the options per gap when any gap offers them (dropdowns and word banks), and is an empty list for plain text entries. anyOrder is true when every gap accepts every answer.
Importing selectable text
To import QTI items with selectable text (hottext), set import.qti3.hottext to true instead of interaction. The settings expressions are evaluated against { text, answers, maxChoices, selectableHidden, points }:
| Variable | Description |
|---|---|
text | The text, with each selectable part marked as [part]. |
answers | The indices of the correct parts. |
maxChoices | How many parts may be selected, or 0 when there is no limit. |
selectableHidden | true when the item hides which parts are selectable (the qti-unselected-hidden class). |
Example: single vs multiple choice
If you have two question types that both use ChoiceInteraction — one for single-choice and one for multiple-choice — use condition to tell them apart:
{
"import": {
"qti3": {
"interaction": {
"type": "ChoiceInteraction"
},
"condition": "=$.interaction.maxChoices = 1",
"settings": {
"options": "=$map($.interaction.choices, function($c) { { 'text': $c.content, 'correct': $c.identifier in $.correctResponse } })",
"shuffleOptions": "=$.interaction.shuffle"
}
}
}
}{
"import": {
"qti3": {
"interaction": {
"type": "ChoiceInteraction"
},
"condition": "=$.interaction.maxChoices > 1",
"settings": {
"options": "=$map($.interaction.choices, function($c) { { 'text': $c.content, 'correct': $c.identifier in $.correctResponse } })",
"maxSelections": "=$.interaction.maxChoices",
"shuffleOptions": "=$.interaction.shuffle"
}
}
}
}When multiple question types define a condition for the same interaction type, Examplary evaluates them in order and selects the first match. If no condition is defined, the mapping always matches that interaction type.
Deprecated question types are excluded from this generic interaction-type matching step: their import.qti3 mapping is skipped, even if its interaction.type/condition would otherwise match. Matching continues to the next non-deprecated type whose mapping matches, or falls back to a built-in type if none does.
Testing your configuration
Testing export
- Create a test exam with your custom question type
- Export the exam to QTI format from the Examplary UI
- Verify the generated XML matches your expectations
Use the JSONata Exerciser to debug export expressions. Set up your context as { "question": {...}, "questionType": {...}, "exam": {...} } and test each expression individually.
Testing import
- Obtain a QTI file containing the interaction type your mapping targets
- Import the QTI file into an exam from the Examplary UI
- Verify the question settings were reconstructed correctly from the imported data
To debug import expressions, use the JSONata Exerciser with a context of { "interaction": {...}, "response": {...}, "correctResponse": [...], "points": 1 } that matches the structure of the QTI interaction you're importing.
Troubleshooting
Expression returns undefined
Make sure the path exists in your data. Use the conditional operator to provide defaults:
$.question.settings.shuffleOptions != null ? $.question.settings.shuffleOptions : trueLiteral strings in expressions
Inside a =-prefixed expression, use single quotes for string literals — this avoids having to escape double quotes within the JSON string value:
{ "cardinality": "='single'" }Single quotes inside an expression also work inline within larger expressions:
{ "cardinality": "=$.question.settings.maxSelections = 1 ? 'single' : 'multiple'" }Array indexing issues
Remember that JSONata arrays are zero-indexed. Use the #$i position binding to get the index of items in an array:
// Get all items matching a condition with their original indices
$.question.settings.options#$i[correct = true].('choice_' & $string($i))
// → ["choice_1"] for the example above where Paris (index 1) is correct