Course Authoring Guide
> See glossary.md for assessment terminology (stem, choice, key, distractor, explanation, item bank, question pool, question-slot, etc.).
This document teaches an AI assistant how to create courses for the Course App platform. Courses are built in two layers: a question bank (the "what gets asked") and a course (the "how it's taught"). Both are written as composable TypeScript DSL scripts.
Architecture Overview
Persistence layout:
- Question bank — named collection of questions plus optional pools (selector queries over those questions).
- Course — metadata (title, description, mode, passing score) grouping topics → sections. Sections' content trees contain
question-slotplaceholders that reference a pool. - Slot resolution — at first load the server picks N questions from the named pool, persists the pick against the learner's registration, and streams a
ResolvedCourseto the renderer.
Questions are never embedded in section content, and courses do not carry a questions array. Authoring order for a brand-new course is:
- POST the question bank DSL → get back a
bankIdand the pool IDs you defined. - Write course DSL that references those pools inside
questionSlot(...)calls. - POST the course DSL to create v1.0 of the course.
Multi-bank courses are fine. Pool IDs are tenant-unique string references; a single course can draw slots from pools in several banks — the course has no explicit bank pointer. Organising by topic (one bank per topic, plus a "finale" bank) is the recommended pattern for any course with more than ~15 questions.
Two authoring paths
Once a course exists, there are two ways to publish a new version. Pick the one that matches what you're doing — they're not interchangeable in spirit, even though both end up calling the same publish path internally.
| Path | Endpoint | When to use |
|---|---|---|
| Operations (primary) | POST /t/:slug/api/v1/courses/:id/operations | Editing an existing course. Fix a typo, add a section, swap a question pool, reorder topics. Submit a typed list of verbs naming exactly the ids you're touching. |
| Whole-course DSL | POST /t/:slug/api/v1/courses/:id/versions | Initial creation, full rewrites, or DSL imports from another tool. The server diffs against the current head and figures out the operation set internally. |
This guide leads with the whole-DSL form because it's the most direct way to learn the DSL and to author v1.0 of a fresh course. For every subsequent edit, prefer operations — they're explicit about intent ("I am replacing this section" vs "I am adding this topic"), they validate ids precisely, and they produce a cleaner audit trail in operation_log. See Part 4 — Editing an existing course below and the full operation reference in course-versioning.md.
DSL execution sandbox
Bank, course, and framework scripts run in a restricted JS interpreter. Write literal arrays of questions/sections — do not use runtime generators such as Array.from({length: N}, ...), factory loops, or IIFEs that close over outer state. Available helpers are the imported builders plus a small set of pure statics: Array.from, Array.of, Array.isArray, Object.keys/values/entries/fromEntries/assign, Math.max/min/floor/ceil/round/abs, JSON.stringify/parse, Number.isFinite/isInteger, String.fromCharCode. When in doubt: enumerate.
question_banks ── questions (N) ── pools (selectors) ◄──┐
│
courses ── topics (N) ── sections (N) │
│ │
└── content.kids[].questionSlot(slotId, rule)
│
slot_resolutions (per-registration pick)
Rich Text (Markdown in Strings)
All text strings in the DSL support markdown formatting. The renderer parses markdown when it's present — plain strings pass through unchanged.
Inline Formatting
// Bold / italic / code
bankRadioQuestion("q1", "What does the **HTTP** protocol stand for?", [...], "a");
// Links in lesson body
lessonParagraph("HTTP Methods", "See [MDN](https://developer.mozilla.org) for details.");
Supported Syntax
| Syntax | Renders As |
|---|---|
bold | bold |
italic | italic |
` code ` | code |
text | clickable link |
- item or * item | unordered list |
1. item | ordered list |
| blank line | paragraph break |
Multi-Line Content
lessonParagraph(
"HTTP Methods",
"The most common HTTP methods are:\n\n- **GET** — retrieve\n- **POST** — create\n- **PUT** — replace\n- **DELETE** — remove\n\nEach method has defined semantics.",
);
Part 1 — Authoring a Question Bank
Element Structure (CourseEl)
Every rendered element follows this shape:
interface CourseEl {
tag: string; // Element type
attr?: Record<string, unknown>; // Attributes
kids?: (CourseEl | string)[]; // Children
interaction?: Interaction; // Only on question elements
}
Bank builders produce QuestionDefinition objects (shown below), not CourseEls. The server wraps the content tree and stores them in the bank.
Atoms (Primitives)
Used inside custom question content if you need to hand-roll one. Most of the time the bank*Question molecules below cover what you need.
| Function | Purpose |
|---|---|
questionStem(content) | Question stem. content is a markdown string or an array of mixed strings and media elements for multi-part stems (e.g. [text, image(src, alt), text]). |
image(src, alt) | Inline image element — use inside questionStem() or other content containers. |
choice(content, id) | Answer option. content is a markdown string, a single media element, or an array mixing the two. |
choiceLabel(text, id) | Text-only answer option (sugar over choice) |
choiceImage(src, alt, id) | Image-only answer option (sugar over choice) |
feedbackCorrect(text) | Green success message |
feedbackIncorrect(text) | Red error message |
hintToggle(text) | Collapsible hint |
sectionHeading(text, level?) | Heading (default level 2) |
progressIndicator(current, total) | Progress bar |
explanationText(text) | Detailed explanation paragraph |
divider(margin?) | Horizontal separator |
Bank Question Molecules
All ten bank builders return QuestionDefinition. First arg is always a bank-unique id — this is the ID you reference from pool selectors.
Common final-arg shape:
interface BankQuestionOpts {
scored?: boolean; // default true (false for long-text & likert)
retryable?: boolean;
gating?: boolean;
tags?: string[]; // used by byTagsSelector
competencyIds?: string[]; // used by byCompetenciesSelector
contentRefs?: Array<{ sectionId: string; anchorId?: string }>;
}
Non-scored questions (scored: false)
Use scored: false for surveys, reflections, opinion polls, and self-check prompts — anything where you want to collect a response but not judge it as correct/incorrect. bankLikertQuestion and bankLongTextQuestion default to scored: false; any other bank builder can be made unscored by passing scored: false in opts.
Effects of scored: false:
- The renderer submits the response and the server persists it (
learner_responses.is_correct = NULL, xAPIexperiencedverb instead ofanswered). - No correct/incorrect feedback UI; no Try Again button. A subtle "Response recorded." acknowledgment is shown after submission.
- The question is excluded from section-completion score thresholds, course completion tallies, and
question_stats(p_value, discrimination, attempts). - Question pools that target a difficulty (p-value) drop unscored questions before selection.
- Any
correctResponsesPatternon an unscored question is ignored at runtime.
Explanations (e.g. a reflection prompt showing a teacher note after submission) still render for unscored questions.
bankRadioQuestion(id, stem, choices, correctId, opts?)
bankRadioQuestion(
"q-http-meaning",
"What does **HTTP** stand for?",
[
{ id: "a", text: "HyperText Transfer Protocol" },
{ id: "b", text: "High Tech Transfer Protocol" },
],
"a",
{ tags: ["http", "basics"], competencyIds: ["comp_http_fundamentals"] },
);
bankCheckboxQuestion(id, stem, choices, correctIds, opts?)
bankCheckboxQuestion(
"q-valid-methods",
"Which are valid HTTP methods?",
[
{ id: "a", text: "GET" }, { id: "b", text: "POST" },
{ id: "c", text: "FETCH" }, { id: "d", text: "DELETE" },
],
["a", "b", "d"],
);
bankTrueFalseQuestion(id, stem, correctAnswer, opts?)
bankTrueFalseQuestion("q-stateless", "HTTP is stateless.", true);
bankTextInputQuestion(id, stem, correctAnswer, opts?)
bankTextInputQuestion("q-404", "What status code means 'Not Found'?", "404", {
placeholder: "Enter status code…",
});
bankLongTextQuestion(id, stem, opts?)
Essay-style. Unscored by default.
bankLongTextQuestion("q-essay-http", "Explain the HTTP request lifecycle:", {
rows: 6,
maxLength: 1000,
});
bankNumericQuestion(id, stem, correctAnswer, opts?)
bankNumericQuestion("q-add", "What is 2 + 2?", 4, { min: 0, max: 100, step: 1 });
bankMatchingQuestion(id, stem, pairs, opts?)
bankMatchingQuestion(
"q-match-terms",
"Match each term to its definition:",
[
{ leftId: "l1", leftText: "HTTP", rightId: "r1", rightText: "Protocol for web" },
{ leftId: "l2", leftText: "DNS", rightId: "r2", rightText: "Domain name lookup" },
],
);
bankSequencingQuestion(id, stem, items, correctOrder, opts?)
bankSequencingQuestion(
"q-http-lifecycle",
"Put the HTTP lifecycle in order:",
[
{ id: "a", text: "DNS resolution" },
{ id: "b", text: "TCP handshake" },
{ id: "c", text: "HTTP request" },
{ id: "d", text: "Server response" },
],
["a", "b", "c", "d"],
);
bankImageSelectionQuestion(id, stem, images, correctId, opts?)
bankImageSelectionQuestion(
"q-icon-db",
"Which icon represents a database?",
[
{ id: "img1", src: "/icons/db.png", alt: "Database icon" },
{ id: "img2", src: "/icons/server.png", alt: "Server icon" },
],
"img1",
);
bankLikertQuestion(id, stem, scale, opts?)
Rating scale. Unscored by default. Pass a number for a numeric scale or a custom label array.
bankLikertQuestion("q-confidence", "How confident are you?", 5, {
endLabels: ["Not confident", "Very confident"],
});
bankLikertQuestion("q-rate", "Rate this course:", [
{ id: "poor", text: "Poor" },
{ id: "fair", text: "Fair" },
{ id: "good", text: "Good" },
{ id: "excellent", text: "Excellent" },
]);
Pool Selectors
Pools are named queries over the bank. Four selector kinds:
| Function | Purpose |
|---|---|
explicitSelector(questionIds) | Hand-picked list of question IDs |
byTagsSelector(tags, match?) | Match by tag; match: "any" (default) or "all" |
byCompetenciesSelector(competencyIds, minProficiency?) | Questions tagged with these competencies |
compoundSelector(selectors) | AND of sub-selectors |
pool(id, name, selector)
Defines a selectable group within the bank:
pool("warmup-pool", "Warmup Questions", byTagsSelector(["basics"])),
pool("finale-pool", "Finale Questions", explicitSelector(["q-http-meaning", "q-stateless"])),
pool("phishing-any", "Phishing Coverage", byCompetenciesSelector(["comp_phishing"])),
questionBank(meta, body)
Top-level compositor — always exported as the default of the bank DSL script:
export default questionBank(
{ name: "HTTP Basics Bank", description: "Baseline HTTP questions" },
{
questions: [ /* bank*Question(...) */ ],
pools: [ /* pool(...) */ ],
},
);
Complete Bank Example
import {
questionBank, pool, byTagsSelector, explicitSelector,
bankRadioQuestion, bankCheckboxQuestion, bankTrueFalseQuestion,
} from "@course/components";
export default questionBank(
{ name: "HTTP Basics Bank", description: "Baseline HTTP knowledge" },
{
questions: [
bankRadioQuestion("q-meaning", "What does **HTTP** stand for?", [
{ id: "a", text: "HyperText Transfer Protocol" },
{ id: "b", text: "High Tech Transfer Protocol" },
], "a", { tags: ["basics", "terminology"] }),
bankCheckboxQuestion("q-methods", "Which are valid HTTP methods?", [
{ id: "a", text: "GET" }, { id: "b", text: "POST" },
{ id: "c", text: "FETCH" }, { id: "d", text: "DELETE" },
], ["a", "b", "d"], { tags: ["basics", "methods"] }),
bankTrueFalseQuestion("q-stateless", "HTTP is stateless.", true, {
tags: ["basics", "theory"],
}),
],
pools: [
pool("basics-pool", "All basics", byTagsSelector(["basics"])),
pool("finale-pool", "Finale", explicitSelector(["q-meaning", "q-stateless"])),
],
},
);
Part 2 — Authoring a Course
About ids in the DSL
The id argument on topic(...), lessonSection(...), assessmentSection(...), and friends is an authoring-time symbol. It exists so other parts of the same DSL script can cross-reference the entity (a section's prerequisites: ["lesson-foo"] resolves against the literal id you wrote here). The server assigns canonical nanoid PKs on persist; your literal slugs are not preserved as database keys. After the POST, fetch GET /courses/{id}/dsl (or /structure for an id-only view) to read back the assigned ids you'll need for subsequent operations.
Practical implications: two unrelated courses in the same tenant can both declare a section called lesson-welcome without colliding, and you should never assume the literal id you wrote is queryable post-publish.
Organisms
lessonSection(id, title, contentEls, opts?)
Teaching section. Default role learning.
lessonSection("lesson-http-intro", "Introduction to HTTP", [
lessonParagraph("What is HTTP?", "HTTP is the foundation of the web…"),
lessonParagraph("How it Works", "When you visit a URL…"),
]);
opts:
prerequisites?: string[]— IDs of sections that must be completed firstrole?: string— named section role preset (learningdefault,practice, or custom)overrides?: SectionRoleOverrides— per-section property tweaks
assessmentSection(id, title, contentEls, opts?)
Assessment section. Default role graded. Content is typically one or more questionSlot(...) calls.
assessmentSection("assess-http-basics", "HTTP Knowledge Check", [
questionSlot("warmup", { pool: "basics-pool", count: 3, ordering: "random" }),
questionSlot("finale", { pool: "finale-pool", count: 2, ordering: "fixed" }),
], { prerequisites: ["lesson-http-intro"], passingScore: 0.7 });
opts adds passingScore?: number — if set, completion becomes score-threshold rather than all-interactions.
questionSlot(slotId, rule) — atom
Placeholder that the server resolves to concrete questions. slotId must be unique within the section.
interface SelectionRule {
pool: string | string[]; // Pool ID, or ordered list to union before selecting
count: number; // How many questions to draw
ordering: "fixed" | "random";
seed?: string; // Override deterministic seeding (default = registrationId + slotId)
}
Examples:
// Single pool, 3 random questions
questionSlot("warmup", { pool: "basics-pool", count: 3, ordering: "random" });
// Union two pools, take 2 in fixed order
questionSlot("finale", { pool: ["finale-pool", "bonus-pool"], count: 2, ordering: "fixed" });
// Deterministic seed for reproducible sampling in a demo or test
questionSlot("demo", { pool: "basics-pool", count: 1, ordering: "random", seed: "demo-seed-v1" });
topic(id, title, sections, opts?)
Ordered grouping of sections within a course.
topic("topic-http", "HTTP Fundamentals", [
lessonSection(...),
assessmentSection(...),
], { description: "Core HTTP concepts" });
course(meta, topics)
Top-level compositor — always the default export.
course(
{
title: "Web Development Basics",
description: "Learn the foundations of web development.",
version: "1.0",
mode: "teaching", // "teaching" | "assessment"
passingScore: 0.7, // optional, 0-1
competencyFrameworkId: "fw_abc123", // optional
},
[
topic("topic-http", "HTTP", [...]),
topic("topic-html", "HTML", [...]),
],
);
> meta.version is informational only. Whatever you put here is ignored on write — the server computes the real label (a MAJOR.MINOR string like "1.0" or "1.4") when the course is published or re-versioned. The new course always starts at "1.0". For incremental edits to an existing course, prefer POST /t/{slug}/api/v1/courses/{id}/operations (see Part 4); for full rewrites, POST /t/{slug}/api/v1/courses/{id}/versions accepts a whole DSL. Both endpoints take an optional bump: "minor" | "major" field — see course-versioning.md for the full lifecycle.
Additional Content Molecules
lessonParagraph(heading, body)
lessonParagraph("Overview", "This lesson covers the **HTTP request lifecycle**.");
mediaBlock(src, type, caption?)
mediaBlock("/images/http-diagram.png", "image", "HTTP request/response cycle");
interactiveVideo(src, cues, opts?) + videoCue(time, content, opts?)
Embed a video that pauses at timestamped cue points to show a question (or any CourseEl). Pair with questionSlot() so the cue pulls from your bank.
interactiveVideo(
"/videos/http-intro.mp4",
[
videoCue(15, questionSlot("check-1", { pool: "intro-pool", count: 1, ordering: "random" }), {
label: "Check your understanding",
}),
videoCue(48, questionSlot("check-2", { pool: "methods-pool", count: 1, ordering: "random" }), {
pauseOnReach: false, // let the video keep playing while the cue appears
}),
],
{ poster: "/videos/http-intro.jpg", caption: "HTTP in 60 seconds" },
);
Cues fire once the first time playback crosses each time (seconds). By default the video pauses when a cue fires — the learner presses play to resume. Seeking backwards does not retrigger cues.
sectionResults(sectionId, opts?)
Renders a learner-facing results card for a graded section: score, pass/fail badge keyed off the section's score-threshold rule, threshold display, and (optionally) a per-question breakdown using the same infrastructure other widgets use.
lessonSection("lesson-wrap-up", "Thanks for finishing", [
lessonParagraph("How you did", "Here's your finale outcome:"),
sectionResults("assess-finale", { showQuestionBreakdown: true }),
]);
Options (all default to sensible values):
showScore?: boolean(defaulttrue) — display raw + scaled score.showPassFailBadge?: boolean(defaulttrue) — display the pass/fail badge. Only renders when the target section hascompletionRule.type === "score-threshold"AND a complete client correctness signal is available; graded mode (server-emitted outcome,correct === nullon the client) suppresses the badge but keeps the threshold line visible.showThreshold?: boolean(defaulttrue) — display the configured threshold so the learner sees what they had to hit.showQuestionBreakdown?: boolean(defaultfalse) — list each scored question with its correctness state. Defence-in-depth: never reveals the canonical correct answer when the section role hasstripAnswerKeys: true.
Empty-state behaviour: when the referenced section hasn't been completed yet (or doesn't exist), renders nothing. Place it on a section that the learner only reaches after the graded section is complete (e.g. a closing page gated by prerequisites).
The sectionId attr is run through the same publish-time cross-reference rewriter as prerequisites, so author ids in your DSL are resolved to the persisted nanoids automatically — see course-versioning.md §"Cross-reference rewriting at publish".
sectionRetry(opts?)
Renders a "you didn't pass — retry this section" affordance inside a section that uses completionRule: { type: "score-threshold" }. On click, every response in the section is reset and any randomised UI (e.g. sequencing) is re-shuffled. Re-emits the section-level failed outcome to xAPI on retry, so the activity timeline records each attempt.
assessmentSection("assess-final", "Final", [
// ...questions...
sectionRetry(),
], {
passingScore: 0.7,
retryable: true, // explicitly opt in
});
Renders nothing if dropped into a section that isn't score-threshold-gated, isn't retryable, or hasn't yet been failed by the learner. The molecule is layout-aware: the retry banner inserts itself only when the learner needs it.
Set retryable: true on the section to opt in. Course-level passingScore is ignored for outcome calculation when any section uses score-threshold — only per-section thresholds drive passed / failed.
Section Roles
Every section has a roleKey that controls six behavioral properties (immediate feedback, answer-key scrubbing, retriability, scoring mode, gating, xAPI record mode). Three presets are built in:
| Role | immediateFeedback | stripAnswerKeys | scoringMode | Typical use |
|---|---|---|---|---|
learning | true | true | none | Default for lessonSection |
practice | true | false | formative | Low-stakes drills |
graded | false | true | summative | Default for assessmentSection |
Override per-section via { overrides: { immediateFeedback: true, scoringMode: "formative" } }.
Completion Rules & Prerequisites
Each section can declare a completionRule that decides when the learner has "finished" it. Other sections that list this section in their prerequisites stay locked (🔒 in the player, navigation disabled) until the rule is satisfied.
Three rule types are supported:
| Rule | Shape | When complete |
|---|---|---|
view-time | { type: "view-time", minSeconds?: number } | Section has been visited and at least minSeconds (default 0) elapsed since first visit |
all-interactions | { type: "all-interactions" } | Every scored interaction in the section has been submitted (a section with no scored interactions completes on visit) |
score-threshold | { type: "score-threshold", threshold?: number } | Every scored interaction submitted and the fraction correct ≥ threshold (default 1.0) |
The high-level organisms set the rule automatically:
lessonSection→{ type: "view-time", minSeconds: 0 }(any visit completes).assessmentSectionwithoutpassingScore→{ type: "all-interactions" }.assessmentSectionwithpassingScore→{ type: "score-threshold", threshold: passingScore }.
// Visit completes the lesson; learner is then allowed into the assessment.
lessonSection("lesson-intro", "Introduction", [...]);
// Must answer every scored question correctly enough to clear 0.7.
assessmentSection("assess-final", "Final", [...], {
prerequisites: ["lesson-intro"],
passingScore: 0.7,
});
The player evaluates these rules live against learner responses and visit timing, exposes the set of completed sections via the onProgressChange callback (progress.completedSections), and uses it to compute prerequisite tooltips for locked sections.
Part 3 — Creating a Course: Step by Step
Use this path for initial creation of a new course (v1.0) or a complete rewrite. For incremental edits to an existing course, jump to Part 4.
1. Author and POST the question bank
// bank.ts
import {
questionBank, pool, byTagsSelector,
bankRadioQuestion, bankCheckboxQuestion,
} from "@course/components";
export default questionBank(
{ name: "HTTP Basics Bank" },
{
questions: [
bankRadioQuestion("q1", "What is HTTP?", [...], "a", { tags: ["intro"] }),
bankCheckboxQuestion("q2", "Valid methods?", [...], ["a", "b"], { tags: ["methods"] }),
],
pools: [ pool("intro-pool", "Intro", byTagsSelector(["intro"])) ],
},
);
POST the source of that file to the banks endpoint:
POST /t/{tenantSlug}/api/v1/question-banks
Authorization: Bearer course_...
Content-Type: application/json
{ "dsl": "<bank DSL script as a string>" }
Response 201:
{ "id": "bnk_abc", "name": "HTTP Basics Bank", "questionCount": 2, "poolCount": 1 }
Pool IDs are the ones you named (intro-pool), not generated — so your course DSL can hardcode them.
> Editing an existing bank? Use the dedicated bank-edit endpoints rather than re-POSTing the whole DSL: PATCH /questions/{id} for cosmetic edits, POST /questions/{id}/versions for substantive (correctness-affecting) edits, POST /question-banks/{id}/questions to add a new question, and DELETE /questions/{id} to deprecate one. Full reference and a worked example are in docs/api.md and the matching AI skill: GET /api/docs/skill/edit-question-bank.
2. Author the course referencing those pools
// course.ts
import {
course, topic, lessonSection, assessmentSection,
lessonParagraph, questionSlot,
} from "@course/components";
export default course(
{ title: "Web Dev Basics", version: "1.0", mode: "teaching", passingScore: 0.7 },
[
topic("topic-http", "HTTP", [
lessonSection("lesson-intro", "Intro", [
lessonParagraph("Overview", "HTTP is how the web talks."),
]),
assessmentSection("assess-intro", "Quiz", [
questionSlot("warmup", { pool: "intro-pool", count: 2, ordering: "random" }),
], { prerequisites: ["lesson-intro"], passingScore: 0.7 }),
]),
],
);
3. POST the course
POST the course DSL to create v1.0 of the course. The server ignores any version field in the DSL meta and stamps the new row as "1.0".
POST /t/{tenantSlug}/api/v1/courses
Authorization: Bearer course_...
Content-Type: application/json
{ "dsl": "<course DSL script>", "slug": "web-dev-basics" }
Response 201:
{
"id": "crs_abc",
"slug": "web-dev-basics",
"url": "/c/{tenantSlug}/web-dev-basics",
"fullUrl": "http://localhost:8789/c/{tenantSlug}/web-dev-basics"
}
slug is optional and derived from the title if omitted. AI agents authenticate via the Device Authorization Flow — see docs/ai-course-creation.md.
4. Learner loads the course
When a learner opens the course URL the server walks each section, resolves every questionSlot against its pool, persists the picked question IDs to slot_resolutions keyed on (registrationId, slotId), and streams the resolved tree to the renderer. Reloads hit the same resolutions so learners see stable questions.
Part 4 — Editing an existing course
Once v1.0 exists, every edit becomes a new course version. Course rows are immutable — there is no "edit this section in place" operation. The closest equivalent is "publish a new version that replaces this section with a new one," which mints a new section row and leaves v1.0's section untouched (still referenced by every learner who started on v1.0).
This means incremental editing IS versioning. The model isn't "edit the document, then version it" — it's "describe the change, the server publishes a new version." See course-versioning.md for the design rationale.
Why operations beat re-POSTing the whole DSL
You can re-POST the whole DSL to POST /courses/:id/versions — the server will diff it against the current head and figure out the operation set internally. But for incremental edits, prefer the operations endpoint:
- Explicit intent.
addSection,replaceSectionContent,reorderTopicssay what you mean. The server doesn't have to guess whether a missing section was a removal or a typo in your re-submitted DSL. - Per-id validation. If you reference a topic id that doesn't exist, the response names exactly which operation failed and why. Whole-DSL diffs are forgiving in ways that hide drift.
- Clean audit trail. Each operation writes its own row to
operation_log. Whole-DSL submits log a singlewholeCoursePublishrow with the entire DSL as payload — useful, but coarser. - Concurrency-safe. Both paths return
409 head_movedwhen the chain head moved between your read and your write. Operations make it cheap to retry: re-fetch the head, re-validate your op batch against it, resubmit. Whole-DSL retries mean re-sending the entire course.
The operation set
The full operation reference (validation rules, error shapes, classifier behaviour) lives in course-versioning.md. Quick summary:
| Operation | Effect |
|---|---|
editCourseMeta | Edit the course's title and/or description. |
addTopic | Add a topic. afterTopicId (optional) places it after that topic; null prepends. |
removeTopic | Remove a topic by id. |
reorderTopics | Reorder the course's topics. Must be a permutation of the current set. |
editTopicMeta | Edit a topic's title and/or description. |
addSection | Add a section to a topic. afterSectionId (optional) places it after that section; null prepends. |
removeSection | Remove a section from a topic. |
reorderSections | Reorder sections within a topic. Permutation of the topic's current section set. |
replaceSection | Replace a section in full. The replacement's declared id may differ — same-id means "edit," different-id means "swap." |
replaceSectionContent | Narrower form of replaceSection — replaces only the section body, leaves title/role/prereqs/completionRule alone. |
Submitting an operation batch
POST /t/{tenantSlug}/api/v1/courses/{courseId}/operations
Authorization: Bearer course_...
Content-Type: application/json
{
"ops": [
{
"op": "replaceSectionContent",
"topicId": "topic-http",
"sectionId": "lesson-intro",
"newContent": <CourseEl tree as built by lessonSection's content arg>
}
]
}
Response 201:
{
"id": "crs_v11",
"version": "1.1",
"versionMajor": 1,
"versionMinor": 1,
"originalCourseId": "crs_abc",
"supersededId": "crs_abc",
"recommendedBump": "major",
"bumpSignals": ["ops[0].replaceSectionContent: section content changed"],
"adminUrl": "/t/{tenantSlug}/courses/crs_v11",
"fullAdminUrl": "http://localhost:8789/t/{tenantSlug}/courses/crs_v11",
"nextStep": "review"
}
Up to 100 operations per batch. Operations apply transactionally — either the whole batch publishes a new version, or none of it does. Pass bump: "minor" | "major" in the request body to override the classifier's recommendation; otherwise the server uses recommendedBump automatically.
Common edit recipes
Fix a typo in a paragraph. Build the new section content with the same builders you used originally, then submit one replaceSectionContent:
{
"ops": [{
"op": "replaceSectionContent",
"topicId": "topic-http",
"sectionId": "lesson-intro",
"newContent": <output of lessonSection(...)'s content arg>
}]
}
Note: the classifier defaults this to major because lesson content can shift downstream question p-values. Pass "bump": "minor" explicitly if you know the edit is purely cosmetic.
Add a new topic at the end.
{
"ops": [{
"op": "addTopic",
"afterTopicId": "topic-html", // last existing topic
"topic": <output of topic("topic-css", "CSS Basics", [...])>
}]
}
Reorder topics. Pass the full ordered id list — must be a permutation of the current topics:
{
"ops": [{
"op": "reorderTopics",
"topicIds": ["topic-html", "topic-http", "topic-css"]
}]
}
Coherent multi-op batch. Operations within a batch see the projected post-batch state, so you can add a section AND another section that prerequisites it in the same batch:
{
"ops": [
{
"op": "addSection",
"topicId": "topic-http",
"afterSectionId": "lesson-intro",
"section": <lessonSection("lesson-deep-dive", "Deep Dive", [...])>
},
{
"op": "addSection",
"topicId": "topic-http",
"afterSectionId": "lesson-deep-dive",
"section": <assessmentSection("assess-deep-dive", ..., {
prerequisites: ["lesson-deep-dive"] // resolves against post-batch state
})>
}
]
}
Validation errors
If any operation fails validation, the whole batch is rejected with 400:
{
"error": "Operation batch validation failed",
"phase": "validation",
"details": {
"code": "operation_validation_failed",
"validationErrors": [
{
"opIndex": 0,
"code": "topic_not_found",
"message": "Topic id 'topic-xyz' does not exist in course head version",
"details": { "topicId": "topic-xyz" }
}
]
}
}
The validationErrors array is designed for fix-and-resubmit in one round-trip. See apps/web/src/routes/t/$tenantSlug/api/v1/courses_.$courseId_.operations.tsx for the full set of error codes.
409 head_moved
If another publisher landed a new version between your last read and your submit, the response is 409 Conflict with details.code === "head_moved" and details.currentHeadId naming the new head. Re-fetch the head's DSL, recompute your operations against it, resubmit.
Authoring with the in-app AI chat
Everything in this document describes the API-direct authoring path: write DSL, POST it via the public REST API. The same DSL is also producible by AI agents in the Course App's in-app chat surface at /t/{slug}/ai-chat. The chat operates in two modes:
- Create mode — the AI authors a brand-new question bank / course / competency framework end-to-end, posting through the same
POST /question-banksandPOST /coursesendpoints documented above. Discoverable via thecreate-courseskill atGET /api/docs/skill/create-course. - Edit mode — the AI applies surgical edits to an existing draft course using operation builders — DSL helpers (
addTopic,replaceSectionContent,apply, …) that produce the sameOperationBatchshape consumed byPOST /courses/{id}/operations. The chat page wires these into 12 granular tools plus anapply_course_operationsmega-tool (13 tools total), all routed throughapplyOperationsToDraftso edits land in-place on a draft (no version bump until the author publishes). Discoverable via theedit-courseskill atGET /api/docs/skill/edit-course.
The DSL the chat AI emits is the DSL described in this document — the same atoms / molecules / organisms, the same questionSlot shape, the same fragment evaluation. The chat surface adds a fragment-mode entry point on the executor (executeTopicFragment, executeSectionFragment, executeCourseElFragment, executeOperationBatchFragment) so subtree payloads can be evaluated without a full course wrapper, but the builder library itself is unchanged.
For the design behind the in-app edit flow (auto-fork, session pinning, tool surface, analytics), see ai-course-editing.md.
Aligning to a Competency Framework
- Create or fetch a framework via
/t/{slug}/api/v1/competency-frameworks(seedocs/competency-frameworks.md). - Reference it from the course meta:
competencyFrameworkId: "fw_abc". - Tag individual questions in the bank with
competencyIds: [...]. - Use
byCompetenciesSelector(["comp_1", "comp_2"])to build pools that target specific competencies.
At assessment time the renderer writes per-competency coverage into assessmentResults.competencyCoverage.
Best Practices
- Unique IDs everywhere — Questions (bank-unique), pools (bank-unique), sections/topics/slots (course-unique). Use descriptive prefixes:
q-http-meaning,lesson-intro,assess-final,pool-basics,warmup-slot.
- Tag generously — Tags on questions are the primary input to
byTagsSelectorpools. Plan a small taxonomy before you start authoring.
- Pool organization — A few broad pools (by topic/competency) plus a couple of
explicitSelectorpools for fixed finales usually beats many narrow ones.
- Mix question types — Radio, checkbox, true/false, fill-in. Keeps assessments engaging; also exercises more of the interaction pipeline.
- Progressive difficulty — Put low-stakes drills in
role: "practice"sections (formative scoring) beforegradedassessments.
- Prerequisites — Use
prerequisites: ["lesson-id"]on assessment sections to force completion order.
- Passing scores — 0.6–0.8 is typical. Set on course meta as a fallback; override per-assessment with
passingScore.
- Separate the layers — Sections describe layout and what pool to draw from. Banks describe the content of questions. Never inline answer data in a section's content tree.
Interaction Types Reference
| Type | xAPI Type | Correct Pattern Format |
|---|---|---|
| Single choice | choice | ["a"] |
| Multiple choice | multiple-choice | ["a[,]b[,]d"] (sorted) |
| True/false | true-false | ["true"] or ["false"] |
| Fill-in | fill-in | exact text |
| Numeric | numeric | number as string |
| Long fill-in | long-fill-in | free text (typically unscored) |
| Matching | matching | ["a[.]1[,]b[.]2"] |
| Sequencing | sequencing | ["a[,]b[,]c"] |
| Likert | likert | scale value (typically unscored) |
| Image choice | image-choice | ["img1"] |
The bank builders compute correctResponsesPattern in these formats for you.
xAPI Verbs Emitted
initialized— course loadsexperienced— section viewedanswered— question submittedcompleted— all scored interactions answeredpassed/failed— score vs passing thresholdsuspended/resumed— tab blur/focus
Imports
// Bank DSL
import {
// Bank question builders
bankRadioQuestion, bankCheckboxQuestion, bankTrueFalseQuestion,
bankTextInputQuestion, bankLongTextQuestion, bankNumericQuestion,
bankMatchingQuestion, bankSequencingQuestion, bankImageSelectionQuestion,
bankLikertQuestion,
// Pool selectors + organisms
explicitSelector, byTagsSelector, byCompetenciesSelector, compoundSelector,
pool, questionBank,
} from "@course/components";
// Course DSL
import {
// Atoms
questionStem, image, choice, choiceLabel, choiceImage, feedbackCorrect, feedbackIncorrect,
hintToggle, sectionHeading, progressIndicator, explanationText, divider,
questionSlot,
// Content molecules
lessonParagraph, mediaBlock, interactiveVideo, videoCue,
// Organisms
lessonSection, assessmentSection, courseHeader, courseFooter, scoreCard,
topic, course,
} from "@course/components";
import type {
CourseEl, Interaction, CourseDefinition, CourseSection, CourseTopic,
SelectionRule, QuestionDefinition, QuestionPoolDefinition,
QuestionBankDefinition, PoolSelector,
CompletionRule, StyleDef, SectionRoleOverrides,
} from "@course/shared";