Skip to content

Repository files navigation

StayTuned

Stay Learning — Mastra

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.

Contents

Setup

npm install

Node 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.

Using it

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-travel

new 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.

Mastra Studio showing the create-course workflow as a vertical graph — intake, scaffold, audience, curriculum, outcomes, assessment, design-gate — with a form on the right for topic, audience, experience and time budget

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 mid-run: intake and scaffold ticked at 5ms and 15ms, audience spinning at 5140ms, and curriculum, outcomes, assessment and design-gate still pending, with the intake form on the right filled in for Java developers in financial markets

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.

How a course is built

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

Mastra Studio workflows list: create-course with 7 steps, plan-modules with 3, run-wave with 6, repair-course with 3

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.

The gates

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.

The wave

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.

The repair loop

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.

State and staleness

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 edit

Delete .state/hashes.json and it can be rebuilt by adopting the tree.

Validation

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 typecheck

Scorers

A 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

Mastra Studio scorers list showing distractor-quality and outcome-coverage, each with the description it was registered under

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.

Observability

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.

Mastra Studio observability timeline for a create-course run, spans nested from the workflow run through steps intake at 0.005s, scaffold at 0.016s and audience at 51.9s, into agent run audience-analyst, the model call claude-sonnet-4-6, and its text chunk

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.

Mastra Studio span detail for a model_generation span named claude-sonnet-4-6, showing input tokens 1557, output tokens 17 and total 1574, above the JSON input carrying the audience analyst's system prompt

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.

Configuration

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 -- models

Mastra Studio agents list: Audience Analyst, Curriculum Designer, Outcomes Designer, Assessment Designer, Module Planner, Lesson Writer, Exercise Generator, Quiz Generator and Project Designer, each with the model backing it

The 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.

Reading the courses

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 web

It renders the course library, per-course overview, lessons split into steps, exercises, quizzes, the glossary and the capstone, and tracks progress in the browser.

Layout

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

Differences from the original

  • The orchestrator is gone. It was an agent that read course.yaml and 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.

About

Stay Learning Agent Pipeline for Course Creation - Mastra Edition

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages