Building and generating courses

Every course of your own starts from one button, Courses → Create a course (/courses). Your courses are private until you share them (below). On the course list each of yours has Open (to learn from it) and Edit files (this page).

It makes a starter course called Untitled course, with one example lesson, and opens its page with the Generate the course dialog open. Fill it in to have AI write the course, or close it to write the course yourself.

Build one by hand

  1. Close the Generate dialog. Click the title at the top of the page to rename the course.
  2. The course page (/courses/<id>) shows its lessons as a table. The first row is the course settings (course.json).
    • + on a unit adds a lesson there; name it on the spot.
    • Click a name to rename it; drag a row by its grip (or focus it and use ↑ / ↓) to reorder it; ✕ deletes it.
    • ⧉ duplicates a lesson: the copy, called “ (copy)”, lands right below it with new ids, ready to edit.</li> <li><strong>▸</strong> opens a row’s JSON in place. On a new lesson, paste a whole lesson file there. With the <strong>JSON</strong> switch (top right of the table) off, the same file opens as a form instead.</li> <li><strong>Paste a word list</strong> (language courses) turns a list you already have, one <code>word — meaning</code> per line (or a comma, <code>=</code> or tab between them), into a new lesson with those words. It shows what it read first, and the lines it couldn’t, before you add it. No AI key needed.</li> </ul> </li> <li>There is no Save button: every change is saved as you make it (typing, three seconds after the last keystroke). The circle by the course title is green ✓ when everything is saved, yellow while saving, and a red ! with the reason under the title when something is not. The whole course is checked on each save; if it would not load, nothing changes.</li> </ol> <p>The <strong>Undo</strong> and <strong>Redo</strong> icons under the table take back the last stretch of editing (or the last AI change) and put it back, up to ten steps. <strong>Download all files</strong>, at the bottom of the page, exports the course as one JSON file.</p> <p>The format: <a href="/docs/course-structure" data-testid="docs-link" data-slug="course-structure">How a course is built</a>, and every field in the <a href="/docs/course-format" data-testid="docs-link" data-slug="course-format">course format</a>.</p> <h2 id="write-lessons-with-an-llm-you-already-use">Write lessons with an LLM you already use</h2> <p>Paste the <a href="/docs/course-format.md" data-testid="docs-link">course format</a> (raw Markdown) into the chat, then ask for one lesson at a time:</p> <blockquote> <p>Here is the course.json of my course (paste it). Write the lesson file for “Ordering coffee” in unit “1”, group “phrases”, following the format exactly. Reply with JSON only.</p> </blockquote> <p>On the course page: <strong>+</strong> on the unit, name the lesson, <strong>▸</strong>, paste the reply. It saves itself. If it does not validate, paste the error back to the model. An LLM that can fetch pages can start from <a href="/llms.txt" data-testid="docs-link"><code>/llms.txt</code></a> instead.</p> <p>Scripts can send up to 100 files at once while signed in: <code>POST /api/courses/<id>/files</code> with <code>{"files": [{"path": "lessons/coffee.json", "content": "…"}]}</code>. The reply lists what was saved and the course’s validation error, if any.</p> <h2 id="generate-one-with-ai">Generate one with AI</h2> <p>The <strong>Generate the course</strong> dialog opens on a new course, and stays on offer (under <em>Use AI</em>) while you have only changed names (the title, lesson titles and lessons added by name). The AI keeps those names and plans the named lessons first. It needs an API key (Claude, OpenAI or Gemini) saved on your profile; without one the dialog is greyed out with a link to the profile.</p> <ol> <li>Say what you want to learn in a sentence or two.</li> <li>Optionally add your material: notes, homework, a syllabus (pasted, or text files dropped in; up to 60 000 characters). The course teaches exactly that.</li> <li>Pick the kind of course (or let the AI decide).</li> <li>Pick how much to write: the <strong>Full course</strong> (the AI decides how many lessons, up to 15), a <strong>Preview</strong> (one unit with one full lesson), or an <strong>Outline</strong> (every unit and lesson, each a bulleted list of what it will teach plus one example question). The (i) beside the choice explains them.</li> </ol> <p>A progress modal shows the phase and a progress bar, with <em>Cancel</em>. On the course list the course is greyed out as <em>generating</em>, with a <em>Watch</em> link. The model writes an outline, then one lesson at a time; the app validates each one and, if it is wrong, sends it back with the error, up to twice. When it is ready the modal goes and the course’s lessons are there.</p> <ul> <li>A run that stops (error, cancel, restart) keeps what was written. Its card on the course list has a red ! that opens why. <strong>Resume</strong> writes only the missing lessons (or <strong>Retry</strong> when not even the outline was saved).</li> <li>To start a generated course over, delete it and create a new one.</li> </ul> <h2 id="change-a-course-with-ai">Change a course with AI</h2> <p><strong>Use AI</strong>, under the lesson table, uses the first API key saved on your profile (it names the provider only when you have two or more). The switch <em>Allow changes to existing content</em> picks the mode. A second switch, <em>Outline only</em>, makes the lessons it writes outlines instead of full lessons.</p> <ul> <li>Off, <em>Only add</em>: one new lesson, written and placed by the AI. Nothing else in the course may change, or nothing is saved.</li> <li>On, <em>Allow overall changes</em> (any course of yours that loads): say what should change (“more word problems”, “drop the history lesson”). The course keeps working as it was until the whole new version validates.</li> </ul> <p>When the job ends, <strong>Review AI changes</strong> opens: every file the AI added, changed or removed, with a side-by-side diff. <em>Review AI changes</em> under Use AI opens it again later. <strong>Undo</strong> under the table brings the old version back.</p> <p>With <em>Do Not Show Me AI Features</em> on in your profile, none of this is shown.</p> <h2 id="share-a-course">Share a course</h2> <p><strong>Share</strong> on the course page: type e-mail addresses, <em>Send invitations</em>. Each person gets a link; the course then sits on their list (under <em>Shared with you</em> until opened). They learn from it with their own progress; only you can change it, and your edits reach them at once. The dialog lists every address with <em>invited</em> or <em>opened</em>; <em>Remove</em> stops sharing with one.</p> <h2 id="fixing-validation-errors">Fixing validation errors</h2> <p>Every error names the file and the field, for example:</p> <pre><code>lessons/coffee.json: unit 'B' is not in the manifest (units: A1, A2) </code></pre> <ul> <li>On the lesson table, the save is refused and the red ! by the title says why; fix the JSON in the row (▸) and it is saved again.</li> <li>A course that does not load shows its files instead of the table, with the error on top: open the file named, fix it, <em>Save and validate</em>.</li> <li>Common causes: a <code>unit</code> not listed in <code>course.json</code>, a <code>question_id</code> used twice in the course, a multiple-choice <code>correct_answer</code> that is not one of its <code>options</code>, a <code>slug</code> with capitals or spaces.</li> </ul> <p>The rules behind each error are in the <a href="/docs/course-format" data-testid="docs-link" data-slug="course-format">course format</a>.</p> </article> </div> </main> <footer class="site-footer"> <div class="container muted small">Skrongle · <a href="/terms" data-testid="footer-terms">terms</a> · <a href="/privacy" data-testid="footer-privacy">privacy</a> · <a href="/cookies" data-testid="footer-cookies">cookies</a> </div> </footer> <script> // A link to the page you are on says so (aria-current), for screen readers // and tests alike; the Study and Play mode bars set it on the server already. (function () { var here = location.pathname.replace(/\/$/, '') + location.search; document.querySelectorAll('a[href]:not([aria-current])').forEach(function (a) { if (a.origin === location.origin && !a.hash && a.pathname.replace(/\/$/, '') + a.search === here) a.setAttribute('aria-current', 'page'); }); })(); (function () { var m = document.getElementById('account-menu'); if (!m) return; document.addEventListener('click', function (e) { if (m.open && !m.contains(e.target)) m.open = false; }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && m.open) { m.open = false; m.querySelector('summary').focus(); } }); })(); </script> <script> // The app's text for the scripts: I18N holds the keys under common.js (every // page) and what a page adds with Object.assign(I18N, …); t(key, {name: value}) // and tn(key, n) read it (i18n/README.md). window.I18N = {"common.js.all_saved":"All changes saved","common.js.close":"Close","common.js.could_not_save_reload":"Your changes could not be saved. Reload the page.","common.js.leave_anyway":"Leave anyway","common.js.leave_fields":"Some fields need attention and are not saved. Leave and lose those changes?","common.js.leave_latest":"Your latest changes are not saved yet. Leave and lose them?","common.js.leave_lose_these":"{why} Leave and lose those changes?","common.js.leave_title":"Leave this page?","common.js.meaning":"Meaning","common.js.no_voice":"(no {language} voice)","common.js.not_saved":"Changes not saved","common.js.notation_label":"Music notation","common.js.saving":"Saving…","common.js.server_error":"Could not save your changes (the server said {status}). Trying again…","common.js.signed_out":"You have been signed out. Sign in again in another tab, then keep editing.","common.js.stay":"Stay","common.js.tap_to_hear":"Tap to hear it","common.js.unreachable":"Could not reach the server. Your changes are not saved yet; trying again…"}; window.t = function (key, args) { var s = I18N[key]; if (s == null) { console.warn('i18n: no text for ' + key); s = key; } return args ? s.replace(/\{(\w+)\}/g, function (m, n) { return n in args ? args[n] : m; }) : s; }; window.tn = function (key, n) { return t(key + (n === 1 ? '.one' : '.other'), { n: n }); }; </script> <script> // A phone's mode bar scrolls sideways (#423): bring the current tab into view. document.querySelectorAll('.modebar ul').forEach(function (u) { var a = u.querySelector('[aria-current]'); if (!a || u.scrollWidth <= u.clientWidth) return; var ur = u.getBoundingClientRect(), ar = a.getBoundingClientRect(); if (ar.right > ur.right || ar.left < ur.left) u.scrollLeft += ar.left - ur.left - (ur.width - ar.width) / 2; }); </script> <script src="/static/modal.js?v=a63a84e628" defer></script> <script src="/static/speech.js?v=a63a84e628" defer></script> <script src="/static/say.js?v=a63a84e628" defer></script> </body> </html>