Examplary
Start for free
Developer docs

QTI interchange

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's definition. 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:

definition.ts (partial)
new QuestionType({
  export: {
    qti3: {
      interaction: {
        type: "ChoiceInteraction",
        options: { ... },
      },
    },
  },
});
FieldRequiredDescription
interaction.typeYesThe QTI interaction type to generate. Must be one of the supported types (PascalCase).
interaction.optionsNoA record of JSONata expressions whose evaluated results are passed as properties to the QTI interaction constructor.
Tip

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.

PCI context limitation

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 — your question type's definition
  • $.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 strings in your definition, 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"]
Learn more

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

definition.ts (partial)
new QuestionType({
  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

definition.ts (partial)
new QuestionType({
  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.

definition.ts (partial)
new QuestionType({
  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.

definition.ts (partial)
new QuestionType({
  export: {
    qti3: {
      gaps: {
        text: "=$.question.settings.text",
        answers: "=$.question.settings.answers",
        caseInsensitive: "=$.question.settings.caseInsensitive",
      },
    },
  },
});
FieldRequiredDescription
textYesA JSONata expression returning the text with the gaps, as HTML.
markerNoThe string that marks a gap in the text. Defaults to ___.
answersYesA JSONata expression returning the correct answer for each gap, in order.
caseInsensitiveNoA JSONata expression returning whether answers are accepted regardless of letter case.
anyOrderNoA 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 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
AssociateInteractionAssociate pairsAssociation exercises
GraphicAssociateInteractionAssociate pairs on imageImage-based association
GraphicGapMatchInteractionGap match on imageImage-based drag-and-drop
GraphicOrderInteractionOrder items on imageImage-based sequencing
HottextInteractionSelect text regionsHottext selection
PortableCustomInteractionPortable Custom InteractionEncapsulates a custom question type as a self-contained portable bundle; use in import.qti3 to match PCIs exported from other systems
PositionObjectInteractionPosition object on imageImage positioning
SelectPointInteractionSelect point on imagePoint selection on image
EndAttemptInteractionEnd attempt buttonManual 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's definition. 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:

definition.ts
new QuestionType({
  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",
      },
    },
  },
});
FieldRequiredDescription
interaction.typeYesThe QTI interaction type this import mapping applies to. Must be one of the supported types (PascalCase).
conditionNoA 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).
settingsNoA 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:

VariableTypeDescription
$.interactionobjectThe raw QTI interaction element as a plain JavaScript object.
$.responseobjectThe interaction's response declaration, for example with an areaMapping for select point.
$.correctResponsestring[]Array of correct response identifiers from the QTI correctResponse element.
$.pointsnumberThe 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.

definition.ts (partial)
new QuestionType({
  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, selectableHidden, points }:

VariableDescription
textThe text, with each selectable part marked as [part].
answersThe correct parts, as they appear between the brackets in text.
selectableHiddentrue 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:

definition.ts (single-choice)
new QuestionType({
  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",
      },
    },
  },
});
definition.ts (multiple-choice)
new QuestionType({
  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

  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
Tip

Use the JSONata Exerciser to debug export expressions. Set up your context as { "question": {...}, "questionType": {...}, "exam": {...} } and test each expression individually.

Testing import

  1. Obtain a QTI file containing the interaction type your mapping targets
  2. Import the QTI file into an exam from the Examplary UI
  3. Verify the question settings were reconstructed correctly from the imported data
Tip

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
  : true;

Literal strings in expressions

Inside a =-prefixed expression, use single quotes for string literals — this avoids having to escape double quotes within the string:

{
  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

Learn more