Skip to main content
Jump to a section

MCP server

Author courses from any MCP client

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.
Token
claude mcp add --transport http scormbusters https://scormbusters.com/api/mcp \
  --header "Authorization: Bearer oc_pat_YOUR_TOKEN"
OAuth
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.
claude_desktop_config.json (token via mcp-remote)
{
  "mcpServers": {
    "scormbusters": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://scormbusters.com/api/mcp",
        "--header",
        "Authorization:${SCORMBUSTERS_AUTH}"
      ],
      "env": {
        "SCORMBUSTERS_AUTH": "Bearer oc_pat_YOUR_TOKEN"
      }
    }
  }
}
CursorOAuth sign-in or token

Add to Cursor

  • Add to Cursor installs the server with sign-in (OAuth): Cursor shows the config, you confirm, and you sign in when it first connects.
  • Or add it to ~/.cursor/mcp.json (all projects) or .cursor/mcp.json (one project). Omit headers to sign in with OAuth instead.
mcp.json
{
  "mcpServers": {
    "scormbusters": {
      "url": "https://scormbusters.com/api/mcp",
      "headers": {
        "Authorization": "Bearer oc_pat_YOUR_TOKEN"
      }
    }
  }
}
VS Code (Copilot agent mode)OAuth sign-in or token

Install in VS Code

  • 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.
.vscode/mcp.json
{
  "inputs": [
    {
      "type": "promptString",
      "id": "scormbusters-key",
      "description": "Scormbusters personal access token",
      "password": true
    }
  ],
  "servers": {
    "scormbusters": {
      "type": "http",
      "url": "https://scormbusters.com/api/mcp",
      "headers": {
        "Authorization": "Bearer ${input:scormbusters-key}"
      }
    }
  }
}
WindsurfToken
  • Add the server to ~/.codeium/windsurf/mcp_config.json, then refresh the MCP list in Cascade.
mcp_config.json
{
  "mcpServers": {
    "scormbusters": {
      "serverUrl": "https://scormbusters.com/api/mcp",
      "headers": {
        "Authorization": "Bearer oc_pat_YOUR_TOKEN"
      }
    }
  }
}
Codex CLIOAuth sign-in or token
  • Add the server, then sign in through the browser with codex mcp login.
  • For a token instead, put it in an environment variable and name that variable with bearer_token_env_var in ~/.codex/config.toml.
OAuth
codex mcp add scormbusters --url https://scormbusters.com/api/mcp
codex mcp login scormbusters
~/.codex/config.toml (token)
[mcp_servers.scormbusters]
url = "https://scormbusters.com/api/mcp"
bearer_token_env_var = "SCORMBUSTERS_TOKEN"
ChatGPTOAuth sign-in
  • 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.
Responses API (tools entry)
{
  "type": "mcp",
  "server_label": "scormbusters",
  "server_url": "https://scormbusters.com/api/mcp",
  "authorization": "oc_pat_YOUR_TOKEN",
  "require_approval": "always",
  "allowed_tools": [
    "search",
    "fetch",
    "list_courses",
    "get_course",
    "get_lesson"
  ]
}
Agents SDK (Python)
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
Token
gemini mcp add --transport http scormbusters https://scormbusters.com/api/mcp \
  --header "Authorization: Bearer oc_pat_YOUR_TOKEN"
settings.json
{
  "mcpServers": {
    "scormbusters": {
      "httpUrl": "https://scormbusters.com/api/mcp",
      "headers": {
        "Authorization": "Bearer oc_pat_YOUR_TOKEN"
      }
    }
  }
}
LM StudioToken
  • 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.
mcp.json
{
  "mcpServers": {
    "scormbusters": {
      "url": "https://scormbusters.com/api/mcp",
      "headers": {
        "Authorization": "Bearer oc_pat_YOUR_TOKEN"
      }
    }
  }
}
Open WebUIOAuth sign-in or token
  • Admin Settings → Integrations → External Tool Servers → + Add Connection. Type: MCP (Streamable HTTP); URL: the endpoint.
  • 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.
config.yaml
extensions:
  scormbusters:
    name: scormbusters
    type: streamable_http
    uri: https://scormbusters.com/api/mcp
    enabled: true
    timeout: 300
    headers:
      Authorization: Bearer oc_pat_YOUR_TOKEN
ZedOAuth sign-in or token
  • Add the server under context_servers in Zed's settings.json. Leave headers out and Zed prompts you to sign in.
settings.json
{
  "context_servers": {
    "scormbusters": {
      "url": "https://scormbusters.com/api/mcp",
      "headers": {
        "Authorization": "Bearer oc_pat_YOUR_TOKEN"
      }
    }
  }
}
ClineToken
  • MCP Servers → Remote Servers: name, the endpoint URL, transport Streamable HTTP. Then Configure MCP Servers opens cline_mcp_settings.json; add the header there.
cline_mcp_settings.json
{
  "mcpServers": {
    "scormbusters": {
      "type": "streamableHttp",
      "url": "https://scormbusters.com/api/mcp",
      "headers": {
        "Authorization": "Bearer oc_pat_YOUR_TOKEN"
      },
      "disabled": false,
      "autoApprove": []
    }
  }
}
AnythingLLMToken
  • 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".
anythingllm_mcp_servers.json
{
  "mcpServers": {
    "scormbusters": {
      "type": "streamable",
      "url": "https://scormbusters.com/api/mcp",
      "headers": {
        "Authorization": "Bearer oc_pat_YOUR_TOKEN"
      }
    }
  }
}
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.
client config
{
  "mcpServers": {
    "scormbusters": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://scormbusters.com/api/mcp",
        "--header",
        "Authorization:${AUTH}"
      ],
      "env": {
        "AUTH": "Bearer oc_pat_YOUR_TOKEN"
      }
    }
  }
}
Raw HTTP (curl)Token
  • 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.
initialize
curl -s https://scormbusters.com/api/mcp \
  -H "Authorization: Bearer $SCORMBUSTERS_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
