How the lesson builder works
A parent says what to learn. For words, Mochi picks them, learns any shapes it doesn't know yet, and teaches them in a short lesson. For math, Mochi dreams up activities for the child, the parent picks one, and an agent builds it.
Last updated October 9, 2026 · See the change log
The big picture
Three parts, all on infrastructure Story Studio already uses:
- The page (this site): the conversation, your saved lessons, and the lesson player. Static files on S3 behind CloudFront.
- The quick steps: one Supabase edge function,
lessonmaker, on zaya-app. It asks Claude Sonnet 5 (on Bedrock) to pick words and write Mochi's lines, saves lessons, and voices them. Each call takes a few seconds.
- The slow step: making a morph Mochi doesn't know yet. It's a
make_morph job on the Story Studio AWS worker, where a Claude Opus 5.5 agent draws, films and fixes the animation (about 20 minutes).
What happens, step by step
- You sign in with the same account as Story Studio. The builder lists Your lessons: ready ones can be played, building ones show their progress.
- You say what to learn ("Teach words in a living room").
clarify checks it's a set of picturable words and asks how many words and whether any come to mind.
- Mochi proposes words.
propose picks exactly that many, including your examples, and prefers words Mochi can already become. Each is marked Ready (in the library) or New. The server checks the model's choices against the real library.
- You approve, removing or adding words.
- The lesson is saved at once (
start). Each New word gets a make_morph job, Mochi's lines are written (copy), and the lesson is stored as building. From here a refresh or a closed tab loses nothing.
- The AWS worker makes the new morphs, several at a time (see below). Each finished morph goes into the shared library, so later lessons offer it as Ready.
- The lesson finishes itself. Once all its new morphs are done, made or given up on, the next
list or get puts the lesson together and voices every line. That's the builder checking every 8 seconds, or anyone opening the page. A word whose morph failed is still taught, with Mochi presenting it as itself, and is left out of the quiz.
- Play opens the player. Mochi turns into each thing and says what it is, and the child can tap to hear the word again. Then a short quiz: Mochi becomes three of the things, and wrong picks get an in-scene reaction.
How a new morph is made
The same way the 74 hand-made morphs were made. Each one was built by a Claude Code agent with one plain brief, so the worker runs that brief too. The brief is chat-mascots/lessons/harness/units/morph-agent.mjs.
- Study: read the morph engine and several finished morphs shaped like the word, in full, and match their style.
- Design rules from your feedback: recognisable outline first, Mochi's face kept on the object, no floating symbols, no gaps between parts, physical motion, one clear gag.
- Film and fix: play it in headless Chromium, capture frames every 33–50 ms through the change, the gag and the change back, look at the frame sheets, fix what looks off, and repeat until it's genuinely good.
After the agent, the job runs MochiMorphs.check() in a sandbox. It plays every outline, the whole gag and every prop without drawing, and fails on errors or broken paths. Then it uploads the file to studio-library/morphs/<name>.js and marks the library entry ready. There's no separate reviewer. An earlier version with a separate vision reviewer made clearly worse morphs, because the writer never saw its own animation.
Finished morphs are saved to the shared library automatically and reused by every later lesson. Nobody approves them first: the agent's own film-and-fix loop is the quality gate.
What a lesson is
A lesson is data, not code: a list of beats. The model only writes words and lines; lessonmaker puts the beats together, so every lesson has the same shape. One player plays any lesson.
{ "id": "beach-422ed7", "title": "Words at the beach", "language": "en",
"beats": [
{ "kind": "say", "line": "Let's go to the beach! Watch me change.", "mood": "excited" },
{ "kind": "morph", "unit": "shell", "word": "shell", "lines": ["Look! I am a shell.", "Can you say shell?"] },
…
{ "kind": "recap", "count": 3, "prompt": "What am I?", "wrong": "Not quite. Try again!",
"items": [{ "unit": "shell", "word": "shell", "right": "Yes! I am a shell." }, …] },
{ "kind": "end", "line": "Hooray! You learned three words today." } ] }
The saved file (studio-library/lessons/<id>.json) holds the spec, the voice (a clip URL and lip-sync timings for every line) and the URLs of any worker-made morphs it uses.
| Beat | What happens | File |
say | Mochi says a line | beats/say.js |
morph | Mochi becomes the thing, the word shows (tap it or Mochi to hear it), Mochi says its lines | beats/morph.js |
recap | Mochi becomes things and the child picks each word; a wrong pick gets a confused in-character reaction | beats/recap.js |
end | A cheer, and Play again | beats/end.js |
Units are the reusable building blocks a beat uses. Morphs are the first kind. A new exercise is a new beat file. A new building block is a new unit kind: a library, a way to make one, a check, and a way to keep it.
Where everything lives
| Part | Where |
| Builder, player, tech spec | design repo, chat-mascots/lessons/ (builder.html is published as this folder's page; cloud.js talks to zaya-app) |
| Morph engine and hand-made library | chat-mascots/mochi-morphs.js, morphs/*.js, morphs/index.json (built by scripts/morph-index.mjs) |
| The morph brief | chat-mascots/lessons/harness/units/morph-agent.mjs (shared by the worker and local runs) |
lessonmaker function | zaya repo, supabase/functions/lessonmaker/index.ts |
make_morph job | design repo, studio-worker/src/jobs/make_morph.mjs, on the Story Studio ECS worker (us-east-1) |
| Hosting | S3 bucket stories.mayalearn.com behind CloudFront; the function stories-folder-index serves folders' index.html and adds the trailing slash |
| Voice | zaya's shared narration() (Mochi's app voice, American), each line kept once in the games bucket |
Data (zaya-app)
| Where | What |
studio_lessons | One row per lesson: owner, title, topic, building/ready/failed, the words (with each new word's library entry and job), Mochi's lines, and the saved file's URL. People read only their own. Only the function writes. |
studio_jobs, kind make_morph | The worker's queue: payload { word, like }, status, a live log (the agent's milestones), cost, and the result. |
studio_library, kind morph | Worker-made morphs (slug morph-<name>): status, files.module (the file's URL), manifest { name, word, about }. Shared with the team. |
Storage studio-library | morphs/<name>.js and lessons/<id>.json (public by URL) |
Sign-in and access
The same as Story Studio: Google with a fortifylearning.com account, or a password-users username that has stories access. lessonmaker checks storymaker_allowed() on every call. Saved lessons are readable by anyone with the link, which suits sharing a lesson with a child.
Time, cost and limits
| Typical |
| Asking, proposing, writing lines, saving | 3–5 seconds each |
| A new morph | about 20 minutes (deer and squirrel: 19–20 min, about 50 agent turns) |
| Model cost of a new morph | about $2.20 (deer $2.20, squirrel $2.17), plus GPU machine time |
| Worker start-up when idle | a few minutes (it scales to zero) |
| New morphs at once | up to 4 per machine × 2 machines = 8. More words wait in the queue. |
| A lesson with only Ready words | ready in seconds |
Changing it
Deploying
- Pages:
chat-mascots/lessons/deploy.sh. It only uploads the builder's files. Don't use studio/deploy.sh for builder changes: it publishes Story Studio from your working copy, unreleased edits included.
- Function:
supabase functions deploy lessonmaker --project-ref brwzqlnbpkrqlulqzany --use-api from the zaya repo.
- Worker (after changing the brief, the engine or the job):
studio-worker/scripts/deploy.sh. It snapshots chat-mascots/, so build from a clean export of the last commit plus your own changes.
- Database: zaya-app's migration history is out of sync, so apply a new migration on its own (
supabase db query --linked -f <file>, then supabase migration repair --status applied <version>), never db push.
Running it locally
npm run voice in chat-mascots/, then localhost:5173/lessons/builder.html. The same pages run against server.mjs: the quick steps in lessons/harness/, morphs from a local claude -p agent into morphs/_drafts/ (with a Keep in library button), and lessons saved to lessons/out/.
Known gaps
- Google sign-in return: it returns here only if this address is on zaya-app's allowed redirect list, which hasn't been checked. Username sign-in always works, and signing in to Story Studio signs you in here too.
- English only for now. Morphs already carry Spanish, French, German and Japanese words, so other languages mostly need a voice for them.
- The prompts exist twice: in
lessonmaker (hosted) and lessons/harness/stages.mjs (local). Change both.
- No human review of new morphs before they join the shared library, and no way yet to remove one from the builder.
- Morph speed: about 20 minutes each, mostly the agent studying and planning before its first draft.
Math activities
The builder's Math mode makes a short, fun activity for a child from whatever the parent asks, like "Division, and my son loves bananas!" There's no curriculum: the parent's request is the brief.
- Understand the request (
math_understand, Sonnet 5): the learner, the goal and the level. It asks at most two short questions, only when something essential is missing (how old they are, what they already know).
- A menu (
math_menu, Opus 5.5, about a minute): 6 activities where doing the thing IS the math, ordered as a path from the gentlest way in, each following the craft guide (lessons/math/CRAFT.md).
- The parent picks one or two (the first, if they don't choose), or steers the menu in their own words. One box under the cards takes changes to any idea, or a request for something completely different.
math_menu runs again with the feedback and the ideas they saw. It changes only what was asked, and any idea it keeps (same name) comes back exactly as it was. Changed ideas are written to stand on their own, because the building agent never sees the feedback. The parent can do this as many times as they like, and each round builds on the last. This is the taste step.
- An agent builds it (
math_start queues a make_activity job on the AWS worker). An Opus 5.5 agent reads the craft guide, the math runtime, the hand-made example activities and games, and writes the activity as one file. It plays it through with the robot, films it, looks at the frames and fixes it. The job then runs the robot itself, voices every line, uploads studio-library/activities/<id>.js and .voice.json, and marks the lesson ready. On AWS that has taken 20 to 60 minutes (the first, Monkey Share Party, took 55), and up to 4 build at once per machine. Each attempt gets 75 minutes and up to 3 attempts. The work is checkpointed to storage every 2 minutes, so a retry carries on, and the agent uses Opus 5.5 through Bedrock's global profile, falling back to the US Opus 5.5 profile. When both are out of capacity it carries on with Opus 5.5 straight from Anthropic's API (two keys, each with its own workspace, tried in turn), and only then drops to Opus 5 and Sonnet 5 on Bedrock. The switch resumes the same session and says so in the job log.
What makes them work
- One scene, Mochi in it. Mochi is a character in the world, never a narrator beside a board. When Mochi is the material, it's Mochi's real body:
ctx.become(shape) morphs its own soft dough and face into a sheet, a loaf or a folded half, like the morphs.
- No arms. Mochi has no arms or hands. When it uses something (a match, a pointer, a stick holding up a sign),
ctx.hold({ to, tip }) draws just the tool coming straight out of its body, behind it, and keeps it on Mochi as it moves.
- The math is the mechanic. Doubles are Mochi splitting in two, arrays are cookies stamped into Mochi's dough, halves are folds that match.
- The robot (
lessons/math/robot.mjs) plays every activity as a kid. It answers each turn wrong first, then right, and fails the activity if a wrong answer is accepted, a right one rejected, the end never reached, or a line breaks the kid-language rules.
- Wrong answers show evidence in the scene, specific to the mistake: the lonely ball with no buddy, the flap that sticks out, the bun count running past the basket count.
Where it lives
| Part | Where |
| Math player, runtime, gadgets, craft guide, hand-made activities | design repo chat-mascots/lessons/math/, published at /lesson-builder/math/ (math.html?lesson=mochi-splits for a hand-made one, ?activity=<id> for a built one) |
| The activity brief | chat-mascots/lessons/harness/units/lesson-agent.mjs (shared by the worker and local runs) |
| The job | studio-worker/src/jobs/make_activity.mjs |
| Data | studio_lessons rows of kind math (the chosen idea, the brief and the job in activity) |
| Locally | scripts/math-menu.mjs --prompt "…", then scripts/make-math-lesson.mjs <id> --menu <menu> --pick <n> (add --revise "<feedback>" to revise one) |
Known gaps
- Mochi's real body is one outline, so when it has to become several pieces (bread slices), the pieces are drawn to look like its dough. A runtime way to split Mochi's real body would fix it.
- The runtime still lacks a few things several activities worked around: where the speech bubble sits, letting taps pass through Mochi, and drawing outside Mochi's box.
- Activities are checked by the robot and by the agent's own eye, not yet by a person before parents see them.
Change log
- October 9, 2026
- Library tab: the app's Mochi-native lessons as a matrix of units against path roles (meet, do, nuance, use, stretch), read from zaya-app, each playable in the player's app mode on an iPhone, iPhone SE, iPad, iPad mini or a laptop browser, true to size or full screen. Every lesson must work on all three screens; activities draw their world past the picture's edges so the player fits them uncropped, and Mochi speaks in Cherry Twinkle at 0.9×.
- Mochi-native lessons in the app (1.3.0):
scripts/mochi-curriculum.mjs syncs the curriculum JSON into zaya-app (a curriculum of kind activity, its published activities in lesson_activities) and uploads hand-made activities; the new mochi-lessons function serves it (curriculum, open, answer, finish, with progress in activity_progress); the player has an app mode (math.html?app=1: fitted to a landscape screen, an exit button, and a message bridge); the app plays each activity full screen in landscape. Older apps keep their storygames.
- A Mochi-native curriculum, separate from the app's textbook one:
lessons/math/curriculum/grade-3.json (13 units in prerequisite order, each a path of activities with a role: meet, do, nuance, use, stretch), shown on the new Curriculum tab with what's built.
- Activity builds fall back to Anthropic's API directly (two keys in the worker's secret,
ANTHROPIC_FALLBACKS) when both Bedrock Opus 5.5 profiles are out of capacity, before any downgrade to Opus 5 or Sonnet 5.
- Play turns in math activities: a kid's turn can be the real action, not a button. Two new gadgets, Draw (draw a shape with a finger, freehand or on dots, and it's recognised) and Launcher (aim and shoot at targets, which can move), plus
kit.drag and kit.loop for agents to build their own. task({ button: null }) takes the action itself as the answer. CRAFT.md says to use them only when they fit.
- The math menu takes the parent's own words. One box under the cards (changes to any idea, or something completely different) remakes the menu, keeping the untouched ideas word for word.
- Math activities in the builder: a request becomes a menu (asking a short question when it must), the parent picks, and a
make_activity agent on the AWS worker builds, checks, voices and saves it. Math activities show in Your lessons too.
- October 8, 2026
- Lessons are saved the moment they're started (
studio_lessons), the builder lists your lessons, and a lesson finishes itself when its morphs are done. A refresh no longer loses one.
- The AWS worker runs up to 4 morph jobs per machine at once (up to 8 in total).
- Hosted at stories.mayalearn.com/lesson-builder: the
lessonmaker function, make_morph jobs on the AWS worker, and worker-made morphs saved to the shared library.
- New morphs are made the way the library was: one Opus 5.5 agent that films and fixes its own work. This replaced a write, check and separate-review pipeline whose morphs looked clearly worse.
- First version: the lesson player, the morph library index and loader, and a local builder.