OpenCourse course archive format — v1.8

This document is the source of truth for course authors. A OpenCourse course is a self-contained directory holding a course.json manifest plus its assets, distributed as a .zip of that directory. The desktop app ships no courses at all: you import an archive, or add a course from an OpenCourse server you have connected to, and the app validates it against the v1.8 JSON Schema and renders it directly.

A course can be about anything. Nothing in the format is specific to programming: a course on economics or music theory is markdown, images, visualizations and quizzes, and needs no runtime at all. What the format also gives you is the exercise block — a runnable, auto-graded coding exercise — which any course may use, whatever its subject. An economics course with one Python exercise for computing a Gini coefficient is a first-class course.

You can get this document, the JSON Schema and a working example course out of the app itself — Library ▸ Get the course format…. The example imports as-is, so it doubles as a starting template.

What changed in v1.8: Image blocks support optional structured attribution, displayed beneath the image and retained in exports. Versions 1.0–1.7 remain importable.

What changed in v1.7: Lessons can contain editable, runnable python-cell blocks. An optional notebook declaration enables a course notebook and supplies starter cells. The notebook and lesson cells share a persistent, initially empty venv separate from exercise environments. No code runs automatically and no course-specified packages are installed. Versions 1.0–1.6 remain importable.

What changed in v1.6: Exercises are Python-only and use the interpreter bundled with OpenCourse. Each course still gets a local virtual environment. Projects can use any language; learners install and configure their own tools. Older Python archives remain compatible. Courses declaring C, LLVM IR, or another exercise language are rejected regardless of their declared format version.

What changed in v1.5: A course has a version of its own, MAJOR.MINOR.PATCH (required from schema 1.5; older archives import as 0.1.0). Every element may carry a uid, the server identity an OpenCourse server's archives use so that progress follows an element from one version of a course to the next. Authors never write uids by hand: see Versions and server identity.

What changed in v1.4: Lessons can include optional flashcards collections of question–answer pairs with optional question images. Use schema version 1.4 or later for flashcards. Versions 1.0–1.3 remain importable.

What changed in v1.3: A module can be a project (type: "project"), with a definition, requirements, concrete deliverables, acceptance criteria and optional starter files. Existing untyped lesson modules remain valid. A project can be the first, last or only module in a course. Use schema version 1.3 or later for project modules.

What changed in v1.2 (these runtime additions remain optional; every block must now include a slug regardless of the declared version):

Where Field Why
course runtime How the course's exercises are built and run: { language, version, packages, flags }. Replaces python_version, which still works.
course subject Free text shown on the library card and course page: "Programming", "Economics".
exercise runtime Overrides the course runtime for one exercise, to customize its minimum Python version and dependencies.
exercise extra_files Read-only support files written next to the learner's work: a helper module or a fixture.
exercise expected_output Verify by stdout instead of by a test file — the only workable shape when the learner's whole program is the answer.
exercise stdin Fed to the program under the expected_output shape.
exercise match How stdout is compared: exact | trimmed (default) | lines.

What changed in v1.1:

Where Field Why
course python_version Superseded by runtime.version. Still honoured.
course prerequisites Strings shown on the course page before a learner starts.
lesson objectives "By the end you can…" bullets, rendered at the top of the lesson.
lesson estimated_minutes Per-lesson time estimate.
exercise solution Reference implementation, revealed on request.
exercise tests The course's checks, run against the learner's file.
exercise test_command Python only; defaults to pytest -q.
exercise packages Superseded by runtime.packages. Still honoured.

Archive layout#

<any-name>.zip
├── course.json                  # manifest (required, at archive root)
└── assets/                      # all referenced files (required if any references exist)
    ├── cover.png
    ├── img/diagram-1.png
    ├── video/intro.mp4
    └── viz/linked-list/         # a visualization bundle (an entry HTML + its relative deps)
        ├── index.html
        ├── style.css
        └── app.js

Rules:

  • course.json must be at the archive root. One wrapping folder is tolerated, because macOS Finder's Compress puts the folder you compressed at the top — my-course/course.json imports fine, a/course.json alongside b/course.json does not.
  • All referenced asset paths are relative to the archive root, e.g. assets/img/diagram-1.png.
  • Paths must not contain .. or absolute components. Symlinks are rejected.
  • Limits, enforced on import: 200 MB per archive, 100 MB per file, 5 000 members. They live in app/src/core/import.ts.
  • __MACOSX/, .DS_Store and ._* are dropped rather than treated as errors.