tools/call
curl -s https://scormbusters.com/api/mcp \
  -H "Authorization: Bearer $SCORMBUSTERS_KEY" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_courses","arguments":{"status":"draft"}}}'

Open models

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

CredentialPrefixGet it fromScopesLifetime
API keyoc_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 tokenoc_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 requestuntil revoked or expired (90 days by default, at most a year)
OAuth access tokenoc_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:courses60 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.

EndpointURL
Protected resource metadata (RFC 9728)https://scormbusters.com/.well-known/oauth-protected-resource/api/mcp (also at /.well-known/oauth-protected-resource)
Authorization server metadata (RFC 8414)https://scormbusters.com/.well-known/oauth-authorization-server
Dynamic client registration (RFC 7591)POST https://scormbusters.com/oauth/register
Authorization (consent page)GET https://scormbusters.com/oauth/authorize
TokenPOST https://scormbusters.com/oauth/token
Token revocation (RFC 7009)POST https://scormbusters.com/oauth/revoke
GET /.well-known/oauth-protected-resource/api/mcp
{
  "resource": "https://scormbusters.com/api/mcp",
  "authorization_servers": [
    "https://scormbusters.com"
  ],
  "bearer_methods_supported": [
    "header"
  ],
  "scopes_supported": [
    "read:courses",
    "write:courses"
  ],
  "resource_name": "Scormbusters MCP server",
  "resource_documentation": "https://scormbusters.com/docs/mcp"
}
GET /.well-known/oauth-authorization-server
{
  "issuer": "https://scormbusters.com",
  "authorization_endpoint": "https://scormbusters.com/oauth/authorize",
  "token_endpoint": "https://scormbusters.com/oauth/token",
  "registration_endpoint": "https://scormbusters.com/oauth/register",
  "revocation_endpoint": "https://scormbusters.com/oauth/revoke",
  "revocation_endpoint_auth_methods_supported": [
    "none"
  ],
  "response_types_supported": [
    "code"
  ],
  "grant_types_supported": [
    "authorization_code",
    "refresh_token"
  ],
  "code_challenge_methods_supported": [
    "S256"
  ],
  "token_endpoint_auth_methods_supported": [
    "none"
  ],
  "scopes_supported": [
    "read:courses",
    "write:courses"
  ],
  "client_id_metadata_document_supported": true,
  "authorization_response_iss_parameter_supported": true
}
  • 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.

ToolDoesScope
list_coursesList coursesread:courses
get_courseGet course structureread:courses
create_courseCreate coursewrite:courses
update_courseUpdate coursewrite:courses
add_moduleAdd modulewrite:courses
add_lessonAdd lessonwrite:courses
get_lessonGet lesson contentread:courses
add_blockAdd blockwrite:courses
update_blockUpdate blockwrite:courses
delete_blockDelete blockwrite:courses
list_block_typesList block typesread:courses
update_moduleUpdate modulewrite:courses
update_lessonUpdate lessonwrite:courses
set_flowSet branching flowwrite:courses
audit_courseAudit courseread:courses
export_courseExport course packageread:courses
get_scene_slideGet Scenes slideread:courses
view_scene_imagesView a slide's picturesread:courses
update_scene_objectUpdate Scenes objectwrite:courses
update_scene_slideUpdate Scenes slidewrite:courses
upload_assetUpload assetwrite:courses
import_courseImport legacy coursewrite:courses
create_review_linkCreate review linkwrite:courses
get_review_feedbackGet review feedbackread:courses
apply_review_suggestionApply review suggestionwrite:courses
resolve_review_commentResolve review commentwrite:courses
reply_review_commentReply to review commentwrite:courses
search_librarySearch the libraryread:courses
replace_in_libraryReplace across the librarywrite:courses
undo_library_replaceUndo a library replacewrite:courses
searchSearch course contentread:courses
fetchFetch course contentread:courses

list_courses

List courses

scope read:coursesread-only

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

FieldTypeRequiredDescription
status"draft" | "published" | "archived"noOptional status filter
querystringnoOptional: only courses whose title contains this text (case-insensitive)
limitintegernoPage size (default 50, max 100) (min 1, max 100)
cursorstringnonextCursor 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
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "list_courses",
    "arguments": {
      "status": "draft",
      "query": "forklift",
      "limit": 1
    }
  }
}

Example result (structuredContent)

{
  "courses": [
    {
      "id": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
      "title": "Forklift Safety Refresher",
      "description": "Annual certification refresher for warehouse staff.",
      "status": "draft",
      "editor_mode": "classic",
      "created_at": "2026-09-20T09:00:00.000Z",
      "updated_at": "2026-09-25T10:00:00.000Z"
    }
  ],
  "total": 3,
  "nextCursor": "eyJvIjoxfQ"
}

Side effects

None.

Errors

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

Input

FieldTypeRequiredDescription
courseIdstringyesCourse id (uuid)
Input JSON Schema
{
  "type": "object",
  "properties": {
    "courseId": {
      "type": "string",
      "description": "Course id (uuid)"
    }
  },
  "required": [
    "courseId"
  ],
  "additionalProperties": false
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "get_course",
    "arguments": {
      "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11"
    }
  }
}

Example result (structuredContent)

{
  "id": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
  "title": "Forklift Safety Refresher",
  "description": "Annual certification refresher for warehouse staff.",
  "status": "draft",
  "passing_score": 80,
  "language": "en",
  "flow_mode": "linear",
  "updated_at": "2026-09-25T10:00:00.000Z",
  "modules": [
    {
      "id": "8b2d4e61-1c3f-4a7e-b5d9-0e6f2a7c3d22",
      "title": "Pre-operation checks",
      "sort_order": 0,
      "lessons": [
        {
          "id": "c41e7f83-5d2a-4b6c-8e1f-9a0b3c4d5e33",
          "title": "The daily inspection",
          "sort_order": 0,
          "blocks": [
            {
              "id": "f7410216-805d-4e9f-b142-cd3e6f708166",
              "type": "heading",
              "sort_order": 0
            },
            {
              "id": "0852132a-916e-4fa0-8253-de4f70819277",
              "type": "image",
              "sort_order": 1
            },
            {
              "id": "19632438-a27f-4ab1-9364-ef508192a388",
              "type": "multiple_choice",
              "sort_order": 2
            }
          ]
        }
      ]
    }
  ],
  "editorUrl": "https://scormbusters.com/courses/3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
  "previewUrl": "https://scormbusters.com/courses/3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11/preview"
}

