NotebookLM, self-hosted
Most tutorials ship 200 lines that break in front of a real user.
Every “build a NotebookLM clone” tutorial ships a demo that falls over the moment a real document and a real user arrive. I wanted the shipped version — the one with migrations, retries, cancellation and a story for what happens when the model returns nonsense.
It runs two ways from one codebase: hosted, on Neon and S3; or entirely on your machine, with an embedded Postgres, local files and a local text-to-speech model, so it works with no account and no network.
The whole server is one Hono app. Next.js mounts it under a catch-all route; Electron mounts it through the dev server, then as a bundle. Behaviour is identical because the only thing that differs is the adapter holding the database, the storage and the auth — Neon or an embedded Postgres, S3 or the local filesystem.
The question is rewritten into a small set of phrasings. If the model errors, the original question is used and nothing is lost.
Cosine search against the embedding table matching the model’s dimension — five sibling tables exist so changing embedding model is not destructive.
A case-insensitive match over chunk text, merged with the vector hits. Vector results win collisions.
Weak matches are dropped, then the model scores the survivors and the top slice is kept. Both the expansion and the rerank degrade to the unprocessed list rather than failing.
Chunks go into the prompt carrying ids, and citations are streamed as the model echoes them, so a claim is linked to its source as it is written.
A planner splits the question, searches in parallel, scores and summarises the sources, then writes the report section by section.
The agent grades its own report. If it finds gaps and rates itself below eight out of ten, it runs a second bounded round and appends what it found, then lists which claims it could actually support.
Silent fallback on a thrown error is a footgun.
agent/harness.ts, on why one error is deliberately not caughtThis is the half that tutorials leave out.
- Keys are encrypted with the user bound inProvider keys are never environment variables and never plaintext in the database. They are encrypted with the user id mixed into the encryption itself, so decrypting one user’s key as another fails rather than succeeding quietly.
- Changing embedding model is non-destructiveFive sibling tables, one per dimension, and retrieval only reads rows written by the model currently configured.
- Closing the window does not cancel the workIf the modal closes mid-generation the job keeps running so the result still lands; cancelling is a separate, explicit path.
- Two-host audio, locally if you wantThe script is generated as speaker turns, then voiced — hosted, or by a local model with no key and no network. Concurrency differs per backend for a written-down reason.
- Results are cached on content, not countsThe cache key fingerprints the actual chunks, because chunk ids regenerate on re-ingest and a row count would miss a change.
Shipped, documented, and without a test suite.
- No testsThere is typechecking, linting and formatting, and no test files. That is the largest gap in the project and it is not disguised here.
- 29 releasesVersioned and changelogged from 0.1.9 to 0.3.0.
- MITIncluding a 45-page write-up of how it was built.
The interesting work was in the failure paths.
Any of these steps can fail. The question that took the time was which ones are allowed to take the answer down with them, and which should quietly hand back what they had.