Skip to main content

Custom Interactions: Your Own Code, Tracked

Run your own HTML, CSS and JavaScript in a lesson, sandboxed, and have it report a score, completion and saved state to Scormbusters and to the LMS through a small postMessage API.

Last updated 6 min read

The problem

I built an interaction with Claude or ChatGPT and it works in the browser, but the LMS cannot see the score and the learner loses their place when they come back.

Step by step

  1. Add the block

    In the block picker choose Custom Interaction (under Interactive) and give it a title.

  2. Paste your code

    Paste self-contained HTML with inline script and style. A new block starts with a working example.

  3. Call the API

    Use OpenCourse.reportScore, OpenCourse.complete and OpenCourse.saveState where your code knows the result.

  4. Set points and preview

    Set Points if it should score, then click Run preview to try it in the editor.

What the block does

A Custom Interaction runs your own code in a lesson and 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.

The same code behaves the same way in the Scormbusters player and in an exported SCORM 1.2, SCORM 2004, cmi5 or xAPI package: both build the frame from the same source and accept the same messages.

Tip: An assistant connected to Scormbusters over MCP can write the block for you: ask it to add a custom_html block, and it reads the field reference from list_block_types.

The sandbox

Your code runs in a frame sandboxed to scripts alone (sandbox="allow-scripts", loaded from srcdoc). 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 or show alert dialogs.

The frame also has its own content policy, so code must be self-contained:

  • inline <script> and <style> only, no external script files
  • pictures and media from https: or data: URLs, fonts as data: URLs
  • no network requests (fetch, XHR, WebSockets)

Note: A library loaded from a CDN will not load. Paste it inline, or ask your assistant for a version with no dependencies.

The `OpenCourse` object

Before your code runs, the frame gets a global OpenCourse object (it keeps the product's former name so existing interactions and exported packages keep working):

  • OpenCourse.ready(cb) calls cb(init) once the course answers; init.state is the saved state, init.completed whether it was completed, init.mode is app, package or preview
  • OpenCourse.reportScore(score, max, passed) records a result; passed is optional
  • OpenCourse.complete() marks it complete
  • OpenCourse.saveState(value) saves any JSON value of up to 1,000 characters
  • OpenCourse.resize(px) sets the height; usually unnecessary, as the frame follows your content
  • OpenCourse.on(type, cb) listens for the replies scored, saved, completed and error

A complete example, ready to paste:

<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(a) { document.getElementById("result").textContent = a === "mercury" ? "Correct." : "Not quite: it is Mercury."; } document.querySelectorAll("button").forEach(function (b) { b.addEventListener("click", function () { var a = b.getAttribute("data-answer"); show(a); OpenCourse.saveState({ answer: a }); OpenCourse.reportScore(a === "mercury" ? 1 : 0, 1); OpenCourse.complete(); }); }); </script>

The messages underneath

OpenCourse is a thin wrapper over postMessage. Every message is an object with protocol: "opencourse-interaction" and a type: ready, score (score, max, optional passed), complete, state (state) or resize (height). The course replies with init, scored, saved, completed or error.

The course accepts a message only from the block's own frame, only from the sandbox's null origin and only in those shapes. A message over 4,000 characters is refused, and messages are rate limited: a burst of 30, then 10 a second, and 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 that fraction of the block's points. It counts as passed when your code sends passed: true, or at full marks when it sends no passed.

In Scormbusters the result is recorded like any other answer: it counts toward the lesson score and the pass mark, respects the attempt limit, and shows in analytics and the quiz.submitted webhook, marked as self-reported. In an exported package it is one question in the lesson score and reaches the LMS as a numeric interaction.

Note: A self-reported score is not tamper-proof: your code decides it in the learner's browser, like any client-side SCORM content. Use the built-in question blocks where a result must be checked by the server.

Saved state and resume

saveState keeps one value per learner, per attempt, and ready hands it back. In Scormbusters it is saved with the learner's attempt. In a SCORM package it is kept in suspend_data within the 4,096-character SCORM 1.2 limit; a save that would not fit is refused (the reply says ok: false) and earlier saves stay. Keep state to answers and positions, not content.

Accessibility

The Title is required: screen readers announce it as the name of the interaction, and the accessibility checker flags a block without one. The optional Text alternative appears under the frame and is linked to it as its description.

What happens inside the frame is the author's responsibility, and no automated check can see inside it. Make it work from the keyboard, label every control, keep text contrast at 4.5:1 and announce results with a role="status" element.

Part of the documentation: Build.

Found something here that does not match what the product does? That is a bug in the guide — see all guides or tell us from the Help Center.