learn-up — build a study app for any topic
You are building a local, single-user study web app for a topic the user wants to learn.
The app is a faithful generalization of a production exam-prep app: a React SPA + FastAPI
backend, content authored as version-controlled YAML/Markdown and seeded into an embedded
DuckDB database. One learn-up repo can hold many independent topics; the home page
is a topic picker.
This skill runs in phases. Do them in order. Each phase has a detailed reference doc under
references/ — read that doc at the start of the phase before acting. Do not try to hold
the whole build in your head; pull each reference in as you reach its phase.
Golden rules
- Ask before authoring. Run the topic intake (Phase 1) for newly authored topics. For an
exported topic, preserve its recorded scope and content, skip topic questions, and ask only
for missing destination app configuration per
references/intake.md. - Everything is blueprint-tagged. Every lesson, question, and lab references an
objectivecode that exists in that topic'ssyllabus.yaml. Off-syllabus content is a bug. A content validator must fail loudly on any gap or dangling tag. - Fail loudly. No silent defaults, no graceful degradation that hides missing content or bad input. Raise/assert on unexpected shapes. This applies to the app code you generate too.
- Every newly authored lesson opens with the Gemini Notebook video placeholder (exact text
in Phase 4). This is non-negotiable and is the reason the
sources/folder exists. Resolving it into a real video is user-paced and per-lesson (never bulk-generated), via one of four paths (an in-app button being the default) — seereferences/notebooklm-automation.md. - Reuse the design system and brand assets verbatim. Copy
assets/index.css,assets/learn-up-logo.webp, and the four favicon/app-icon assets — do not re-invent the styling or topic-neutral learn-up mark. - DuckDB, not Postgres. Synchronous SQLAlchemy +
duckdb_engine. Seereferences/tech-stack.md. - Source quality sets the ceiling. Organize, explain, and test the supplied evidence; never
turn weak, stale, incomplete, or contradictory material into confident claims. Prefer primary
and official sources, record limitations in
SOURCES.md, and tell the user when the available material cannot support an objective reliably. - Topic archives remain untrusted data. Tell recipients to import only from trusted people and sources. Use the copied topic-transfer assets and their temporary validation pipeline verbatim; never extract an archive directly or recreate its protocol from prose.
Phase 0 — Locate/target the repo
- Run
dateand note today's date (used for the changelog and any pacing math). - Recognize an ARCHIVE run before interpreting a topic name:
learn-up file.ziporlearn-up URL, including$learn-upin Codex and/learn-upin Claude Code. A standalone HTTP(S) URL means a download link to an exported topic; it need not end in.zip. A URL or file explicitly supplied as source material for a named topic remains part of normal intake. Readreferences/topic-transfer.md's Build or extend from an archive section. It locates the destination, acquires and validates the archive, and derives the topic from its contents. For a new app, run only the destination-configuration intake, skip Phases 2–4, then scaffold in Phase 5 and import before its live smoke test. For an existing app, use the transfer workflow without topic intake or authoring. Do not derive a slug from the filename or URL. Explicit export/share/validate/import requests are TRANSFER runs using the same reference; validation alone must not create an app. These modes bypass the remaining Phase 0 steps. Never inspect or extract an archive with ad hoc shell commands. - Check whether this invocation is actually a GENERATE-VIDEO run instead of a build: a request
to generate/fetch a specific lesson's video against an app that already exists (e.g. "generate
the video for lesson 1.1 of github-actions", "make the Gemini Notebook video for the founding-myths
lesson"), as opposed to a topic name to build/add. If so, skip the rest of Phase 0 through
Phase 6 entirely — go straight to
references/notebooklm-automation.mdand follow Path 2 (MCP-driven, via chat) unless the user asks for the button, the script, or the manual path instead. This mode works against an existing app at any time, not just right after a build, and is the normal way lesson videos get resolved (never bulk — see golden rule 4). - Otherwise, determine the topic from the invocation text or the user's request. If absent,
ask for it. Invocation syntax varies by host: Claude Code commonly uses
/learn-up, while Codex uses$learn-up; do not assume one syntax in generated user-facing text. - Decide the target
learn-updirectory:- If the current working directory already is a
learn-uprepo (haspyproject.tomlwith namelearn-upand acontent/dir), or a./learn-upsubdir exists → this is an ADD-TOPIC run. Readreferences/multi-topic.mdand confirm with the user you're adding a new topic to the existing app, not starting over. - Otherwise → this is a NEW-APP run. You'll create the
learn-up/folder in Phase 5.
- If the current working directory already is a
- Derive a
topic_slug(kebab-case, e.g. "Ancient Rome" →ancient-rome). It namespaces this topic's content, sources, and routes; it must be unique within the repo.
Phase 1 — Intake (interactive) → references/intake.md
ARCHIVE runs: follow the exported-topic branch in references/intake.md; the topic interview
and intake-writing instructions below apply only to newly authored topics.
Use the host's interactive question or selection UI when it is available; otherwise run the same short structured interview directly in chat. You must learn:
- Current knowledge of the topic (novice / some / strong) — sets lesson depth and prerequisites.
- Objective — why they're learning it, target outcome, any deadline. Free-form; capture verbatim.
- Existing material — invite them to drop files into
sources/<topic_slug>/now, or point you at URLs/paths. Anything they give is a first-class source. - Exam/certification? — if the topic has a real cert exam, enable the mock + strategy modules and capture its real facts (question count, time, pass mark). Else keep them optional.
- Hands-on? — if practical (a tool/language/craft), enable labs (guided self-check exercises).
- Content language — the language every lesson/question/lab is authored in for this topic.
Defaults to English. Never changes the app's UI chrome, even on an ADD-TOPIC run into an app whose
UI is in a different language than the new topic's content — see
references/multi-topic.md. - NotebookLM output language — the exact supported locale code for generated artifacts, selected
from
notebooklm language list --json(for example,en,es, ores_419). Prefer a regional variant when the user specifies one; do not infer a generic locale when their preference is known. - FAQ LLM backend — choose
claude_cli,codex_cli,antigravity_cli, oropenhandsbased on the user's active agent and preference. Prefer the authenticated CLI they are already using; never silently switch providers or introduce an API charge.
Write every questionnaire answer, follow-up answer, and user-supplied configuration constraint into
the structured sources/<topic_slug>/INTAKE.md contract in references/intake.md so later phases
and the About page can show them. Do not proceed until the objective and the two module toggles are
settled.
Phase 2 — Gather sources → references/sources.md
Use the host's web search/browsing capability to find the most authoritative, current sources for
this topic (official docs, standards, canonical textbooks/guides, reputable references). Download
the best ones into sources/<topic_slug>/ as PDF, plain text, or Markdown, alongside anything the
user provided. NotebookLM's upload endpoint does not support .html or .htm files: convert every
HTML page to a readable .txt, .md, or .pdf file before it enters this upload corpus. Keep a
sources/<topic_slug>/SOURCES.md index: filename, title, user-provided/agent-gathered origin, URL,
authority/freshness, why, and limitations. These files are what the user will feed to Gemini
Notebook per lesson, so name them clearly and use each converted filename in the index and lesson
placeholders. Index every received source, even when it is not used.
Phase 3 — Design the syllabus → references/content-schema.md
Size the topic and write content/<topic_slug>/syllabus.yaml (the generalized "blueprint"):
- Small topic → a single domain with several subdomains (objectives).
- Bigger topic → up to 10 domains, each with subdomains (objectives).
Record the intake's authoring language as
content_language(for example,EnglishorEspañol) and its validated NotebookLM locale code asnotebooklm_output_language(for example,enores_419) insyllabus.yaml. Assign each domain aweight_pctby importance; weights must sum to 100. Every objective gets a stablecode("1.1","1.2", …), atitle, and atopics:list. Confirm the outline with the user before authoring lessons.
Phase 4 — Author content → references/content-schema.md
For every objective, author (all tagged with the objective code):
- A lesson
content/<topic_slug>/lessons/<objective>/<slug>.md— Markdown with frontmatter, a body, and a## Why It Matterssection. The very first line of every lesson body is the video placeholder (see below). - 3–5 questions
content/<topic_slug>/questions/<objective>.yaml— single/multi choice, each choice (including distractors) carrying an explanation. - Optionally a lab if hands-on was enabled.
Plus, if enabled: the mock/assessment
mocks/<code>.yamland strategy content.
The mandatory first line of each lesson body (replace <this document name> with the lesson's own
source filename, and <topic_slug>/<slug> with this lesson's own values — the same ones used in
its file path content/<topic_slug>/lessons/<objective>/<slug>.md):
[placeholder]Upload this document and the documents in /sources to Gemini Notebook and ask it to create a video summary with the prompt "Limit the topics to what's in <this document name>.", then put it here. There's no drag-and-drop: click Generate Video above to do this automatically, or if you generate the video yourself, either ask your LLM assistant to place it, or do it yourself — save the download as media/<topic_slug>/<slug>.mp4, replace this entire placeholder block with a markdown video link to that file, then re-seed the content.[/placeholder]
The added sentence exists because the naive fix — just dropping the .mp4 at the right path — is
not enough and silently looks like it worked: the video-status check falls back to "does the file
exist on disk," which flips to done immediately, while the lesson's actual body/DB row still holds
the raw placeholder text until something rewrites it. See references/notebooklm-automation.md's
"manual-placement pitfall" note for the mechanism.
Phase 5 — Scaffold or extend the app
ARCHIVE new-app runs: use the exported syllabus and topic settings when scaffolding below.
Follow references/topic-transfer.md's new-app sequence to install dependencies and import through
the real adapter before the live smoke test. Skip source gathering and syllabus/content authoring;
preserve exported content and resolved video links. Record destination configuration in root
ABOUT.md instead of rewriting imported intake. Phase 6 uses the destination's selected backend.
- NEW-APP: build the repo per
references/tech-stack.md,references/backend.md,references/frontend.md,references/ui-design.md. Configureapp/main.pywith the mandatory localhost security middleware stack (TrustedHostMiddlewarefor loopback hosts,Sec-Fetch-Site: cross-siteand foreign-origin rejection, and per-runX-LearnUp-Tokenvalidation) perreferences/backend.md. Configure the frontend API client to retrieve the session token on initial load and sendX-LearnUp-Tokenon all API requests perreferences/frontend.md. Copyassets/index.cssverbatim intofrontend/src/index.css. Copyassets/learn-up-logo.webp,assets/favicon.ico,assets/favicon-32.png,assets/favicon-512.png, andassets/apple-touch-icon.pngverbatim intofrontend/public/, then add the canonical favicon and theme-color tags fromreferences/frontend.mdtofrontend/index.html. Copyassets/gitignore.templateto.gitignoreat the repo root, then rungit init. Do not make an initial commit — leave that for the user to review and do themselves. Create rootABOUT.mdand its About API/page perreferences/about.md. Create the generated repo's ownAGENTS.mdby copyingassets/agents.template.mdrather than writing one from memory. Replace<app_version>with1.0and<faq_llm_backend>with the intake-selected backend, remove the template note, and tailor feature/command statements to the app actually generated. Before handoff, fail if any angle-bracket template placeholder remains, and verifyAGENTS.mdagrees with the generatedREADME.md, configuration, structure, and enabled modules. The generated README must identify the current app compatibility version and point maintainers toAGENTS.md's About/version rules. Also scaffold the Gemini Notebook video-placeholder resolution tooling perreferences/notebooklm-automation.md: themedia/static mount inapp/main.py, the/mediavite proxy entry,assets/lesson_video_service.py→app/services/lesson_video.py,assets/generate_lesson_video.py→scripts/generate_lesson_video.py,assets/VideoGenerationPanel.tsx→frontend/src/components/VideoGenerationPanel.tsx, the twovideo/generate+video/statusAPI endpoints andVideoStatusOutschema, the lesson page's "Generate Gemini Notebook video" button wiring (rendered inline byMarkdownin place of the placeholder, not a separate box — seereferences/frontend.md), the opt-innotebooklmdependency group inpyproject.toml, and a "Generating lesson videos" section in the generatedREADME.md. Also copyassets/SelectionAsk.tsx→frontend/src/components/SelectionAsk.tsxverbatim for the select-to-ask FAQ widget — seereferences/frontend.md's Components section for why. Scaffold topic transfer perreferences/topic-transfer.md: copyassets/topic_transfer/toapp/services/topic_transfer/, the CLI/router/frontend helper/component/contract-test assets to their documented destinations, all byte-for-byte. Generate only the narrowtopic_transfer_adapter.py, register the copied router, and mount the copied panel. Do not edit or recreate the archive protocol package. Add the trusted-source disclaimer to README, About, CLI, and UI. - ADD-TOPIC: follow
references/multi-topic.md— usually you only add content + seed the new topic; no app-code changes if the app was built topic-aware (it should be). A current app already includes topic transfer. If an older app does not, add the copied assets as a backward-compatible app update, increment the app minor version, and document it before importing. - If you parallelize backend and frontend work (e.g. across two subagents), both MUST be pinned to
the exact API contract in
references/backend.md's "API contract" section — that section is the literal source of truth (field names, nullability, flat-vs-nested shapes), not a paraphrase. Two independently-built halves working only from prose descriptions of "what the dashboard shows" or "what a lesson looks like" will diverge — this happened in a real build (nested vs. flat dashboard data,checksvsself_check, a raw FAQ string vs. a parsed list, a list-returning endpoint treated as single-object, several fields the frontend expected that the backend never returned) and produced an app that built cleanly on both sides but crashed on first page load. A cleannpm run buildonly proves the frontend's own types are internally consistent — it proves nothing about whether they match the real backend. - Before moving to Phase 6, run a live integration smoke test no matter how confident either side
seemed in isolation: start the backend,
curlevery endpoint the frontend calls with real seeded data, and diff the JSON response againstfrontend/src/api/types.tsfield-for-field. Fix every mismatch (prefer adjusting the frontend to match the backend's contract, since the backend's tests already pin its behavior) and rebuild both sides before considering Phase 5 done. Then actually open the app in a browser (dashboard, a lesson detail page reached via the objective route, a quiz question, a lab) and check the browser console for errors — a 200 response on every network request does not mean the page rendered anything. Explicitly check the topbar itself while inside a topic (nav links to Lessons/Labs?/Strategy?/Badges/About, and the topic name) — a route-nesting mistake (AppShellas the parent ofTopicLayoutinstead of nested inside it, seereferences/frontend.mdRouting section) leaves the topbar silently stuck on "no topic loaded" on every page forever, with a perfectly fine-looking page body right below it and zero console errors. Don't just glance at whether the main content rendered — check the chrome around it too.
Phase 6 — Seed, validate, run
Per the generated README.md:
- Install the dependencies for the configured FAQ backend before seeding or starting the app.
Build one
uv synccommand containing every optional group needed for this run;uv syncprunes packages from groups that are not selected, so syncing one group separately can remove another feature's dependencies:- With the intake-selected
claude_cli,codex_cli, orantigravity_clibackend, runuv sync; no CLI backend needs a Python dependency group or an app-specific API key. - With
LEARNUP_LLM_BACKEND=openhands, include--group openhands. This is required even though OpenHands is an optional dependency group;uv syncalone does not installopenhands.sdk. - When NotebookLM tooling is enabled or being used, include
--group notebooklmas well. For example, an OpenHands app that also generates lesson videos must runuv sync --group openhands --group notebooklm, not two separate sync commands. - If
.envexists, read itsLEARNUP_LLM_BACKENDvalue without exposing secrets. Treat an unset value as the backend selected during intake and compiled intoSettings; fail loudly on an unknown value rather than silently selecting a different backend.
- With the intake-selected
- After syncing, verify the selected backend's availability with the app's backend check before
starting the FAQ smoke test. For
openhands, this must check both thatopenhands.sdkimports and that the configured provider settings are present. Do not auto-detect or fall back to another backend. uv run python -m app.content.seed(loads every topic's content into DuckDB)uv run python -m app.content.validate(fails loudly on coverage gaps, dangling tags, malformed app versions, missing About documents, or incomplete intake/source records) It also fails when the copied topic-transfer package, implementation identifier, router/CLI/UI assets, or contract test is missing or changed.- Before starting either server, check whether the default ports (8011 backend, 5173 frontend) are
already bound by an unrelated process using a platform-appropriate command or Python socket
probe (see
references/tech-stack.md). If so, do not kill it — pick free ports instead, updatefrontend/vite.config.ts's proxytargetto match the backend port you actually used, and report the real ports to the user (don't assume 8011/5173). uv run uvicorn app.main:app --reload --host 127.0.0.1 --port <port>and, fromfrontend/,npm install && npm run dev -- --port <port>. Confirmuv run uvicorn ...works with zero env-var overrides — if it needsLEARNUP_DATABASE_URL(or any prefix) set manually to avoid a collision, that's a bug inapp/config.py's env-prefix setup, fix it rather than documenting the workaround.- Smoke-test the select-to-ask FAQ using the backend actually selected by configuration. With
claude_cli, test the authenticatedclaudeCLI on PATH. Withcodex_cli, test the authenticatedcodexCLI on PATH. Withantigravity_cli, test the authenticatedagyCLI on PATH usingagy -p. WithLEARNUP_LLM_BACKEND=openhands, test the configured provider/model (including a local Ollama endpoint when used) after installing theopenhandsgroup. If the selected backend cannot answer, report its concrete missing dependency, executable, credential, model, or endpoint; do not switch backends silently. Seereferences/backend.md's "Select-to-ask FAQ". Also smoke-test localhost security rejection: confirmcurl -H "Host: attacker.com" http://127.0.0.1:<port>/api/healthreturns HTTP 400 Bad Request, and/api/topicswithoutX-LearnUp-Tokenreturns HTTP 401 Unauthorized. Report the URLs. If validation fails, fix the content gap it names — do not weaken the validator. - Fetch each topic's
/api/t/<topic_slug>/about, open its About page, and compare the displayed app version, questionnaire/configuration values, source entries, and change histories against the files on disk. A fresh NEW-APP build must report version1.0. - Run
uv run pytest tests/test_topic_transfer_contract.py, export one real topic, validate the archive withscripts/manage_topic_transfer.py import ... --dry-run, and confirm the About page shows the trusted-source warning and transfer provenance after an import smoke test.
Reference index
| File | Read during |
|---|---|
references/intake.md |
Phase 1 — the interview questions & module toggles |
references/sources.md |
Phase 2 — searching + downloading sources |
references/content-schema.md |
Phases 3–4 — syllabus, lessons, questions, labs, mocks, placeholder |
references/tech-stack.md |
Phase 5 — stack, deps, DuckDB adaptation, commands |
references/data-model.md |
Phase 5 — DuckDB schema (multi-topic), SQLAlchemy models |
references/backend.md |
Phase 5 — FastAPI structure, API surface, algorithms (SM-2, grading, readiness, mock), and the authoritative API contract every response must match |
references/frontend.md |
Phase 5 — React pages, routing, topic picker, components |
references/ui-design.md |
Phase 5 — design tokens, fonts, components (uses assets/index.css) |
references/multi-topic.md |
ADD-TOPIC runs — extending an existing learn-up repo |
references/notebooklm-automation.md |
Phase 5 scaffolding, and any GENERATE-VIDEO run — resolving the video placeholder (button / MCP on-demand / script / manual) |
references/about.md |
Phase 5 scaffolding and later app/content updates — About data, compatibility versioning, validation, and rendering |
references/topic-transfer.md |
Phase 5 scaffolding and TRANSFER runs — canonical assets, archive format, secure validation, compatibility, updates, and Q&A merge |
Assets (copy into the generated repo):
assets/index.css→frontend/src/index.cssverbatim (the whole design system, incl. the.video-placeholdercall-out). Optionally retheme only the color tokens at the top per topic.assets/learn-up-logo.webp→frontend/public/learn-up-logo.webpverbatim. Render it on the home topic picker and asTopBar's linked home mark exactly as specified inreferences/frontend.md. Preserve this app-wide asset on ADD-TOPIC runs.assets/favicon.ico,assets/favicon-32.png,assets/favicon-512.png, andassets/apple-touch-icon.png→frontend/public/verbatim. These are the topic-neutral learn-up book-and-fruit-tree mark; wire all four intofrontend/index.htmlexactly as shown inreferences/frontend.md. Never regenerate or retheme them per topic.assets/agents.template.md→ the generated repo'sAGENTS.md. Copy it first; fill<app_version>and<faq_llm_backend>, tailor it to the actual build, delete its template note, and fail if an angle-bracket placeholder remains. Do not re-create its rules from memory.assets/gitignore.template→ the repo root's.gitignoreverbatim (excludeslearn_up.duckdband its.walsidecar,.claude/runtime state,.venv/,node_modules/,frontend/dist/,.env, and — critically —*.mp4, since generated lesson videos undermedia/<topic_slug>/can be tens of MB each and don't belong in git history). Rungit initright after copying it, before any files are ever staged.assets/lesson_video_service.py→app/services/lesson_video.pyverbatim.assets/generate_lesson_video.py→scripts/generate_lesson_video.pyverbatim.assets/VideoGenerationPanel.tsx→frontend/src/components/VideoGenerationPanel.tsxverbatim. All three are the "Generate Gemini Notebook video" button / script (Paths 1 & 3 ofreferences/notebooklm-automation.md) — don't re-derive any of them from prose.assets/SelectionAsk.tsx→frontend/src/components/SelectionAsk.tsxverbatim — the select-to-ask trigger + question panel. A from-scratch reimplementation shipped a real bug once (panel unmounts itself the instant you click into its own textbox); seereferences/frontend.md's Components section for the mechanism.- Topic-transfer assets → the exact destinations in
references/topic-transfer.md, all verbatim:assets/topic_transfer/,assets/manage_topic_transfer.py,assets/topic_transfer_router.py,assets/topicTransfer.ts,assets/TopicTransferPanel.tsx, andassets/test_topic_transfer_contract.py. The Python package is the wire-format source of truth; model-authored code may implement only its narrow adapter and registration points.
Work through the phases top-down. Keep the user in the loop at the syllabus outline (Phase 3) and before a long content-authoring pass (Phase 4).