A course generator built entirely from VS Code Copilot customization files. The agents,
skills, prompts, instructions and hooks under .github/ are the runtime, and courses/ is
the data they produce. The only application code is web/, a static site that reads that
data and renders it.
Give it a topic and an audience, and it works through audience analysis, curriculum design, learning outcomes, lesson plans and lesson prose — one stage at a time, writing each stage to disk before the next one starts.
- Setup
- Using it
- Reading the courses
- How a course is built
- Skills
- State and staleness
- Validation
- Editing courses by hand
- Layout
- Design constraints
./scripts/setup.shCreates .venv with PyYAML, which the validator needs. Run it once.
Everything is driven from the chat panel. Type / and the prompt name.
Creates a course from scratch.
/new-course a two-hour introduction to Docker for backend developers who have never used containers
It will ask for anything missing from the brief — topic, audience, experience level, time budget, constraints — as a single batch of questions rather than one at a time. Then it runs audience, curriculum, outcomes and assessment, and stops for your approval before any lesson is planned, reporting the module sequence, the time budget, the outcome count and how each outcome is assessed. That checkpoint is deliberate: outcomes are cheap to change and prose is not. After approval it plans every lesson in order, stops once more, and then writes the whole course in a single parallel wave.
This prompt must be run from a top-level chat session. An orchestrator invoked as a subagent has no
agenttool and cannot delegate to the stage agents.
Re-plans and rewrites a single lesson, leaving every other file untouched.
/regenerate-lesson m01-l02
This is the operation the file-based state exists for. If the id does not exist it lists the valid ones and stops.
Rewrites one lesson's quiz and nothing else.
/regenerate-quiz m01-l01
The cheapest regeneration in the pipeline: the quiz is a leaf artefact, so nothing depends on
it and nothing downstream goes stale. Measured on intro-to-containers, regenerating one
quiz left 17 of the course's 19 files byte-identical — the two that changed were the quiz and
the append-only run log.
Read-only. Reports what exists, what is stale, and what the next action is.
/course-status intro-to-containers
Omit the slug to report on every course.
web/ is a static site that renders courses/ for a learner — lessons, exercises, quizzes,
glossary and capstone, with completion tracked in the browser.
cd web
npm install
npm run devIt enumerates courses/ rather than holding a list, so a course appears on the site as soon
as it appears on disk; in dev that happens without a restart. It only ever reads. See
web/README.md.
one stage at a time
audience -> curriculum -> outcomes -> assessment -> lesson plans
then all at once, in a single wave
lesson prose + exercises + quiz for every lesson in every module
capstone project once
Each stage is a separate agent that can only see what came before it. course-orchestrator
is the entry point and the only agent you invoke directly; it delegates to the nine stage
agents below and keeps course.yaml in step with what is on disk.
| Agent | Produces |
|---|---|
audience-analyst |
audience.yaml — learner profile, prerequisites, time budget, misconceptions |
curriculum-designer |
curriculum.yaml — modules, lessons, sequencing, pacing, cognitive load |
outcomes-designer |
outcomes.yaml — measurable outcomes with Bloom levels and evidence |
assessment-designer |
assessment.yaml — diagnostic, formative items per outcome, checkpoints, summative |
project-designer |
project.yaml — capstone brief, per-module milestones, rubric against module skills |
lesson-planner |
one <lesson>.plan.yaml — sections, minutes, outcomes covered, terminology |
lesson-writer |
one <lesson>.md plus its <lesson>.glossary.yaml fragment |
exercise-generator |
one <lesson>.exercises.yaml — standalone practice with hints and solutions |
quiz-generator |
one <lesson>.quiz.yaml — one item per outcome, every distractor justified |
The nine stage agents are not user-invocable. Go through course-orchestrator.
Prose, exercises and quiz are siblings: all three derive from the lesson plan and none of them depends on the others, which is why a quiz can be regenerated on its own.
The stages in the first block — audience through lesson planning — each read what the last one wrote, so they run one at a time. The second block does not. The capstone, and the prose, exercises and quiz of every lesson in every module, all derive from artefacts that are finished by the time planning ends, and none of them reads another. So they are dispatched together, in one batch. A twelve-lesson course is thirty-seven delegations and one round trip rather than thirty-seven round trips.
What makes that safe is not optimism about file locking. It is that no two agents in the wave write the same file, and that holds only because they do not write the shared files at all:
| File | Written by |
|---|---|
course.yaml |
the running stage, if exclusive; otherwise the orchestrator |
glossary.yaml |
the orchestrator, always |
.state/run-log.md |
the running stage, if exclusive; otherwise the orchestrator |
A wave agent returns the manifest change and the log line it would have made, and the orchestrator applies them once the wave is in. Four agents appending to one log lose each other's writes without any of them failing, and a lost status flip is worse than a wrong one: nothing downstream can tell it went missing.
The glossary is the interesting case, because the lesson writer authors the definitions it
uses and needs them before it writes a word. It cannot wait for a merge. So it writes them
beside its lesson, in <lesson>.glossary.yaml, carrying only the terms that lesson
introduces and no first_used — the file belongs to one lesson, so the field would restate
the filename. The orchestrator merges the fragments afterwards and sets it.
That merge is only well-defined because a term is introduced by exactly one plan, which the validator checks. Two plans claiming the same term produce two fragments defining it, and the merge cannot choose between them; either definition might be the good one. Catching it before the wave costs one re-plan, and catching it after costs two lessons.
A lesson plan is written against its siblings. It must not introduce a term an earlier plan already introduced, and it spends from a module-wide terminology budget that only the other plans can tell it the balance of. Planning is also the cheapest stage in the course, so serialising it costs least and buys the most.
That pushes work onto the planner. Anything a wave agent needs to know about the rest of the
course has to be in the plans, because the plans are the last place it can still be written
down. continuity is that field: the running example domain, and what the learner already
holds coming in. It is what the lesson writer used to read the previous lesson's prose to
learn, which is exactly the dependency that made lessons sequential.
It is optional, and deliberately so — a plan without it can still be written, just not alongside the lesson before it. The orchestrator leaves those lessons out of the wave rather than guessing.
Each agent sees only its own inputs, so it cannot quietly paper over a defect upstream. A lesson planner that finds an outcome it cannot cover in the time available reports it rather than silently trimming the outcome. In practice this is where most real defects surface.
Assessment sitting before lesson planning is the same argument. A quiz written after the prose can only test what the prose happened to cover, so it can never reveal that the lesson missed its outcome — the two agree by construction. Deciding the evidence first gives the lesson a target it did not choose for itself.
Examples are the one thing that is not its own artefact. An example cannot be regenerated
without rewriting the paragraphs around it, and a file that can never change alone is a lie
about granularity. So the planner types each one — worked, completion, contrasting,
non_example, analogy — and the writer renders it as an ### heading titled verbatim.
The type carries obligations a restatement cannot meet: a contrasting example needs two
cases and a named dimension they vary on, an analogy needs the point where it breaks down,
a worked example needs three steps or more. Carrying another kind's field fails, because a
wrong label is worse than none — it satisfies the mix check dishonestly. And a lesson with
an outcome at apply or above must carry at least one worked or completion example:
analogies and contrasts explain and bound a concept, but neither shows the learner how the
thing is done.
The heading is what stops a type being a claim the prose never honours.
Everything else in a course marks one outcome at a time. That is what makes quizzes and
exercises markable, and it is also what makes them incomplete: a learner can pass every item
and still be unable to do the job, because the job needs several skills held at once under a
constraint that decides between them. The capstone is the only artefact that tests that, so
its rubric maps to the module-level skills_unlocked in curriculum.yaml rather than to
lesson outcomes.
That mapping is checked in both directions, and each direction catches a different lie. An orphan criterion marks something the course never taught. An unassessed skill means the course claims a capability nothing ever asks the learner to demonstrate — the more common failure, and the one nobody notices, because every individual artefact still passes.
Milestones make the same argument about time. One per module, placed after it, so a misunderstanding surfaces while it is still cheap to fix rather than the night before the hand-in. A milestone may only be marked against skills its own module or an earlier one unlocked; if it needs a later skill, either the milestone or the module order is wrong.
The capstone sits outside the taught-time budget, like exercises, but is bounded by it. A capstone longer than the course preparing it is teaching new material, and that belongs in a module.
Two of the most reliable findings about retention are also two of the easiest to fake, so the pipeline counts them instead of asking an agent whether a course "reinforces its material".
Retrieval. Every glossary term must be asked for in an exercise or a quiz at or after the lesson that introduces it. Being shown a term is not being taught it — the learner recognises it and mistakes recognition for recall.
Spacing. Every term must be met again in learner material after its own lesson. The lesson's own quiz does not count: that is minutes later, with the term still in working memory. Neither does the lesson plan, because nobody reads the plan. For terms introduced in the final lesson the capstone is the only place left, which is one more reason a course should have one.
Both checks count the course's own words, and that is deliberate. A later passage that
paraphrases a term rather than using it does not satisfy spacing. Defining vocabulary and
then avoiding it teaches the concept and withholds the language, and the language is half of
what a course is for — a learner who never acquired the phrase can follow every sentence and
still not recognise it in a manual page or a colleague's question. Enforcing this caught a
real defect on the first run: the capstone marked entirely in paraphrase, asking for "the
reference form to pin it by" in a course that had taught tag and digest.
And a module after the first that re-uses none of the earlier modules' terminology is a separate course sharing a folder. That is checked at the module boundary rather than the lesson boundary, because within a module continuity is nearly automatic — consecutive lessons repeat each other's words without anyone trying. Across a boundary is where a course actually breaks in two, and where the break is invisible, since every module still reads well on its own.
Skills carry the domain knowledge the agents share. They are loaded on demand, not injected into every request.
course-state— the schema, id rules, artefact status lifecycle and the write procedure. Every agent that touches a course file reads this first. Its schema reference is the authority on file formats.instructional-design— backward design, Bloom verb selection, assessment design, example design, capstone design, retrieval and spacing, and cognitive load heuristics. Used by the curriculum, outcomes, assessment, project and planning stages.
courses/<slug>/course.yaml is the manifest. It records every artefact's status:
| Status | Meaning |
|---|---|
missing |
not generated yet |
current |
consistent with everything upstream of it |
stale |
something upstream changed and this has not been regenerated |
Rewriting an artefact marks everything downstream stale, and stale means it must be
regenerated — not merely reviewed. Correcting two fields in curriculum.yaml will
invalidate the outcomes, the assessment, every lesson plan and every lesson. That is
intentional. The alternative is a course whose prose quietly disagrees with its own
curriculum.
Regeneration is largely idempotent in practice: re-deriving an artefact whose inputs have not changed usually reproduces it byte for byte, and the agents will decline to write a file they did not change rather than log a write that did not happen.
.github/skills/course-state/scripts/validate.py runs automatically after every edit, via
the PostToolUse hook in .github/hooks/. No agent has shell access; the hook is how
validation reaches the shell.
To run it by hand:
.venv/bin/python .github/skills/course-state/scripts/validate.py # all courses
.venv/bin/python .github/skills/course-state/scripts/validate.py courses/my-course
.venv/bin/python .github/skills/course-state/scripts/validate.py --strict- FAIL blocks. Something is wrong now.
- WARN marks a state that is valid midway through a write procedure but wrong once the run stops — for example, a lesson's prose not yet matching its regenerated plan. Finish the procedure and it clears.
--strictpromotes every warning to a failure. Use it in CI, or after a run you believe is finished.
Fix the cause of a failure, not the symptom. Numbers that disagree usually mean one side is wrong, not that both need forcing into line.
Don't. Anything under courses/** is regenerable output; a manual edit is silently
overwritten the next time that file is generated, and it will not be reflected in the plan
it came from. Change the artefact upstream of the problem and re-run.
.github/
agents/ eight agents; course-orchestrator is the entry point
prompts/ the four slash commands
skills/ course-state, instructional-design
instructions/ auto-applied rules for courses/**/*.md and courses/**/*.yaml
hooks/ PostToolUse validation
copilot-instructions.md
courses/<slug>/
course.yaml the manifest
audience.yaml curriculum.yaml outcomes.yaml assessment.yaml project.yaml glossary.yaml
modules/<module>/<lesson>.plan.yaml
modules/<module>/<lesson>.md
modules/<module>/<lesson>.glossary.yaml
modules/<module>/<lesson>.exercises.yaml
modules/<module>/<lesson>.quiz.yaml
.state/run-log.md
web/ static site that renders courses/ (read-only)
scripts/setup.sh
implementation-plan.md the spec this was built from
Three rules hold across the whole pipeline, and are worth preserving if you extend it.
- No
model:field anywhere in.github/. The pipeline is provider-agnostic; pinning a vendor string in one agent breaks that. - No agent has the
executetool. Validation reaches the shell only through the hook. - Every subagent is
user-invocable: false.course-orchestratoris the single entry point, so stages cannot be run out of order.