Side effects

None.

Errors

  • Course <courseId> not found in your organization.

Notes

  • 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

FieldTypeRequiredDescription
titlestringyesCourse title (1–255 chars, no control characters)
descriptionstringnoOptional course description shown in the catalog
structureobjectnoOptional full course structure, created with the course in one transaction (all or nothing)
structure.modulesobject[]no
structure.modules[].titlestringyesModule title (1–255 chars)
structure.modules[].lessonsobject[]no
structure.modules[].lessons[].titlestringyesLesson title (1–255 chars)
structure.modules[].lessons[].questionPoolSizeinteger | nullnoQuestion 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[].blocksobject[]no
structure.modules[].lessons[].blocks[].typestringyesBlock type, e.g. "paragraph", "heading", "multiple_choice"
structure.modules[].lessons[].blocks[].contentobjectnoBlock content matching that type's schema
structure.modules[].lessons[].blocks[].settingsobjectnoOptional 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."
                        }
                      ]
                    }
                  }
                ]
              }
            ]
          }
        ]
      }
    }
  }
}

Example result (structuredContent)

{
  "id": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
  "title": "Forklift Safety Refresher",
  "description": "Annual certification refresher for warehouse staff.",
  "status": "draft",
  "passing_score": 80,
  "language": "en",
  "flow_mode": "linear",
  "updated_at": "2026-09-25T10:00:00.000Z",
  "modules": [
    {
      "id": "8b2d4e61-1c3f-4a7e-b5d9-0e6f2a7c3d22",
      "title": "Pre-operation checks",
      "sort_order": 0,
      "lessons": [
        {
          "id": "c41e7f83-5d2a-4b6c-8e1f-9a0b3c4d5e33",
          "title": "The daily inspection",
          "sort_order": 0,
          "blocks": [
            {
              "id": "f7410216-805d-4e9f-b142-cd3e6f708166",
              "type": "heading",
              "sort_order": 0
            },
            {
              "id": "0852132a-916e-4fa0-8253-de4f70819277",
              "type": "image",
              "sort_order": 1
            },
            {
              "id": "19632438-a27f-4ab1-9364-ef508192a388",
              "type": "multiple_choice",
              "sort_order": 2
            }
          ]
        }
      ]
    }
  ],
  "editorUrl": "https://scormbusters.com/courses/3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
  "previewUrl": "https://scormbusters.com/courses/3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11/preview"
}

Side effects

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.

Input

FieldTypeRequiredDescription
courseIdstringyesCourse id (uuid)
titlestringnoNew title (1–255 chars)
descriptionstringnoNew description
status"draft" | "published" | "archived"no
passing_scoreintegernoQuiz pass threshold in percent (min 0, max 100)
Input JSON Schema
{
  "type": "object",
  "properties": {
    "courseId": {
      "type": "string",
      "description": "Course id (uuid)"
    },
    "title": {
      "type": "string",
      "description": "New title (1–255 chars)"
    },
    "description": {
      "type": "string",
      "description": "New description"
    },
    "status": {
      "type": "string",
      "enum": [
        "draft",
        "published",
        "archived"
      ]
    },
    "passing_score": {
      "type": "integer",
      "minimum": 0,
      "maximum": 100,
      "description": "Quiz pass threshold in percent"
    }
  },
  "required": [
    "courseId"
  ],
  "additionalProperties": false
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "update_course",
    "arguments": {
      "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
      "status": "published",
      "passing_score": 80
    }
  }
}

Example result (structuredContent)

{
  "ok": true,
  "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
  "applied": {
    "status": "published",
    "passing_score": 80
  }
}

Side effects

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.

Input

FieldTypeRequiredDescription
courseIdstringyesCourse id (uuid)
titlestringyesModule title (1–255 chars)
Input JSON Schema
{
  "type": "object",
  "properties": {
    "courseId": {
      "type": "string",
      "description": "Course id (uuid)"
    },
    "title": {
      "type": "string",
      "description": "Module title (1–255 chars)"
    }
  },
  "required": [
    "courseId",
    "title"
  ],
  "additionalProperties": false
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "add_module",
    "arguments": {
      "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
      "title": "Operating safely"
    }
  }
}

Example result (structuredContent)

{
  "id": "8b2d4e61-1c3f-4a7e-b5d9-0e6f2a7c3d22",
  "title": "Operating safely",
  "sort_order": 1
}

Side effects

Appends a module at the end of the course.

Errors

  • <field> must be a string
  • <field> must be 1–255 characters
  • <field> must not contain control characters (tabs, newlines…)
  • Course <courseId> not found in your organization.

Notes

  • Reorder with update_module; the module starts with no lessons.

add_lesson

Add lesson

scope write:courseswrites

Append a lesson to a module. A lesson is a page of blocks the learner reads top to bottom.

Input

FieldTypeRequiredDescription
moduleIdstringyesModule id (uuid)
titlestringyesLesson title (1–255 chars)
Input JSON Schema
{
  "type": "object",
  "properties": {
    "moduleId": {
      "type": "string",
      "description": "Module id (uuid)"
    },
    "title": {
      "type": "string",
      "description": "Lesson title (1–255 chars)"
    }
  },
  "required": [
    "moduleId",
    "title"
  ],
  "additionalProperties": false
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "add_lesson",
    "arguments": {
      "moduleId": "8b2d4e61-1c3f-4a7e-b5d9-0e6f2a7c3d22",
      "title": "Check your knowledge"
    }
  }
}

Example result (structuredContent)

