Adding a course¶
The short version¶
A new course is validated data plus one catalog registration:
src/courses/<course-id>/course.ts
│
▼
loadCourse validation
│
▼
src/courses/index.ts
│
▼
generic routes + shell + search + progress + review
Do not add a page, layout, sidebar, progress store, or course-specific renderer. The generic route
under src/app/[courseId] derives the full workspace from the course bundle.
Before authoring¶
Collect:
- The official course code and name.
- A one-sentence description.
- Ordered topics and their source material.
- Resource collections such as definitions, summaries, formula sheets, or documents.
- Assessment collections such as flashcards, quizzes, exercises, and past papers.
- Provenance for material that may need future auditing.
Use stable, lowercase, kebab-case identifiers. IDs become storage and URL references; renaming them later can break links or progress history.
1. Create the course bundle¶
Create src/courses/<course-id>/course.ts. Larger courses may split topics and collections into
nearby data files and compose them in course.ts.
This is a minimal in-repository example:
import type { CourseBundleInput } from "@/course-os/services/content/loadCourse";
import { universalCourseSections } from "../shared";
export const courseExampleInput: CourseBundleInput = {
manifest: {
schemaVersion: 1,
id: "example-course",
code: "EXAMPLE",
name: "Example Course",
description: "A concise description shown in the course switcher and overview.",
status: "active",
icon: "BookOpen",
sections: universalCourseSections,
topicIds: ["introduction"],
resourceCollectionIds: ["definitions"],
assessmentCollectionIds: ["practice"],
},
topics: [
{
id: "introduction",
slug: "introduction",
title: "Introduction",
description: "The first topic.",
order: 1,
blocks: [
{
id: "introduction-theory",
type: "core.theory",
version: 1,
props: {
title: "Start here",
paragraphs: ["Course content is semantic, serializable data."],
},
meta: {
tags: ["introduction"],
sourceRefs: [{ kind: "lecture", id: "lecture-1" }],
estimatedMinutes: 5,
},
},
],
},
],
collections: [
{
id: "definitions",
kind: "resource",
title: "Definitions",
order: 1,
blocks: [
{
id: "example-definitions",
type: "core.definition-list",
version: 1,
props: {
entries: [
{
term: "Course bundle",
definition: "A manifest, ordered topics, and ordered collections.",
},
],
},
},
],
},
{
id: "practice",
kind: "assessment",
title: "Practice",
order: 2,
blocks: [
{
id: "intro-practice",
type: "assessment.practice-session",
version: 1,
props: {
id: "intro-practice",
title: "Introduction practice",
questions: [
{
id: "intro-question-1",
prompt: "What makes course content portable?",
kind: "short-text",
modelAnswer: "It is validated, serializable, semantic data.",
},
],
},
},
],
},
],
};
The JSON example at course-example.json is useful when generating content
outside TypeScript. The fixture under src/courses/fixture is the smallest executable contract
example.
Manifest rules¶
schemaVersionis currently1.id, topic IDs, slugs, collection IDs, and block IDs use lowercase kebab-case.codeis the human-facing TU/e code and may be uppercase.statusisactive,planned, orarchived. Status is metadata; every registered course is currently listed by the workspace, so do not useplannedas an access-control mechanism.iconnames alucide-reactexport. Prefer a simple subject icon and verify it renders.sectionsmust contain all six universal sections. ReuseuniversalCourseSections.- Every
topicIdsentry must resolve to exactly one topic, and every topic must be listed. - Every resource or assessment collection must be listed in the matching manifest array, and every listed collection must exist.
- Array order in the manifest is the navigation order.
orderremains required on topics and collections and should agree with that order.
Redirecting legacy URLs¶
If a course was published elsewhere before it moved into the hub, its old URLs belong in the
manifest as pathAliases — never in the shared router. Each key is a slash-separated course-relative
path, each value names the collection it lands on and, optionally, a block inside it:
pathAliases: {
quizzes: { collectionId: "quizzes" },
exam: { collectionId: "practice-exams" },
"exam/papers": { collectionId: "exam-papers" },
"exam/midterm": { collectionId: "practice-exams", blockId: "midterm-practice-exam" },
},
Rules enforced by resolveCoursePath and loadCourse:
- The longest matching alias wins, so
exam/papersbeats a bareexam. - Without an explicit
blockId, the segment after the matched alias becomes the block id —/course/quizzes/quiz-foundationsresolves to thequiz-foundationsblock. - An alias may not shadow a universal section (
topics,resources,exam-preparation,progress,review,search), which is matched first. collectionIdmust name a collection the course actually declares; the course fails to load otherwise.
Choosing a layout¶
Topics and collections take an optional presentation field, "scroll" (the default) or
"paged". A paged container shows one block at a time behind a picker, a position counter,
and previous/next controls; a scrolling container stacks every block on one page.
Use "paged" when the blocks are independent exercises the reader works through one at a
time — a question bank, a set of code exercises, a stack of practice papers. Keep the default
for prose and mixed theory/practice, where scrolling preserves the reading flow.
{
id: "simulation-exercises",
kind: "assessment",
title: "PP2 simulation exercises",
order: 8,
presentation: "paged",
blocks: [/* ... */],
}
Paging is a presentation choice only. Block ids stay stable, deep links of the form
/<course-id>/topics/<slug>#<block-id> still resolve to the right block, and progress is
recorded identically either way.
2. Compose content from registered blocks¶
Every block has the versioned envelope:
{
id: "stable-id",
type: "namespace.capability",
version: 1,
props: { /* type-specific semantic data */ },
meta: { /* optional tags, sources, time, and mastery */ },
}
Use the learning block specification for the current catalog.
The executable source of truth is src/course-os/registry/blockRegistry.ts plus each definition's
parseProps function.
Important boundaries:
- Props must be serializable. Do not put React components, callbacks, CSS classes, storage keys, or route strings into course content.
- Use an existing block when the difference is only wording or subject matter.
- A new block type is justified only by new rendering, interaction, evaluation, or accessibility behavior.
- Use
meta.sourceRefsfor traceable source material andmeta.masteryWeightonly when the default weighting is inappropriate. - Available resources require a site-relative or HTTPS
href. Missing source documents should be represented explicitly withavailable: false.
3. Register the course¶
In src/courses/index.ts:
- Import the new
CourseBundleInput. - Pass it through the existing
loadRegisteredCoursehelper. - Add the loaded bundle to
registeredCourses.
Example:
import { courseExampleInput } from "./example-course/course";
export const registeredCourses = [
// existing courses
loadRegisteredCourse(courseExampleInput),
] as const;
Registration automatically supplies:
- The course switcher and hub row.
- Overview and topic pages.
- Resource and assessment collection pages.
- Progress and review views.
- Search indexing and canonical links.
- Static route parameters for the generic course route.
Publication and operations cost¶
Initial publication writes every distinct course document, copied asset, the immutable manifest, and the mutable discovery documents. Later publications reuse content-addressed documents and assets, so unchanged material costs reads but no duplicate stored bytes. A new course moves these operational meters: total Blob bytes, largest live course-document bytes, advanced and simple Blob operations, transfer, and route/output growth.
After publishing, run npm run operations and review the new course in inventory.byCourse, the
largest payload list, and publication attribution. The blocking thresholds and their provenance live
in src/course-os/operations/usageBudgets.ts; do not raise one merely to admit a new course. Shard a
large index or reduce copied payload first. See Platform operations.
If you are adding a permanent course, update catalog assertions in
src/courses/courses.test.ts so the expected registered course list and any important source counts
include it.
4. Validate¶
Run the narrow content gate first:
Then run the repository gate:
Validation errors include a data path such as
course.topics[0].blocks.introduction-theory.props.paragraphs. Fix the data at that path; do not
weaken the parser to accommodate one course.
5. Review in the browser¶
With npm run dev running, verify:
/<course-id>— overview./<course-id>/topicsand every topic./<course-id>/resourcesand every resource collection./<course-id>/exam-preparationand every assessment collection./<course-id>/progressand/<course-id>/review.- Global search finds a distinctive phrase and links back into the correct course.
- The course appears in the sidebar and command palette with the correct icon and description.
- Light mode, dark mode, mobile navigation, keyboard focus, and print views remain usable.
Definition of done¶
- Course bundle is data-only and uses stable IDs.
- Manifest references exactly match topics and collections.
- Every block type/version exists in the registry and its props validate.
- Sources and unavailable resources are represented honestly.
- The course is registered once in
src/courses/index.ts. - Catalog tests and important source-count assertions are updated.
-
npm run validate:contentpasses. -
npm run checkpasses. - All generic course routes and cross-course search are manually verified.
- No course-specific route, shell, progress store, or renderer was introduced.
Agent handoff prompt¶
When delegating course creation to a coding agent, provide the source material and say:
Read
docs/authoring/adding-a-course.mdanddocs/authoring/learning-block-specification.md. Build the course as a data-onlyCourseBundleInput, preserve source provenance, register it in the catalog, update catalog assertions, and run both content and repository gates. Do not add course-specific routes or UI.
This gives the agent constraints, sources of truth, deliverables, and a verifiable finish line.