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.

    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))"
            }
          }
        }
      }
    }

    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.
    $.correctResponsestring[]Array of correct response identifiers from the QTI correctResponse element.
    $.pointsnumberThe 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:

    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.

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