Skip to content

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

  • schemaVersion is currently 1.
  • id, topic IDs, slugs, collection IDs, and block IDs use lowercase kebab-case.
  • code is the human-facing TU/e code and may be uppercase.
  • status is active, planned, or archived. Status is metadata; every registered course is currently listed by the workspace, so do not use planned as an access-control mechanism.
  • icon names a lucide-react export. Prefer a simple subject icon and verify it renders.
  • sections must contain all six universal sections. Reuse universalCourseSections.
  • Every topicIds entry 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. order remains 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/papers beats a bare exam.
  • Without an explicit blockId, the segment after the matched alias becomes the block id — /course/quizzes/quiz-foundations resolves to the quiz-foundations block.
  • An alias may not shadow a universal section (topics, resources, exam-preparation, progress, review, search), which is matched first.
  • collectionId must 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.sourceRefs for traceable source material and meta.masteryWeight only when the default weighting is inappropriate.
  • Available resources require a site-relative or HTTPS href. Missing source documents should be represented explicitly with available: false.

3. Register the course

In src/courses/index.ts:

  1. Import the new CourseBundleInput.
  2. Pass it through the existing loadRegisteredCourse helper.
  3. 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:

npm run validate:content

Then run the repository gate:

npm run check

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>/topics and every topic.
  • /<course-id>/resources and every resource collection.
  • /<course-id>/exam-preparation and every assessment collection.
  • /<course-id>/progress and /<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:content passes.
  • npm run check passes.
  • 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.md and docs/authoring/learning-block-specification.md. Build the course as a data-only CourseBundleInput, 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.