Personal project · live, in use · Sep 2026
Nomi
Notes into cited flashcards, and a companion that acts on them
An installable study app, named after its companion, that turns a student's own notes into flashcards and quizzes that cite the page every answer came from, and grades written answers against a rubric. Nomi, an animated owl, chats, answers questions about your own progress without an AI call, and on one tap can build a study set from pasted notes or write a reviewer on a topic. Built for real users on free tiers.
- tests passing
- 952
- lines of TypeScript
- 23.1k
- tables, all RLS-guarded
- 13
- actions on Nomi's allow-list
- 8
- monthly running cost
- $0
- lines of design docs
- 6.6k
01
The product in one sentence
"I give the app my notes. It asks me about them, shows me where every answer came from, remembers what I got wrong, and grades my written answers."
The first two are table stakes — NotebookLM already does them. The last two are the product, and they are the reason this exists.
I wrote the spec before writing the app: fourteen numbered decisions, each with what changed from the original brief and why. Since then every deliberate reversal has been logged next to the evidence or the decision behind it — nineteen so far — and the project has declined a feature on measured grounds four times. Most of the recent work came from using the app every day and writing down what was wrong with it.
02
The whole app, from sign-up to Nomi
The walkthrough below is the app end to end — signing in with a code, the privacy notice read once, the four tabs, and a conversation with Nomi — redrawn from its source rather than photographed. It follows this site's light and dark the way the app follows the phone's, and it works: a wrong code is refused, a new name reaches Home and Nomi's greeting, and the chat's answers come from a copy of the app's own brain. Only the account is made up.
Sign-in is an email and a six-digit code, and nothing else. When Google sign-in and SMS codes were asked for, both were researched and declined. SMS is paid on every route: Supabase's phone providers charge per message, and Firebase has required a billing account for phone sign-in since September 2024. Google's sign-in costs nothing, but on an iPhone home-screen app it opens in Safari, which doesn't share storage with the installed app, so the session never arrives — the same reason there is no magic link. What would reopen it is written down: a sign-in completed inside the installed app.
The app took its companion's name; the first tab used to be called Study. The address didn't change with it, and that was checked rather than assumed: a pages.dev address is the Cloudflare project's name and can't be renamed, and moving to a new one would sign everyone out, empty what each device keeps, and ask everyone to add the home-screen app again.
Walkthrough · 1 of 8
The four tabs
Nomi
Enter your email and we’ll send you a 6-digit code to sign in.
By signing in, you agree to the Terms of Use, confirm you are 18 or older, and acknowledge the Privacy Policy.
Playable · redrawn from the app’s screens · the account is made up
03
The companion: two brains, one conversation
Nomi is an owl that greets you on the first tab, lives in its own chat, and is the student's way of asking the app anything.
It has two brains, and choosing which one answers is the interesting part. Questions about the student's own study — their streak, what is due, what they missed, what to study next — are answered by a pure function from numbers the app already has: no network call, no quota spent, and no chance of a model rounding "3 days" into "about a week". Measured live, "what's my streak?" answered in 431ms; a Gemini reply in the same session took 4.2 seconds.
The matching is deliberately narrow, because a wrong instant answer is worse than a slower right one. Every pattern needs words that make the question about this student, and the tests hold near-misses that must fall through to Gemini instead — "what is due process?", "why do lightning bolts streak?".
Everything else becomes a real multi-turn conversation with Gemini, carrying a short brief of the student's own facts, saved and listed like any chat app. The cost is bounded rather than ignored: sixty Gemini replies a day, the last twenty messages as context, and the brain's answers free. In the database a message can only be written into a conversation its author owns — checking the user alone would let someone who learned another person's conversation id write into it under their own name — and messages have no update policy at all, because a conversation is a record of what was said.
Built from the app’s own five layers and rig
04
Nomi can act — but only from a closed list
Nomi can also do things. Paste a page of notes into the chat and it offers to turn them into a study set. Ask it for a reviewer on a topic — in English or Filipino — and it offers to write one, save it in Notes and make cards from it. It can also add notes to a set, rename a set, save text as a note, and change your name, study pet or picture.
That list is the whole list. It is a TypeScript union — the allow-list is a type — and the function that carries an action out is an exhaustive switch over it, so a new kind of action cannot be added without deciding what it does. Seven of the eight reuse exactly what the app's own screens call; writing a reviewer is the one thing only Nomi can do. Nothing on the list deletes, signs out or touches the API key, a test fails if that file so much as imports a function that could, and nothing happens until the student taps to confirm.
Requests are recognised by patterns first, with the near-misses tested — "call me later", "is a cat a good pet?", "how do I make flashcards about history?". Where a request slips past the patterns and reaches Gemini anyway, at no extra call, the reply may name a topic or a title in two optional fields, and the app puts that through the same checks a typed request gets: under nine words, not "this" or "ito", not just a count. The model can name a topic; it can never propose a write, and Nomi makes the offer in its own words, not the model's.
The reviewer is the one place cards don't come from the student's own notes: the facts are Gemini's. So the reviewer is saved in Notes, where the student can read it and put it right, and its cards go through the same grounded pipeline as a paste. It's written before anything is saved, so a refusal leaves no empty note and no empty set. And nothing can tell a wrong fact from a right one — that trade is written down, so nobody later mistakes these cards for ones drawn from a student's own material.
For pasted notes, the card count Nomi suggests is the largest of 10, 20, 40 or 60 the notes can actually support, never below 10: a 965-word ballad supports 13, so it offers 10. Suggesting more than the notes hold would be choosing padding on the student's behalf. A reviewer starts at 20, since there are no notes to measure.
Pasted notes, 1540 words: “Photosynthesis converts light energy into chemical energy stored in glucose. In the light-dependent reactions, water is split and…”
Want me to make a new set, "Photosynthesis", with 20 cards? I picked 20 for notes this long.
New set
Photosynthesis · 20 cards
Playable — nothing is written until the tap
05
An owl cut into five parts
The character sheet draws Nomi in eight poses, but the drawings don't line up — every one is a different size and position — so cross-fading between them ghosts. Instead a script cuts the single canonical pose into same-size layers: the body, two wings that rotate at the shoulder, and the irises, which squash to blink and shift to look. When the owl was redrawn, its head tilted, so the eyes are no longer level — the left iris sits 34 pixels above the right — and blinking both toward one line would slide them while they close. Each eye is now its own layer with its own line: five layers, 35.5 KB in all.
The hard part is behind a wing, where flat art has no body to reveal. For the first owl the script fitted an ellipse to the silhouette above and below each wing. That fit taught it one rule for good: its first version was degenerate and returned NaN for every row, NaN fails every comparison, and the check for leftover pixels quietly passed over exactly those rows — so the script throws on any non-finite value. The new head is wider than the body, and the same ellipse would have fitted the head and left a ghost of the wing behind every wave, so the body's edge under each wing is placed by hand, and the colour behind it is blended between two brown samples, because the pale belly borders the wing. A debug sheet of the poses on dark and light found four more faults, down to an orange wedge on the face mid-blink where a sampling ring crossed the beak. At rest, 2 pixels of 138,110 are now more than 8 off the original.
The pivots and eye lines are written into the app's rig file in the same run, so the numbers can never describe a different cut. The motion is pure data — eight states as keyframe tracks over channels that don't know the owl's size — so the new owl needed none of it changed: the same 135° wave still reads as a wing raised beside the head. It has 24 tests, and 4 of 4 deliberately planted bugs caught. Every owl on this page is built from those same five files and those same numbers.