{
  "id": "d52f8094-6e3b-4c7d-9f20-ab1c4d5e6f44",
  "title": "Check your knowledge",
  "module_id": "8b2d4e61-1c3f-4a7e-b5d9-0e6f2a7c3d22",
  "sort_order": 1
}

Side effects

Appends an empty lesson at the end of the module.

Errors

  • <field> must be a string
  • <field> must be 1–255 characters
  • <field> must not contain control characters (tabs, newlines…)
  • Module <moduleId> not found in your organization.

get_lesson

Get lesson content

scope read:coursesread-only

Get a lesson with the full content and settings of every block, in order.

Input

FieldTypeRequiredDescription
lessonIdstringyesLesson id (uuid)
Input JSON Schema
{
  "type": "object",
  "properties": {
    "lessonId": {
      "type": "string",
      "description": "Lesson id (uuid)"
    }
  },
  "required": [
    "lessonId"
  ],
  "additionalProperties": false
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "get_lesson",
    "arguments": {
      "lessonId": "c41e7f83-5d2a-4b6c-8e1f-9a0b3c4d5e33"
    }
  }
}

Example result (structuredContent)

{
  "id": "c41e7f83-5d2a-4b6c-8e1f-9a0b3c4d5e33",
  "title": "The daily inspection",
  "module_id": "8b2d4e61-1c3f-4a7e-b5d9-0e6f2a7c3d22",
  "sort_order": 0,
  "blocks": [
    {
      "id": "f7410216-805d-4e9f-b142-cd3e6f708166",
      "type": "heading",
      "content": {
        "text": "Module 1: Hazard Identification",
        "level": "h2"
      },
      "settings": {},
      "sort_order": 0
    },
    {
      "id": "0852132a-916e-4fa0-8253-de4f70819277",
      "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"
      },
      "settings": {},
      "sort_order": 1
    }
  ]
}

Side effects

None.

Errors

  • Lesson <lessonId> not found in your organization.

add_block

Add block

scope write:courseswrites

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

FieldTypeRequiredDescription
lessonIdstringyesLesson id (uuid)
typestringyesBlock type
contentobjectyesContent matching the block type's schema
settingsobjectnoOptional presentation settings
positionintegernoOptional sort position; defaults to the end (min 0)
Input JSON Schema
{
  "type": "object",
  "properties": {
    "lessonId": {
      "type": "string",
      "description": "Lesson id (uuid)"
    },
    "type": {
      "type": "string",
      "description": "Block type"
    },
    "content": {
      "type": "object",
      "description": "Content matching the block type's schema",
      "additionalProperties": true
    },
    "settings": {
      "type": "object",
      "description": "Optional presentation settings",
      "additionalProperties": true
    },
    "position": {
      "type": "integer",
      "minimum": 0,
      "description": "Optional sort position; defaults to the end"
    }
  },
  "required": [
    "lessonId",
    "type",
    "content"
  ],
  "additionalProperties": false
}

Example call

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

Example result (structuredContent)

{
  "id": "19632438-a27f-4ab1-9364-ef508192a388",
  "type": "multiple_choice",
  "sort_order": 0
}

Side effects

Inserts the block (at the end, or at position with the siblings renumbered 0..n-1) and pushes it to any editor that has the lesson open.

Errors

  • Unknown block type "<type>". Valid types: <list>. Call list_block_types for each type's content schema.
  • <type> needs content.<field> (<type>): <field description> Call list_block_types for the full reference.
  • content.<field> must be a <type>, not <actual>. Call list_block_types for the full reference.
  • multiple_choice needs exactly one option with correct: true (found <n>). Use multi_select for several.
  • content too large (max 512KB)
  • settings too large (max 512KB)
  • Lesson <lessonId> not found in your organization.
  • position must be a non-negative integer

Notes

  • Soft content problems come back as a warnings array on the result; the block is still created.
  • Every argument, position included, is checked before anything is written: an error result means no block was added.

update_block

Update block

scope write:courseswritesdestructive

Replace a block's content and/or settings, or move it (sort_order). Content is replaced whole, not merged — send the complete object.

Input

FieldTypeRequiredDescription
blockIdstringyesBlock id (uuid)
contentobjectnoComplete replacement content
settingsobjectnoComplete replacement settings
sort_orderintegerno(min 0)
Input JSON Schema
{
  "type": "object",
  "properties": {
    "blockId": {
      "type": "string",
      "description": "Block id (uuid)"
    },
    "content": {
      "type": "object",
      "description": "Complete replacement content",
      "additionalProperties": true
    },
    "settings": {
      "type": "object",
      "description": "Complete replacement settings",
      "additionalProperties": true
    },
    "sort_order": {
      "type": "integer",
      "minimum": 0
    }
  },
  "required": [
    "blockId"
  ],
  "additionalProperties": false
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "update_block",
    "arguments": {
      "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"
      }
    }
  }
}

Example result (structuredContent)

{
  "ok": true,
  "blockId": "0852132a-916e-4fa0-8253-de4f70819277",
  "applied": [
    "content"
  ]
}

Side effects

Replaces content and/or settings whole (no merge) and/or moves the block, then pushes the change to open editors.

Errors

  • <type> needs content.<field> (<type>): <field description> Call list_block_types for the full reference.
  • content.<field> must be a <type>, not <actual>. Call list_block_types for the full reference.
  • multiple_choice needs exactly one option with correct: true (found <n>). Use multi_select for several.
  • Block <blockId> not found in your organization.
  • content too large (max 512KB)
  • settings too large (max 512KB)
  • sort_order must be a non-negative integer (the 0-based position)
  • Nothing to update.

Notes

  • Read the block with get_lesson, change what you need, and send the complete object back — omitted fields are dropped.

delete_block

Delete block

scope write:courseswritesdestructive

Permanently delete one block from a lesson.

Input

FieldTypeRequiredDescription
blockIdstringyesBlock id (uuid)
Input JSON Schema
{
  "type": "object",
  "properties": {
    "blockId": {
      "type": "string",
      "description": "Block id (uuid)"
    }
  },
  "required": [
    "blockId"
  ],
  "additionalProperties": false
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "delete_block",
    "arguments": {
      "blockId": "19632438-a27f-4ab1-9364-ef508192a388"
    }
  }
}

