I started collecting ancient and world coins this year. It got out of hand fast, the way these things do. What I wanted next was a catalogue that read like a story instead of a spreadsheet, and that catalogue did not exist, so I built it.
Numistoria is my collection presented as a museum: twenty themed "worlds" that read as one continuous story, with an exhibit page for every coin, whether I own it or am still hunting for it. Right now that is 296 coins across 20 worlds, 25 of them actually in hand. The rest are the chase.
The fun part, and the reason it is worth a blog post, is what sits behind it. Almost every interesting decision came from one problem: I wanted an LLM to write thousands of words of engaging history, and I wanted it to be constitutionally incapable of making up a single fact about my actual coins.
Two halves with one narrow boundary
There are two codebases and they never speak to each other.
The private half is Coinex: a Next.js admin, a pile of scripts, and one GitHub Actions job. It knows what I paid, who sold it to me, and what I plan to spend next. It never serves a public request.
The public half is Numistoria: Vite, React, Cloudflare Workers. It only ever knows the story.
The only thing that crosses the gap is a single JSON file. A publish step scrubs it and drops it into R2, and the public site reads that. No live connection, no shared database, no CORS. The private engine could catch fire and the museum would keep serving yesterday's bundle without noticing. Keeping the boundary that thin is the whole security model, and it means I never have to reason about "could this field leak" at request time. It either made it into the scrubbed bundle or it does not exist as far as the public site is concerned.
Letting the model write the story, but never the facts
The narrative comes from Claude, one call per world rather than per coin. That is deliberate. The model gets the theme, the era, and each coin's recorded facts in display order, and it returns an intro plus a hook and body for every coin, written as a connected arc. Each coin's write-up refers back to the last one and sets up the next, so a "world" reads like a chapter instead of a list of captions.
Coins I do not own yet are written as the continuation of the arc, the missing chapter, never as if they are sitting in a drawer. The prompt is allowed to elaborate on well-established history. It is flatly forbidden from inventing anything about this coin's condition, provenance, or price. And the output is validated against a Zod schema before it is allowed to touch a file, so a malformed response fails at the door rather than halfway through a render.
The part I am actually proud of: honest citations
Here is the thing every developer who has shipped an LLM feature already knows. Ask a model for sources and it will hand you catalogue numbers and URLs with total, radiant confidence, and a real fraction of them will be fiction.
I did not try to prompt my way out of that. I removed the opportunity.
There is a hand-curated file, sources.ts, that holds about thirty real institutions and databases: Nomisma, OCRE, RPC Online, the British Museum, Perseus, and so on. Each one has a short identifier. When the model writes a coin's deep dive, it is allowed to return keys from that list plus a one-line note on why each is relevant. Nothing else. Then a resolveReferences function maps those keys to verified labels and real landing URLs and silently drops anything it does not recognise.
So the code owns every single link that reaches the site. The model only gets to choose among links I have already vetted. It can be as confident as it likes; it cannot invent a citation, because it is never holding the pen that writes URLs. If you take one idea from this post, take that one. It generalises to any place you want model-written text with trustworthy references.
Every deep dive also carries a reviewed flag that starts out false, and the site says so honestly rather than pretending a machine-written page is authoritative.
The Hunt
The want-lists are not busywork either. A curator model proposes real, collectible coin types under a price ceiling for each world, and has to include exactly one "white whale" that is allowed to blow the budget.
Then a weekly GitHub Actions job goes shopping. It searches dealers in order of trust (CNG, VCoins, MA-Shops, and eBay's API dead last), throws out obvious replicas by title before spending a single token, and skips anything priced way over the ceiling. What survives goes to Claude with up to two photos and a note about how much to trust the venue, and comes back as a structured verdict: is it a match, BUY or MAYBE or SKIP, eye appeal, and an authenticity risk rating. The prompt is blunt that screening photos is not authentication, that suspiciously cheap is a red flag, and that an uncertified eBay coin caps at MAYBE no matter how good it looks. I get an email digest on Saturday afternoon. Some weeks it finds nothing, and that is fine.
Why build it this way
Every hall on the site shows an "X of N acquired" meter, so the museum is also a completion tracker that fills in as the Hunt does its job. The public site is tiny on purpose: React, Vite, a twenty-line hash router, one same-origin JSON load and nothing else at runtime.
I built it with heavy AI collaboration and I am not shy about that. The judgment is mine: what the boundary is, which facts are sacred, which institutions count as a source. The model does the tireless middle, writing and re-writing narrative for 296 coins without complaint. That split, human owns the taste and the guardrails, model does the volume, is how I build most things now.
It is live at numistoria.danmat.dev and the public site is open source at github.com/DanMat/Numistoria. Go find the white whales. I would love to hear how you would have drawn the private-to-public boundary differently.


Top comments (0)