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.jsonmust 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.jsonimports fine,a/course.jsonalongsideb/course.jsondoes 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_Storeand._*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#
- 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 separatedocument.jsoncontains authored content and local IDs. course.jsonis validated against the JSON Schema inapp/src/core/schema.ts. A manifest that fails validation is listed with its error instead of being rendered.- Every referenced asset must exist relative to the course root, with an extension on the allowlist above.
- Markdown is rendered with
markdown-it(CommonMark, no raw HTML) and sanitized through a tag/attribute allowlist before it reaches the DOM. - 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. Anyuids 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
uidthat 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.