Example result (structuredContent)

{
  "ok": true,
  "deleted": "19632438-a27f-4ab1-9364-ef508192a388"
}

Side effects

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.

Input

FieldTypeRequiredDescription
typestringnoOptional: one block type to describe in full
category"Text" | "Media" | "Layout" | "Interactive" | "Assessment"no
Input JSON Schema
{
  "type": "object",
  "properties": {
    "type": {
      "type": "string",
      "description": "Optional: one block type to describe in full"
    },
    "category": {
      "type": "string",
      "enum": [
        "Text",
        "Media",
        "Layout",
        "Interactive",
        "Assessment"
      ]
    }
  },
  "additionalProperties": false
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "list_block_types",
    "arguments": {
      "type": "multiple_choice"
    }
  }
}

Example result (structuredContent)

{
  "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

FieldTypeRequiredDescription
moduleIdstringyesModule id (uuid)
titlestringnoNew title (1–255 chars)
sort_orderintegernoNew 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
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "update_module",
    "arguments": {
      "moduleId": "8b2d4e61-1c3f-4a7e-b5d9-0e6f2a7c3d22",
      "title": "Before you drive",
      "sort_order": 0
    }
  }
}

Example result (structuredContent)

{
  "ok": true,
  "moduleId": "8b2d4e61-1c3f-4a7e-b5d9-0e6f2a7c3d22",
  "applied": {
    "title": "Before you drive",
    "sort_order": 0
  }
}

Side effects

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

FieldTypeRequiredDescription
lessonIdstringyesLesson id (uuid)
titlestringnoNew title (1–255 chars)
sort_orderintegernoNew position among the module's lessons (0-based) (min 0)
question_pool_sizeinteger | nullnoQuestion 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
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "update_lesson",
    "arguments": {
      "lessonId": "d52f8094-6e3b-4c7d-9f20-ab1c4d5e6f44",
      "sort_order": 1
    }
  }
}

Example result (structuredContent)

{
  "ok": true,
  "lessonId": "d52f8094-6e3b-4c7d-9f20-ab1c4d5e6f44",
  "applied": {
    "sort_order": 1
  }
}

Side effects

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

FieldTypeRequiredDescription
courseIdstringyesCourse id (uuid)
flow_mode"linear" | "branching"yes
graphobjectnoRequired when flow_mode is "branching"
graph.nodesobject[]yes
graph.nodes[].lessonIdstringyesA lesson id from get_course
graph.nodes[].isEntrybooleannoExactly one node must be the entry
graph.nodes[].isExitbooleannoAt least one node must be an exit
graph.edgesobject[]yes
graph.edges[].fromstringyesSource lesson id
graph.edges[].tostringyesTarget lesson id
graph.edges[].conditionobjectnoCondition object, or omit for an unconditional edge. See the branching tutorial.
graph.edges[].labelstringnoShort label shown on the edge, e.g. "score ≥ 80%"
graph.edges[].priorityintegernoLower 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
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "set_flow",
    "arguments": {
      "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
      "flow_mode": "branching",
      "graph": {
        "nodes": [
          {
            "lessonId": "c41e7f83-5d2a-4b6c-8e1f-9a0b3c4d5e33",
            "isEntry": true
          },
          {
            "lessonId": "d52f8094-6e3b-4c7d-9f20-ab1c4d5e6f44",
            "isExit": true
          },
          {
            "lessonId": "e6309105-7f4c-4d8e-a031-bc2d5e6f7055"
          }
        ],
        "edges": [
          {
            "from": "c41e7f83-5d2a-4b6c-8e1f-9a0b3c4d5e33",
            "to": "d52f8094-6e3b-4c7d-9f20-ab1c4d5e6f44"
          },
          {
            "from": "d52f8094-6e3b-4c7d-9f20-ab1c4d5e6f44",
            "to": "e6309105-7f4c-4d8e-a031-bc2d5e6f7055",
            "condition": {
              "type": "assessment_score",
              "operator": "<",
              "value": 80
            },
            "label": "score < 80%"
          },
          {
            "from": "e6309105-7f4c-4d8e-a031-bc2d5e6f7055",
            "to": "d52f8094-6e3b-4c7d-9f20-ab1c4d5e6f44",
            "label": "try again"
          }
        ]
      }
    }
  }
}

Example result (structuredContent)

{
  "ok": true,
  "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
  "flow_mode": "branching",
  "nodes": 3,
  "edges": 3,
  "editorUrl": "https://scormbusters.com/courses/3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11"
}

Side effects

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.

Input

FieldTypeRequiredDescription
courseIdstringyesCourse id (uuid)
slideIdstringnoScenes courses only: just this slide's findings
severity"error" | "warning" | "info"noScenes courses only: just this severity
Input JSON Schema
{
  "type": "object",
  "properties": {
    "courseId": {
      "type": "string",
      "description": "Course id (uuid)"
    },
    "slideId": {
      "type": "string",
      "description": "Scenes courses only: just this slide's findings"
    },
    "severity": {
      "type": "string",
      "enum": [
        "error",
        "warning",
        "info"
      ],
      "description": "Scenes courses only: just this severity"
    }
  },
  "required": [
    "courseId"
  ],
  "additionalProperties": false
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "audit_course",
    "arguments": {
      "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11"
    }
  }
}

Example result (structuredContent)

{
  "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

FieldTypeRequiredDescription
courseIdstringyesCourse id (uuid)
format"scorm12" | "scorm2004" | "cmi5" | "xapi"noDefaults to scorm2004
localestringnoOptional BCP-47 locale to export a stored translation, e.g. "de" (block courses only)
languagesstring[]noBlock 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.
forcebooleannoScenes courses only: export even though the preflight finds errors. The result then carries a warning to pass on.
replaceOriginalbooleannoOnly 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
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "export_course",
    "arguments": {
      "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
      "format": "scorm12"
    }
  }
}