Allowed file types#

Category Extensions
Images .png .jpg .jpeg .gif .webp .svg
Video .mp4 .webm
Audio .mp3 .ogg
Viz bundles .html .htm .css .js .mjs .json .map .woff .woff2 .ttf .otf .txt

Plus .json for the manifest and .md. Anything else is refused on import, and the import is refused as a whole — a course is never half-installed.

course.json reference#

Top-level object#

Field Type Required Description
schema_version string yes "1.0" to "1.8". The app writes "1.8".
slug string (kebab-case) yes Portable kebab-case name. Every import creates a separate library course, even when slugs match.
title string yes Course title shown in the catalog.
version string from 1.5 The course's own version: three numbers, MAJOR.MINOR.PATCH, such as 1.2.0. No pre-release tags. A new course starts at 0.1.0.
uid string (UUID v4) no Server identity of the course. Written by the app when it publishes; ignored on import.
description string no Markdown (rendered on the detail page).
author string no Free-form.
difficulty enum no beginner | intermediate | advanced (default: beginner).
estimated_hours integer no Hint to learners. Keep it consistent with the sum of estimated_minutes.
subject string no Free text: "Programming", "Economics". Shown on the library card and the course page.
notebook object no Enables a Python notebook; optional cells array of { content } starter cells. Requires 1.7.
runtime Runtime no Default runtime for every exercise in the course. Omit it entirely for a course with no exercises.
python_version string no The v1.1 spelling of runtime.version. Still honoured; prefer runtime.
prerequisites string[] no What a learner should already know or have installed.
tags string[] no Used for search/filter.
cover_image string no Asset path, e.g. assets/cover.png.
modules Module[] yes At least one module.

Module#

A module is either a group of lessons or one project. Module slugs must be unique within the course. They remain portable author names; local UUIDs identify learner work and progress.

For a lesson module, type is optional; when supplied it must be "lessons".

Field Type Required
slug kebab-case string yes
title string yes
type "lessons" no
lessons Lesson[] (at least one) yes

For a project module, set type: "project" and replace lessons with project. A module cannot contain both fields. The project is a complete activity in the course sequence, with its own workspace and conversations.

Field Type Required
slug kebab-case string yes
title string, 1–500 characters yes
type "project" yes
project ProjectDefinition yes

ProjectDefinition#

Field Type Required Description
definition markdown string yes Brief and goal, 1–20,000 characters.
requirements Requirement[] yes 1–50 { id, description } objects; IDs unique within this array, descriptions at most 2,000 characters.
deliverables Deliverable[] yes 1–30 concrete outputs, each assessed against its criteria.
objectives string[] no 1–30 learning goals, each at most 1,000 characters.
estimated_minutes integer no 1–10,000.
starter_files { path, content }[] no Up to 100 UTF-8 files seeded on the first open only.

A Deliverable has a unique stable kebab-case id, a title (up to 500 characters), a markdown description (up to 2,000 characters), and 1–30 acceptance_criteria strings (each up to 1,000 characters). Optional paths lists 1–30 suggested relative filenames. Paths guide review; alternate implementations in the project folder can still be discussed. Requirement IDs are also stable kebab-case strings.

Starter paths follow the exercise extra_files path contract: ordinary ASCII path segments beginning with a letter or digit, no absolute paths, .., dotfiles or .command files. Duplicate starter paths and file/directory collisions are invalid. A starter file is at most 256 KiB; all starter contents together are at most 1 MiB. The serialized project context (course/module titles, brief, goals, requirements and deliverables) is at most 48,000 characters; an oversized project is refused on import.

json{
  "type": "project",
  "slug": "course-outline-project",
  "title": "Build a course outline",
  "project": {
    "definition": "Design a three-lesson course on a subject you know.",
    "requirements": [
      { "id": "audience", "description": "Define the audience and prerequisites." }
    ],
    "deliverables": [
      {
        "id": "outline",
        "title": "Course outline",
        "description": "Provide an outline with learning goals and lesson order.",
        "paths": ["OUTLINE.md"],
        "acceptance_criteria": ["Each learning goal has a corresponding lesson."]
      }
    ],
    "starter_files": [{ "path": "OUTLINE.md", "content": "# Course outline\n" }]
  }
}

