Examplary
  • Start for free
    Developer docs
    Question types

    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.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:

    question-type.json
    {
      "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 — the question type definition from your question-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"]
    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

    question-type.json (partial)
    {
      "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

    question-type.json (partial)
    {
      "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.

    question-type.json (partial)
    {
      "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.

    question-type.json (partial)
    {
      "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.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:

    question-type.json
    {
      "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.

    question-type.json (partial)
    {
      "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 }:

    VariableDescription
    textThe text, with each selectable part marked as [part].
    answersThe indices of the correct parts.
    maxChoicesHow many parts may be selected, or 0 when there is no limit.
    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:

    question-type.json (single-choice)
    {
      "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"
          }
        }
      }
    }
    question-type.json (multiple-choice)
    {
      "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 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

    Learn more