Example result (structuredContent)

{
  "downloadUrl": "<signed storage URL>",
  "filename": "Forklift_Safety_Refresher_SCORM1.2.zip",
  "format": "scorm12",
  "sizeBytes": 482113,
  "expiresIn": "24 hours"
}

Side effects

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

FieldTypeRequiredDescription
slideIdstringyesSlide id (uuid), from get_course or an audit_course finding
offsetintegernoSkip 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
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "get_scene_slide",
    "arguments": {
      "slideId": "3b84465a-c491-4cd3-b586-0172a314c5aa"
    }
  }
}

Example result (structuredContent)

{
  "slide": {
    "id": "3b84465a-c491-4cd3-b586-0172a314c5aa",
    "courseId": "2a743549-b380-4bc2-a475-f0619203b499",
    "name": "Leadership styles",
    "order": 4,
    "notes": ""
  },
  "layers": [
    {
      "id": "<layer id>",
      "name": "Autocratic"
    }
  ],
  "objectCount": 2,
  "objects": [
    {
      "id": "4c95576b-d5a2-4de4-8697-1283b425d6bb",
      "type": "shape",
      "name": "Rectangle 12",
      "interactive": true,
      "accessibleName": null,
      "states": 0,
      "triggers": 1
    },
    {
      "id": "<object id>",
      "type": "text",
      "name": "Text_Caption_95",
      "text": [
        {
          "path": "content.html",
          "label": "Text",
          "value": "Click each style to learn more"
        }
      ],
      "states": 0,
      "triggers": 0
    }
  ]
}

Side effects

None.

Errors

  • Slide <slideId> not found in your organization.

Notes

  • 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

FieldTypeRequiredDescription
slideIdstringyesSlide id (uuid), from get_course or an audit_course finding
objectIdsstring[]noOnly 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
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "view_scene_images",
    "arguments": {
      "slideId": "3b84465a-c491-4cd3-b586-0172a314c5aa"
    }
  }
}

Example result (structuredContent)

{
  "slide": {
    "id": "3b84465a-c491-4cd3-b586-0172a314c5aa",
    "name": "PMBOK"
  },
  "images": [
    {
      "objectId": "4c95576b-d5a2-4de4-8697-1283b425d6bb",
      "name": "book1",
      "altText": null,
      "decorative": false,
      "image": "shown"
    }
  ],
  "total": 1,
  "next": "Describe each picture with update_scene_object { objectId, alt_text } …"
}

Side effects

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

FieldTypeRequiredDescription
objectIdstringyesObject id (uuid), from get_scene_slide or an audit_course finding
textobject[]noNew words for existing text fields
text[].pathstringyesA path from get_scene_slide's text list, e.g. "content.html" or "content.options.0.text"
text[].valuestringyesThe new text (plain text, or HTML for an html path)
alt_textstring | nullnoImage description (max 1000); "" for decorative, null to clear
aria_labelstring | nullnoAccessible name for a control or group (max 1000); null to clear
aria_role"none" | "button" | "link" | "image" | "heading" | "text" | "checkbox" | "radio" | nullnoARIA role, as the editor's Accessibility panel offers them; null to clear
namestringnoObject name in the editor's layer list (1–255 chars)
hiddenbooleannoHidden at slide start (a trigger may still show it)
focusableboolean | nullnoWhether 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
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "update_scene_object",
    "arguments": {
      "objectId": "4c95576b-d5a2-4de4-8697-1283b425d6bb",
      "aria_label": "Show the Autocratic style"
    }
  }
}

Example result (structuredContent)

{
  "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.

Input

FieldTypeRequiredDescription
slideIdstringyesSlide id (uuid)
namestringnoSlide name (1–255 chars)
notesstringnoSlide notes (max 20,000 chars; "" clears them)
Input JSON Schema
{
  "type": "object",
  "properties": {
    "slideId": {
      "type": "string",
      "description": "Slide id (uuid)"
    },
    "name": {
      "type": "string",
      "description": "Slide name (1–255 chars)"
    },
    "notes": {
      "type": "string",
      "description": "Slide notes (max 20,000 chars; \"\" clears them)"
    }
  },
  "required": [
    "slideId"
  ],
  "additionalProperties": false
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "update_scene_slide",
    "arguments": {
      "slideId": "3b84465a-c491-4cd3-b586-0172a314c5aa",
      "name": "Six leadership styles"
    }
  }
}

Example result (structuredContent)

{
  "id": "3b84465a-c491-4cd3-b586-0172a314c5aa",
  "courseId": "2a743549-b380-4bc2-a475-f0619203b499",
  "changed": [
    "name"
  ]
}

Side effects

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

FieldTypeRequiredDescription
urlstringyesPublic https URL of the file to import
filenamestringnoOptional 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
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "upload_asset",
    "arguments": {
      "url": "https://example.com/assets/forklift-inspection.jpg"
    }
  }
}

Example result (structuredContent)

{
  "url": "<public storage URL>",
  "mimeType": "image/jpeg",
  "sizeBytes": 184220
}

Side effects

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

FieldTypeRequiredDescription
urlstringnoPublic https URL of the package. Give this or storagePath, not both.
storagePathstringnoPath of a package already staged in course-imports storage (<org id>/<file>). Give this or url.
filenamestringnoFile 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
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "import_course",
    "arguments": {
      "url": "https://example.com/legacy/forklift-safety-scorm12.zip"
    }
  }
}

Example result (structuredContent)

{
  "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.

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.

Input

FieldTypeRequiredDescription
courseIdstringyesCourse id (uuid)
Input JSON Schema
{
  "type": "object",
  "properties": {
    "courseId": {
      "type": "string",
      "description": "Course id (uuid)"
    }
  },
  "required": [
    "courseId"
  ],
  "additionalProperties": false
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "get_review_feedback",
    "arguments": {
      "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11"
    }
  }
}

Example result (structuredContent)

