A course generator. Give it a topic, an audience, an experience level and a time budget, and it works through audience analysis, curriculum design, learning outcomes, assessment, lesson plans and lesson prose — writing each stage to disk before the next one starts.
This is a port of stay-learning, which was built
entirely from VS Code Copilot customization files. There the runtime was .github/: agents,
prompts and skills that a chat panel drove. Here it is Mastra workflows
and a command line, so the same pipeline can run unattended, be resumed after a crash, and be
called from something other than an editor.
What did not change is the part that matters: courses are files. courses/<slug>/ is a
tree of YAML and Markdown that git can diff, a person can edit, and the viewer in web/ can
render without this pipeline present. Nothing about a finished course depends on Mastra.
- Setup
- Using it
- How a course is built
- State and staleness
- Validation
- Scorers
- Observability
- Configuration
- Reading the courses
- Layout
- Differences from the original
npm installNode 24 or newer. There is no build step — the CLI and the workflows are TypeScript run directly, using Node's native type stripping.
An Anthropic API key is needed for anything that calls a model:
export ANTHROPIC_API_KEY=sk-ant-...The read-only commands (list, validate, status, models, adopt) need no key, and
neither does a run that a gate stops.
Any OpenAI-compatible endpoint can answer instead, which needs no provider key at all:
node tools/claude-proxy/server.ts # in one terminal
STAY_MODEL_URL=http://127.0.0.1:8787/v1 npm run course -- new ...tools/claude-proxy serves /v1/chat/completions from a local Claude Code session, translating
each request into one headless turn with the agent scaffolding switched off. Structured output maps
onto the CLI's own JSON Schema validation, so the guarantee every stage depends on survives the trip.
npm run course -- <command> [args]| Command | What it does | Calls models |
|---|---|---|
list |
Every course slug under courses/ |
no |
status [slug…] |
Per-artefact stale / edited / absent, and why |
no |
validate [slug…] |
Every mechanical defect, grouped by file. Exit 1 if any | no |
models |
Which model is behind each role | no |
adopt [slug…] |
Declare the tree consistent, so hand edits stop reporting as drift | no |
new --topic … --audience … --experience … --time … |
Stages one to four, then stops at the design gate | yes |
plan <slug> |
Plans every module, then stops at the plan gate | yes |
write <slug> |
The wave: prose, exercises and quiz per lesson, plus the capstone | yes |
repair <slug> |
Hands mechanical defects back to whoever wrote the file | yes |
resume <run-id> [--approved true] [--comment "…"] |
Answers whatever a suspended run is waiting for | depends |
A typical run:
npm run course -- new --topic "how HTTP requests travel" \
--audience "backend developers" \
--experience beginner \
--time "120 minutes"
# → suspends at the design gate, prints a run id
npm run course -- resume <run-id> --approved true
npm run course -- plan how-http-requests-travel
# → suspends at the plan gate
npm run course -- resume <run-id> --approved true
npm run course -- write how-http-requests-travel
npm run course -- validate how-http-requests-travel
npm run course -- repair how-http-requests-travelnew needs four facts that cannot be inferred — topic, audience, experience, time. Leaving
one out suspends the run at intake and asks, rather than guessing.
mastra dev opens Mastra's local studio against the same workflows, which is useful for
watching a wave run or inspecting a suspended gate.
create-course in the studio. The graph is the first row of the pipeline below, and the form
on the right is the intake: the four fields it cannot infer, plus the ones it defaults.
The same graph, running. Each step reports its own time, which makes the shape of the cost visible
while it happens: the two steps that only touch the disk finish in milliseconds, and everything from
audience onward is waiting on a model.
Stages one to four run in sequence, each reading what the last one wrote:
intake → scaffold → audience → curriculum → outcomes → assessment → GATE 1
↓
plan every module → GATE 2
↓
┌─────────────────────── the wave ───────────────────────┐
│ per lesson: prose + glossary, exercises, quiz │
│ alongside: the capstone │
└────────────────────────────────────────────────────────┘
↓
merge glossary → repair loop
The same pipeline as four registered workflows. The step counts include the gate guards —
plan-modules and run-wave each begin by refusing to run against an unapproved course.
Each stage is a separate agent that can only see what came before it. That is a deliberate constraint rather than an implementation detail: a stage that could see the whole course would quietly optimise against artefacts a person has not approved yet.
Two points in the pipeline stop and wait for a person. They are placed where the cost changes: everything before GATE 1 is four model calls, and everything after it is one call per module and then three per lesson. GATE 2 sits in front of the writing for the same reason — a section in the wrong place costs one plan to fix before it and a whole lesson to fix after.
A gate is not there to check the rules. Those are checked mechanically, and the gate shows you the count. It is there for the one question a validator cannot answer: whether this is the course you wanted.
Because the pipeline is three commands and three processes, an approval given to course new
would be gone by the time course write starts. So each decision is written to
.state/gates.json, along with the hash of every artefact the gate covered:
{
"version": 1,
"gates": {
"design": {
"approved": true,
"at": "2026-08-10T17:53:02.924Z",
"covered": {
"audience.yaml": "3d9aaa963e02973c",
"curriculum.yaml": "1f7d2a7a33440e75",
"outcomes.yaml": "ebf84a3a08b877e4",
"assessment.yaml": "9be466acf83006eb"
}
}
}
}plan refuses to run without the design gate; write refuses without both. An approval stops
counting in three cases: never answered, answered no, or answered about files that have since
changed. The third is the one worth having — approving a curriculum and then editing it by hand
should not leave the approval behind to wave the edit through. A refused run ends cleanly and
exits non-zero, because a person saying no is not a crash.
Planning is serial and writing is not. Once every plan exists, no lesson needs anything from another lesson, so the whole course is written at once — four lessons in flight by default, the capstone alongside them.
What makes that safe is not optimism about file locking. It is that no two agents in the wave
write the same file. Each lesson writer owns its own .md and its own .glossary.yaml; the
shared files — course.yaml, glossary.yaml, .state/ — are not written by wave agents at
all. They are written once, afterwards, by the step that gathers the results. Four agents
appending to one run log lose each other's lines; one step writing four lines does not.
Planning stays out of the wave because the terminology budget is a thing being allocated rather than checked. A module is planned in one call, so a term cannot be introduced twice by construction, and the cap is spent rather than audited.
repair takes every mechanical defect, hands it back to the stage that wrote the file with the
defect list attached, and sees whether the count goes down.
Only leaf artefacts are repaired — prose, exercises, quizzes, the capstone. A defect in the audience, the curriculum, the outcomes, the assessment or a lesson plan is reported and never auto-fixed, because those were approved at a gate and everything downstream was written against what they said. Silently editing one turns an approval into a signature on a document that has since changed.
It stops on any of three conditions: nothing left to hand to an agent, a round that failed to reduce the count, or the two-round cap. The middle one matters most — a model asked twice for the same correction usually returns the same text, and without that check the loop would spend its whole budget confirming a stalemate. Whatever survives is reported, which is the outcome a person can act on.
Three files under courses/<slug>/.state/, all tracked by git:
| File | What it holds |
|---|---|
hashes.json |
Each artefact's own hash, and the hashes of its direct inputs when it was written |
gates.json |
Gate decisions, and what each one covered |
run-log.md |
One line per stage, appended |
Staleness is derived rather than declared. The original stored current | stale | missing in
course.yaml and required every agent to keep it honest; a stored flag can disagree with the
disk in either direction, and a lost flip is invisible. Here stale is a question — rehash the
inputs and see whether they still match.
Only direct inputs are recorded, which means a regeneration that reproduces its input stops
the cascade there. Correcting two fields in curriculum.yaml no longer invalidates every lesson
when the outcomes come back byte-identical.
npm run course -- status how-http-requests-travel
npm run course -- adopt how-http-requests-travel # after a deliberate hand editDelete .state/hashes.json and it can be rebuilt by adopting the tree.
Ten validators run over a loaded course and return a flat list of defects, each with a code, the file it belongs to, and where inside it:
$ npm run course -- validate
how-http-requests-travel: 7 defects
project.yaml
prj [rc1] level 'developing' says 'correctly', which grades rather than describes.
modules/m01-before-the-first-http-byte/l01-from-a-name-to-an-address.md
st [m01-l01] has a 33-word sentence, the limit is 30
They cover structure, outcomes and their Bloom levels, assessment coverage, the capstone rubric,
lesson plans, practice floors, prose style and diagrams, the glossary, and retrieval and spacing
counted rather than asserted. The suite is a TypeScript rewrite of the original's validate.py,
and the test asserts it reaches the same verdict on the same fixture course.
npm test
npm run typecheckA validator answers a question with a fact; a scorer answers one with an opinion. The dividing line is whether counting settles it. Everything countable is a validator, and the three places the validators decline to check name what is left:
"What is not here is anything requiring judgement: whether an analogy holds, whether a distractor is plausible, whether the prose is worth reading." —
validators/index.ts
Two are built:
| Scorer | What it measures |
|---|---|
distractor-quality |
Whether a multiple-choice item measures anything — per wrong option, does it embody the belief why_wrong claims, is that belief one this learner actually holds, and can it be eliminated without knowing the material |
outcome-coverage |
Whether the lesson teaches each outcome it claims, as opposed to naming it in a heading |
Both registered, so the studio can run either by hand against a course. The description is the one on the scorer itself — it is what a reader sees when deciding which to reach for, so it says what the scorer measures rather than what it is called.
distractor-quality works because the schema already does half the job: why_wrong is required
on every incorrect option and must "name the belief someone holding it would have". That turns a
vague question — is this distractor plausible — into a checkable one, because each distractor
arrives with its claim attached. The score is the fraction of distractors that survive all three
tests, per item rather than per quiz, because an average over a quiz hides which item is weak.
outcome-coverage asks the model to quote the passage that teaches each outcome. A model asked
for a verdict alone will supply one for an outcome the lesson never touches; a model asked to
quote has to find the passage first. mentioned scores half — an outcome the lesson gestures at
is not the same failure as one it never reaches.
Scorers do not gate anything. A defect is a fact and can stop a run; a score is arguable, and that is the point. They run over a set of courses to answer whether a change to a prompt or a reference document made the output better — a question about the method, not about one file. They are registered with Mastra so the studio can run them by hand, and deliberately not attached to the agents: a scorer on the quiz generator would fire inside the wave, once per lesson, turning a judgement about the method into a per-run cost.
They mark with the judging role, which until now was declared in models.ts and used by
nothing.
Two more are specified and unbuilt, each named in the validator that declined to check it: whether an analogy holds, and whether Bloom trends upward across a module given that a lesson opening a genuinely new topic is allowed to drop back.
Every agent call and workflow step emits a span, written to the same storage as the runs and read
back by the studio. It is on by default; STAY_TRACING=0 turns it off for a batch run where the
trace rows are not worth the writes.
One run, nested: the step, the agent inside it, the model call inside that. The durations are the
column worth reading — intake and scaffold are milliseconds because they only touch the disk,
and audience is fifty seconds because it is the first stage that thinks.
The reason to leave it on is that the failures worth catching here are not exceptions. A stage that returns a well-formed artefact from a misread profile costs a whole wave downstream, and nothing in the artefact says so. The only way to see it is to read what the stage was actually sent.
A single model call: what it cost, and the exact prompt behind it. That system prompt is the one assembled from the shared preamble and the agent's own instructions — which is the thing to check first when a stage starts producing something odd.
| Variable | Default | What it changes |
|---|---|---|
ANTHROPIC_API_KEY |
— | Required for any stage that calls a model, unless STAY_MODEL_URL sends it elsewhere |
COURSES_DIR |
<root>/courses |
Where courses live |
STAY_RUNS_DB |
<root>/.runs/runs.db |
Workflow run storage, which is what makes gates survive a process |
STAY_MODEL |
— | Move every role to one model |
STAY_MODEL_<ROLE> |
— | Move one role: ANALYSIS, PLANNING, WRITING, JUDGING |
STAY_MODEL_URL |
— | Send every role to an OpenAI-compatible endpoint instead of to a provider |
STAY_TRACING |
— | 0 stops spans being written |
STAY_WAVE_CONCURRENCY |
4 |
Lessons in flight during the wave and the repair loop |
STAY_REPAIR_ROUNDS |
2 |
Cap on repair rounds |
Both roots resolve against the project root rather than the working directory, because mastra dev
bundles into .mastra/output and serves from there — relative paths gave the terminal and the studio
a courses tree each.
src/mastra/config/models.ts is the only file in the project that names a provider. Every stage
asks for a role — the kind of thinking it needs — and never for a model, so swapping provider
is one edit there and none anywhere else. The roles are separated because the jobs differ:
deciding what a course contains is the expensive reasoning, and producing four quiz distractors
from a finished plan is not.
npm run course -- modelsThe nine stage agents, and the role split resolved to actual models — planning on the stronger model because every later stage inherits its mistakes, writing on the cheaper one because it runs once per lesson per artefact and its output is checked.
web/ is a static Astro site that reads courses/ directly. It is unchanged from the original
— the pipeline was rewritten around it, not the other way round.
npm install --prefix web # once
npm run webIt renders the course library, per-course overview, lessons split into steps, exercises, quizzes, the glossary and the capstone, and tracks progress in the browser.
cli/index.ts the command line
src/mastra/
index.ts agents, workflows and run storage registered
agents/ the nine stage agents
workflows/ create-course, plan-modules, wave, repair, persist
schemas/ zod schemas, shared by agents and validators
validators/ the mechanical rules
scorers/ the judgements the validators decline to make
store/ files, paths, manifest, hashes, gates, course loading
prompts/ shared preamble and the reference material agents cite
config/models.ts role → model
courses/<slug>/ the output, and the point
web/ the viewer
- The orchestrator is gone. It was an agent that read
course.yamland delegated; here the sequencing is a workflow, so it is code rather than instructions a model has to follow. - Staleness is derived, not stored. See State and staleness.
- Gates are enforced. In the original, approval rode on conversational continuity — the orchestrator asked and kept going in the same session. Split across three commands, that had to become something written down.
- A repair loop exists. The original reported defects and left them.
- Modules are planned in one call, not one lesson at a time, which makes the terminology cap an allocation rather than a check.
- The validator is TypeScript, not Python, so there is no second runtime to install.






