Skip to main content

QTI interchange

Examplary supports importing and exporting questions in QTI 3.0 format.

By default, it will run your custom assessment component in a QTI-compatible way, but you can also define custom mappings for your question types. This is great if your question type implements a default interaction type supported in the QTI spec, or if you want to customize how your question data is represented in QTI.

This is done through the interchange field in your question-type.json file, using JSONata expressions to define the mapping between your question settings and QTI elements.

Basic structure

The interchange configuration lives under the interchange.qti3 key:

question-type.json
{
"interchange": {
"qti3": {
"interactionType": "choiceInteraction",
"export": { ... },
"import": { ... }
}
}
}
FieldRequiredDescription
interactionTypeYesThe QTI interaction type (e.g., choiceInteraction, extendedTextInteraction)
exportNoConfiguration for exporting questions to QTI
importNoConfiguration for importing QTI interactions as questions
tip

You can define just export, just import, or both. At least one must be present.

JSONata expressions

All dynamic values in the interchange configuration are JSONata expressions. JSONata is a lightweight query and transformation language for JSON data.

Export context

During export, the question object is available as $question:

{
"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.

Import context

During import, the parsed QTI interaction is available as $interaction:

{
"type": "choiceInteraction",
"attributes": {
"shuffle": true,
"max-choices": 1
},
"prompt": "Select the correct answer.",
"choices": [
{ "identifier": "A", "content": "London" },
{ "identifier": "B", "content": "Paris" },
{ "identifier": "C", "content": "Berlin" }
],
"correctResponse": ["B"]
}

Access properties using $interaction.prompt, $interaction.choices, etc.

Common JSONata patterns

Here are some JSONata patterns you'll use frequently:

// Simple property access
$question.settings.shuffleOptions // → true

// Conditional (ternary)
$question.settings.maxSelections = 1 ? 'single' : 'multiple' // → "single"

// String concatenation
'choice_' & $string($index) // → "choice_0", "choice_1", etc.

// Filter array by property
$question.settings.options[correct = true] // → [{ "text": "Paris", "correct": true }]

// Map array to new structure
$question.settings.options.(text) // → ["London", "Paris", "Berlin"]

// Check if value is in array (import)
identifier in $interaction.correctResponse // → true or false

// Get array indices of matching items
$question.settings.options[correct].$index // → [1] (indices where correct is true)
Learn more

For comprehensive JSONata documentation, visit jsonata.org. You can also use the JSONata Exerciser to test your expressions interactively.

Export configuration

The export configuration defines how to transform your question into QTI XML.

Interaction attributes

Map your settings to QTI interaction attributes:

question-type.json (partial)
{
"interchange": {
"qti3": {
"interactionType": "choiceInteraction",
"export": {
"attributes": {
"shuffle": "$question.settings.shuffleOptions",
"max-choices": "$question.settings.maxSelections"
},
"prompt": "$question.description"
}
}
}
}

This generates:

<qti-choice-interaction
response-identifier="RESPONSE"
shuffle="true"
max-choices="1">
<qti-prompt>Select the correct answer.</qti-prompt>
...
</qti-choice-interaction>

Choices

For interactions with choice elements, use the choices configuration:

question-type.json (partial)
{
"export": {
"choices": {
"source": "$question.settings.options",
"identifier": "'choice_' & $string($index)",
"content": "text",
"fixed": "fixed"
}
}
}
FieldDescription
sourceJSONata expression returning the array to map
identifierJSONata expression for each choice's ID (has access to $index)
contentJSONata expression for the choice text (evaluated per item)
fixedOptional: JSONata expression for whether the choice is fixed (not shuffled)
tip

Inside identifier, content, and fixed expressions, you're iterating over each item in the source array. Properties of the current item are directly accessible (e.g., text, fixed), and $index gives you the current position.

This generates:

<qti-simple-choice identifier="choice_0">London</qti-simple-choice>
<qti-simple-choice identifier="choice_1">Paris</qti-simple-choice>
<qti-simple-choice identifier="choice_2">Berlin</qti-simple-choice>

Response declaration

Configure the response declaration to define cardinality, base type, and correct responses:

question-type.json (partial)
{
"export": {
"responseDeclaration": {
"identifier": "RESPONSE",
"cardinality": "$question.settings.maxSelections = 1 ? 'single' : 'multiple'",
"baseType": "'identifier'",
"correctResponse": "$map($question.settings.options, function($opt, $idx) { $opt.correct ? 'choice_' & $string($idx) : null })[$ != null]"
}
}
}

The correctResponse expression filters the options array to find correct answers, then maps them to choice identifiers.

Response processing

Specify a standard response processing template:

question-type.json (partial)
{
"export": {
"responseProcessingTemplate": "https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct.xml"
}
}

Common templates:

TemplateUse case
match_correct.xmlExact match scoring (multiple choice, ordering)
map_response.xmlPartial credit with score mapping
map_response_point.xmlPartial credit for point-based responses

Import configuration

The import configuration defines how to extract question settings from a QTI interaction.

Basic settings extraction

Map QTI attributes and elements to your settings:

question-type.json (partial)
{
"interchange": {
"qti3": {
"interactionType": "choiceInteraction",
"import": {
"description": "$interaction.prompt",
"settings": {
"shuffleOptions": "$boolean($interaction.attributes.shuffle)",
"maxSelections": "$number($interaction.attributes.`max-choices`)"
}
}
}
}
}
Attribute names with hyphens

QTI attribute names often contain hyphens (e.g., max-choices). In JSONata, wrap these in backticks:

$interaction.attributes.`max-choices`

Array extraction

For extracting choice arrays, use the object syntax with source and each:

question-type.json (partial)
{
"import": {
"settings": {
"options": {
"source": "$interaction.choices",
"each": {
"text": "content",
"correct": "identifier in $interaction.correctResponse"
}
}
}
}
}
FieldDescription
sourceJSONata expression returning the source array
eachMap of property names to JSONata expressions (evaluated per item)

Inside each expressions:

  • Properties of the current item are directly accessible (content, identifier)
  • Use $interaction to access the full interaction context
  • Use $index for the current index

Complete examples

Multiple choice

question-type.json
{
"id": "examplary.default.multiple-choice",
"name": { "en": "Multiple choice" },

"settings": [
{ "id": "options", "type": "array" },
{ "id": "shuffleOptions", "type": "boolean", "default": true },
{ "id": "maxSelections", "type": "number", "default": 1 }
],

"interchange": {
"qti3": {
"interactionType": "choiceInteraction",

"export": {
"attributes": {
"shuffle": "$question.settings.shuffleOptions",
"max-choices": "$question.settings.maxSelections"
},
"prompt": "$question.description",
"choices": {
"source": "$question.settings.options",
"identifier": "'choice_' & $string($index)",
"content": "text"
},
"responseDeclaration": {
"cardinality": "$question.settings.maxSelections = 1 ? 'single' : 'multiple'",
"baseType": "'identifier'",
"correctResponse": "$map($question.settings.options, function($o, $i) { $o.correct ? 'choice_' & $string($i) : null })[$ != null]"
},
"responseProcessingTemplate": "https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct.xml"
},

"import": {
"description": "$interaction.prompt",
"settings": {
"options": {
"source": "$interaction.choices",
"each": {
"text": "content",
"correct": "identifier in $interaction.correctResponse"
}
},
"shuffleOptions": "$boolean($interaction.attributes.shuffle)",
"maxSelections": "$number($interaction.attributes.`max-choices`)"
}
}
}
}
}

Essay / Extended text

question-type.json
{
"id": "examplary.default.essay",
"name": { "en": "Essay" },

"settings": [
{ "id": "maxWords", "type": "number", "default": 500 },
{
"id": "format",
"type": "enum",
"options": [
{ "value": "plain", "label": "Plain text" },
{ "value": "rich", "label": "Rich text" }
],
"default": "plain"
}
],

"interchange": {
"qti3": {
"interactionType": "extendedTextInteraction",

"export": {
"attributes": {
"expected-length": "$question.settings.maxWords * 6",
"format": "$question.settings.format = 'rich' ? 'xhtml' : 'plain'"
},
"prompt": "$question.description",
"responseDeclaration": {
"cardinality": "'single'",
"baseType": "'string'"
}
},

"import": {
"description": "$interaction.prompt",
"settings": {
"maxWords": "$round($number($interaction.attributes.`expected-length`) / 6)",
"format": "$interaction.attributes.format = 'xhtml' ? 'rich' : 'plain'"
}
}
}
}
}

Ordering

question-type.json
{
"id": "examplary.default.ordering",
"name": { "en": "Ordering" },

"settings": [
{ "id": "items", "type": "array" },
{ "id": "shuffleItems", "type": "boolean", "default": true }
],

"interchange": {
"qti3": {
"interactionType": "orderInteraction",

"export": {
"attributes": {
"shuffle": "$question.settings.shuffleItems"
},
"prompt": "$question.description",
"choices": {
"source": "$question.settings.items",
"identifier": "'item_' & $string($index)",
"content": "text"
},
"responseDeclaration": {
"cardinality": "'ordered'",
"baseType": "'identifier'",
"correctResponse": "$sort($question.settings.items, function($a, $b) { $a.correctPosition - $b.correctPosition }).$map($question.settings.items, function($item, $idx) { $item = $ ? 'item_' & $string($idx) : null })[$ != null]"
},
"responseProcessingTemplate": "https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct.xml"
},

"import": {
"description": "$interaction.prompt",
"settings": {
"items": {
"source": "$interaction.choices",
"each": {
"text": "content",
"correctPosition": "$indexof($interaction.correctResponse, identifier)"
}
},
"shuffleItems": "$boolean($interaction.attributes.shuffle)"
}
}
}
}
}

Supported interaction types

The following QTI 3.0 interaction types are supported:

Interaction typeDescriptionTypical use
choiceInteractionSingle or multiple choiceMultiple choice questions
extendedTextInteractionLong text responseEssays, open questions
textEntryInteractionShort text responseFill-in-the-blank, short answer
orderInteractionArrange items in orderSequencing, ranking
matchInteractionMatch pairs of itemsMatching exercises
inlineChoiceInteractionDropdown within textCloze tests
hotspotInteractionClick on image regionsImage-based questions
gapMatchInteractionDrag items into gapsDrag-and-drop fill-in
sliderInteractionNumeric sliderNumeric estimation
uploadInteractionFile uploadDocument submissions
drawingInteractionFreeform drawingDiagrams, sketches
mediaInteractionAudio/video responseRecording responses

Testing your configuration

To test your interchange configuration:

  1. Create a test exam with your custom question type
  2. Export the exam to QTI format from the Examplary UI
  3. Verify the generated XML matches your expectations
  4. Import the exported QTI file into a new exam
  5. Verify the questions were reconstructed correctly
tip

Use the JSONata Exerciser to debug your expressions. Set up your context with $question or $interaction as a bound variable and test each expression individually.

Troubleshooting

Expression returns undefined

Make sure the path exists in your data. Use the conditional operator to provide defaults:

$question.settings.shuffleOptions ? $question.settings.shuffleOptions : true;

Or use the null coalescing pattern:

$question.settings.shuffleOptions != null
? $question.settings.shuffleOptions
: true;

Attribute names with special characters

Wrap attribute names containing hyphens or other special characters in backticks:

$interaction.attributes.`max-choices`
$interaction.attributes.`response-identifier`

Array indexing issues

Remember that JSONata arrays are zero-indexed. Use $index within array mapping contexts:

"choice_" & $string($index); // → "choice_0", "choice_1", ...

Accessing the interaction context inside each expressions

Use $interaction to access the full interaction when inside an each block:

// Inside each expression for options
identifier in $interaction.correctResponse;

Literal strings in expressions

When you want a literal string value (not a property path), wrap it in quotes:

"'identifier'"; // Returns the string "identifier"
"'single'"; // Returns the string "single"

Learn more