Scormbusters runs a Model Context Protocol server. Connect any MCP-capable assistant, IDE agent or script and it can read, build, audit, branch and export real courses in your organization, guided by tutorials it reads in-band.
Everything an agent builds is an ordinary course: open it in the visual editor, preview it as a learner, export SCORM. Courses and lessons cannot be deleted over MCP; the only destructive tool is delete_block.
Endpoint
https://scormbusters.com/api/mcp
For agents and LLMs: this whole reference as one markdown file at /docs/mcp.md (also /docs/mcp/llms.txt), and every tool with its schema, scope, example and errors as JSON at /docs/mcp/tools.json. Both are generated from the server's own definitions.
Quickstart
Clients that support OAuth need only the endpoint URL: they discover the sign-in flow, open your browser, and you approve the connection once. Clients that send a header use a token from Settings → API. Client menus change between releases; if a step has moved, the snippet is still the part that matters.
ChatGPT and claude.ai (and Claude Desktop) connect by signing in: paste the endpoint URL, approve the connection in your browser, done. They never need an API key, and you should not paste one into them.
Clients that send a header instead use a personal access token (oc_pat_…) from Settings → API — it acts as you, with your role. An owner or admin can instead issue an org key (oc_live_…) for shared automation. Either goes in Authorization: Bearer ….
Clients that only launch local (stdio) servers reach this remote server through the mcp-remote bridge, which signs in with OAuth by default or forwards a token header.
Claude CodeOAuth sign-in or token
Add the server and run /mcp inside Claude Code to sign in through the browser, or pass a personal access token as a header.
claude mcp add --transport http scormbusters https://scormbusters.com/api/mcp
# then, in a session: /mcp → scormbusters → Authenticate
Claude Desktop and claude.aiOAuth sign-in
Customize → Connectors → + Add → Add custom connector. Paste the endpoint URL and keep the recommended sign-in option (Claude's published identity), so there is no client ID or secret to fill in. No API key is involved.
Your browser opens the Scormbusters sign-in and consent page. Approve, and the tools appear in the chat's tool menu.
The desktop config file (claude_desktop_config.json) launches local stdio servers only; to use a token from it, bridge with mcp-remote as shown.
Install in VS Code adds the server with sign-in (OAuth): VS Code shows the config, you confirm, and you sign in when it first connects.
Or add .vscode/mcp.json to the workspace (or run “MCP: Add Server” and choose HTTP). The input below prompts for the token once and stores it securely; omit headers and inputs to use OAuth.
Open Copilot Chat in Agent mode and enable the scormbusters tools.
Open Plugins (chatgpt.com/plugins), select +, then Add custom MCP server. Paste the endpoint URL and choose OAuth sign-in; ChatGPT registers itself and opens the Scormbusters sign-in and consent page. No API key is involved.
Deep research and company knowledge use the search and fetch tools: search returns matching course outlines, lessons and slides with links to the editor, fetch returns their text.
OpenAI Responses API and Agents SDKToken
Responses API: add the server as an mcp tool. The token goes in authorization and must be sent with every request; the API does not run the OAuth flow for you.
allowed_tools narrows what the model sees; search and fetch alone make a read-only research connector.
Agents SDK: use the streamable HTTP server class with the token as a header.
from agents import Agent
from agents.mcp import MCPServerStreamableHttp
async with MCPServerStreamableHttp(
params={"url": "https://scormbusters.com/api/mcp", "headers": {"Authorization": "Bearer oc_pat_YOUR_TOKEN"}},
) as scormbusters:
agent = Agent(name="Course author", mcp_servers=[scormbusters])
Claude API (MCP connector)Token
Pass the server in mcp_servers with the token as authorization_token, and reference it from an mcp_toolset entry in tools — the API rejects a server no toolset names. Send the anthropic-beta header shown.
The connector calls tools only (no resources or prompts) and does not run the OAuth flow; supply a token you hold.
POST /v1/messages
curl https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "anthropic-beta: mcp-client-2025-11-20" \
-H "content-type: application/json" \
-d '{"model":"claude-opus-5","max_tokens":16000,"mcp_servers":[{"type":"url","url":"https://scormbusters.com/api/mcp","name":"scormbusters","authorization_token":"oc_pat_YOUR_TOKEN"}],"tools":[{"type":"mcp_toolset","mcp_server_name":"scormbusters"}],"messages":[{"role":"user","content":"Which of my courses mention fire safety?"}]}'
Gemini CLIOAuth sign-in or token
Add the server to ~/.gemini/settings.json (or .gemini/settings.json in a project). Use httpUrl — url means the older SSE transport.
Without headers, Gemini CLI discovers the sign-in flow on its first 401; run /mcp auth scormbusters to sign in again later.
OAuth
gemini mcp add --transport http scormbusters https://scormbusters.com/api/mcp
# then, in a session: /mcp auth scormbusters
Program tab in the right sidebar → Install → Edit mcp.json, add the server, save. LM Studio documents header auth only, so use a token (or mcp-remote for sign-in).
See Open models below for choosing a local model that handles the tools.
Auth: Bearer with a token in the Key field, or OAuth 2.1 (Dynamic Client Registration) so each person signs in as themselves. For OAuth, set WEBUI_SECRET_KEY or connections break when the container restarts.
URL
https://scormbusters.com/api/mcp
GooseOAuth sign-in or token
goose configure → Add Extension → Remote Extension (Streamable HTTP), enter the endpoint, and either add an Authorization header or leave headers out to sign in through the browser.
Or edit the extensions section of Goose's config.yaml directly.
Edit plugins/anythingllm_mcp_servers.json in AnythingLLM's storage folder (it appears once you open Agent Skills), then refresh the MCP servers list. The type is "streamable".
Any stdio-only client (mcp-remote)OAuth sign-in or token
mcp-remote runs locally as a stdio server and forwards to the endpoint. With no header it signs in through the browser and keeps the credentials in ~/.mcp-auth.
With a token, keep the space out of args ("Authorization:${AUTH}") and put "Bearer …" in env — several clients split args on spaces.
Every request is one JSON-RPC 2.0 message in a POST body; every response is a single JSON object (no SSE, no session id). Send initialize first, then call methods.
Any model works if its client speaks MCP and the model handles tool calling well. Pick one that is trained for tool use (its model card says function calling or tool use); 7B parameters or more is the practical floor, and larger models follow multi-step instructions more reliably.
The server exposes 32 tools, about 7.5k tokens of tool definitions sent with every request. Give the model a context window well above that (16k or more), and enable only the tools a task needs where your client allows it.
Point small models at a narrow task — "search my courses for fire safety and summarize the evacuation lesson" (search, fetch), "audit course X" — rather than building a whole course in one go. Building with create_course and the block reference works best on frontier models.
Authentication
Every request needs a credential in the Authorization: Bearer … header. Two kinds are accepted, and both scope every read and write to one organization — an agent can never see or change another organization's content.
Credential
Prefix
Get it from
Scopes
Lifetime
API key
oc_live_
Settings → API (owners and admins)
whatever the key was given; MCP tools need read:courses and/or write:courses (or *)
until revoked, or its optional expiry
Personal access token
oc_pat_
Settings → API (any member, for themselves)
what the person asks for, never more than their role: members read:courses and write:courses, viewers read:courses — re-checked on every request
until revoked or expired (90 days by default, at most a year)
OAuth access token
oc_at_
the OAuth flow below (any member of the organization)
the requested scope, narrowed to the person's role: members and above can grant read:courses and write:courses, viewers only read:courses
60 minutes, refreshable
The server also accepts either credential in an X-API-Key header when there is no Authorization header; Authorization: Bearer is the standard form and what MCP clients send.
API keys
Create one in Settings → API: name it, tick Read Courses and Write Courses, optionally set an expiry. The secret is shown once.
Read-only agents (audits, exports) only need read:courses; every tool lists the scope it requires.
Revoking a key in Settings → API stops it on the next request.
Calls are attributed in the audit log to api-key:<key id>.
OAuth 2.1
OAuth-capable clients need only the endpoint URL. A request without a valid credential gets HTTP 401 with a WWW-Authenticate header pointing at the protected-resource metadata; the client discovers the authorization server from there, registers itself, and opens the consent page in the browser.
Endpoint
URL
Protected resource metadata (RFC 9728)
https://scormbusters.com/.well-known/oauth-protected-resource/api/mcp (also at /.well-known/oauth-protected-resource)
Registration: POST /oauth/register with JSON { "client_name": "…", "redirect_uris": ["https://…"] }. Public clients only (token_endpoint_auth_method: "none", no client secret). 1–10 redirect URIs, each https or http on localhost / 127.0.0.1. client_name is truncated to 120 characters. Returns 201 with a client_id (occ_…). Limited to 100 registrations per IP address per 60 minutes (HTTP 429 too_many_requests with Retry-After beyond that).
Authorization: GET /oauth/authorize?response_type=code&client_id=…&redirect_uri=…&code_challenge=…&code_challenge_method=S256&state=…. PKCE with S256 is required. redirect_uri must exactly match a registered one.
Scope: send scope as space-separated scopes, or omit it for everything the person's role allows. The grant is narrowed, never widened: unknown scopes are dropped, and so are scopes above the person's role (members and above can grant read:courses and write:courses; viewers only read:courses). The token response's scope says what was granted. A request left with nothing grantable is refused with error=invalid_scope ("None of the requested scopes are supported…" or "Your role cannot grant …").
The person signs in and approves; the consent page lists exactly the scopes being granted. Their account must belong to an organization. Denial redirects back with error=access_denied.
Authorization codes are single-use and expire after 10 minutes. Replaying a used code revokes every token the client holds for that person.
Token: POST /oauth/token, form-encoded or JSON. grant_type=authorization_code needs code, client_id, redirect_uri and code_verifier; grant_type=refresh_token needs refresh_token and client_id, and takes an optional scope to narrow the new tokens (a scope outside the original grant is refused with invalid_scope, Requested scope exceeds the original grant).
Access tokens (oc_at_…) last 60 minutes (expires_in: 3600). Refresh tokens (oc_rt_…) last 30 days and rotate: each refresh returns a new pair and revokes the old one.
A token stops working as soon as its person leaves the organization or their role no longer covers the token's scopes (a member with write:courses demoted to viewer); this is checked on every request and every refresh.
Token errors use the OAuth format { "error": "invalid_grant", "error_description": "…" } — e.g. Unknown authorization code, Authorization code already used, Authorization code expired, Client mismatch, redirect_uri mismatch, PKCE verification failed, Unknown or revoked refresh token, Refresh token expired.
Revocation: POST /oauth/revoke with token (access or refresh) and client_id, form-encoded or JSON. Revoking either token of a grant ends both. Unknown or already-revoked tokens answer 200 as RFC 7009 requires; a token issued to a different client is refused with 401 invalid_client (Token was not issued to this client).
People see and revoke their own connections in Settings → Connected apps; owners and admins see and can revoke everyone's in the organization. Revoking there ends every token the client holds for that person on its next request.
Calls are attributed in the audit log to the person who approved the connection, with the client id (oauth:<client id>).
Tools
32 tools, generated from the definitions the server returns from tools/list. Results arrive as JSON text in content[0].text and, for object results, as structuredContent. Ids are UUIDs; take them from earlier results, never invent them.
List the organization's courses (id, title, status, editor_mode, dates), newest first, one page at a time. Returns {courses, total, nextCursor}: total counts every match; while nextCursor is not null, call again with cursor: nextCursor to get the rest — never assume one page is the whole library. Filter by title with query.
Input
Field
Type
Required
Description
status
"draft" | "published" | "archived"
no
Optional status filter
query
string
no
Optional: only courses whose title contains this text (case-insensitive)
limit
integer
no
Page size (default 50, max 100) (min 1, max 100)
cursor
string
no
nextCursor from the previous page; omit for the first page
Input JSON Schema
{
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"draft",
"published",
"archived"
],
"description": "Optional status filter"
},
"query": {
"type": "string",
"description": "Optional: only courses whose title contains this text (case-insensitive)"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Page size (default 50, max 100)"
},
"cursor": {
"type": "string",
"description": "nextCursor from the previous page; omit for the first page"
}
},
"additionalProperties": false
}
cursor is not one list_courses returned; omit it to start from the first page.
status must be one of <statuses>.
Notes
Pages hold 50 courses by default (limit up to 100), newest created first; the order does not change when a course is edited, so paging never skips or repeats one.
total counts every course matching the filters. Keep calling with cursor: nextCursor until nextCursor is null.
get_course
Get course structure
scope read:coursesread-only
Get a course's full outline: modules, lessons, and each lesson's block list (ids and types, not content). Use get_lesson to read block content. A Scenes course (kind: "scenes" — imported Storyline/Captivate courses, slide-based) returns its slides instead: id, name, order, objectCount, layerNames and a notes excerpt; read a slide's objects with get_scene_slide.
Blocks are listed by id and type only; read their content with get_lesson.
create_course
Create course
scope write:courseswrites
Create a course, optionally with its whole structure in one call: modules containing lessons containing blocks. This is the best way to build a course — draft the full structure first, then create it in one shot and refine with add_block/update_block. Read the tutorials (resources) before your first course: they cover which blocks to use and how to write good content for each. Courses start as drafts; publish with update_course.
Input
Field
Type
Required
Description
title
string
yes
Course title (1–255 chars, no control characters)
description
string
no
Optional course description shown in the catalog
structure
object
no
Optional full course structure, created with the course in one transaction (all or nothing)
structure.modules
object[]
no
structure.modules[].title
string
yes
Module title (1–255 chars)
structure.modules[].lessons
object[]
no
structure.modules[].lessons[].title
string
yes
Lesson title (1–255 chars)
structure.modules[].lessons[].questionPoolSize
integer | null
no
Question pool: how many of the lesson's question blocks each learner attempt gets, drawn at random (stable when the attempt is resumed; scored out of the drawn questions only). Omit or null for every question. (min 1, max 1000)
structure.modules[].lessons[].blocks
object[]
no
structure.modules[].lessons[].blocks[].type
string
yes
Block type, e.g. "paragraph", "heading", "multiple_choice"
structure.modules[].lessons[].blocks[].content
object
no
Block content matching that type's schema
structure.modules[].lessons[].blocks[].settings
object
no
Optional presentation settings
Input JSON Schema
{
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Course title (1–255 chars, no control characters)"
},
"description": {
"type": "string",
"description": "Optional course description shown in the catalog"
},
"structure": {
"type": "object",
"description": "Optional full course structure, created with the course in one transaction (all or nothing)",
"properties": {
"modules": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Module title (1–255 chars)"
},
"lessons": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Lesson title (1–255 chars)"
},
"questionPoolSize": {
"type": [
"integer",
"null"
],
"minimum": 1,
"maximum": 1000,
"description": "Question pool: how many of the lesson's question blocks each learner attempt gets, drawn at random (stable when the attempt is resumed; scored out of the drawn questions only). Omit or null for every question."
},
"blocks": {
"type": "array",
"items": {
"type": "object",
"description": "One content block. Call list_block_types for the content schema of each type.",
"properties": {
"type": {
"type": "string",
"description": "Block type, e.g. \"paragraph\", \"heading\", \"multiple_choice\""
},
"content": {
"type": "object",
"description": "Block content matching that type's schema",
"additionalProperties": true
},
"settings": {
"type": "object",
"description": "Optional presentation settings",
"additionalProperties": true
}
},
"required": [
"type"
],
"additionalProperties": false
}
}
},
"required": [
"title"
],
"additionalProperties": false
}
}
},
"required": [
"title"
],
"additionalProperties": false
}
}
},
"additionalProperties": false
}
},
"required": [
"title"
],
"additionalProperties": false
}
Example call
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "create_course",
"arguments": {
"title": "Forklift Safety Refresher",
"description": "Annual certification refresher for warehouse staff.",
"structure": {
"modules": [
{
"title": "Pre-operation checks",
"lessons": [
{
"title": "The daily inspection",
"blocks": [
{
"type": "heading",
"content": {
"text": "Module 1: Hazard Identification",
"level": "h2"
}
},
{
"type": "image",
"content": {
"url": "https://example.com/assets/forklift-inspection.jpg",
"alt": "A warehouse worker checking a forklift's tires during a pre-shift inspection",
"caption": "Daily pre-shift forklift inspection"
}
},
{
"type": "multiple_choice",
"content": {
"question": "<p>You spot a small chemical spill in an aisle. What should you do first?</p>",
"options": [
{
"text": "Mop it up yourself right away",
"correct": false,
"feedback": "Not until you know what the chemical is — check the label and SDS first."
},
{
"text": "Secure the area and check the safety data sheet",
"correct": true,
"feedback": "Right — identify the substance before anyone touches it."
},
{
"text": "Ignore it if it is smaller than your hand",
"correct": false,
"feedback": "Size doesn't determine hazard — some chemicals are dangerous in tiny amounts."
}
]
}
}
]
}
]
}
]
}
}
}
}
Creates a draft course with its modules, lessons and blocks. A course with no modules gets "Module 1" / "Lesson 1"; a module with no lessons gets "Lesson 1".
Errors
<field> must be a string
<field> must be 1–255 characters
<field> must not contain control characters (tabs, newlines…)
description must be 5000 characters or fewer
structure.modules must be an array
structure.modules[<i>].lessons[<j>].blocks[<k>] must be an object with a string type
Unknown block type "<type>". Valid types: <list>. Call list_block_types for each type's content schema.
"<lesson title>": <type> needs content.<field> (<type>): <field description> Call list_block_types for the full reference.
"<lesson title>": content.<field> must be a <type>, not <actual>. Call list_block_types for the full reference.
"<lesson title>": multiple_choice needs exactly one option with correct: true (found <n>). Use multi_select for several.
block content too large (max 512KB)
block settings too large (max 512KB)
Notes
Titles (course, module, lesson) are trimmed and must be 1–255 characters with no control characters. Errors name the field, e.g. structure.modules[0].lessons[2].title.
Every title and block is validated before anything is written, and the course is then created in one database transaction: an error result means nothing was created, so it is safe to fix the call and retry.
Soft problems (unknown content fields, values outside an enum) do not fail the call; they come back in a warnings array on the result.
update_course
Update course
scope write:courseswritesdestructive
Update a course's title, description, passing score (0–100), or status. Setting status to "published" makes it available to learners.
Updates the course row and pushes the change to any editor that has the course open. status "published" makes the course available to learners and fires the organization's `course.published` webhook, exactly as publishing in the editor does.
Errors
Course <courseId> not found in your organization.
<field> must be a string
<field> must be 1–255 characters
<field> must not contain control characters (tabs, newlines…)
description must be 5000 characters or fewer
status must be "draft", "published", or "archived"
passing_score must be an integer 0–100
Nothing to update.
add_module
Add module
scope write:courseswrites
Append a module (a chapter grouping lessons) to a course.
Append one content block to a lesson (or insert at a position). The content object must match the block type's schema — call list_block_types first if unsure.
Input
Field
Type
Required
Description
lessonId
string
yes
Lesson id (uuid)
type
string
yes
Block type
content
object
yes
Content matching the block type's schema
settings
object
no
Optional presentation settings
position
integer
no
Optional sort position; defaults to the end (min 0)
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "add_block",
"arguments": {
"lessonId": "d52f8094-6e3b-4c7d-9f20-ab1c4d5e6f44",
"type": "multiple_choice",
"content": {
"question": "<p>You spot a small chemical spill in an aisle. What should you do first?</p>",
"options": [
{
"text": "Mop it up yourself right away",
"correct": false,
"feedback": "Not until you know what the chemical is — check the label and SDS first."
},
{
"text": "Secure the area and check the safety data sheet",
"correct": true,
"feedback": "Right — identify the substance before anyone touches it."
},
{
"text": "Ignore it if it is smaller than your hand",
"correct": false,
"feedback": "Size doesn't determine hazard — some chemicals are dangerous in tiny amounts."
}
]
},
"position": 0
}
}
}
Permanently deletes the block. There is no undo over MCP.
Errors
Block <blockId> not found in your organization.
list_block_types
List block types
scope read:coursesread-only
The authoritative reference for all 37 block types: category, description, exact content field schema, and a realistic example for each. Pass a type to get just that block, or a category (Text, Media, Layout, Interactive, Assessment) to browse.
{
"type": "multiple_choice",
"label": "Multiple Choice",
"category": "Assessment",
"description": "Single correct answer quiz",
"contentFields": [
{
"name": "question",
"type": "string",
"required": true,
"description": "Question text as an HTML string (TipTap)."
},
{
"name": "options",
"type": "{ text: string; correct: boolean; feedback: string }[]",
"required": true,
"description": "Answer options. Exactly ONE option should have correct: true (the editor enforces this with radio buttons; the grader accepts whichever index has correct === true). feedback is per-option text shown to the learner after submitting when settings.showFeedback is on; use an empty string when there is none. The editor caps options at 6. A true/false question is a multiple_choice with the two options \"True\" and \"False\"; there is no separate type."
}
],
"example": {
"question": "<p>You spot a small chemical spill in an aisle. What should you do first?</p>",
"options": [
{
"text": "Mop it up yourself right away",
"correct": false,
"feedback": "Not until you know what the chemical is — check the label and SDS first."
},
{
"text": "Secure the area and check the safety data sheet",
"correct": true,
"feedback": "Right — identify the substance before anyone touches it."
},
{
"text": "Ignore it if it is smaller than your hand",
"correct": false,
"feedback": "Size doesn't determine hazard — some chemicals are dangerous in tiny amounts."
}
]
},
"notes": "GRADER AUTHORITY (lib/grading.ts): the learner response is the chosen option INDEX (number); options[chosen].correct === true wins. correct and feedback are stripped from learner-facing content by redactAnswers, so grading only happens server-side. settings.shuffleOptions: boolean shuffles display order; settings.showFeedback: boolean (default true) shows per-option feedback."
}
Side effects
None.
Errors
Unknown type "<type>". Valid types: <list>.
Notes
Without arguments (or with category) it returns a compact list: type, category, description and contentFields as "name?: type" strings.
update_module
Update module
scope write:courseswritesdestructive
Rename a module or change its position (sort_order) within the course.
Input
Field
Type
Required
Description
moduleId
string
yes
Module id (uuid)
title
string
no
New title (1–255 chars)
sort_order
integer
no
New position among the course's modules (0-based) (min 0)
Input JSON Schema
{
"type": "object",
"properties": {
"moduleId": {
"type": "string",
"description": "Module id (uuid)"
},
"title": {
"type": "string",
"description": "New title (1–255 chars)"
},
"sort_order": {
"type": "integer",
"minimum": 0,
"description": "New position among the course's modules (0-based)"
}
},
"required": [
"moduleId"
],
"additionalProperties": false
}
Renames and/or moves the module; sibling modules are renumbered 0..n-1.
Errors
Module <moduleId> not found in your organization.
<field> must be a string
<field> must be 1–255 characters
<field> must not contain control characters (tabs, newlines…)
sort_order must be a non-negative integer (the 0-based position)
Nothing to update.
update_lesson
Update lesson
scope write:courseswritesdestructive
Rename a lesson, change its position (sort_order) within its module, or make it a question pool (question_pool_size: each learner attempt gets that many of the lesson's question blocks at random, is scored out of those, and keeps the same draw when resumed; null gives every question).
Input
Field
Type
Required
Description
lessonId
string
yes
Lesson id (uuid)
title
string
no
New title (1–255 chars)
sort_order
integer
no
New position among the module's lessons (0-based) (min 0)
question_pool_size
integer | null
no
Question pool: how many of the lesson's question blocks each learner attempt gets, drawn at random (stable when the attempt is resumed; scored out of the drawn questions only). Omit or null for every question. (min 1, max 1000)
Input JSON Schema
{
"type": "object",
"properties": {
"lessonId": {
"type": "string",
"description": "Lesson id (uuid)"
},
"title": {
"type": "string",
"description": "New title (1–255 chars)"
},
"sort_order": {
"type": "integer",
"minimum": 0,
"description": "New position among the module's lessons (0-based)"
},
"question_pool_size": {
"type": [
"integer",
"null"
],
"minimum": 1,
"maximum": 1000,
"description": "Question pool: how many of the lesson's question blocks each learner attempt gets, drawn at random (stable when the attempt is resumed; scored out of the drawn questions only). Omit or null for every question."
}
},
"required": [
"lessonId"
],
"additionalProperties": false
}
Renames and/or moves the lesson within its module; siblings are renumbered 0..n-1.
Errors
Lesson <lessonId> not found in your organization.
<field> must be a string
<field> must be 1–255 characters
<field> must not contain control characters (tabs, newlines…)
sort_order must be a non-negative integer (the 0-based position)
Nothing to update.
set_flow
Set branching flow
scope write:courseswritesdestructive
Turn a course into an adaptive path (or back to linear). Describe the graph in lessons and conditions — nodes reference lesson ids from get_course, edges carry conditions (assessment_score on the lesson the edge leaves, or none for the one fallback edge). Layout, geometry, and editor metadata are computed for you. Read opencourse://tutorials/branching first; pass flow_mode "linear" with no graph to switch branching off.
Input
Field
Type
Required
Description
courseId
string
yes
Course id (uuid)
flow_mode
"linear" | "branching"
yes
graph
object
no
Required when flow_mode is "branching"
graph.nodes
object[]
yes
graph.nodes[].lessonId
string
yes
A lesson id from get_course
graph.nodes[].isEntry
boolean
no
Exactly one node must be the entry
graph.nodes[].isExit
boolean
no
At least one node must be an exit
graph.edges
object[]
yes
graph.edges[].from
string
yes
Source lesson id
graph.edges[].to
string
yes
Target lesson id
graph.edges[].condition
object
no
Condition object, or omit for an unconditional edge. See the branching tutorial.
graph.edges[].label
string
no
Short label shown on the edge, e.g. "score ≥ 80%"
graph.edges[].priority
integer
no
Lower evaluates first when several edges leave one node
Input JSON Schema
{
"type": "object",
"properties": {
"courseId": {
"type": "string",
"description": "Course id (uuid)"
},
"flow_mode": {
"type": "string",
"enum": [
"linear",
"branching"
]
},
"graph": {
"type": "object",
"description": "Required when flow_mode is \"branching\"",
"properties": {
"nodes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"lessonId": {
"type": "string",
"description": "A lesson id from get_course"
},
"isEntry": {
"type": "boolean",
"description": "Exactly one node must be the entry"
},
"isExit": {
"type": "boolean",
"description": "At least one node must be an exit"
}
},
"required": [
"lessonId"
],
"additionalProperties": false
}
},
"edges": {
"type": "array",
"items": {
"type": "object",
"properties": {
"from": {
"type": "string",
"description": "Source lesson id"
},
"to": {
"type": "string",
"description": "Target lesson id"
},
"condition": {
"type": "object",
"description": "Condition object, or omit for an unconditional edge. See the branching tutorial.",
"additionalProperties": true
},
"label": {
"type": "string",
"description": "Short label shown on the edge, e.g. \"score ≥ 80%\""
},
"priority": {
"type": "integer",
"description": "Lower evaluates first when several edges leave one node"
}
},
"required": [
"from",
"to"
],
"additionalProperties": false
}
}
},
"required": [
"nodes",
"edges"
],
"additionalProperties": false
}
},
"required": [
"courseId",
"flow_mode"
],
"additionalProperties": false
}
Saves the course's flow mode and (for branching) its graph, laid out automatically, and pushes it to open editors. Switching to linear keeps the stored graph so it can be switched back on.
Errors
Course <courseId> not found in your organization.
flow_mode must be "linear" or "branching"
flow_mode "branching" requires a graph
The graph needs at least one node.
Each lesson can appear in the graph only once.
Node lesson <lessonId> is not a lesson of this course. Call get_course for valid lesson ids.
Exactly one node must have isEntry: true (got <n>). The entry is where every learner starts.
At least one node must have isExit: true — the lesson(s) where the course can complete.
edges[<i>].from (<id>) is not a node in the graph
edges[<i>].to (<id>) is not a node in the graph
edges[<i>].condition: compound conditions are not supported; use one assessment_score condition per edge.
edges[<i>].condition: condition type "<type>" is not supported. Valid: <always, assessment_score>.
edges[<i>].condition: assessment_score needs an operator (<operators>)
edges[<i>].condition: assessment_score needs a numeric value (0–100)
edges[<i>].condition: assessment_score tests the lesson this edge leaves, so its lessonId must be <from> (or omitted).
"<lesson>" has <n> unconditional edges; a lesson can have only one fallback.
"<lesson>" is an exit but has an unconditional edge out of it, so the course could never end there. Remove that edge, or make it conditional.
"<lesson>" is not an exit and has no unconditional edge, so a learner whose score matches no condition would be stranded there. Add a fallback edge, or mark it isExit.
Unreachable node(s): <titles>. Every node needs a path from the entry.
Notes
See “Branching flows” below for the portability rules these errors enforce.
audit_course
Audit course
scope read:coursesread-only
Check a whole course for accessibility and design problems: useless or missing alt text, uncaptioned video, broken quiz answer keys, heading-order violations, empty or overlong lessons. Returns findings by severity (error, warning, suggestion; Scenes courses report error, warning, info). Run this after building and fix every error before telling the human you're done. On a Scenes course it runs the same preflight the Scenes export runs (unnamed controls, images without alt text, broken trigger references, contrast, quiz keys…): findings carry slideId/objectId, and byRule says which tool fixes each rule (update_scene_object, update_scene_slide, or the editor). Findings are capped at 150 per call; filter with slideId or severity.
{
"findings": [
{
"severity": "suggestion",
"rule": "course-description",
"message": "The course has no description. Learners see it in the catalog — one or two sentences on what they'll be able to do."
},
{
"severity": "warning",
"rule": "heading-order",
"message": "Heading level jumps to h3 after h1. Levels must descend without skipping.",
"lessonId": "c41e7f83-5d2a-4b6c-8e1f-9a0b3c4d5e33",
"lessonTitle": "The daily inspection",
"blockId": "f7410216-805d-4e9f-b142-cd3e6f708166",
"blockType": "heading"
},
{
"severity": "error",
"rule": "alt-text",
"message": "Alt text \"IMG_0042.jpg\" is a file name or a placeholder, not a description. Write what the image teaches.",
"lessonId": "c41e7f83-5d2a-4b6c-8e1f-9a0b3c4d5e33",
"lessonTitle": "The daily inspection",
"blockId": "0852132a-916e-4fa0-8253-de4f70819277",
"blockType": "image"
},
{
"severity": "warning",
"rule": "empty-lesson",
"message": "Lesson has no blocks.",
"lessonId": "d52f8094-6e3b-4c7d-9f20-ab1c4d5e6f44",
"lessonTitle": "Check your knowledge"
}
],
"summary": {
"error": 1,
"warning": 2,
"suggestion": 1
}
}
Side effects
None.
Errors
Course <courseId> not found in your organization.
Notes
Rules: course-description, empty-lesson, lesson-length, block-content, alt-text (skipped for an image with decorative: true), alt-text-reused, video-captions, video-alternative, audio-transcript, heading-order, quiz-options, quiz-answer-key, hotspot-area-name, custom-interaction-title.
Severities are error, warning and suggestion; summary counts each.
On a Scenes course the result is the Scenes export preflight instead (kind: "scenes"): severities error, warning and info; findings with slideId/objectId; byRule with each rule's message, count and fixWith (update_scene_object, update_scene_slide or editor). At most 150 findings per call; slideId and severity filter.
export_course
Export course package
scope read:coursesread-only
Build a standards-compliant package (SCORM 1.2, SCORM 2004, cmi5, or xAPI) and return a download link that works for 24 hours. Hand the link to the human; the package uploads to any LMS. Block and Scenes courses both export. A Scenes course is refused, with the findings, while the preflight finds errors (as the editor's export is): fix them (audit_course says how) or pass force: true only when the human wants a work-in-progress package. When the human will upload the package over a course already in their LMS that this one was imported from ("replace the original", "update the old course", "keep learners' completions"), pass replaceOriginal: true in the original's format; otherwise leave it off, because the default package is a new course to the LMS.
Input
Field
Type
Required
Description
courseId
string
yes
Course id (uuid)
format
"scorm12" | "scorm2004" | "cmi5" | "xapi"
no
Defaults to scorm2004
locale
string
no
Optional BCP-47 locale to export a stored translation, e.g. "de" (block courses only)
languages
string[]
no
Block courses only: one package carrying the course's own language and its translations, with a language picker for the learner (LMS preference, then browser, then the course's language) and a switch in the player. Progress and score do not depend on the language. ["all"] takes every complete translation; a list of locales (e.g. ["fr", "de"]) takes those, complete or not (missing strings show in the course's language). The result's languages.notes name what was left out or incomplete; pass them on. Overrides locale.
force
boolean
no
Scenes courses only: export even though the preflight finds errors. The result then carries a warning to pass on.
replaceOriginal
boolean
no
Only for a course imported from an LMS package: keep the original package's identifiers so the LMS can take it as a new version of the old course. Refused when the course was not imported from a package, or when the original had no identity in this format (e.g. cmi5 for a SCORM original): export in the format the refusal names. The result's replaceOriginal.notes say what to check (a changed SCORM version, a multi-part original); pass them on. Tell the human to upload it through the LMS's replace workflow and to try a test copy first.
Input JSON Schema
{
"type": "object",
"properties": {
"courseId": {
"type": "string",
"description": "Course id (uuid)"
},
"format": {
"type": "string",
"enum": [
"scorm12",
"scorm2004",
"cmi5",
"xapi"
],
"description": "Defaults to scorm2004"
},
"locale": {
"type": "string",
"description": "Optional BCP-47 locale to export a stored translation, e.g. \"de\" (block courses only)"
},
"languages": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 1,
"description": "Block courses only: one package carrying the course's own language and its translations, with a language picker for the learner (LMS preference, then browser, then the course's language) and a switch in the player. Progress and score do not depend on the language. [\"all\"] takes every complete translation; a list of locales (e.g. [\"fr\", \"de\"]) takes those, complete or not (missing strings show in the course's language). The result's languages.notes name what was left out or incomplete; pass them on. Overrides locale."
},
"force": {
"type": "boolean",
"description": "Scenes courses only: export even though the preflight finds errors. The result then carries a warning to pass on."
},
"replaceOriginal": {
"type": "boolean",
"description": "Only for a course imported from an LMS package: keep the original package's identifiers so the LMS can take it as a new version of the old course. Refused when the course was not imported from a package, or when the original had no identity in this format (e.g. cmi5 for a SCORM original): export in the format the refusal names. The result's replaceOriginal.notes say what to check (a changed SCORM version, a multi-part original); pass them on. Tell the human to upload it through the LMS's replace workflow and to try a test copy first."
}
},
"required": [
"courseId"
],
"additionalProperties": false
}
Builds the package and stores the zip in the organization's file storage (under exports/). Annotated read-only because course content is not changed.
Errors
Course <courseId> not found in your organization.
This course was not imported from an LMS package, so there is no original identity to keep.
The original was published as <original format>, not cmi5, so there is no cmi5 identity to keep. Export as <original format> to replace it.
Notes
format defaults to scorm2004. A locale that is not a valid BCP-47 tag is ignored and the source language is exported.
Builds can take a while for large courses; the endpoint allows up to 60 seconds per request.
A Scenes course is built by the same builder as the Scenes editor's export, and refused like it while its preflight finds errors; force: true exports anyway and adds a warning to the result.
replaceOriginal: true is the editor's "Replace the original in your LMS": for a course imported from an LMS package, the package keeps the original's identifiers (SCORM manifest, organization, item and resource of its first SCO; cmi5 course and first AU id; xAPI activity id) so the LMS can take it as a new version of the old course. Use it only when the human will upload it over that course, in the original's format.
With replaceOriginal the result carries replaceOriginal: { notes, howTo }: notes flag a changed SCORM version or a multi-part original, howTo is the upload guidance the editor shows. Replacement is verified in Moodle only; tell the human to try a test copy first.
get_scene_slide
Get Scenes slide
scope read:coursesread-only
Read one slide of a Scenes course: its name and notes, its layers, and per object the id, type, name, layer, hidden flag, words (text: [{ path, label, value }], long values cut at 280 characters), alt_text, aria_label, aria_role, the accessibleName a screen reader will announce (null on a control that has none), and how many states and triggers it has. Geometry and trigger bodies are left out. 100 objects per call; the reply says how to page.
Input
Field
Type
Required
Description
slideId
string
yes
Slide id (uuid), from get_course or an audit_course finding
offset
integer
no
Skip this many objects (for slides with more than 100) (min 0)
Input JSON Schema
{
"type": "object",
"properties": {
"slideId": {
"type": "string",
"description": "Slide id (uuid), from get_course or an audit_course finding"
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "Skip this many objects (for slides with more than 100)"
}
},
"required": [
"slideId"
],
"additionalProperties": false
}
accessibleName null marks a control a screen reader cannot name: give it an aria_label with update_scene_object.
view_scene_images
View a slide's pictures
scope read:coursesread-only
See the pictures on one Scenes slide (up to 6, undescribed ones first, downscaled) so you can write accurate alt text with update_scene_object — never describe an image you have not seen. Each picture comes with its objectId and current alt text.
Input
Field
Type
Required
Description
slideId
string
yes
Slide id (uuid), from get_course or an audit_course finding
objectIds
string[]
no
Only these image objects (from get_scene_slide or the audit)
Input JSON Schema
{
"type": "object",
"properties": {
"slideId": {
"type": "string",
"description": "Slide id (uuid), from get_course or an audit_course finding"
},
"objectIds": {
"type": "array",
"items": {
"type": "string"
},
"maxItems": 6,
"description": "Only these image objects (from get_scene_slide or the audit)"
}
},
"required": [
"slideId"
],
"additionalProperties": false
}
None. The content also carries each picture as an MCP image block (JPEG, at most 768 px on its longer side).
Errors
Slide <slideId> not found in your organization.
objectIds must be an array of object ids from get_scene_slide.
None of those objectIds is an image on this slide.
Notes
Only pictures stored in your organization's media library are shown; any other image is listed as not viewable.
Text rendered as an image (common in Captivate captions) should get that text as its alt text.
update_scene_object
Update Scenes object
scope write:courseswritesdestructive
Change what an author would retype on one object of a Scenes course: its words (text: [{ path, value }] with a path from get_scene_slide — plain text is fine, it becomes paragraphs), alt_text ("" marks an image decorative), aria_label (the name a screen reader announces), aria_role, name, or hidden. Triggers, variables, states' behavior and geometry are never touched. Open editors update live, and the first edit in a session saves a restorable version.
Input
Field
Type
Required
Description
objectId
string
yes
Object id (uuid), from get_scene_slide or an audit_course finding
text
object[]
no
New words for existing text fields
text[].path
string
yes
A path from get_scene_slide's text list, e.g. "content.html" or "content.options.0.text"
text[].value
string
yes
The new text (plain text, or HTML for an html path)
alt_text
string | null
no
Image description (max 1000); "" for decorative, null to clear
aria_label
string | null
no
Accessible name for a control or group (max 1000); null to clear
ARIA role, as the editor's Accessibility panel offers them; null to clear
name
string
no
Object name in the editor's layer list (1–255 chars)
hidden
boolean
no
Hidden at slide start (a trigger may still show it)
focusable
boolean | null
no
Whether it is a keyboard control. null (the default) infers it: a control when it has a click/hover trigger or is a native control. false for text or images an import wrongly made interactive; true only for a real control the inference misses.
Input JSON Schema
{
"type": "object",
"properties": {
"objectId": {
"type": "string",
"description": "Object id (uuid), from get_scene_slide or an audit_course finding"
},
"text": {
"type": "array",
"description": "New words for existing text fields",
"items": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "A path from get_scene_slide's text list, e.g. \"content.html\" or \"content.options.0.text\""
},
"value": {
"type": "string",
"description": "The new text (plain text, or HTML for an html path)"
}
},
"required": [
"path",
"value"
],
"additionalProperties": false
}
},
"alt_text": {
"type": [
"string",
"null"
],
"description": "Image description (max 1000); \"\" for decorative, null to clear"
},
"aria_label": {
"type": [
"string",
"null"
],
"description": "Accessible name for a control or group (max 1000); null to clear"
},
"aria_role": {
"type": [
"string",
"null"
],
"enum": [
"none",
"button",
"link",
"image",
"heading",
"text",
"checkbox",
"radio",
null
],
"description": "ARIA role, as the editor's Accessibility panel offers them; null to clear"
},
"name": {
"type": "string",
"description": "Object name in the editor's layer list (1–255 chars)"
},
"hidden": {
"type": "boolean",
"description": "Hidden at slide start (a trigger may still show it)"
},
"focusable": {
"type": [
"boolean",
"null"
],
"description": "Whether it is a keyboard control. null (the default) infers it: a control when it has a click/hover trigger or is a native control. false for text or images an import wrongly made interactive; true only for a real control the inference misses."
}
},
"required": [
"objectId"
],
"additionalProperties": false
}
{
"id": "4c95576b-d5a2-4de4-8697-1283b425d6bb",
"courseId": "2a743549-b380-4bc2-a475-f0619203b499",
"slideId": "3b84465a-c491-4cd3-b586-0172a314c5aa",
"changed": [
"aria_label"
],
"versionSaved": "Saved a restorable version \"Before assistant edits\" first.",
"accessibleName": "Show the Autocratic style"
}
Side effects
Updates the object and broadcasts the change to open Scenes editors. The first edit to a course in 30 minutes saves a version ("Before assistant edits") the author can restore.
Errors
Object <objectId> not found in your organization.
"<path>" is not a text field on this object. Use a path from get_scene_slide's text list: <paths>.
aria_role must be one of <roles> (or null).
Nothing to change: pass at least one of text, alt_text, aria_label, aria_role, name, hidden, focusable.
This picture is a control (it has a click trigger), often an invisible click area: decorative would hide it from keyboard users. Give it an aria_label saying what clicking it does.
Notes
Triggers, variables, states' behavior and geometry cannot be changed over MCP; findings that need them name the editor.
update_scene_slide
Update Scenes slide
scope write:courseswritesdestructive
Rename a slide of a Scenes course or rewrite its notes (the narration script / speaker notes). Open editors update live.
Updates the slide and broadcasts the change to open Scenes editors; saves a restorable version first, as update_scene_object does.
Errors
Slide <slideId> not found in your organization.
Nothing to change: pass name or notes.
upload_asset
Upload asset
scope write:courseswrites
Fetch a public https image, video, audio, or PDF (max 25MB) into the organization's media library and return its permanent URL — use that URL in image/video/audio block content. Counts against the plan's storage quota.
Input
Field
Type
Required
Description
url
string
yes
Public https URL of the file to import
filename
string
no
Optional filename hint, used for the extension
Input JSON Schema
{
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Public https URL of the file to import"
},
"filename": {
"type": "string",
"description": "Optional filename hint, used for the extension"
}
},
"required": [
"url"
],
"additionalProperties": false
}
Stores the file in the organization's media library. Counts against the plan's storage quota.
Errors
Could not use that URL: <reason> It must be a public https URL.
Content type "<type>" is not allowed. Images, video, audio, and PDF only.
This upload would exceed the organization's storage quota. Free space in the media library or upgrade the plan.
Notes
Reasons include: the URL resolves to a private or internal address, too many redirects (max 3), the source answered with a non-2xx status, or the file is larger than 25MB.
import_course
Import legacy course
scope write:courseswrites
Import a legacy e-learning package as a new draft course. A published .zip is recognized by its contents: Captivate (Classic 9–2019 and the all-new 12/13), Storyline, Rise 360, Lectora, iSpring, Articulate Studio (recognized; Flash reported), SCORM 1.2/2004, AICC, cmi5, xAPI, Adapt/Evolve, H5P and IMS Common Cartridge/QTI. Source files: .cptx, .story, .awt, .pptx, .h5p, .imscc and Moodle backups (.mbz). Pass a public https url to the file (max 100MB), or the storagePath of a file already staged in the app's course-imports storage. Returns the new courseId and the import report — what could not be recreated — so you can plan fixes: then audit_course, fix, and export_course. Limited to 10 imports per person (or API key) per 24 hours.
Input
Field
Type
Required
Description
url
string
no
Public https URL of the package. Give this or storagePath, not both.
storagePath
string
no
Path of a package already staged in course-imports storage (<org id>/<file>). Give this or url.
filename
string
no
File name with extension, e.g. "safety.zip" — needed when the URL or path does not end in one; the extension picks the parser
Input JSON Schema
{
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Public https URL of the package. Give this or storagePath, not both."
},
"storagePath": {
"type": "string",
"description": "Path of a package already staged in course-imports storage (<org id>/<file>). Give this or url."
},
"filename": {
"type": "string",
"description": "File name with extension, e.g. \"safety.zip\" — needed when the URL or path does not end in one; the extension picks the parser"
}
},
"additionalProperties": false
}
{
"courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
"title": "Forklift Safety Refresher",
"sourceFormat": "scorm12",
"lessonCount": 6,
"assetCount": 14,
"report": {
"notRecreated": [
{
"feature": "Flash interactions",
"count": 2,
"detail": "Rebuild them with native interactive blocks."
}
],
"notes": []
},
"warnings": [],
"editorUrl": "https://scormbusters.com/courses/3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
"next": "Run audit_course on courseId, fix what it and report.notRecreated describe, then export_course."
}
Side effects
Creates a draft course from the package, uploads its media to the organization's media library, and deletes a staged storagePath file once read. Uses one of the 10 imports per 24 hours.
Errors
Pass exactly one of url or storagePath.
Cannot tell the package type from "<name>". Pass filename with one of these extensions: <extensions>.
storagePath must be a file in your organization's course-imports folder (<org id>/<file>).
No staged file at <storagePath>.
Could not use that URL: <reason> It must be a public https URL.
File too large (max 100MB)
Importing a course needs the member role or higher in this organization.
Import limit reached (<n> per 24 hours). Try again tomorrow.
Failed to parse file: <parser message>
Nothing in "<title>" could be converted into editable content, so no course was created. <reasons>
Notes
The same pipeline as Courses → Import in the app, with the same limit: an OAuth connection shares the person's 10 imports per 24 hours with their imports in the app; an API key has its own budget. The limit refuses imports when it cannot be checked.
report.notRecreated lists what the importer could not rebuild (with counts and advice); report.notes lists what it changed. Both are always arrays. Plan fixes from them, then run audit_course.
The URL is fetched with the same guard as upload_asset: public https only, private and internal addresses refused at every redirect, at most 3 redirects, capped at 100MB.
Storyline, Captivate and Lectora imports become slide-based (Scenes) courses: slideCount and objectCount are returned, and their content is edited in the app rather than with the block tools.
AI modernization, offered by the app's importer, is not available over MCP.
create_review_link
Create review link
scope write:courseswrites
Mint a shareable review link for a course. Anyone with the link sees the content read-only — block courses as lessons, scenes courses in the real player — and leaves structured feedback (typed comments, suggested replacement wording, per-block or per-slide anchors) without signing in. Hand the URL to the human to send to their reviewer; links expire after 30 days and can be revoked in the editor's Share dialog.
{
"reviewUrl": "https://scormbusters.com/review/5a7b9c0d-1e2f-4a3b-8c4d-6e7f8091a2b3",
"sessionId": "4b6c8d9e-0f1a-4b2c-9d3e-5f60718293a4",
"title": "SME review — compliance pass",
"expiresAt": "2026-11-01T09:00:00.000Z",
"next": "Give reviewUrl to the human to send to their reviewer; later, read what came back with get_review_feedback."
}
Side effects
Creates a review link anyone can open without signing in (read-only content, comment-only). Expires after 30 days; revocable from the editor's Share dialog.
Errors
Course <courseId> not found in your organization.
Could not create the review link.
Notes
The reviewer page shows block courses lesson by lesson and scenes courses in the real player, and collects structured feedback: a comment kind, an optional suggested replacement wording, and a per-block or per-slide anchor with a snapshot of what the reviewer was looking at.
get_review_feedback
Get review feedback
scope read:coursesread-only
Everything reviewers have said about a course, across all of its review links: comments sorted most-urgent first with their kind (factual error, missing content, wording, …), the reviewer's suggested replacement text where given, whether the content has changed or been deleted since (context_state), reply threads, resolution state, and per-link verdicts (approved / changes requested). Start here before editing in response to a review; a comment whose context_state is "changed" may no longer apply.
{
"sessions": [
{
"id": "4b6c8d9e-0f1a-4b2c-9d3e-5f60718293a4",
"title": "SME review — compliance pass",
"status": "changes_requested",
"expires_at": "2026-11-01T09:00:00.000Z",
"last_viewed_at": "2026-10-02T08:12:00.000Z",
"created_at": "2026-10-01T09:00:00.000Z",
"comment_count": 2
}
],
"comments": [
{
"id": "6c8d0e1f-2a3b-4c4d-ae5f-708192a3b4c5",
"kind": "factual",
"text": "The retention period is six years now, not five.",
"suggested_text": "Keep every record for six years from the date of issue.",
"author_name": "Dana (Legal)",
"block_id": "f7410216-805d-4e9f-b142-cd3e6f708166",
"lesson_id": "c41e7f83-5d2a-4b6c-8e1f-9a0b3c4d5e33",
"lesson_title": "Introduction",
"slide_id": null,
"slide_title": null,
"context_state": "current",
"context_excerpt": "Keep every record for five years from the date of issue.",
"resolved": false,
"replies": [],
"created_at": "2026-10-02T08:10:00.000Z"
}
],
"summary": {
"total": 2,
"actionable": 1,
"approvals": 1,
"questions": 0,
"unclassified": 0,
"byKind": {}
},
"unresolved_summary": {
"total": 1,
"actionable": 1,
"approvals": 0,
"questions": 0,
"unclassified": 0,
"byKind": {}
}
}
Side effects
None.
Errors
Course <courseId> not found in your organization.
Notes
comments are sorted most-urgent first (factual errors before wording), threads nested under their first comment, and each carries context_state: current (safe to act on), changed (the content moved on — reread before acting), deleted, or unknown.
suggested_text is the reviewer's replacement wording; when context_state is current, apply_review_suggestion lands it in one call.
A session's status is the reviewer's overall verdict: open, changes_requested, or approved.
apply_review_suggestion
Apply review suggestion
scope write:courseswritesdestructive
Accept a reviewer's suggested wording as written: replaces the text of the block the comment points at and marks the comment resolved. Refuses (409) when the block has been edited since the suggestion was written, when it was deleted, or when a concurrent edit lands first — never applies a suggestion onto words it was not about. On a refusal, read the comment and edit by hand with update_block instead.
Replaces the anchored block's text with the reviewer's suggested wording (the block's revision advances, so a concurrent editor gets a conflict rather than losing work) and marks the comment resolved.
Errors
Course <courseId> not found in your organization.
Comment not found
This comment has no suggested wording
The block this suggestion was written for no longer exists
The block has been edited since this suggestion was written — review it by hand instead
This block has no text field the suggestion could replace
The block changed while applying — try again
Notes
Only applies onto the exact words the reviewer was reading (a stored content hash is checked server-side). On any refusal, read the comment and make the edit yourself with update_block, then resolve_review_comment.
resolve_review_comment
Resolve review comment
scope write:courseswritesdestructive
Mark a reviewer's comment resolved (or reopen it). Resolve after the change it asked for has actually been made — the reviewer sees the resolution on their page, so a false resolve burns trust.
Input
Field
Type
Required
Description
courseId
string
yes
Course id (uuid)
commentId
string
yes
The review comment id, from get_review_feedback
resolved
boolean
yes
true to resolve, false to reopen
Input JSON Schema
{
"type": "object",
"properties": {
"courseId": {
"type": "string",
"description": "Course id (uuid)"
},
"commentId": {
"type": "string",
"description": "The review comment id, from get_review_feedback"
},
"resolved": {
"type": "boolean",
"description": "true to resolve, false to reopen"
}
},
"required": [
"courseId",
"commentId",
"resolved"
],
"additionalProperties": false
}
Sets the comment's resolved flag; the reviewer sees the resolution on their page.
Errors
Course <courseId> not found in your organization.
Comment not found
Could not update the comment.
Notes
Resolve only after the change it asked for was actually made.
reply_review_comment
Reply to review comment
scope write:courseswrites
Post a reply in a review comment's thread. The reviewer sees it on their review page, labeled as coming from the author's side — use it to ask what a comment means or to say what was changed, instead of letting the question leak into email. Keep it short and concrete.
Input
Field
Type
Required
Description
courseId
string
yes
Course id (uuid)
commentId
string
yes
The thread's first comment id, from get_review_feedback
text
string
yes
The reply, plain text, max 5000 characters
Input JSON Schema
{
"type": "object",
"properties": {
"courseId": {
"type": "string",
"description": "Course id (uuid)"
},
"commentId": {
"type": "string",
"description": "The thread's first comment id, from get_review_feedback"
},
"text": {
"type": "string",
"description": "The reply, plain text, max 5000 characters"
}
},
"required": [
"courseId",
"commentId",
"text"
],
"additionalProperties": false
}
Example call
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "reply_review_comment",
"arguments": {
"courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
"commentId": "6c8d0e1f-2a3b-4c4d-ae5f-708192a3b4c5",
"text": "Updated to six years — thanks for catching it."
}
}
}
Posts a reply into the comment's thread, visible on the reviewer's page and labeled as coming from the author's side.
Errors
Course <courseId> not found in your organization.
Comment not found
Reply text is required.
Reply must be 5000 characters or fewer.
Reply to the thread's first comment, not to a reply.
Could not post the reply.
Notes
Over an OAuth connection the reply carries the person's name; over an API key it is posted as "The author's assistant".
search_library
Search the library
scope read:coursesread-only
Enterprise. Find text across every course in the organization: block content (paragraphs, questions, options, feedback, captions, alt text…), Scenes slides (text objects, states, alt text, slide and layer names, notes) and course, module and lesson titles. Returns one row per occurrence with context, whether it can be replaced automatically, and an editorUrl that opens the editor at it. Use it to answer "where do we still say X?" before deciding on a replace_in_library.
Input
Field
Type
Required
Description
find
string
yes
Text to find (1–200 characters), or a pattern with regex: true
matchCase
boolean
no
Match upper and lower case exactly (default false)
wholeWord
boolean
no
Only whole words (default false)
regex
boolean
no
Treat find as a JavaScript regular expression (default false). Nested repetition like (a+)+ and backreferences are refused
courseIds
string[]
no
Only these courses (uuids)
statuses
"draft" | "published" | "archived"[]
no
Only courses with these statuses
sources
string[]
no
Only courses imported from these tools (e.g. "storyline", "rise"); "none" for courses built in Scormbusters
limit
integer
no
Most occurrences to list (default 100); totals always count every match (min 1, max 500)
Input JSON Schema
{
"type": "object",
"properties": {
"find": {
"type": "string",
"description": "Text to find (1–200 characters), or a pattern with regex: true"
},
"matchCase": {
"type": "boolean",
"description": "Match upper and lower case exactly (default false)"
},
"wholeWord": {
"type": "boolean",
"description": "Only whole words (default false)"
},
"regex": {
"type": "boolean",
"description": "Treat find as a JavaScript regular expression (default false). Nested repetition like (a+)+ and backreferences are refused"
},
"courseIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "Only these courses (uuids)"
},
"statuses": {
"type": "array",
"items": {
"type": "string",
"enum": [
"draft",
"published",
"archived"
]
},
"description": "Only courses with these statuses"
},
"sources": {
"type": "array",
"items": {
"type": "string"
},
"description": "Only courses imported from these tools (e.g. \"storyline\", \"rise\"); \"none\" for courses built in Scormbusters"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 500,
"description": "Most occurrences to list (default 100); totals always count every match"
}
},
"required": [
"find"
],
"additionalProperties": false
}
{
"totals": {
"courses": 1,
"occurrences": 2,
"replaceable": 1,
"needsManualEdit": 1,
"coursesSearched": 12,
"coursesInScope": 12,
"truncated": false,
"partial": false,
"skippedTooLong": 0
},
"courses": [
{
"courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
"title": "Forklift Safety Refresher",
"status": "published",
"source": null,
"occurrences": 2
}
],
"matches": [
{
"occurrenceId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11|block|f7410216-805d-4e9f-b142-cd3e6f708166|content:text|0|8e3f12a4",
"courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
"courseTitle": "Forklift Safety Refresher",
"where": "The daily inspection › Heading › Text",
"before": "Keep every record for ",
"match": "five years",
"after": " from the date of issue.",
"replaceable": true,
"editorUrl": "https://scormbusters.com/courses/3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11?lesson=c41e7f83-5d2a-4b6c-8e1f-9a0b3c4d5e33&block=f7410216-805d-4e9f-b142-cd3e6f708166"
},
{
"occurrenceId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11|block|19632438-a27f-4ab1-9364-ef508192a388|content:question|0|5b1d07e2",
"courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
"courseTitle": "Forklift Safety Refresher",
"where": "Check your knowledge › Multiple Choice › Question",
"before": "How long do we keep records? (",
"match": "five years",
"after": ")",
"replaceable": false,
"reason": "spans-formatting",
"editorUrl": "https://scormbusters.com/courses/3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11?lesson=d52f8094-6e3b-4c7d-9f20-ab1c4d5e6f44&block=19632438-a27f-4ab1-9364-ef508192a388"
}
]
}
Side effects
None.
Errors
Enter something to find.
Search text must be <n> characters or fewer.
That pattern is not a valid regular expression: <reason>
That pattern repeats a group that itself repeats or alternates (like (a+)+ or (a|b)*), which can take minutes to run. Simplify it.
Backreferences are not supported in library search.
<field> must be a list of valid values.
Library search and replace need the member role or higher in this organization.
Library search and bulk replace — finding and changing text across every course at once — is an Enterprise feature. Find and edit within a course on every plan.
Notes
Searched: every learner-visible field of every block type (the same fields translation covers — never answer keys, URLs or ids), Scenes text objects and their state overrides, question text, options, feedback, alt text, accessible names, captions, slide names and notes, layer and object names, and course, module and lesson titles and descriptions.
Rich text is matched as the learner reads it: tags skipped, entities decoded, bold or linked words joined. A match that crosses formatting has replaceable: false and reason spans-formatting — edit it by hand at its editorUrl.
totals count every match; matches lists at most limit of them. partial: true means the search stopped at its time budget before every course was read — narrow it with courseIds, statuses or sources.
replace_in_library
Replace across the library
scope write:courseswritesdestructive
Enterprise. Replace text across many courses at once, in two calls. First call with dry_run: true — nothing is written; you get the before/after preview of every occurrence and a confirm_token. Show the human the preview. Only if they approve, call again with the same arguments, dry_run: false and the confirm_token. Rich text is only changed inside one run of text: a match that crosses formatting (part bold) is reported as needing a manual edit, never guessed at. Every changed lesson or Scenes course gets a version-history snapshot first, each course is audited, and the whole edit can be reversed with undo_library_replace.
Input
Field
Type
Required
Description
find
string
yes
Text to find (1–200 characters), or a pattern with regex: true
matchCase
boolean
no
Match upper and lower case exactly (default false)
wholeWord
boolean
no
Only whole words (default false)
regex
boolean
no
Treat find as a JavaScript regular expression (default false). Nested repetition like (a+)+ and backreferences are refused
courseIds
string[]
no
Only these courses (uuids)
statuses
"draft" | "published" | "archived"[]
no
Only courses with these statuses
sources
string[]
no
Only courses imported from these tools (e.g. "storyline", "rise"); "none" for courses built in Scormbusters
replace
string
yes
Replacement text (may be empty to delete the matches). With regex: true, $1…$9 and $<name> insert groups
dry_run
boolean
yes
true to preview without writing (required first); false to apply
confirm_token
string
no
The confirm_token from the dry run; required with dry_run: false
occurrenceIds
string[]
no
Optional: replace only these occurrences (ids from the preview). Omit to replace every replaceable occurrence
limit
integer
no
Most preview rows to list (default 50) (min 1, max 200)
Input JSON Schema
{
"type": "object",
"properties": {
"find": {
"type": "string",
"description": "Text to find (1–200 characters), or a pattern with regex: true"
},
"matchCase": {
"type": "boolean",
"description": "Match upper and lower case exactly (default false)"
},
"wholeWord": {
"type": "boolean",
"description": "Only whole words (default false)"
},
"regex": {
"type": "boolean",
"description": "Treat find as a JavaScript regular expression (default false). Nested repetition like (a+)+ and backreferences are refused"
},
"courseIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "Only these courses (uuids)"
},
"statuses": {
"type": "array",
"items": {
"type": "string",
"enum": [
"draft",
"published",
"archived"
]
},
"description": "Only courses with these statuses"
},
"sources": {
"type": "array",
"items": {
"type": "string"
},
"description": "Only courses imported from these tools (e.g. \"storyline\", \"rise\"); \"none\" for courses built in Scormbusters"
},
"replace": {
"type": "string",
"description": "Replacement text (may be empty to delete the matches). With regex: true, $1…$9 and $<name> insert groups"
},
"dry_run": {
"type": "boolean",
"description": "true to preview without writing (required first); false to apply"
},
"confirm_token": {
"type": "string",
"description": "The confirm_token from the dry run; required with dry_run: false"
},
"occurrenceIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "Optional: replace only these occurrences (ids from the preview). Omit to replace every replaceable occurrence"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"description": "Most preview rows to list (default 50)"
}
},
"required": [
"find",
"replace",
"dry_run"
],
"additionalProperties": false
}
{
"dry_run": false,
"editId": "2a4c6e80-9b1d-4f3a-8c5e-7d9f0b1a2c3d",
"totals": {
"courses": 1,
"replaced": 1,
"skipped": 0
},
"courses": [
{
"courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
"title": "Forklift Safety Refresher",
"replaced": 1,
"skipped": 0
}
],
"next": "Every changed lesson and Scenes course has a version-history snapshot labeled with this replace. To reverse the whole edit, call undo_library_replace with editId."
}
Side effects
dry_run: true writes nothing. dry_run: false first saves a version-history snapshot of every lesson and Scenes course it will change (labeled "Bulk replace: '<find>' → '<replace>'"), then rewrites the matching text, writes one audit-log entry per changed course, records the edit for undo_library_replace, and updates any editor that has the course open.
Errors
dry_run is required: call with dry_run: true first, check the preview with the human, then repeat with dry_run: false and the confirm_token.
confirm_token is required with dry_run: false. Run a dry run first and pass the confirm_token it returns.
The library changed since the dry run, or the arguments differ from it. Run replace_in_library with dry_run: true again and confirm the new preview.
replace must be a string (it may be empty, to delete the matches).
replace must be <n> characters or fewer.
Bulk edits are limited to <n> an hour. Try again later.
Library search and replace need the member role or higher in this organization.
Enter something to find.
Notes
The confirm_token is a digest of the arguments and of exactly which occurrences the dry run previewed. If the library changes in between (someone edits a matching field) or any argument differs, the apply is refused and nothing is written: dry-run again.
A block is only written at the revision the replace read; an author's save that lands in between wins and the occurrence is reported as edited-concurrently. A course too large to snapshot is left unchanged, with an error naming why.
Inserted text is escaped in rich text, so a replacement can never add markup. A fill-in-the-blank sentence whose [blank] count would change is skipped (blank-mismatch).
No confirm_token is issued when the search was cut short (more than 5000 matches, or it ran out of time): narrow it with courseIds, statuses or sources.
undo_library_replace
Undo a library replace
scope write:courseswritesdestructive
Enterprise. Reverse one replace_in_library: every field it changed goes back to what it was, wherever the text is still what the replace wrote. Fields someone edited since are left alone and listed as conflicts. Only the person who made the edit, or an admin, can undo it.
Saves a "Before undo" version-history snapshot of each affected lesson and Scenes course, puts every changed field back to its value before the replace where it still holds what the replace wrote, audits each course, and marks the edit undone.
Errors
editId must be the id replace_in_library returned.
Bulk edit not found in your organization.
This bulk edit has already been undone.
Only the person who made this bulk edit, or an admin, can undo it.
Bulk edits are limited to <n> an hour. Try again later.
Notes
Fields edited after the replace are not touched and come back in conflicts (reason edited-since, or deleted); restore them from the course's version history if needed.
search
Search course content
scope read:coursesread-only
Search the words in the organization's courses — titles, descriptions, lesson text, questions and Scenes slide text — and return up to 20 matching course outlines, lessons and slides, best first: {results: [{id, title, url}]}. Read one with fetch(id).
Input
Field
Type
Required
Description
query
string
yes
Words to look for, e.g. "fire extinguisher types"
Input JSON Schema
{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Words to look for, e.g. \"fire extinguisher types\""
}
},
"required": [
"query"
],
"additionalProperties": false
}
The shape ChatGPT connectors and deep research expect; any MCP client can use it. Results are course outlines (course:<id>), lessons (lesson:<courseId>:<lessonId>) and Scenes slides (slide:<courseId>:<slideId>), ranked by how many of the query's words they contain.
Searches every course in the organization, reading the courses whose titles match first; if a very large library runs past the time budget, the rest are matched on title and description only.
For exact phrases, regular expressions and every occurrence with context, use search_library (Enterprise).
fetch
Fetch course content
scope read:coursesread-only
Read the text of one search result — a whole course, a lesson or a slide — as {id, title, text, url, metadata}. A course is capped at 50,000 characters; fetch its lessons for the rest.
Input
Field
Type
Required
Description
id
string
yes
An id from search, e.g. "course:<uuid>" or "lesson:<courseId>:<lessonId>"
Input JSON Schema
{
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "An id from search, e.g. \"course:<uuid>\" or \"lesson:<courseId>:<lessonId>\""
}
},
"required": [
"id"
],
"additionalProperties": false
}
Unknown id "<id>". Pass an id exactly as search returned it.
Nothing with id <id> in your organization.
Notes
text is plain text, one line per field (markup removed), labeled with the block or object it came from.
Resources
Tutorials the model reads in-band with resources/read and { "uri": "…" }. All are Markdown written for a model audience. The server's instructions tell clients to start with getting-started.
opencourse://tutorials/getting-startedGetting started. The mental model and the golden path: plan, create in one call, refine, publish. Read this first.
opencourse://tutorials/assessmentsWriting assessments. Choosing assessment blocks, question craft, feedback, and scoring.
opencourse://tutorials/branchingBranching: adaptive paths. How to build an adaptive course graph with set_flow: the input shape, condition types, and design guidance.
opencourse://tutorials/blocksBlock reference. Every block type's exact content schema with a realistic example — generated from the editor's own definitions.
opencourse://tutorials/publishingPublishing and delivery. Statuses, what the human can do with what you build, export formats, and agent etiquette.
opencourse://tutorials/imported-coursesFixing an imported course. Imported Storyline/Captivate courses are Scenes courses (slides of objects): how to audit them, fix names, alt text and words, and export or replace the original in the LMS.
Prompts
Fetched with prompts/get; clients usually offer them as slash commands. Each returns one user message that walks the model through the task with the tools above.
build-courseBuild a course
Plan and build a complete course on a topic, following Scormbusters' design conventions.
topic (required) — What the course teaches
audience (optional) — Who it is for
length_minutes (optional) — Rough target length in minutes
improve-courseImprove an existing course
Audit a course for accessibility and design issues, then fix what can be fixed.
course (required) — Course title or id to improve
fix-imported-courseFix an imported course
Make a course imported from Storyline, Captivate or another slide tool pass its accessibility preflight, then export it — optionally to replace the original in the LMS.
course (required) — Course title or id
replace_in_lms (optional) — "yes" when the export will replace the original package in the LMS
Errors
Two layers. Protocol problems come back as a JSON-RPC error; problems with what a tool was asked to do come back as a normal result with isError: true and the explanation in content[0].text, so the model can read it and correct itself.
JSON-RPC errors
Code
HTTP
Message
When
-32001
401
Authentication required. Connect with your Scormbusters account (OAuth), or create a personal access token under Settings → API and send it as 'Authorization: Bearer oc_pat_…' (admins can also use an organization key, oc_live_…). Setup guide: /docs/mcp
The Mcp-Protocol-Version request header names a version this server does not speak.
-32600
400
Invalid JSON-RPC 2.0 request
jsonrpc is not "2.0" or method is missing.
-32601
200
Method not found: <method>
Any method not listed under Transport.
-32602
200
Unknown tool: <name>
tools/call with a name tools/list doesn't have.
-32602
200
Unknown prompt: <name>
prompts/get with an unknown name.
-32002
200
Unknown resource: <uri>
resources/read with an unknown URI.
-32603
200
Internal error
Unexpected server failure outside a tool.
The 401 and 429 responses are sent before the body is read, so their id is always null.
Tool errors (isError: true)
Missing scope: This credential is missing the "<scope>" scope required by <tool>. API keys get scopes in Settings → API; OAuth connections carry the scopes approved for them (a viewer's connection is read-only).
A mistake in the arguments (unknown id, bad block content, invalid graph…): a specific message — each tool below lists its own.
Anything else: The operation failed on the server. Try again; if it persists, tell the human to check the Scormbusters logs.
Ids from another organization are reported exactly like ids that don't exist: Course <id> not found in your organization.
Rate limits
120 requests per 60-second window, counted per credential: per API key, and per OAuth client per person — two connectors the same person approved each have their own budget.
Every authenticated POST counts, including initialize, list calls and notifications.
Over the limit: HTTP 429, JSON-RPC error -32000, and Retry-After: 60 — the longest the current window can have left.
No rate-limit headers are sent on successful responses.
Counters live in the database, so the budget is global across every server instance. If the counter cannot be reached the request is allowed rather than refused.
import_course has its own, stricter limit: 10 imports per 24 hours per person (shared with their imports in the app) or per API key. Over it the tool returns an isError result. Unlike the request limit, it refuses imports when the counter cannot be reached, because an import is expensive work.
replace_in_library (applying, not dry runs) and undo_library_replace share a limit of 20 per 60 minutes per person (shared with bulk edits in the app) or per API key, refused when the counter cannot be reached.
Transport and protocol
Streamable HTTP in stateless mode: one JSON-RPC 2.0 request per POST, answered with one application/json response. No SSE stream, no Mcp-Session-Id, no server-initiated messages.
Item
Value
Protocol versions
2026-07-28 (per-request _meta, server/discover), and 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05 (initialize handshake) on the same endpoint
Negotiation
initialize echoes the client's protocolVersion when supported, otherwise answers 2025-11-25
Mcp-Protocol-Version header
Every response carries the version in force: the one negotiated, on the initialize response; on later requests, the version the client sends in its own Mcp-Protocol-Version header (2025-03-26 when it sends none, as the transport spec says). An unsupported header value gets HTTP 400.
Any message without an id is accepted with HTTP 202 and no body
Batching
Not supported — a JSON array body is rejected
GET
HTTP 405 (no stream to offer)
CORS
Any origin; allowed headers Content-Type, Authorization, X-API-Key, Mcp-Protocol-Version, Mcp-Session-Id
Time limit
60 seconds per request
Tool results carry the JSON-encoded return value in content[0].text. When the value is an object it is also sent as structuredContent; array results (list_block_types without a type) are text only.
tools/list annotates every tool with readOnlyHint (true for the read tools), destructiveHint (true for every write that can overwrite or remove what is there; false for the ones that only add), openWorldHint (true for upload_asset and import_course, which fetch a URL you give them) and the tool's title.
Arguments are not validated against the input schemas as a whole; each tool checks what it uses and reports problems as tool errors. Stick to the schemas — undeclared fields are ignored.
Server instructions sent in initialize
Scormbusters is an eLearning authoring platform: courses contain modules, modules contain lessons, lessons are ordered lists of content blocks (37 types: text, media, quizzes, scenarios…). Courses can branch: set_flow turns a course into an adaptive path.
Before building your first course, read the tutorial resources — start with opencourse://tutorials/getting-started, then opencourse://tutorials/course-design. The exact content schema for every block type comes from the list_block_types tool (or opencourse://tutorials/blocks); never guess content fields. Branching is documented in opencourse://tutorials/branching.
The golden path: plan the full outline, create it in ONE create_course call with the complete structure, refine with get_course / get_lesson / add_block / update_block, then run audit_course and fix every error before you tell the human you're done. Build in draft; let the human publish unless they asked you to. export_course returns a download link (SCORM 1.2, SCORM 2004, cmi5 or xAPI) valid for 24 hours when they want the package. Exporting an imported course for the first time uses one of the organization's migrations; tell the human before you do it.
To modernize a legacy course (SCORM, Rise, Storyline, Captivate, PowerPoint…), import_course it from a URL, read the returned report, run audit_course, fix what both describe, and export_course.
To change wording across many courses at once (Enterprise), search_library first, then replace_in_library with dry_run: true, show the human the preview, and apply with the confirm_token only once they approve. undo_library_replace reverses it.
Workflows
Real tool sequences. Every step here is checked by the test suite against the tool's schema and the content rules, so they run as written once you substitute your own ids.
Import a legacy course, audit accessibility, fix, export SCORM
import → audit_course → fix → export_course. The agent imports the legacy package from a URL, plans fixes from the import report and the audit, fixes them, and hands back a clean package.
import_course — Import the package as a draft course. Keep courseId; read report.notRecreated — each entry is something to rebuild with native blocks.
update_block — alt-text: replace a file-name alt with a real description. Send the complete content object — update_block does not merge.
{
"blockId": "0852132a-916e-4fa0-8253-de4f70819277",
"content": {
"url": "https://example.com/assets/forklift-inspection.jpg",
"alt": "A worker checking a forklift's tyre pressure before a shift",
"caption": "Daily pre-shift forklift inspection"
}
}
update_block — heading-order: a lesson's first heading must be h2 (the lesson title is the h1).
If the file is not reachable by URL, the person can upload it in the app instead (Courses → Import); list_courses with status "draft" then finds it, newest first.
Imported SCO content arrives as embedded HTML blocks; the audit checks native blocks. Rebuilding an imported lesson with native blocks (add_block, then delete_block the embed after the person agrees) is what makes its content auditable and accessible.
Build a course from scratch
Plan the whole outline, create it in one call, then audit and refine.
resources/read — Read the golden path, then opencourse://tutorials/course-design.
create_course — One call with the full structure. Read warnings on the result: they name ignored or suspicious fields.
{
"title": "Forklift Safety Refresher",
"description": "Annual certification refresher for warehouse staff.",
"structure": {
"modules": [
{
"title": "Pre-operation checks",
"lessons": [
{
"title": "The daily inspection",
"blocks": [
{
"type": "heading",
"content": {
"text": "The daily inspection",
"level": "h2"
}
},
{
"type": "image",
"content": {
"url": "https://example.com/assets/forklift-inspection.jpg",
"alt": "A warehouse worker checking a forklift's tires during a pre-shift inspection",
"caption": "Daily pre-shift forklift inspection"
}
}
]
},
{
"title": "Check your knowledge",
"blocks": [
{
"type": "multiple_choice",
"content": {
"question": "<p>You spot a small chemical spill in an aisle. What should you do first?</p>",
"options": [
{
"text": "Mop it up yourself right away",
"correct": false,
"feedback": "Not until you know what the chemical is — check the label and SDS first."
},
{
"text": "Secure the area and check the safety data sheet",
"correct": true,
"feedback": "Right — identify the substance before anyone touches it."
},
{
"text": "Ignore it if it is smaller than your hand",
"correct": false,
"feedback": "Size doesn't determine hazard — some chemicals are dangerous in tiny amounts."
}
]
}
}
]
}
]
}
]
}
}
audit_course — Fix every error with update_block / add_block before reporting back.
Content → quiz; a score under 80% routes to a review lesson that loops back to the quiz. set_flow only accepts graphs that behave identically in the web player and in exported SCORM.
resources/read — The input shape and design guidance.
{
"uri": "opencourse://tutorials/branching"
}
get_course — Collect lesson ids: nodes reference lessons, never modules.
Only two condition types are accepted: always (or no condition) and assessment_score. assessment_passed, lesson_completed, variable/choice conditions and compound conditions are rejected because the web player and SCORM export evaluate them differently.
assessment_score always tests the lesson the edge leaves; its lessonId may be omitted, and any other lessonId is rejected. Operators: > >= < <= == !=; value is a percentage 0–100.
Exactly one entry node, at least one exit node, and every node reachable from the entry.
Every non-exit node needs exactly one unconditional (fallback) edge; an exit node may have conditional edges but no fallback.
Conditional edges are evaluated in the order given (or by priority, lower first); the fallback is always evaluated last.