Skip to main content

Build

Custom interactions

Run your own HTML, CSS and JavaScript in a lesson, sandboxed, and track it: the Scormbusters API and postMessage protocol for score, completion, saved state and height, the sandbox, and the limits.

Last reviewed

A Custom Interaction block (custom_html) runs your own HTML, CSS and JavaScript inside a lesson: a drag-free simulation, a calculator, a branching mini-game, anything you or your assistant can write as one self-contained page. The block tracks it like a built-in question. It can report a score that counts toward the lesson and the course pass mark, mark itself complete, save a small state so a returning learner picks up where they left off, and size its frame to its content. It behaves the same in the Scormbusters player and in an exported SCORM 1.2, SCORM 2004, cmi5 or xAPI package.


Table of contents

  1. Adding one
  2. The sandbox
  3. The `OpenCourse` object
  4. The message protocol
  5. Scoring
  6. Saved state
  7. Accessibility
  8. Limits

Adding one

In the block editor, open the block picker and choose Custom Interaction (under Interactive). Give it a Title, paste your code, and set Points if it should score. Run preview runs it in the editor.

An assistant connected over MCP creates the same block with add_block:

{
  "type": "custom_html",
  "content": {
    "title": "Sort the hazards",
    "html": "<p>Is a frayed cable a hazard?</p><button id=\"yes\">Yes</button><script>document.getElementById('yes').onclick=function(){OpenCourse.reportScore(1,1);OpenCourse.complete();};</script>",
    "textAlternative": "A frayed cable is a hazard: stop and report it.",
    "points": 1
  }
}

list_block_types gives the full field reference, and audit_course flags a custom interaction with no title.


The sandbox

Your code runs in an iframe loaded from srcdoc with sandbox="allow-scripts" and nothing else. Its origin is opaque ("null"), so it has no cookies, no storage shared with Scormbusters or the LMS, no access to the page around it and no learner session. It cannot open pop-ups, submit forms, show alert dialogs or navigate the page.

The frame's document also carries its own Content Security Policy:

default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';
img-src data: blob: https:; media-src data: blob: https:; font-src data:;
connect-src 'none'; form-action 'none'; base-uri 'none'

So the code must be self-contained: inline <script> and <style>, pictures and media from https: or data: URLs, fonts as data: URLs, and no network requests (fetch, XHR, WebSockets) and no external script files. The same policy applies in the app and in every exported package, so an interaction that works in preview works in the LMS.


The `OpenCourse` object

Before your code runs, the frame gets a global OpenCourse object. It keeps the product's former name so that interactions already written against it, and packages already exported, keep working.

CallWhat it does
OpenCourse.ready(cb)Calls cb(init) once the course has answered. init is { version, mode, state, completed, scored, points }; mode is "app", "package" or "preview".
OpenCourse.reportScore(score, max, passed?)Records a result: score out of max, optionally with your own pass/fail.
OpenCourse.complete()Marks the interaction complete.
OpenCourse.saveState(value)Saves any JSON value of up to 1,000 characters for this learner.
OpenCourse.resize(px)Sets the frame height. Usually unnecessary: the height follows your content automatically (set OpenCourse.autoResize = false to turn that off).
OpenCourse.on(type, cb)Listens for replies: scored, saved, completed, error.

A complete example:

<p>Which planet is closest to the sun?</p>
<button type="button" data-answer="mercury">Mercury</button>
<button type="button" data-answer="venus">Venus</button>
<p id="result" role="status"></p>
<script>
  OpenCourse.ready(function (init) {
    if (init.state && init.state.answer) show(init.state.answer);
  });
  function show(answer) {
    document.getElementById("result").textContent =
      answer === "mercury" ? "Correct." : "Not quite: it is Mercury.";
  }
  document.querySelectorAll("button").forEach(function (b) {
    b.addEventListener("click", function () {
      var answer = b.getAttribute("data-answer");
      show(answer);
      OpenCourse.saveState({ answer: answer });
      OpenCourse.reportScore(answer === "mercury" ? 1 : 0, 1);
      OpenCourse.complete();
    });
  });
</script>

The message protocol

OpenCourse is a thin wrapper. Underneath, the frame and the course exchange postMessage objects carrying protocol: "opencourse-interaction"; anything else is ignored.

From the interaction to the course:

typeFields
readynone; the course replies with init
scorescore (number), max (number above 0, at most 1,000,000), passed (optional boolean)
completenone
statestate (any JSON value, at most 1,000 characters serialized)
resizeheight (number; clamped to 80 to 4,000 pixels)

From the course to the interaction: init, scored (score, maxScore, passed, recorded), saved (ok), completed, and error (for, error). Error codes: bad_score, bad_state, state_too_large, bad_height, unknown_type, too_large, not_json, not_scored, rate_limited.

The course accepts a message only from the block's own frame and only with the "null" origin. A message over 4,000 characters is refused, and messages are rate limited (a burst of 30, then 10 a second; a score at most once a second).


Scoring

Set Points (1 to 100) to make the block a scored question; 0 leaves it unscored. A report of score out of max earns score / max of the block's points, rounded to two decimals. It counts as passed when you send passed: true, or, when you send no passed, at full marks.

In the Scormbusters player the result goes through the same answer route as every other question: it is stored as an attempt, counts toward the lesson score and the course pass mark, respects the block's attempt limit, and appears in analytics and the quiz.submitted webhook. It is recorded with selfReported: true.

In an exported package the block counts as one question in the lesson score (like every exported question, by whether it was passed) and is reported to the LMS as a numeric interaction with its points as the weighting.

A self-reported score is not tamper-proof. Your code decides it in the learner's browser, like any client-side SCORM content, and a determined learner can change it. Where a result must be checked by the server, use the built-in question blocks. A custom interaction is never drawn by a question pool; it always shows.


Saved state

saveState keeps one JSON value per learner, per attempt, per block, and ready hands it back. In the Scormbusters player it is saved with the learner's attempt (a restarted attempt starts empty). In a SCORM package it lives in suspend_data alongside the course's own progress, inside the 4,096-character SCORM 1.2 budget; a save that would overflow it is refused and the interaction gets saved with ok: false, while everything already saved stays. In cmi5 and xAPI packages it is part of the progress record in the LRS State API.

Keep state small: answers and positions, not content.


Accessibility

The Title is required: it names the frame for screen readers, and the editor's accessibility checker and audit_course flag a custom interaction without one. The optional Text alternative is shown under the frame and linked to it as its description.

Everything inside the frame is the author's responsibility, and no automated checker can see inside it: make it work from the keyboard, label every control, keep text contrast at 4.5:1, and announce results (a role="status" element works).


Limits

LimitValue
Code size100,000 characters (enforced by the database)
Saved state1,000 characters of JSON
One message4,000 characters
Points0 to 100
Frame height80 to 4,000 pixels
Sandboxallow-scripts only
Networknone