Projects have no exercise solution, tests, test_command, expected_output or runtime provisioning. Learners work in their local folder using an installed editor. The project assistant receives the definition, requirements and deliverables implicitly and can list, search and read current UTF-8 files only within that project folder. Read files are sent to OpenAI using the key from Settings. Symlinks, secrets, dependency/build folders and nonregular files are excluded; binary artifacts can be listed but their contents cannot currently be reviewed. Reads/searches are bounded and explicitly report truncation. The assistant gives conversational feedback, identifies unverified criteria and does not execute code or automatically grade work. The learner chooses when to mark completion.

Project workspaces survive reopening, in-app editing, module renames/moves and chat deletion. Removing a course permanently deletes its progress, lesson and project conversations, exercise workspace, project directories and initialization metadata; importing it again starts fresh, even with the same course/module slugs. Starter files are never regenerated after initialization, including files the learner deleted. Changed requirements trigger a fresh context snapshot and a notice when the last requested review used an earlier definition. A missing workspace requires an explicit Recreate action. One user's work, progress and project conversations remain separate from every other user's.

Lesson#

Field Type Required
slug kebab-case yes
title string yes
objectives string[] no — rendered as "By the end of this lesson you can…"
estimated_minutes integer no — shown in the sidebar and summed for the course
blocks Block[] yes

Convention: end the theory with a markdown block that starts with ## Key takeaways, placed before the first quiz. The app renders it as a recap card, and it reads naturally as theory → recap → check yourself → build.

Block types#

Each lesson is a list of typed content blocks rendered in order.

Every block in the app has an editable slug: a kebab-case name unique within its lesson. The editor outline shows this name beside the block-type icon. New blocks start as Markdown with a name such as markdown or markdown-2; change Block slug to give the block a meaningful name. Renaming a slug does not change its UUID, progress, chat attachments or learner workspace.

The schema requires slug on all seven block types, including imported courses. Missing, invalid or duplicate names fail validation. The app does not add slugs to existing courses; add them to course.json before importing. Exported format 1.8 ZIPs preserve the authored block slugs.

markdown#

json{ "type": "markdown", "slug": "compiler-overview", "content": "A compiler translates your source *before* it runs…" }

Rendered through a strict HTML allowlist (links, images, code, tables, headings). Inline HTML in the markdown source is stripped.

Code fences are syntax-highlighted when the language is one the app bundles a grammar for: python, c, cpp, javascript, typescript, bash, json, sql, yaml, diff, llvm, and the usual aliases (py, js, ts, sh, yml, ll, …). Anything else renders as plain monospace, which is a perfectly good outcome — don't reach for a fence language just to get colour.

image#

json{ "type": "image", "slug": "compiler-diagram", "src": "assets/img/diagram.png", "alt": "Ref diagram", "caption": "Figure 1" }

An image may include attribution (requires format 1.8):

json"attribution": {
  "source_url": "https://commons.wikimedia.org/wiki/File:Example.jpg",
  "title": "Original image title",
  "author": "Credited creator",
  "author_url": "https://example.org/creator",
  "license": "CC BY 4.0",
  "license_url": "https://creativecommons.org/licenses/by/4.0/"
}

Only source_url is required; omit unknown details instead of inventing them. Credit URLs must be HTTP(S) URLs without credentials. Credits are plain text with clickable links, separate from the teaching caption. The editor can import reusable PNG, JPEG, WebP and GIF images from public HTTPS sources into local course attachments; lessons never depend on remote image loading. Imported images are bounded to 20 MiB and 40 megapixels. Authoring verifies reuse terms before import.

video#

json{ "type": "video", "slug": "intro-video", "src": "assets/video/intro.mp4", "poster": "assets/img/intro-poster.png", "caption": "Intro" }

visualization#

An interactive HTML/CSS/JS bundle rendered in a sandboxed iframe. The src points at the entry HTML; every sibling file in that bundle directory is uploaded and served.

json{
  "type": "visualization", "slug": "linked-list",
  "src": "assets/viz/linked-list/index.html",
  "title": "Linked list walkthrough",
  "height": 480
}

