The mill that grinds questions into knowledge.
Quizmill is an open-source practice-app engine. You bring (or have your AI agent write) a learning pack — a small directory of JSON describing a question bank — and quizmill turns it into a fast, installable, offline-first practice app for that topic: practice sessions biased toward unseen questions, a mistakes queue that re-asks what you got wrong until you get it right, per-question feedback, and optional cross-device sync.
Like a mill, it works by going around: answer, review, retry. The engine is the wheel; packs are the grist.
Quick start
You don't clone this repo to use quizmill — you run it with npx. The
CLI keeps the engine in ~/.quizmill/engine for you and shells out to
it, so a pack is all you ever edit. Requires Node ≥ 26, git and npm.
npx quizmill new my-topic # scaffold a pack (or have your AI agent fill it)
npx quizmill run my-topic # practice at localhost:3000
npx quizmill build my-topic # static app in my-topic-app/ — deploy anywhere
| Command | What |
|---|---|
new [dir] |
scaffold a learning pack with an agent-ready README |
validate <dir> |
schema + cross-reference checks (agents loop on this) |
run [dir|owner/repo] |
activate a pack and start the app |
build [dir|owner/repo] |
emit a deployable static app in <pack-id>-app/ |
bundle <dir> [--out file] |
validate + write one <pack-id>.bundle.json to hand over (see Share a pack as one file) |
share <dir> |
validate + bundle + upload behind a licence key; prints a one-tap install link (see Share a private pack with a link) |
list |
published packs you can install |
upgrade |
re-align the cached engine (see Upgrading) |
Install a published pack
Packs can live in their own GitHub repos. run and build accept a
repo reference and install it straight from there — public repos
anonymously, private repos through your own git clone credentials:
npx quizmill list # the published-pack registry
npx quizmill run quizmill/pack-claude-cert # install + run one
| Pack | What | |
|---|---|---|
| pack-claude-cert | Unofficial Claude Certified Architect (Foundations) practice — 635 questions across the five exam domains, curated from MIT-licensed community banks with per-question attribution | public |
your-name/your-pack |
Private repos install exactly the same way (family packs, exam prep, team material) — anyone whose git clone works can run it |
private |
owner/repo, owner/repo#branch, full GitHub URLs and SSH remotes all
work.
Build a pack about anything
The intended authoring path is your local AI agent. With
Claude Code, the bundled
create-learning-pack skill means you can just say:
"Make me a learning pack about Kubernetes networking"
and the agent will scope it with you, write the pack, loop on the validator until it's clean, activate it, and start the app.
Manual authoring works the same way — scaffold, edit, validate, run:
npx quizmill new my-topic # writes pack.json + questions.json + a README
# edit pack.json + questions.json
npx quizmill validate my-topic # schema + cross-reference checks
npx quizmill run my-topic # activate + start the app
Packs are private by default — they live wherever you put them and
never enter this repo. Only the demo pack ships with the engine, plus
the small starter pack the home app (app.quizmill.dev) is built from
(content/pack-start/: a few questions about using the app, and the
ways a pack gets in on its Home).
Publish a pack
A pack repo is just the pack directory pushed to GitHub — pack.json,
questions.json, optional scenarios.json / concepts.json / assets/,
plus a README (see pack-claude-cert
for the shape). Once pushed, anyone can install it:
npx quizmill run your-name/your-pack-repo
Keep it private and it still works for everyone with repo access. If
the pack is public and you want it listed in npx quizmill list, PR an
entry into tools/pack/registry.json.
If your questions build on someone else's bank, keep the upstream
license, record provenance in each question's sourceRef (the app
links to it in the answer panel), and reproduce upstream notices —
again, pack-claude-cert is the worked example.
Share a pack as one file
Not every pack wants to be a git repo. bundle validates a pack and
writes it as a single .json — the pack files verbatim, plus every
image from assets/ embedded as a data URL, plus schemaVersion, the
pack id, a bundledAt stamp and (in a git checkout) the pack's
revision:
npx quizmill bundle my-topic # → my-topic.bundle.json
npx quizmill bundle my-topic --out ~/Desktop/topic.json
Any quizmill app inserts the file at /packs (pick it, or paste a URL
it's hosted at) — no CLI needed on the receiving end, and the pack
works offline there because its images travel inside the file. Browsers
keep an inserted pack in about 5 MiB of storage, so the bundler warns
when the images push a bundle past 4 MiB.
Share a private pack with a link
Handing a file to a phone is the weak step. share does what bundle
does and then uploads the bundle to the unlock service (the sync
worker in cloudflare/, see its README) behind a licence key, and
prints a link to the home app that installs the pack in one tap:
npx quizmill share my-topic
Uploaded my-topic (150 questions, 612 KiB)
https://app.quizmill.dev/unlock#key=QL-7F3K-9Q2M-X4PD
Opening the link on a phone that has never seen the pack fetches the
bundle with the key, inserts it and makes it active — no Packs visit,
no file picker. The key rides in the URL fragment, which no server ever
sees, and the same key typed under Packs → "Have a licence key?" in an
installed app does the same. Run share again after editing the pack
and the link serves the new bundle; the keys stay as they were, and
(same pack id) learners keep their progress.
npx quizmill share my-topic --new-key "Leo" # one more key (and link) for the pack
npx quizmill share my-topic --list # every key, its uses, revoked or live
npx quizmill share --revoke QL-7F3K-9Q2M-X4PD # that link now opens nothing
share reads ~/.quizmill/config.json:
{ "unlock": { "endpoint": "https://sync.quizmill.dev",
"app": "https://app.quizmill.dev", "token": "…" } }
token is the service's UNLOCK_ADMIN_TOKEN; endpoint and app
have those defaults and --endpoint / --app override them per run
(an author hosting their own worker, or a pack app with its own
origin). A key protects the download, not the content: anyone holding
it gets the whole bundle, and on a device the pack is readable — the
same caveat as a bundle file.
Pack format
A pack is a directory of JSON, specified by the Zod schemas in
tools/pack/schema.ts (schemaVersion: 2 — the
only version the validator accepts; a manifest still saying 1 just
needs the number changed, nothing else):
| File | What |
|---|---|
pack.json |
manifest — title, description, theme colour, categories, optional levels, papers + sources, optional upgrade |
questions.json |
the bank: multiple-choice (2–6 options keyed A–F), explanation, difficulty 1–5, provenance |
scenarios.json |
optional shared scenario stems for case-study style questions |
concepts.json |
optional concept cards surfaced in the answer panel (conceptId on a question) |
assets/ |
optional images referenced by image on a question or option |
Beyond the required fields, all optional:
-
2–6 options keyed
A–F(a fixed 4 is just the common case) -
images —
imageon a question prompt and/or on individual options, resolved from the pack'sassets/directory -
per-option explanations — a short
explanationon an option shows inline under it once the answer is checked, so "why this one is wrong" sits next to the wrong one; the question'sexplanationkeeps the full reasoning -
concept cards — a
concepts.jsonof short explainers; a question points at one withconceptIdand it shows in the answer panel -
levels — a second axis beside category: manifest
levels([{key,label}]) +levelsLabel(the axis name, e.g. "Year" or "Difficulty band") + alevelon each question, exposed as a filter. A short ORDERED ladder, easiest first — the adaptive nudge suggests the next rung and the timed exam deals from the last; at most 8 -
papers — named sets a learner sits whole: manifest
papers([{key,label,series?,categoryKey?}]) +papersLabel(the list's heading, e.g. "Papers" or "Practice exams") + apaperIdon each question. Unordered and many (a published paper, a mock exam, one perseriesgroup), listed on Home to practise whole and offered in Paper practice to print as one sheet in bank order — so keep a paper's questions together inquestions.json, in paper order (the validator warns otherwise). A paper owned by one category names it; one that spans categories omitscategoryKey -
source legend — manifest
sources([{label,name,blurb,url}]) renders a "Question sources" card in Settings; questions reference an entry bysourceRef -
timed exam — manifest
exam({passPct, questionCount?, durationMinutes?, scope?}):passPctalone gives the exam-readiness estimate on Progress; adddurationMinutes(the real exam's length) and the app offers a timed sitting from Home —questionCountquestions drawn from the hardest level (scope.levelor the last declared level), time pro-rated when fewer are available, pausable, no feedback until the end, then a pass verdict and a review of the sitting's mistakes. The pack decides whether the sitting exists; a device can only hide its Home tile (Settings → Timed exam). See Ways to practise -
default-on modes — manifest
modes({drive?, coach?, paper?}): Drive, Coach and Paper are device opt-ins in Settings, off by default; a mode settruehere starts on for a device that hasn't touched its switch, and the device's own choice wins either way (likelook). See Ways to practise -
upgrade card — manifest
upgrade({title, url, price?, blurb?}) for a free sampler pointing at its paid course or fuller bank: one card on Home (below the practice loop) and one in Settings, linking to the httpsurlwith?ref=<packId>-home/-settingsappended so the destination can tell where the tap came from. The engine carries no shop or price of its own — the pack supplies the words. Each tap also adds a fresh&claim=<32 hex>, so a purchase made in a browser layer over an installed app (iOS keeps their storage apart) can unlock the app itself. Theurlmay answer the claim check:POST <url>with{"claim":"…"}(JSON) →{"status":"pending"}(also for a claim it hasn't seen yet),{"status":"paid","key":"…"}(the app runs the licence unlock below with that key) or{"status":"revoked"}. The app asks while a claim is pending — at start and on becoming visible, every 3 s for a minute — and drops claims after 48 h. Any other answer (non-JSON, non-2xx, a CORS failure) and the app stops sending claims for that pack, so a plain checkout page costs one failed request. The JSON body makes the browser send a CORS preflight first: answerOPTIONS <url>with a 2xx,access-control-allow-origin: *,access-control-allow-methods: POSTandaccess-control-allow-headers: content-type, and the POST itself withaccess-control-allow-origin: *. -
licence unlock — every build lets a learner who holds a licence key fetch the pack it opens in one tap: a link to
/unlock#key=<key>(the key rides in the fragment, which never reaches a server) or the "Have a licence key?" entry on/packs. The key alone says which pack: the app callsGET <unlockUrl>/v1/unlock?key=…, expects a pack bundle (200), a refusal{ "error": "…" }it may show (403) or 404, and inserts the bundle under its own manifest id, makes it active and keeps the key underquizmill.<packId>.licence. Two optional manifest hints:upgrade.packIdnames the pack a key entered in THIS app is expected to open (a sampler and its full bank — sent as&pack=, and any other bundle is refused), andupgrade.unlockUrlpoints at the author's own endpoint instead of the build's (seeNEXT_PUBLIC_UNLOCK_URLbelow). CORS*, no cookies; how keys are issued is the author's business.
The validator (npx quizmill validate <dir>) cross-checks ids,
category / scenario / concept / level / paper references, image paths, and
weights, and exits non-zero with per-question errors — agents loop on
it until the pack is clean, so malformed content can't reach the app.
It also warns about every referenced image that isn't a file under
assets/, so a misnamed figure is caught here rather than as a broken
image in the app or a refused bundle.
What the engine gives every pack
- Practice sessions — weighted random selection biased toward
unseen questions (
src/lib/selection.ts) - Mistakes review — wrong answers queue up and are re-asked until
answered correctly (
src/lib/mistakes.ts) - Question feedback — thumbs up/down with comments, surfaced in Settings so pack authors can curate
- Local-first storage — everything in localStorage, namespaced per pack; works fully offline (PWA with a versioned service worker)
- Optional cloud sync — a retry-on-reconnect queue mirroring to a Cloudflare Worker + D1 (sync-key auth), or any server speaking the same protocol; dormant unless configured (see below)
- Static export —
npx quizmill buildproduces a plain static site, deployable to any static host (Cloudflare Pages, GitHub Pages, Netlify…)
Ways to practise
The practice loop above is in every pack. Four more ways sit in a rail under More ways to practise on Home, and one rule covers all of them: the pack says what it ships with; the device has the last word, in Settings → This device.
| Way | What it is | Who switches it on | Card in Settings → This device |
|---|---|---|---|
| Timed exam | one sitting at the real exam's pace — no feedback until the end, then a pass verdict and a review of the mistakes | the pack, by declaring exam.durationMinutes |
Timed exam — shown by default; off only hides the tile |
| Readiness check | a temperature check: one time-boxed sitting across every exam domain — the learner picks 10, 20 or 30 minutes (never more), the plan says how many questions that buys at the exam's pace (three per domain where it can, more by blueprint weight; the floor drops to fit), dealt in rounds so a cut-off sitting still spans the blueprint, more questions while the learner's own pace leaves room, and the clock ends it outright — feedback after every answer, then a result page: a first read on whether you'd pass today, and a study order of the weak domains ranked by weight × shortfall, each a tap into practice; sit it again and the two are compared. Home asks "could you pass today?" until the check is sat or the card is closed | the pack, by declaring exam (a pass line is enough) |
none |
| Drive mode | hands-free voice quiz for the car: questions read aloud, answers by voice | the device; a pack may start it on with modes.drive |
Drive mode |
| Coach mode | a parent or tutor replays a past session with the learner, answers hidden until revealed; nothing is recorded | the device; modes.coach |
Coach mode |
| Paper practice | print a worksheet (or write on it on a tablet), then mark the answers back in as a normal session | the device; modes.paper |
Paper practice |
Drive, Coach and Paper resolve the same way: a device that has never
touched the switch follows the pack's modes block (absent means off).
Once someone flips the switch, that choice is pinned on the device,
either way — it survives installing another pack from the library and
"Reset all local progress", because it is about the device and who is
holding it, not about any pack. The timed exam is the other way round:
only the pack can create a sitting (it needs the exam's real length),
and the device may only hide the tile.
Worked example — the home app at https://app.quizmill.dev. It opens
on the bundled starter pack, which declares neither exam nor modes,
so a fresh phone sees no rail at all — the starter's job is to get a
pack in, not to quiz. Open Settings → This device there and switch
Paper practice on: a Paper tile appears, and it stays on that phone
for every pack installed after it. Insert a pack and the rail follows
that pack's manifest. The bundled solar-system demo pack (the one PR
previews and the E2E suite are built from) declares:
"exam": { "label": "Mock exam", "passPct": 70, "questionCount": 9, "durationMinutes": 15 },
"modes": { "drive": true, "coach": true, "paper": true }
So with the demo active, a fresh phone's rail shows all four tiles:
Drive, Coach, Paper and Mock exam ("9 Qs · 15:00" — the sitting deals
from the hardest level, Advanced, which holds exactly nine questions).
Settings → This device lists the four cards, all on. Switch Coach off
and its tile goes, and stays gone on that phone until it is switched
back on, even after installing a different pack. Switch Timed exam off
and the Mock exam tile goes too, but Progress still shows exam
readiness and /practice/exam/ still answers. A certification pack
that declares exam and no modes starts with just its exam tile; a
kids' pack might declare "modes": { "paper": true } alone; a pack
with neither — the starter, say — shows no rail until the learner
opens Settings.
Deploying a pack as its own app
npx quizmill build my-topic # or: npx quizmill build owner/repo
# deploy the my-topic-app/ directory to any static host
Each deployment is one pack. The PWA name, icon, and theme colour are
generated from the pack manifest, so every pack gets its own
installable app identity. A pack can bring its own artwork instead:
assets/icon.svg (the app icon), assets/icon-maskable.svg (a
full-bleed copy for launchers that cut their own shape) and
assets/icon.png (the iOS home-screen tile, 180 × 180).
Browser floor. The engine's stylesheet (Tailwind CSS 4) relies on
color-mix(), @property and cascade layers, so learners need Safari
16.4+ (iOS 16.4+), Chrome 111+ or Firefox 128+. On an older browser the
app still loads and works, but palette tints render transparent and
much of the layout loses its styling — an iPad stuck on iOS 15 or
16.0–16.3 is the device most likely to be affected.
Note that a static export embeds the questions in the served assets — "private pack" means not in a public repo, not unreadable by users of the deployed app.
Cloud sync (optional)
One env var switches it on; without it, the sync layer stays dormant
and the app is pure-local. (Set env vars for an npx build by
exporting them in the same shell — the CLI passes your environment
through to the engine.)
Cloudflare Worker + D1. Deploy the tiny worker in cloudflare/ to
a free Cloudflare account (~2 minutes, see cloudflare/README.md) and
build with:
NEXT_PUBLIC_SYNC_URL=https://quizmill-sync.<account>.workers.dev
No accounts or email: devices link by sharing a locally-generated sync key (Settings → Sync across devices). Cloud