{
  "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.

Input

FieldTypeRequiredDescription
courseIdstringyesCourse id (uuid)
commentIdstringyesThe review comment id, from get_review_feedback
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"
    }
  },
  "required": [
    "courseId",
    "commentId"
  ],
  "additionalProperties": false
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "apply_review_suggestion",
    "arguments": {
      "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
      "commentId": "6c8d0e1f-2a3b-4c4d-ae5f-708192a3b4c5"
    }
  }
}

Example result (structuredContent)

{
  "blockId": "f7410216-805d-4e9f-b142-cd3e6f708166",
  "revision": 7,
  "resolved": true
}

Side effects

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

FieldTypeRequiredDescription
courseIdstringyesCourse id (uuid)
commentIdstringyesThe review comment id, from get_review_feedback
resolvedbooleanyestrue 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
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "resolve_review_comment",
    "arguments": {
      "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
      "commentId": "6c8d0e1f-2a3b-4c4d-ae5f-708192a3b4c5",
      "resolved": true
    }
  }
}

Example result (structuredContent)

{
  "commentId": "6c8d0e1f-2a3b-4c4d-ae5f-708192a3b4c5",
  "resolved": true
}

Side effects

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

FieldTypeRequiredDescription
courseIdstringyesCourse id (uuid)
commentIdstringyesThe thread's first comment id, from get_review_feedback
textstringyesThe 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."
    }
  }
}

Example result (structuredContent)

{
  "replyId": "7d9e1f20-3b4c-4d5e-bf60-8192a3b4c5d6",
  "createdAt": "2026-10-02T09:30:00.000Z"
}

Side effects

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

FieldTypeRequiredDescription
findstringyesText to find (1–200 characters), or a pattern with regex: true
matchCasebooleannoMatch upper and lower case exactly (default false)
wholeWordbooleannoOnly whole words (default false)
regexbooleannoTreat find as a JavaScript regular expression (default false). Nested repetition like (a+)+ and backreferences are refused
courseIdsstring[]noOnly these courses (uuids)
statuses"draft" | "published" | "archived"[]noOnly courses with these statuses
sourcesstring[]noOnly courses imported from these tools (e.g. "storyline", "rise"); "none" for courses built in Scormbusters
limitintegernoMost 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
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "search_library",
    "arguments": {
      "find": "five years",
      "wholeWord": true,
      "statuses": [
        "published"
      ]
    }
  }
}

Example result (structuredContent)

{
  "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

FieldTypeRequiredDescription
findstringyesText to find (1–200 characters), or a pattern with regex: true
matchCasebooleannoMatch upper and lower case exactly (default false)
wholeWordbooleannoOnly whole words (default false)
regexbooleannoTreat find as a JavaScript regular expression (default false). Nested repetition like (a+)+ and backreferences are refused
courseIdsstring[]noOnly these courses (uuids)
statuses"draft" | "published" | "archived"[]noOnly courses with these statuses
sourcesstring[]noOnly courses imported from these tools (e.g. "storyline", "rise"); "none" for courses built in Scormbusters
replacestringyesReplacement text (may be empty to delete the matches). With regex: true, $1…$9 and $<name> insert groups
dry_runbooleanyestrue to preview without writing (required first); false to apply
confirm_tokenstringnoThe confirm_token from the dry run; required with dry_run: false
occurrenceIdsstring[]noOptional: replace only these occurrences (ids from the preview). Omit to replace every replaceable occurrence
limitintegernoMost 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
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "replace_in_library",
    "arguments": {
      "find": "five years",
      "replace": "six years",
      "wholeWord": true,
      "statuses": [
        "published"
      ],
      "dry_run": false,
      "confirm_token": "9c1e4f0a7b2d4c6e8f10a2b3c4d5e6f7"
    }
  }
}

Example result (structuredContent)

{
  "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.

Input

FieldTypeRequiredDescription
editIdstringyesThe editId replace_in_library returned
Input JSON Schema
{
  "type": "object",
  "properties": {
    "editId": {
      "type": "string",
      "description": "The editId replace_in_library returned"
    }
  },
  "required": [
    "editId"
  ],
  "additionalProperties": false
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "undo_library_replace",
    "arguments": {
      "editId": "2a4c6e80-9b1d-4f3a-8c5e-7d9f0b1a2c3d"
    }
  }
}

Example result (structuredContent)

{
  "editId": "2a4c6e80-9b1d-4f3a-8c5e-7d9f0b1a2c3d",
  "restored": 1,
  "courses": [
    {
      "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
      "title": "Forklift Safety Refresher",
      "restored": 1,
      "conflicts": 0
    }
  ],
  "conflicts": [],
  "next": "The bulk edit is fully undone."
}

Side effects

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.

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

FieldTypeRequiredDescription
idstringyesAn 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
}

Example call

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "fetch",
    "arguments": {
      "id": "lesson:3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11:c41e7f83-5d2a-4b6c-8e1f-9a0b3c4d5e33"
    }
  }
}

Example result (structuredContent)

{
  "id": "lesson:3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11:c41e7f83-5d2a-4b6c-8e1f-9a0b3c4d5e33",
  "title": "Forklift Safety Refresher › The daily inspection",
  "text": "# Forklift Safety Refresher › The daily inspection\nLesson · Lesson title: The daily inspection\nHeading · Text: Before you start",
  "url": "https://scormbusters.com/courses/3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11?lesson=c41e7f83-5d2a-4b6c-8e1f-9a0b3c4d5e33",
  "metadata": {
    "kind": "lesson",
    "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
    "courseTitle": "Forklift Safety Refresher",
    "status": "draft",
    "editorMode": "classic",
    "updatedAt": "2026-09-25T10:00:00.000Z",
    "truncated": false
  }
}

Side effects

None.

Errors

  • 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/course-designCourse design that works. Instructional-design conventions: structure, lesson rhythm, writing style, accessibility rules.
  • 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-course Build 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-course Improve 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-course Fix 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