The iframe is served with sandbox="allow-scripts" (no allow-same-origin), a restrictive Content-Security-Policy, and no access to parent cookies or DOM. Don't rely on network access, localStorage, or parent-page APIs inside a viz. Ship JavaScript as files next to the entry HTML: inline <script> is refused.

Learners can highlight text inside a visualization and ask the side chat about it, as they can in a lesson. A viz needs no code for this: the app appends a small script to every visualization page it serves, which reports selections to the app with postMessage. Two things are reserved for it: messages whose source is opencourse-viz or opencourse-app, and the path /__opencourse/ on the course's host.

quiz#

The id is an author-assigned portable string. In-app editing preserves quiz history through a separate local UUID. Importing a ZIP always creates fresh learner state.

Single-choice:

json{
  "type": "quiz", "id": "q1", "slug": "q1", "kind": "single",
  "question": "What does a compiler produce?",
  "options": [
    { "id": "a", "text": "A running process", "correct": false },
    { "id": "b", "text": "An object file", "correct": true }
  ],
  "explanation": "Compiling and running are separate steps; the linker sits between them."
}

Multiple-choice (answer must exactly match the correct set):

json{
  "type": "quiz", "id": "q2", "slug": "q2", "kind": "multiple",
  "question": "Which of these are demand curves?",
  "options": [
    { "id": "a", "text": "Quantity falls as price rises", "correct": true  },
    { "id": "b", "text": "Quantity rises as price rises", "correct": false },
    { "id": "c", "text": "Quantity is unchanged by price", "correct": true  }
  ]
}

Short-answer text (case-insensitive exact match against any accepted string):

json{
  "type": "quiz", "id": "q3", "slug": "q3", "kind": "text",
  "question": "What function prints to stdout?",
  "answers": ["print", "print()"]
}

exercise#

A prompt, a starter file the learner edits, an optional reference solution, and something the app can check the answer against. Exercises are the one part of the format that needs a runtime, and any course may use them.

There are two ways to say what "correct" means, and an exercise picks exactly one:

  • tests — the course ships a checking program that exercises the functions the learner wrote. This is the shape for almost everything.
  • expected_output — the course states the stdout the learner's whole program must produce. Use it when the contract is "print this".

tests wins if both are present.

The Runtime object#

Valid on the course (defaults) and an exercise (overrides).

Field Type Notes
language enum Only "python"; defaults to Python.
version string Minimum Python version: MAJOR.MINOR[.PATCH] or >=MAJOR.MINOR[.PATCH]. Defaults to >=3.11.
packages string[] pip requirements installed into the local course venv.
flags string[] Legacy field, unused by Python; retained for compatibility.

An exercise's version overrides the course version, which overrides legacy python_version. A bare version is also a minimum, never an exact interpreter selection. Unsupported version expressions are rejected. If bundled Python is too old, setup reports both versions and asks the learner to update OpenCourse.

Python#
json{
  "type": "exercise", "id": "ex-1", "slug": "ex-1",
  "title": "Hello, world",
  "prompt": "Write `greeting()` so that it returns the string `Hello, world`.",
  "starter_code": "def greeting() -> str:\n    ...\n",
  "solution": "def greeting() -> str:\n    return \"Hello, world\"\n",
  "tests": "import exercise\n\ndef test_greeting():\n    assert exercise.greeting() == \"Hello, world\"\n",
  "verification_instructions": "Run the checks, then `python exercise.py`.",
  "hints": ["`return` the string, don't print it."]
}

starter_code becomes exercise.py, solution becomes solution.py, tests becomes test_exercise.py and imports the learner's work as import exercise. The app builds one local virtualenv per course from its bundled interpreter, installs pytest plus runtime.packages, and runs pytest -q. No system Python or Command Line Tools installation is required. Installing dependencies may require internet access. Runtime updates arrive through app updates.

