Turn any PDF book into a voice you can actually talk to.
Upload a PDF, get a library entry, and open a live spoken conversation with the text β
grounded in your pages, not in a model's memory.
No account needed to try it β three classics (Jane Eyre, The Great Gatsby, Dracula) ship as public sample books and can be talked to while signed out.
One upload, end to end: the browser parses the PDF with pdfjs-dist β a Server Action re-resolves
userId from Clerk β the PDF and cover go straight to Vercel Blob through an authorised token β the text
lands in MongoDB Atlas as ranked segments.
Then one call: startVoiceSession β Vapi (ASR Β· LLM Β· ElevenLabs TTS) β the searchBook tool calls
back into the app β an ownership-checked $text query β grounded speech.
The whole codebase exists to make three things work: PDF intake parsed in the browser, segmentation into an index-backed Mongo corpus, and Vapi voice orchestration that re-authorises every retrieval request. Deep dive: EngineeringPoint.md Β· SystemDesign.md
| Section | |
|---|---|
| π± | About |
| π | Get Started |
| π | Integrations |
| β¨ | Features |
| π§± | Tech Stack |
| π οΈ | Engineering Highlights |
| ποΈ | Project Structure |
| π | Scripts |
| π§ͺ | Tests & CI |
| π€ | Contributing |
| π | License |
| π | Live |
ChapterMate is a voice-first reading companion: you bring a PDF, it becomes a book you can speak to.
Three moments make up the product:
- Upload β a title, an author, a voice and a PDF. The file is parsed in the browser, stored in Vercel Blob, and fanned out into a searchable corpus in MongoDB.
- Library β your own books sit beside three seeded public classics, each with its cover, assigned voice and segment count.
- Session β open a book and talk. The agent answers from that book, retrieving the relevant pages on every turn.
|
For the reader
|
For the engineer
|
Requirements: Node.js 22+ (the version CI pins), npm, and accounts for MongoDB Atlas, Clerk, Vercel Blob and Vapi. PostHog is optional β analytics stay switched off when it is not configured.
git clone https://github.com/zannunakiz/ChapterMate.git
cd ChapterMatenpm installcp .env.example .env.local # macOS / Linux
copy .env.example .env.local # Windows (cmd / PowerShell)Every key is documented inline in .env.example, grouped by provider.
npm run db:seed-samples # uploads the bundled PDFs/covers to Blob, then writes books + segmentsnpm run dev # http://localhost:3000The seed script is the server-side twin of the browser pipeline: same 500/50 splitter, same schema, but it
parses with pdfjs-dist/legacy and disableWorker: true because there is no DOM.
Three collections carry the whole product: books (metadata + Blob keys), booksegments (the searchable
windows) and voicesessions (one row per call, closed with endedAt/durationSeconds).
- Every index is compound and
bookId-leading β{ bookId, segmentIndex }unique (ordering + idempotency),{ bookId, content: 'text' }(search),{ bookId, pageNumber }(reserved for page citations). A search can never fan out across the collection. - The connection is cached on
globalThis, so hot reloads and concurrent route invocations share one pool; a failed connect clears the cached promise so the next caller retries instead of inheriting a poisoned one. bufferCommands: falseplus explicit timeouts means an unreachable cluster fails fast instead of silently queueing.mongodb+srv://needs SRV resolution before it can connect at all, which some networks quietly break (querySrv ECONNREFUSED).lib/dns-bootstrap.tsprobes the resolver and only then falls back to public DNS β wired intoinstrumentation.ts, the connection module and the standalone scripts, so no entry point is left broken.
proxy.ts runs clerkMiddleware and marks the landing page, the library, the sessions and the auth pages public
while auth.protect() guards everything else β so the upload form is never reachable signed out. But the edge is
treated as the first gate, never the only one: every Server Action and route handler re-resolves userId with
auth(), and the client's identifier is never trusted, in either direction.
- Ownership is pushed inside the query (
Book.findOne({ _id, ...bookAccessFilter(userId) })), so an unreadable document is never loaded in the first place β no "fetch then check" gap. - Guests are not a special case:
SAMPLE_BOOKS_CLERK_ID = "sample-books"makes public books readable by everyone through the same predicate that protects private ones. - Rejected requests never open a database connection β the tests assert exactly that.
The PDF and the cover are the only binaries in the system, and they never pass through Next.js. The route handler
only authorises: handleUpload β onBeforeGenerateToken() re-checks the Clerk session and pins
allowedContentTypes (application/pdf) and maximumSizeInBytes (10 MB) before signing a client token. The browser
then PUTs the bytes straight to Blob.
That single decision buys a lot: large files never occupy a serverless function's memory or its much shorter
body-size limit, the server stays stateless, and the database only ever stores the URLs it references β
fileURL/fileBlobKey and coverURL/coverBlobKey. Deleting a book deletes its Blob objects too.
Vapi supplies the speech loop (ASR Β· LLM Β· ElevenLabs 11labs TTS); hooks/useVapi.ts is the adapter that owns a
single SDK client, maps the provider's event stream onto an explicit status
(idle β connecting β starting β listening β thinking β speaking), keeps the transcript and guarantees the session
gets closed.
The interesting half is the return path. The assistant's searchBook tool calls back into the app, where the
handler resolves the viewer from Clerk and runs an ownership-checked query β the tool call is authorised per request
rather than trusted because a prompt said so. Retrieval prefers a $text search ranked by textScore, and degrades
to a keyword-regex pair (all keywords first for precision, any keyword second for recall) if the text index is
missing, so a differently-provisioned cluster still answers. Results are narrowed to ~5 segments β roughly 2 500
words, a deliberate context budget β and returned as a plain string, because that is what the model consumes.
PostHog is initialised in instrumentation-client.ts β Next.js' own client hook β so no provider, effect or
double-render can run it twice. Automatic pageviews are disabled on purpose: components/posthog-page-view.tsx
is the single source of truth, firing one $pageview per App Router route and query-string change.
Two details that matter: a module-level guard suppresses the duplicate a double-invoked dev effect would send, and
the session slug is masked before capture (/books/session/[slug]) β because a slug is derived from a book
title, and private titles must not leave the app. Initialisation is skipped entirely when no project token is set,
so local development logs no SDK error, and @vercel/analytics runs alongside it.
3 suites, 20 cases, and no network or cluster involved. The rules live in plain modules β lib/book-access.ts,
lib/feature-flags.ts, lib/utils.ts β so the suites execute the real functions while only the boundary
(Clerk, Mongoose, the models, @vercel/blob) is mocked. The sharpest assertions are the negative ones: a signed-out
caller is refused without a connection being opened, and a book owned by someone else is never loaded, let alone
returned. ts-jest maps the @/* alias the same way the app does, so the tests import production code, unmodified.
|
π A library that is yours
π£οΈ Voice conversations
|
π― Grounded answers, not vibes
π§ͺ Engineering by default
|
Application
| Layer | Choice | Why it is here |
|---|---|---|
| Framework | Next.js 16 (App Router, RSC) | One runtime for UI and backend: Server Components read, Server Actions write β no REST layer to keep in sync |
| UI runtime | React 19 | Concurrent rendering, Suspense boundaries and a hook-owned voice client |
| Language | TypeScript 5 (strict, @/* alias) |
One source of truth from the Mongoose schema to the JSX |
| Styling | Tailwind CSS v4 + tw-animate-css |
Token-driven theme with zero runtime CSS |
| Components | shadcn/ui over Radix primitives | Accessible, copy-owned components β keyboard behaviour included, no opaque dependency |
| Motion | Framer Motion | Entrance choreography that honours prefers-reduced-motion |
| Landing surface | Hand-rolled canvas scenes + Framer Motion | Animated, dependency-light marketing sections that stay honest about the product |
| Icons / toasts | lucide-react, sonner | Icon set and toast queue |
Data & identity
| Layer | Choice | Why it is here |
|---|---|---|
| Database | MongoDB Atlas | Document-shaped corpus: a book is a list of segment documents, and $text search is built in |
| ODM | Mongoose 9 | Schemas, compound/index declarations and textScore sorting in one place |
| Identity | Clerk (@clerk/nextjs) |
Edge middleware for the perimeter, auth() for the per-request truth |
| Files | Vercel Blob (@vercel/blob) |
Client-token uploads so binaries never transit a serverless function |
Platform & quality
| Layer | Choice | Why it is here |
|---|---|---|
| PDF parsing | pdfjs-dist (browser + Node legacy build for seeding) | Text extraction and page-1 cover rendering without a server-side worker |
| Voice | Vapi (@vapi-ai/web) + ElevenLabs voices |
ASR, LLM turn-taking and TTS behind one SDK; the retrieval contract stays ours |
| Validation | Zod + react-hook-form | One schema drives the form, the client gate and the server check |
| Analytics | PostHog + Vercel Analytics | Route-aware pageviews, initialised in an instrumentation hook |
| Tests | Jest 30 + ts-jest | 3 suites / 20 cases over the pure rules β no database, no network |
| CI | GitHub Actions β unit-tests, lint, build |
Three independent, individually-required checks |
| Fonts | next/font (Instrument Sans, Instrument Serif, JetBrains Mono) |
Self-hosted, zero layout shift, mapped to CSS variables |
Everything below is enforced by code, not by convention.
The most deliberate choice in the upload path: no PDF ever reaches the server as a stream. parsePDFFile()
runs pdfjs-dist in the client, so the Next.js runtime never pays for a parser, a worker or a large file buffer.
UploadForm.submit(data)
1. checkBookExist(title) β server action ββΊ duplicate title is a FAILURE, checked before any upload
2. parsePDFFile(pdfFile) ββΊ arrayBuffer β getDocument() β page 1 rendered @ scale 2 β JPEG q0.8
β page: getTextContent().items[].str β join(' ') + "\n"
splitIntoSegments(fullText) β TextSegment[]
no text at all? abort β scanned PDFs validate fine and have nothing to index
3. upload pdf ββΊ POST /api/upload (token β browser PUTs directly to Vercel Blob)
4. upload cover ββΊ POST /api/upload/cover (your image, or the generated page-1 JPEG)
5. createBook({ β¦blob urlsβ¦ }) β server action ββΊ re-checks userId, slug clash, the 10-book limit
6. saveBookSegments(bookId, parsedPDF.content) β server action
The .filter(item => 'str' in item) guard is not decoration: getTextContent().items is a union of text runs and
marked-content markers, so a blind .map(item => item.str) would emit undefined and corrupt the corpus.
splitIntoSegments(text, 500, 50) is a word-window splitter, not a character or sentence splitter:
words = text.split(/\s+/).filter(Boolean)
start = 0 ββ 500 words ββ
while start < words.length: β β
end = min(start + 500, words.length) βΌ βΌ
emit { text: words[start..end].join(' '), ββββββββββββββββββββ
segmentIndex, wordCount } β50β overlap ββ
if end === words.length: break ββββββββββββββββ
start = end - 50 β² β²
β 450 new β
- 500 words is the retrieval unit: long enough to carry a scene's context, short enough that five of them (~2 500 words) fit comfortably in the assistant's context window.
- 50-word overlap stops a sentence from being sliced exactly in half and becoming unfindable in both segments β storage traded for recall, on purpose.
- The function is total and defensive:
segmentSize <= 0andoverlapSize >= segmentSizethrow, and the loop always terminates becausestartstrictly increases. segmentIndexis gapless and 0-based, which is exactly what the{ bookId, segmentIndex }unique index polices β a partially-written batch can never silently overwrite another book's ordering.
Persistence is what makes re-uploads boring:
saveBookSegments(bookId, content)
auth() ββββββββββββββΊ no userId β failure (no connection opened)
isValidObjectId βββββΊ malformed β "Book not found."
Book.findOne({ _id, clerkId: userId }) β ownership INSIDE the query
BookSegment.deleteMany({ bookId }) β idempotent: retry-safe by design
BookSegment.insertMany(segments) β one document per window
Book.updateOne({ _id, clerkId }, { totalSegments })
deleteMany runs before insertMany and is scoped to bookId only (ownership was already proven by the
findOne), so the whole operation is safe to retry. The seed script reuses the same splitter and schema on the
server side β a second implementation would have been a second chance to drift.
browser ββPOST /api/uploadβββΊ handleUpload
ββ onBeforeGenerateToken()
auth() must return a userId β else 401
allowedContentTypes: application/pdf
maximumSizeInBytes: 10 MB
addRandomSuffix: true
tokenPayload: { userId }
browser ββββββββββββ PUT bytes ββββββββββββββββββββββββΊ Vercel Blob
handleUpload only authorises; nothing is proxied. The browser exchanges an authorised token for a direct
upload, so a 10 MB PDF never occupies a serverless function's memory or its much shorter body-size limit, while
Clerk identity, MIME type and maximum size are still enforced server-side at the one moment that matters. The
database stores only what it can point at β fileURL/fileBlobKey and coverURL/coverBlobKey β and the cover
route applies the same guard with a 1 MB / image-only envelope.
A hosted voice platform is a network dependency you do not control, so hooks/useVapi.ts is an explicit adapter
rather than a sprinkle of event handlers. It owns one lazily-created SDK client per page (a missing key fails at
call time with a clear message instead of at build time), and it maps the provider's event stream onto a status the
UI can reason about:
idle ββΊ connecting ββΊ starting ββΊ listening β thinking β speaking ββΊ idle (call-end)
Two guarantees come out of that design. First, the session row exists before audio flows β startVoiceSession
writes to Mongo, returns a session id, and only then does the call start, so a dropped connection can still be
accounted for. Second, the session is always closed: hang-up, error and unmount cleanup all call endVoiceSession,
which is filtered by the owner, so nobody can close β or forge the duration of β someone else's session.
The part recruiters usually ask about is the retrieval bridge, because it inverts the usual direction of traffic:
the assistant's searchBook tool calls back into the app, and that handler resolves the viewer from Clerk before
touching the database. A tool call is a request like any other, so it is authorised like any other β access is never
inferred from what the prompt claims.
query ββΊ extractKeywords() lowercase β strip punctuation β drop words β€ 2 chars
β drop ~90 stop-words (filler + "book / author / chapter / tell me")
β a Set, so no keyword is searched twice
β
ββ keywords.length === 0 βββββββββββββββββββββββΊ empty result, honestly
ββ $text { $search } .sort(textScore) .limit(5) β PRIMARY, index-backed
β throws (no text index) or 0 hits βΌ
ββ PASS 1 content ~ /k1/i AND /k2/i β¦ β ALL keywords (precision)
ββ PASS 2 content ~ /k1/i OR /k2/i β¦ β ANY keyword, ranked in JS (recall)
Three properties fall out of it: it degrades instead of failing (a cluster without a text index still answers),
it prefers precision then recall, and it is bounded β five segments and a narrow projection, so the response
never carries clerkId or anything else the model does not need. Regex keywords are escaped before becoming
patterns, so a spoken phrase containing . or * cannot turn into a pathological expression.
lib/feature-flags.ts is deliberately tiny and deliberately server-only:
isFeatureEnabled(value, fallback = false) // true | 1 | yes | on β true, anything else β false
isDummyBooksEnabled() // FF_DUMMY_BOOKS β render bundled sample data instead of the database
isDummyFormEnabled() // FF_DUMMY_FORM β simulate the upload/save flowBecause the helpers read plain (non-NEXT_PUBLIC_) variables, the flags are resolved on the server and passed down
as props β the client bundle never gets to decide what it is allowed to see, and no flag value is shipped to the
browser. Parsing is strict (only four affirmative spellings count) and the fallback is explicit, which is exactly
what __tests__/feature-flags.test.ts pins down. The result: the same components can run against seeded books or a
live library, and demo/offline modes are a configuration change rather than a fork in the codebase.
| Gate | What it proves |
|---|---|
npm run test:unit |
The access policy, feature-flag parsing and every action fence: signed-out rejection, forged identifiers, the 10-book limit, slug collisions, ownership-scoped reads |
npm run lint Β· npm run build |
ESLint 9 (flat config) and a production build β the build doubles as a TypeScript / routing contract check |
| GitHub Actions | Three independent jobs, so a lint failure can never hide a test failure |
auth() before I/O |
Authorisation is asserted to happen before a database connection is opened β the cheapest possible rejection |
ChapterMate/
βββ app/
β βββ page.tsx # Landing page (marketing sections)
β βββ books/ # Library grid + "add book" upload surface
β β βββ session/[slug]/ # The live voice session for one book
β βββ api/ # Upload routes (PDF, cover) + the Vapi search bridge
βββ components/
β βββ landing/ # Animated marketing sections (canvas + Framer Motion)
β βββ UploadForm.tsx # PDF intake: validate β parse β upload β persist
β βββ VapiControls.tsx # Call UI driven by the useVapi state machine
β βββ Transcript.tsx # Live conversation log
β βββ ui/ # shadcn/ui primitives
βββ hooks/
β βββ useVapi.ts # One SDK client, an explicit status machine, session bookkeeping
βββ lib/
β βββ actions/ # Server Actions β auth() β rules β database
β βββ book-access.ts # The single access policy (guests, owners, sample books)
β βββ feature-flags.ts # Server-only FF_DUMMY_* parsing
β βββ dns-bootstrap.ts # SRV resolver fallback for restrictive networks
β βββ schema.ts # Zod upload schema (shared by the form and the server)
β βββ samples/ # Bundled PDFs + covers used by the seed script
β βββ utils.ts # parsePDFFile, splitIntoSegments, slug + serialisation helpers
βββ database/
β βββ mongoose.ts # Cached global connection with fail-fast timeouts
β βββ models/ # book Β· book-segment Β· voice-session schemas and indexes
β βββ seed-samples.mjs # Server-side twin of the browser pipeline
βββ __tests__/ # Access policy, action fences, feature flags
βββ public/documentation/ # The images in this README
βββ documentation/ # SystemDesign.md Β· EngineeringPoint.md Β· README reference
The split is deliberate: pure rules stay importable (`lib/book-access.ts`,lib/feature-flags.ts,splitIntoSegments) so they can be tested without a database or a browser; Server Actions own the framework boundary (identity, validation, orchestration); and the database layer owns indexing and connection policy and nothing else.
| Script | Purpose |
|---|---|
npm run dev |
Next.js dev server with HMR |
npm run build / npm run start |
Production build and serve |
npm run lint |
ESLint 9 (flat config, eslint-config-next) |
npm run test:unit |
Jest β access policy, action fences, feature flags |
npm run db:seed-samples |
Upload the bundled sample books to Blob and index their segments |
npm run db:check-voices |
Read-only diagnostic: each book's persona and the voice it resolves to |
npm run db:clear |
Drop the database |
npm run test:limit |
Exercise the 10-book rule end to end |
| Suite | Cases | Focus |
|---|---|---|
__tests__/book-access.test.ts |
3 | The policy table itself: a signed-out visitor is denied a private book, an owner is allowed but another signed-in user is not, and guest sessions are attributed to the sample-books owner |
__tests__/book-action.test.ts |
16 | createBook refuses a signed-out caller and a forged identifier without opening a connection, refuses book #11, uses the authenticated owner id and reports a taken slug without echoing the other document; saveBookSegments enforces ownership and rejects malformed ids before any write; searchBookSegments reads nothing for an unreadable book and scopes the query per viewer |
__tests__/feature-flags.test.ts |
1 | isFeatureEnabled accepts true/1/yes/on, rejects everything else, and honours the fallback for missing or blank values |
20 cases, 3 suites, zero database and zero network. jest.config.cjs uses ts-jest in a Node environment with
the same @/* path mapping as the app and clearMocks: true, so suites stay independent β and the negative
assertions above are the point of the suite: they prove authorisation happens before the expensive resource is
touched, which is also what keeps a rejected request cheap.
.github/workflows/unit-tests.yml ("TESTS") runs on every push and pull request with permissions: contents: read,
as three independent jobs:
unit-tests checkout β setup-node@v4 (node 22, npm cache) β npm ci β npm run test:unit (β€10 min)
lint checkout β setup-node@v4 (node 22, npm cache) β npm ci β npm run lint (β€10 min)
build checkout β setup-node@v4 (node 22, npm cache) β npm ci β npm run build (β€15 min)
Installing from the lockfile with npm caching keeps the jobs reproducible and fast, and the production build doubles as a TypeScript / routing contract check.
Issues and pull requests are welcome. The house rules are visible in the code itself:
- The client never supplies an identity β every action and route resolves
userIdfromauth()and re-checks ownership. - Rules stay pure β if a rule needs a database or a browser, it does not belong in a module the tests import.
- Authorise before you spend β refuse first, connect later; an unauthorised request must never open a connection.
- Explain the why in a comment, and ship a test β anything touching access, uploads or retrieval should prove itself.
Released under the MIT License β see LICENSE.
ChapterMate is deployed and running:
| Entry point | Link | What it opens |
|---|---|---|
| π App | chapter-mate.vercel.app | Landing page β library β upload your own PDF β live voice session |
| π Sample sessions | chapter-mate.vercel.app/books | Talk to Jane Eyre, The Great Gatsby or Dracula β no account required |
The sample books run the same intake pipeline as an upload: the bundled PDFs are parsed, segmented and indexed by
npm run db:seed-samples, so the retrieval you hear is the production one.
Built with π§‘ by Richky Abednego
Deep dives: System Design Β· Engineering Point