Body

Left wing

Right wing

Left iris

Right iris
06
Every answer carries its source
Each card shows a chip — Source · p.14 — that opens a short excerpt from the student's own notes, with the matching phrase highlighted.
Two different checks sit behind that chip. Before a card is kept, the generator must point at the sentence in the notes that supports it, and the card is dropped if that sentence doesn't clear a support score of 0.22, after widening one sentence either side. The highlight is a separate, stricter match: an exact substring once the text is normalised, or failing that the best sliding window scored by bigram similarity at 0.85 or above — because models reliably reflow whitespace, swap quote characters and drop a stray word, none of which mean the quote was invented.
The chip is always visible and the excerpt is collapsed behind it. The chip is the product's promise, so it has to be legible without hunting — but the excerpt answers a question you haven't asked yet, since most of the time you read the answer, agree, and move on. The one thing that stays outside the collapse is the warning that your notes may contradict standard knowledge. A warning nobody opened is not a warning.
Card 1 of 3
07
The model never decides a score
Short written answers are graded against a rubric of expected concepts, but the model's only job is to say which of those concepts it found. The arithmetic, the thresholds and the verdict are all computed in code.
So the same answer always scores the same, the scoring is unit-testable without a model, and an empty answer cannot talk its way to full marks — which is exactly what a model asked to "grade this out of 5" will occasionally do.
The project's rule is that a prompt instruction with no deterministic check behind it is a wish. When numbering the notes by line led the model to write "according to line 93" into the cards themselves, the fix wasn't a firmer prompt: cards that mention a position in the notes are now dropped.
Short answer
2 / 3
Why does the AV node delay the impulse before it reaches the ventricles?
“It slows the signal down so the atria empty first.”
- Found: Delay lets the atria empty first
- Found: Protects the ventricles from fast atrial rates
- Missing: The AV node is the only normal route to the ventricles
Close. You also want the AV node as the only normal route between the atria and the ventricles.
08
What a green test suite couldn't see
The app carries 952 tests, and every push now runs them in GitHub Actions — typecheck, then build, then test, in that order on purpose. One check executes the built bundle in a DOM and asserts the page actually mounts, because a mismatched React version once shipped a blank white page while the build succeeded, every test passed and the URL returned 200. On a clean checkout there is no build, so that check would skip — and a skipped check reports success for exactly the failure it was written to catch. So CI builds first, and sets a flag that turns a missing build into a failure.
That check runs on jsdom, which computes no layout: it can prove the app mounts and nothing more. A scroll bug that made three of four tabs unusable on a phone passed all 618 tests at the time. Layout is now checked by a separate probe in real Chrome, at a height small enough that the content must overflow — because at a comfortable height it reports "ok" without testing anything.
The re-theme to teal and amber shipped behind a gate that reads the shipped palette rather than a copy of it: body text at WCAG AA, chart fills at 3:1, and adjacent chart colours at least ΔE 16 apart under both deuteranopia and protanopia. A chart segment had once gone out at 1.27:1 against its card — invisible — because "these colours look fine" had been the whole check.
09
What production taught the rest
Everything runs on free tiers, and most of the architecture is shaped by what those tiers actually do rather than what they advertise. Supabase Free takes no backups, so a scheduled job dumps the database every month — encrypted before upload, since the repository is public. Free projects pause when idle, so another job keeps the database awake. Sign-in is a six-digit code rather than a magic link, because a magic link opens in Safari instead of the installed app and the session never lands where the student is.
The backup was restored for the first time as a drill. Every application table, all 40 of its security policies and all 632 rows matched the dump — and the drill still found three faults: account data had no tables to land in, the trigger that creates a new user's profile isn't in the dump, and uploaded files aren't in a database dump at all. It also caught two things that had nothing to do with restoring: the backup had quietly failed on every run for days, and its passphrase had been lost, leaving every retained copy undecryptable.
Production went down for hours once, on a migration that dropped a column. The code that stopped reading that column had been committed six days before it was deployed; the migration was applied in between, and every deck failed. "Safe to run in either order" had meant commit order, not deploy order. Before any migration that drops or renames something, a script now reports what is actually live — and refuses to compare if it can't find the live commit in git.
10
Saying the uncomfortable thing plainly
Notes are sent to Google using the student's own free key, and the free tier's terms say human reviewers may read that input and Google may keep it to improve its products.
So the app says exactly that, once, right after signing in, in words a person would use: "The key is free, so Google may keep your notes to help improve its products — and a real person at Google may read them." Then a test anyone can apply: "if you wouldn't want a stranger reading it, don't put it here." It can be reread any time from Settings, and when Nomi started chatting through Gemini, I extended the notice to cover that too.
The spec marks one sentence as the part that must survive any later edit for brevity: a real person at Google may read them. It's the fact users are least likely to assume on their own, and the first thing that would be cut for tidiness.
The notice now sits in front of a Terms of Use and a Privacy Policy, both readable before signing in, and the sign-in screen says what signing in agrees to. Nomi is for people 18 and over, because Google's Gemini API terms require it of anyone using the API, and every set Nomi makes goes through it. The policy's claims about the app are checked by tests against the app — and writing it turned up a bug. Settings had always said Delete my data removes all your notes, and it never had: notes, study days and the daily reply count all survived it. A policy can't promise a deletion the code doesn't do, so the code was fixed first.