Field reference#
Field Required Notes
id yes Portable author ID; the app assigns a separate UUID for learner progress.
title, prompt yes Markdown is not rendered in prompt — keep it plain.
runtime no Overrides the course runtime for this exercise.
starter_code yes in practice Written to exercise.py, and never overwritten once the learner has edited it. Omitted, the learner gets an empty file.
solution no Written to the solution file and shown behind a "Show solution" toggle.
tests no Written to test_exercise.py.
test_command no Defaults to pytest -q. Only pytest … runs in-app.
extra_files no [{ path, content }], rewritten on every open. Every path segment must start with a letter or digit — which rules out .., absolute paths and dotfiles — and .command is refused.
expected_output no The stdout the program must produce. Ignored when tests is present.
stdin no Written to the program's stdin and then closed.
match no exact (byte for byte) | trimmed (default: ignores trailing whitespace and trailing blank lines) | lines (also ignores leading indentation).
packages no The v1.1 spelling of runtime.packages.
verification_instructions yes What the learner should see when it works.
hints no Rendered in a collapsed <details>.
Files, environments, and checks#
Purpose File
Learner's work exercise.py
Checks test_exercise.py
Reference solution solution.py
Course dependencies requirements.txt
Course environment .venv/

Packages are pooled across the course's exercises. The app preserves learner source files and progress when recreating a venv after an app update, relocation, or a broken environment. Existing venvs based on system Python are rebuilt using the bundled interpreter.

Tests must be deterministic, fast, and fail for the untouched starter while passing for the reference solution. Use pytest and import the learner's module as exercise. Test network behavior against local fixtures or loopback servers, not the internet. Avoid brittle exact wall-clock assertions.

Run npm run check:exercises in app/ after editing exercises. The gate uses the same bundled interpreter, scaffolder and runner as the app, checking both the starter (must fail) and solution (must pass).

How the app loads a course#

  1. Courses are imported or created in the editor. ZIP imports are unpacked and validated in staging, then stored under <userData>/users/<user-id>/courses/<course-uuid>/package/. Each import receives fresh course and element UUIDs, including repeated imports of the same archive. The separate document.json contains authored content and local IDs.
  2. course.json is validated against the JSON Schema in app/src/core/schema.ts. A manifest that fails validation is listed with its error instead of being rendered.
  3. Every referenced asset must exist relative to the course root, with an extension on the allowlist above.
  4. Markdown is rendered with markdown-it (CommonMark, no raw HTML) and sanitized through a tag/attribute allowlist before it reaches the DOM.
  5. Assets are served over the app's own opencourse:// scheme, resolved strictly inside the course directory; visualizations additionally run in an <iframe sandbox="allow-scripts"> with no same-origin access.

Stable ids and progress#

Progress lives under <userData>/users/<user-id>/progress/<course-uuid>.json. The app assigns UUIDs to modules, lessons, blocks, quiz options, project items and support-file entries. These IDs are private to the library and are never included in course.json or exported ZIPs. Portable slugs and author ids remain unchanged for archive compatibility.

Renaming, editing, reordering or moving an existing element preserves its UUID, completion, attempts, chats and learner files. Duplicating an element or changing its type creates fresh identities. Saving a deletion removes descendants and attached progress, messages and workspaces after one confirmation. Mixed-lesson chats retain messages attached to surviving lessons. Historical quiz answers are not regraded; attempts referencing deleted options are removed.

Authoring in the app#

Choose Create course in the library, or Edit course on an existing course. Use the outline to add, duplicate, reorder or delete modules, lessons and blocks. The editor covers all fields in this format, including quiz answers, exercise solutions/tests, runtime settings, support files and project definitions. Markdown fields accept Markdown syntax directly and have a live preview; code uses the same editor as exercises. Add block creates a Markdown block; choose its type in the editing form. Each block's editable slug identifies it in the outline.

Nothing you change — by hand or through the assistant — is written to the course until you choose Save (⌘S). The editor shows whether there are unsaved changes. Leaving the editor with unsaved changes asks first; choosing Discard changes leaves the course exactly as it was last saved, and a course that was never saved disappears. A new course appears in the library at its first valid save; learning and export become available then. An invalid save keeps your edits in the editor. Concurrent stale saves are rejected instead of overwriting another window's edits. Opening Settings from the editor keeps the edits waiting for you when you come back.

Upload covers, images, videos/posters and HTML visualizations as attachments. Images and HTML source are not edited in the app. A visualization can be one HTML file, a folder or a ZIP containing its dependencies; choose its HTML entry point. Relative paths remain intact under assets/viz/. Replacement uploads receive new immutable paths. Shared attachments stay until no saved or draft content uses them; imported supporting files are retained conservatively.

