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.
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))"
}
}
}
}
}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. |
$.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. |
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.
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": {...}, "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