CodeHTTPMessageWhen
-32001401Authentication 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/mcpMissing, unknown, revoked or expired credential. Carries WWW-Authenticate: Bearer realm="Scormbusters MCP", resource_metadata="…".
-32000429Rate limit exceeded. Retry in <n>s.Over the rate limit (below). Carries Retry-After.
-32700400Parse error: request body must be JSONBody is not JSON.
-32600400Batch requests are not supported; send one request per POST.Body is a JSON array.
-32600400Unsupported MCP-Protocol-Version. Supported: <versions>.The Mcp-Protocol-Version request header names a version this server does not speak.
-32600400Invalid JSON-RPC 2.0 requestjsonrpc is not "2.0" or method is missing.
-32601200Method not found: <method>Any method not listed under Transport.
-32602200Unknown tool: <name>tools/call with a name tools/list doesn't have.
-32602200Unknown prompt: <name>prompts/get with an unknown name.
-32002200Unknown resource: <uri>resources/read with an unknown URI.
-32603200Internal errorUnexpected 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.

ItemValue
Protocol versions2026-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
Negotiationinitialize echoes the client's protocolVersion when supported, otherwise answers 2025-11-25
Mcp-Protocol-Version headerEvery 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.
Server info{"name":"Scormbusters","title":"Scormbusters course authoring","version":"1.2.0"}
Capabilitiestools, resources (no subscribe), prompts — all with listChanged: false
Methodsinitialize, ping, tools/list, tools/call, resources/list, resources/read, resources/templates/list (always empty), prompts/list, prompts/get
NotificationsAny message without an id is accepted with HTTP 202 and no body
BatchingNot supported — a JSON array body is rejected
GETHTTP 405 (no stream to offer)
CORSAny origin; allowed headers Content-Type, Authorization, X-API-Key, Mcp-Protocol-Version, Mcp-Session-Id
Time limit60 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.

  1. import_course — Import the package as a draft course. Keep courseId; read report.notRecreated — each entry is something to rebuild with native blocks.
    {
      "url": "https://example.com/legacy/forklift-safety-scorm12.zip"
    }
  2. audit_course — Get findings by severity. Fix every error; each finding carries lessonId and blockId.
    {
      "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11"
    }
  3. get_course — Read the outline: lesson ids and each lesson's block types.
    {
      "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11"
    }
  4. get_lesson — Read the full content of a block the audit flagged.
    {
      "lessonId": "c41e7f83-5d2a-4b6c-8e1f-9a0b3c4d5e33"
    }
  5. 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"
      }
    }
  6. update_block — heading-order: a lesson's first heading must be h2 (the lesson title is the h1).
    {
      "blockId": "f7410216-805d-4e9f-b142-cd3e6f708166",
      "content": {
        "text": "Before you start",
        "level": "h2"
      }
    }
  7. audit_course — Re-run until summary.error is 0.
    {
      "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11"
    }
  8. export_course — Returns a download link valid for 24 hours. Give it to the person; don't publish unless they asked.
    {
      "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
      "format": "scorm12"
    }
  • 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.

  1. resources/read — Read the golden path, then opencourse://tutorials/course-design.
    {
      "uri": "opencourse://tutorials/getting-started"
    }
  2. list_block_types — Browse; then ask for each type you will use in full.
    {
      "category": "Assessment"
    }
  3. list_block_types — Exact content fields and a complete example. Never guess field names.
    {
      "type": "multiple_choice"
    }
  4. upload_asset — Bring images into the media library; use the returned url in image blocks.
    {
      "url": "https://example.com/assets/forklift-inspection.jpg"
    }
  5. 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."
                        }
                      ]
                    }
                  }
                ]
              }
            ]
          }
        ]
      }
    }
  6. audit_course — Fix every error with update_block / add_block before reporting back.
    {
      "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11"
    }
  7. update_course — Set the pass mark. Leave status as draft unless the person asked you to publish.
    {
      "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
      "passing_score": 80
    }

Branching flows (quiz gate with remediation)

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.

  1. resources/read — The input shape and design guidance.
    {
      "uri": "opencourse://tutorials/branching"
    }
  2. get_course — Collect lesson ids: nodes reference lessons, never modules.
    {
      "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11"
    }
  3. add_lesson — Create the remediation lesson if it doesn't exist.
    {
      "moduleId": "8b2d4e61-1c3f-4a7e-b5d9-0e6f2a7c3d22",
      "title": "Review: the daily inspection"
    }
  4. set_flow — Intro → quiz unconditionally; quiz → review when the quiz score is under 80; review → quiz as the fallback. The quiz is the exit.
    {
      "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
      "flow_mode": "branching",
      "graph": {
        "nodes": [
          {
            "lessonId": "c41e7f83-5d2a-4b6c-8e1f-9a0b3c4d5e33",
            "isEntry": true
          },
          {
            "lessonId": "d52f8094-6e3b-4c7d-9f20-ab1c4d5e6f44",
            "isExit": true
          },
          {
            "lessonId": "e6309105-7f4c-4d8e-a031-bc2d5e6f7055"
          }
        ],
        "edges": [
          {
            "from": "c41e7f83-5d2a-4b6c-8e1f-9a0b3c4d5e33",
            "to": "d52f8094-6e3b-4c7d-9f20-ab1c4d5e6f44"
          },
          {
            "from": "d52f8094-6e3b-4c7d-9f20-ab1c4d5e6f44",
            "to": "e6309105-7f4c-4d8e-a031-bc2d5e6f7055",
            "condition": {
              "type": "assessment_score",
              "operator": "<",
              "value": 80
            },
            "label": "score < 80%"
          },
          {
            "from": "e6309105-7f4c-4d8e-a031-bc2d5e6f7055",
            "to": "d52f8094-6e3b-4c7d-9f20-ab1c4d5e6f44",
            "label": "try again"
          }
        ]
      }
    }
  5. set_flow — Switch branching off. The graph is kept and can be switched back on.
    {
      "courseId": "3f6c1a52-7a0e-4f4b-9d2c-5b8e1f0a9c11",
      "flow_mode": "linear"
    }
  • 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.