Export ZIP… (on the course page) exports the last saved version in format 1.8, with course.json at the archive root and package assets included. Local IDs/documents, drafts, chats, progress, workspaces, environments and recovery data are excluded. Reimporting the export starts a new course with fresh identities and learner state.

Existing user libraries are migrated automatically before loading. UUID mappings, JSON originals and a SQLite backup are retained under .course-migration/ for recovery, including broken courses and ambiguous legacy data. Save operations use a durable journal and SQLite commit marker so interrupted operations recover before a course is exposed.

OpenCourse identifiers#

The display name is OpenCourse. The current archive format version is 1.8; course assets use opencourse://, and the visualization bridge uses /__opencourse/viz-bridge.js and opencourse-viz/opencourse-app messages. These are the only supported app identifiers; there are no old-brand aliases. Course manifests with ordinary relative asset paths remain importable.

The published schema identity is urn:opencourse:course:1.8; this is an identifier, not a hosted schema URL. The bundled example uses the course slug opencourse-example. Keep unrelated course, module, lesson, quiz and exercise IDs stable when editing a course.

Versions and server identity#

version is the author's to set. The editor shows it under Course details, and nothing changes it for you: edit freely, and raise it when you want a learner to see a new version. A server is stricter: it accepts a publish only when the version is higher than every version of that course it has ever published, including versions since deleted.

Element identity comes in two kinds, and the difference is the point:

  • A local course — created in the app, or imported from a .zip — has app-local IDs, private to one library. Importing the same archive twice gives two unrelated courses. Any uids in an imported archive are ignored.
  • A server course — added from an OpenCourse server's catalog — keeps the server's identities: the course and every element carry a uid that stays the same across versions and is the same for every learner. That is what lets an update tell an edited lesson from a new one.

Updating a server course to a newer version keeps all progress attached to an element that still exists, even if it was edited: a completed lesson stays completed, attempts and exercise files stay. Elements the new version removed take their progress with them (the app lists them before updating). New lessons and projects start incomplete.

When you publish, the app writes your course's existing IDs as uids, so your own library is not renamed. A learner cannot change a course they added from a server: saving edits to one creates a local copy with fresh IDs. Export ZIP… never writes uids.

Flashcards and review (v1.4)#

A lesson may have a flashcards array alongside blocks. Cards are review content, not inline lesson blocks. Omit the array or use [] when there are no cards. Cards are supported from version 1.4; project modules have no cards.

json"flashcards": [
  {
    "id": "compiler-output",
    "question": "What does a compiler produce?",
    "answer": "Object code, which the linker combines into an executable.",
    "image": { "src": "assets/img/compiler.svg", "alt": "Compilation pipeline" }
  }
]

id, question and answer are required nonblank strings. The archive ID must be unique within the lesson. Questions and answers support sanitized Markdown. image is optional; its src uses the same course-relative attachment rules as image blocks, and alt is optional. Keep images in this dedicated field so the app can validate, upload and export their attachments.

Each card should test one idea, using only material covered by its associated lesson or earlier lessons. Authors are responsible for this semantic rule. Cards unlock for review only while their associated lesson is marked complete.

In Edit course, use the lesson's Flashcards group to add, edit, duplicate, reorder or delete cards in Manual mode. In AI mode, ask the assistant to create content; the outline's manual creation buttons are hidden. Each card is bound to the lesson where it was created. To associate it with another lesson, create a new card in that lesson and delete the original. The preview reveals answers without affecting progress. Drafts autosave normally; Save course publishes them. Edits and reordering preserve the private card UUID and review history; copies receive fresh IDs and start unreviewed. Deletion uses the existing save confirmation and removes the card's review data. Marking its lesson incomplete temporarily locks the card. Reset review progress on a saved card immediately clears the current user's schedule and history after confirmation, independently of draft saving.

The overview offers Continue and Review, with a 10/15/20-card selector (default 10). Review picks due cards, then unlocked unseen cards. Cards scheduled for the future are not used to fill the requested count; Review is disabled when no due or new cards remain. Each card appears once per session; smaller available queues produce smaller sessions. Reveal the answer and rate Don't know (forgot or incorrect), Hard (recalled with difficulty), Good (correct), or Easy (effortless). Space reveals, keys 1–4 rate, and Escape exits. Each accepted rating saves immediately; exiting or closing the app retains rated cards' progress.

Scheduling uses ts-fsrs 5.4.2's long-term scheduler with a 90% model retention target, no within-day learning/relearning steps, and no interval fuzz. This fits sessions that show each card once: all newly scheduled intervals are at least one day. Intervals adapt to the card's review history and actual elapsed time, including late reviews. With the current default parameters, a new card's first intervals are 1/2/3/8 days for Don't know/Hard/Good/Easy; these are not a fixed ladder for subsequent reviews. Rating buttons show only their labels and keyboard shortcuts; scheduling runs in the background.

Configuration version 2 is recorded with new ratings. Existing history and due dates are preserved; cards saved under the previous minute-step configuration adopt the day-based schedule on their next rating, without resetting progress. The retention target is not a guarantee of observed recall, and the default FSRS parameters are not individually optimized.

The choice of a one-day minimum is a product policy for separate study sessions, not a universal scientific optimum. Research supports retrieval practice and spacing that depends on the retention horizon. Minute-scale learning steps are useful for repeat attempts within one sitting; this app instead uses the library's supported long-term mode.

Recommendations show due and new cards, or the earliest next scheduled review. Estimated recall averages FSRS retrievability across reviewed unlocked cards; it is an estimate, not a course-completion score. Rating logs and card states live in the local user's SQLite database and are excluded from course archives. Importing an exported course creates fresh identities and review progress. Review does not change lesson completion or the Continue destination.

Python cells and course notebook (v1.7)#

A lesson may contain an editable Python cell:

json{"type": "python-cell", "slug": "try-a-calculation", "content": "result = 6 * 7\nresult"}

Cells use ordinary Python. They display stdout, stderr, tracebacks and the final expression's text representation (unless it returns None). They have no tests, solutions or completion requirements. Each Run starts a fresh process, so variables do not survive rerunning a lesson cell or running another cell. Edits and outputs reset when the learner leaves the lesson; Settings and Logs preserve them.

A course containing a Python cell automatically gets a Notebook button below its title in course navigation. Courses without lesson cells can explicitly enable it with "notebook": {}. Optional starter cells are declared as:

json"notebook": {"cells": [{"content": "message = 'Hello'\nmessage"}]}

The starter list is a template copied once, when the learner first enters a lesson or project. Cells receive learner-local IDs and can be edited, reordered or deleted. They never execute automatically. An empty template starts with one blank cell. Deleting all learner cells does not seed them again; changing the template or updating a course does not overwrite learner work. A saved learner notebook stays accessible if the course later removes its Python content.

Notebook cells share one Python process and variables. Stop kernel ends it; Restart kernel creates a fresh namespace. Run after Stop starts a new process. Variables survive navigation but are not restored after app exit, user switching, or course save/update operations. Code, cell order and outputs autosave locally; saved output is marked outdated when the code changes. Notebook activity does not change lesson completion or the course's Continue destination.

The environment is created automatically in the background using the interpreter bundled with the app. The button shows creation/failure status until ready. It is separate from exercise environments and contains only Python's standard library and pip initially—no pytest, IPython or Jupyter. Neither python-cell nor notebook accepts runtime overrides or requirements; course exercise runtime settings do not apply. Learners install packages explicitly using Python code:

pythonimport subprocess, sys
subprocess.check_call([sys.executable, "-m", "pip", "install", "package-name"])

Packages persist across reopening and are available to both notebook and lesson cells. Restart the notebook kernel after upgrading an already imported package. Repair never automatically clears an environment. An explicit rebuild retains a recovery backup but requires reinstalling packages in the new environment.

Only Python cells and text output are supported: no IPython magics, shell syntax, rich image/HTML displays, top-level await or interactive stdin. Source and captured output are limited to 1 MiB per cell/run; extra output is drained and marked truncated. Notebook starter lists support up to 500 cells. Lesson runs have a 120-second timeout; notebook runs continue until finished or stopped.

The notebook's right-side chat shares the course's lesson conversations. It receives a bounded snapshot of saved notebook code and outputs, not live variables. Selecting code or output offers Ask about this, attaching it as a quotation. Notebook code, output and environments are learner data, excluded from exported archives and server publication. Reimporting a course starts fresh learner state.

esc