Recall
Turn your lecture PDFs into practice exams, graded by Claude. Free, open source, runs on your own computer using your Claude Pro/Max plan, with no API key.
Gallery
01 / 07
Demo
Features
- Creates Quiz question from lecture materials
- No API key is needed
- Everything is locally saved
- Uses Existing Claude Subscription
- No API bills
Technologies
Documentation
Recall: Technical Documentation
This document explains how Recall works internally, from the moment a PDF is dropped on the page to the moment a graded attempt is shown. It is written for developers who want to understand, debug or extend the app. For a user-facing overview and quick start, see README.md.
The npm package is named
recall, but several internal identifiers still use the project's earlier name, Exam Helper (exam-helper.db,exam-helper:*localStorage keys,__examHelperDb). They refer to the same app.
Contents
- What the app does
- Technology stack
- Repository layout
- Architecture overview
- Configuration
- Persistence: files and SQLite
- Domain model
- The Claude Code bridge (
lib/claude.ts) - Uploading a PDF
- Generating a question set
- Taking a quiz (client side)
- Grading an attempt
- Onboarding, the proxy and setup checks
- HTTP API reference
- Pages and routing
- Component reference
- Browser storage
- Theming, styling and accessibility
- Limits and constants
- Error handling and troubleshooting
- Security and privacy model
- Behaviours and edge cases worth knowing
- Extending the app
1. What the app does
Recall turns lecture PDFs into practice exams.
- The user uploads one or more PDFs. The server extracts the text of each page and stores the file locally.
- The user picks question types, counts, difficulty, a Claude model and a reading mode, then asks for a question set.
- The server runs the locally installed Claude Code CLI (
claude -p ...) in the background, passing the lecture text (or page images, in Visual mode) and a JSON Schema. Claude returns structured questions, which are cleaned up, shuffled and saved. - The user takes the set in exam or practice mode. Objective questions are marked in code. Written answers are sent to Claude in one batch and marked against a rubric.
- Attempts are saved with per-question results, feedback and a breakdown by type and topic.
Recall has no accounts and no backend of its own. All requests to Claude go through the user's Claude Code login, so usage counts against their Claude subscription and not an API key.
2. Technology stack
| Layer | Choice | Notes |
|---|---|---|
| Framework | Next.js 16.4 (App Router, Turbopack) | cacheComponents and partialPrefetching enabled. Uses proxy.ts (the successor to middleware.ts) and io() from next/cache. |
| UI | React 19.3, Tailwind CSS 4, lucide-react icons | Tailwind is wired through @tailwindcss/turbopack. |
| Fonts | Plus Jakarta Sans (body), Geist Mono (code) | Loaded with next/font/google. |
| Database | better-sqlite3 | Synchronous, native, single file in data/. |
| PDF text | unpdf (pdf.js) | Per-page text extraction on upload. |
| PDF splitting | pdf-lib | Cuts PDFs into 10-page parts for Visual mode. |
| AI | Claude Code CLI | Spawned as a child process in print mode with --json-schema. |
| Language | TypeScript 5 (strict) | Path alias @/* → src/*. |
| Lint | ESLint 9 with eslint-config-next | npm run lint. |
There is no test suite in the repository.
3. Repository layout
.
├── AGENTS.md Notes for coding agents (written by `next dev`)
├── README.md User-facing readme
├── documentation.md This file
├── next.config.ts Next.js config
├── eslint.config.mjs
├── tsconfig.json
├── docs/images/ Screenshots and demo GIF used by the README
├── data/ Runtime data (git-ignored, created on first run)
│ ├── exam-helper.db SQLite database (+ -wal / -shm files)
│ ├── onboarded.json First-run marker
│ ├── uploads/ Original PDFs, stored as <uuid>.pdf
│ └── claude-workdir/ Working directory for Claude Code runs
│ ├── last-generation.json Parsed output of the latest generation
│ └── <quizId>/ Temporary PDF parts during a Visual run
└── src/
├── proxy.ts Redirects to /welcome until onboarding is done
├── app/
│ ├── layout.tsx Root HTML shell, fonts, theme script
│ ├── globals.css Tailwind import, theme tokens, keyframes
│ ├── not-found.tsx 404 for unmatched URLs (outside app chrome)
│ ├── icon.svg, favicon.ico, apple-icon.png
│ ├── welcome/page.tsx First-run landing page
│ ├── (app)/ Route group sharing the app header and footer
│ │ ├── layout.tsx
│ │ ├── not-found.tsx
│ │ ├── page.tsx Library (home)
│ │ ├── generate/page.tsx New combined set
│ │ ├── setup/page.tsx Setup & help
│ │ ├── documents/[id]/page.tsx One material + its sets
│ │ ├── quizzes/[id]/page.tsx Take a set / generation status
│ │ └── attempts/[id]/page.tsx Review a past attempt
│ └── api/ Route handlers (see section 14)
├── components/ React components (server and client)
│ └── quiz/ QuestionCard and ScoreSummary
└── lib/ Server logic, shared types and helpers
src/lib at a glance
| File | Runs on | Responsibility |
|---|---|---|
types.ts | both | All shared types, question type metadata, model presets, BLANK_TOKEN. |
db.ts | server | Opens SQLite, creates the schema, runs migrations, exports data paths. |
repo.ts | server | Every SQL query. Maps rows to typed records. |
claude.ts | server | Spawns the Claude Code CLI; status check. |
generate.ts | server | Builds prompts, splits PDFs, runs generation, sanitises output. |
grade.ts | both | Local grading of objective questions; answer normalisation. |
grade-ai.ts | server | Batch grading of written answers through Claude. |
pdf.ts | server | Per-page text extraction with unpdf. |
shuffle.ts | both | Fisher–Yates shuffle of answer options with an index map. |
eligibility.ts | both | Turns a Claude status into pass/warn/fail checks. |
onboarding.ts | server | Reads/writes the data/onboarded.json marker. |
format.ts | both | Date, size, duration and percentage formatting; scanned-PDF heuristic. |
theme.ts | both | Theme storage key and the inline pre-paint script. |
Files that import "server-only" (db, repo, claude, generate, grade-ai, pdf) fail the build if a client component imports them. grade.ts and shuffle.ts are deliberately free of server imports so QuizRunner can grade practice answers in the browser with the same code the server uses.
4. Architecture overview
Recall is one Next.js process. It serves pages, handles API calls, owns the SQLite file and spawns Claude Code as a child process when it needs a model.
Browser Next.js server (one Node process)
┌──────────────────────────┐ fetch ┌────────────────────────────────────────────┐
│ Client components │──────────▶│ Route handlers (src/app/api/**) │
│ UploadDropzone │ │ │ │
│ GenerateForm │ │ ├─ lib/pdf.ts ── unpdf │
│ QuizRunner │ │ ├─ lib/repo.ts ── better-sqlite3 ─┐ │
│ GeneratingView (polling) │ │ ├─ lib/generate.ts (background) │ │
│ useClaudeStatus (store) │ │ │ └─ lib/claude.ts ──spawn──┐ │ │
│ │◀──RSC─────│ Server components (pages) │ │ │
│ localStorage: drafts, │ │ └─ lib/repo.ts │ │ │
│ prefs, theme, layout │ └──────────────────────────────────┼─┼───────┘
└──────────────────────────┘ │ │
▼ ▼
┌──────────────────────┐ ┌──────────────────┐
│ claude -p (CLI) │ │ data/ │
│ signed in to Claude │ │ exam-helper.db │
│ subscription │ │ uploads/*.pdf │
└──────────┬───────────┘ │ claude-workdir/ │
│ HTTPS └──────────────────┘
▼
Anthropic API
Key design decisions:
- Claude through the CLI, not the SDK. Requests are billed to whatever account
claudeis signed into.ANTHROPIC_API_KEYis removed from the child environment so the CLI cannot fall back to API billing. - Structured output. Every call passes
--json-schema, and the CLI returns a validated object instructured_output. The app never parses free-form model text. - Background generation with polling.
POST /api/quizzesinserts a row with statusgenerating, starts an un-awaited promise and returns at once. The quiz page polls until the status changes. - Synchronous SQLite. better-sqlite3 is synchronous, so queries are plain function calls. Server components call
await io()before reading the database, which tells Next.js (with Cache Components on) that the data is per-request and keeps it out of the prerendered static shell. - Process-global singletons. The DB connection (
globalThis.__examHelperDb) and the set of running generations (globalThis.__examHelperRunning) live onglobalThisso they survive dev-mode hot reloads.
5. Configuration
next.config.ts
cacheComponents: true, // Cache Components rendering model
partialPrefetching: true, // Prefetch route App Shells
serverExternalPackages: ["better-sqlite3", "unpdf"], // load as plain Node modules
turbopack.rules["*.css"] → @tailwindcss/turbopack loader
better-sqlite3 is a native addon and unpdf bundles pdf.js, so both are excluded from bundling and required at runtime.
Environment variables
| Variable | Default | Effect |
|---|---|---|
CLAUDE_BIN | claude | Path to the Claude Code executable. Set it in .env.local when claude is not on the server's PATH. |
Two variables are stripped from the CLI's environment in childEnv():
ANTHROPIC_API_KEY: the CLI would prefer it over the subscription login.CLAUDECODE: set when a process runs inside a Claude Code session. Removing it stops the child CLI from thinking it is nested (for example, whennpm run devwas started from Claude Code).
.gitignore
/data is ignored, so the database, uploads and onboarding marker are never committed. All .env* files are ignored too.
Scripts
| Script | Command |
|---|---|
npm run dev | next dev |
npm run build | next build |
npm start | next start |
npm run lint | eslint |
Node.js 22 or newer is required (per the README).
6. Persistence: files and SQLite
6.1 Data directories
Defined in src/lib/db.ts, all relative to process.cwd():
| Constant | Path | Contents |
|---|---|---|
DATA_DIR | data/ | Database and marker file |
UPLOAD_DIR | data/uploads/ | <documentId>.pdf |
WORK_DIR | data/claude-workdir/ | CLI working directory; last-generation.json; per-quiz temp folders in Visual mode |
open() creates all three with mkdirSync({ recursive: true }) the first time db.ts is imported. onboarding.ts builds its own path to data/onboarded.json and creates data/ itself, so the proxy can check onboarding without loading SQLite.
Because paths come from process.cwd(), the server must be started from the project root.
6.2 Connection setup
const db = new Database("data/exam-helper.db");
db.pragma("journal_mode = WAL"); // concurrent readers while writing
db.pragma("foreign_keys = ON"); // enforce ON DELETE CASCADE
The connection is cached on globalThis.__examHelperDb. migrate(db) runs on every evaluation of the module, which also upgrades a connection that an older version of the code opened before a hot reload.
6.3 Schema
The CREATE TABLE IF NOT EXISTS statements in open() describe the original (version 0) schema. Migrations then bring it to the current version, so a brand-new database also goes through every migration.
Current schema (after migration 2):
documents (
id TEXT PRIMARY KEY, -- UUID
name TEXT NOT NULL, -- display name (file name without .pdf, renameable)
file_name TEXT NOT NULL, -- "<id>.pdf" in data/uploads
size INTEGER NOT NULL, -- bytes
page_count INTEGER NOT NULL,
char_count INTEGER NOT NULL, -- total extracted characters
pages_json TEXT NOT NULL, -- JSON string[]; index 0 = page 1
created_at INTEGER NOT NULL -- ms since epoch
)
quizzes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL, -- 'generating' | 'ready' | 'error'
error TEXT, -- message when status = 'error'
model TEXT NOT NULL, -- model requested (alias or ID)
model_used TEXT, -- model the CLI reported using
config_json TEXT NOT NULL, -- GenerationConfig
questions_json TEXT NOT NULL DEFAULT '[]', -- Question[]
duration_ms INTEGER, -- CLI run time
created_at INTEGER NOT NULL,
started_at INTEGER NOT NULL -- reset on retry; used for stale detection
)
quiz_documents ( -- many-to-many, added in migration 1
quiz_id TEXT NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE,
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
position INTEGER NOT NULL, -- order the documents were selected in
PRIMARY KEY (quiz_id, document_id)
)
INDEX idx_quiz_documents_document ON quiz_documents(document_id)
attempts (
id TEXT PRIMARY KEY,
quiz_id TEXT NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE,
answers_json TEXT NOT NULL, -- Answers (map questionId → Answer)
results_json TEXT NOT NULL, -- QuestionResult[] in question order
score REAL NOT NULL, -- may be fractional (half marks)
max_score REAL NOT NULL,
created_at INTEGER NOT NULL
)
INDEX idx_attempts_quiz ON attempts(quiz_id)
Questions, configs, answers and results are stored as JSON text. Nothing queries inside them; they are always read and written whole.
Cascade chain: deleting a quiz removes its attempts and quiz_documents rows. Deleting a document removes its quiz_documents rows only. Which quizzes go with it is decided in deleteDocument (see 6.5).
6.4 Migrations
Migrations are an array of functions in db.ts, tracked with PRAGMA user_version:
| Version | Change |
|---|---|
| 1 | Adds quiz_documents, copies each quiz's old document_id into it, then rebuilds quizzes without the document_id column (create quizzes_new, copy, drop, rename). This let a set draw from several documents. |
| 2 | Shuffles the options of every existing choice question (models tend to list correct answers first) and remaps the selected indices in every saved attempt so old attempts still show what was picked. |
migrate():
- Reads
user_version; returns if it's already current. - Turns
foreign_keysoff. Droppingquizzeswhile it's on would fireON DELETE CASCADEand wipe all attempts. The pragma can only be changed outside a transaction, so this happens first. - Runs every pending migration inside one transaction.
- Runs
PRAGMA foreign_key_checkand throws (rolling back) if any reference is broken. - Sets
user_versionto the number of migrations. - Turns
foreign_keysback on in afinally.
6.5 Repository layer (lib/repo.ts)
All SQL lives here. Row interfaces (DocumentRow, QuizRow, AttemptRow) mirror the columns, and toDocument, toQuiz and toAttempt convert snake_case rows into camelCase records, parsing the JSON columns.
Documents
| Function | Behaviour |
|---|---|
listDocuments() | All documents, newest first, each with quizCount (from a subquery on quiz_documents). pages_json is not selected. |
getDocument(id) | One document or null. |
getDocuments(ids) | Batch fetch using json_each(?) with a JSON array parameter, returned in the order of ids, skipping missing ones. |
getDocumentPages(id) | Parses pages_json. Only generation needs it. |
insertDocument(doc, pages) | Inserts metadata plus the page array. |
deleteDocument(id) | Transaction: deletes every quiz linked to this document that has no other document, then deletes the document. Combined sets survive and simply lose this source. |
renameDocument(id, name) | Returns false when no row changed. |
Quizzes
| Function | Behaviour |
|---|---|
listQuizzes(documentId?) | With an id: every quiz linked to that document. Without: the 20 most recent quizzes. Calls expireStaleGenerations() first. |
getQuiz(id) | One quiz with its documents. Also expires stale generations first. |
withDocuments(rows) | One extra query joins quiz_documents with documents, ordered by position, and attaches {id, name} lists. A deleted document simply isn't returned. |
insertQuiz({...}) | Transaction: inserts the quiz with status generating and started_at = created_at, then one quiz_documents row per document in selection order. |
markQuizGenerating(id) | Sets status back to generating, clears the error, resets started_at. Used by retry. |
completeQuiz(id, questions, modelUsed, durationMs) | Sets status ready and stores the questions. |
failQuiz(id, error) | Sets status error with a message. |
deleteQuiz(id), renameQuiz(id, title) | As named. |
expireStaleGenerations() marks any quiz still generating whose started_at is more than 20 minutes ago as error ("Generation was interrupted (the server may have restarted)..."). The CLI times out at 15 minutes, so a row that old can only mean the background job died, usually because the server restarted. The check runs lazily whenever quizzes are read; there is no timer.
Attempts
| Function | Behaviour |
|---|---|
listAttempts(quizId) | Newest first. |
attemptStats() | Per quiz: best score / max_score (as a 0–1 fraction, NULLIF avoids divide-by-zero) and attempt count. |
getAttempt(id), insertAttempt(a) | As named. |
7. Domain model
All in src/lib/types.ts.
Question types
type QuestionType = "mcq" | "single" | "fill_blank" | "long_answer" | "artifact";
| Type | UI | Answer kind | Graded by |
|---|---|---|---|
mcq | Checkboxes, select all that apply | choice | Code (partial credit) |
single | Radio buttons | choice | Code |
fill_blank | Inline text inputs in the sentence | blanks | Code (normalised match) |
long_answer | Textarea | text | Claude |
artifact | Code/artifact block + textarea | text | Claude |
QUESTION_TYPES holds the label and description for each type, and its order is the canonical display order: generation sorts questions by it, and the quiz list and score summary group by it. isWritten(type) is true for long_answer and artifact.
Question
interface Question {
id: string; // UUID assigned during sanitising
type: QuestionType;
question: string; // fill_blank: contains "[[BLANK]]" tokens
options: string[]; // choice types only
correctOptions: number[]; // zero-based, sorted
blanks: { answers: string[] }[];// one entry per [[BLANK]], all accepted answers
artifact?: { language: string; content: string }; // artifact type only
modelAnswer: string; // written types
keyPoints: string[]; // written types: marking points
marks: number; // integer ≥ 1
explanation: string;
difficulty: "easy" | "medium" | "hard";
topic: string; // short label, used for "Topics to review"
source: string; // e.g. "p. 12" or "Lecture 3, p. 12"
}
BLANK_TOKEN is the literal string [[BLANK]].
GenerationConfig
interface GenerationConfig {
model: string; // alias ("sonnet") or full ID
effort: "default" | "low" | "medium" | "high" | "xhigh" | "max";
counts: Record<QuestionType, number>;// 0–40 each
difficulty: "easy" | "medium" | "hard" | "mixed";
pageFrom: number; // 0 = whole document
pageTo: number;
focus: string; // optional topics, ≤1000 chars
mode: "text" | "visual";
}
Answers and results
type Answer =
| { kind: "choice"; selected: number[] }
| { kind: "blanks"; values: string[] }
| { kind: "text"; text: string };
type Answers = Record<string /* questionId */, Answer>;
type ResultStatus = "correct" | "partial" | "incorrect" | "unanswered" | "ungraded";
interface QuestionResult {
questionId: string;
status: ResultStatus;
score: number; // multiples of 0.5
maxScore: number; // = question.marks
blankCorrect?: boolean[]; // fill_blank: per-blank correctness
feedback?: string; // written answers (or the grading error)
coveredPoints?: string[];
missedPoints?: string[];
}
QuizRecord, DocumentRecord and AttemptRecord are the typed forms of the table rows. QuizRecord.documents lists the source documents that still exist.
Model presets
MODEL_PRESETS provides sonnet (the default), opus, fable and haiku. These are Claude Code model aliases that resolve to the latest model in each family. Any other ID can be typed in the form.
8. The Claude Code bridge (lib/claude.ts)
Every model call goes through runClaude<T>(options).
8.1 Options
interface ClaudeRunOptions {
input: string; // full user prompt, sent on stdin
systemPrompt: string;
model: string;
effort?: string; // omitted when "default"
schema: object; // JSON Schema for the structured output
tools?: string[]; // built-in tools to enable; [] = none
cwd?: string; // defaults to data/claude-workdir
timeoutMs?: number; // defaults to 15 minutes
}
8.2 The command line
claude -p
--tools "<comma list or empty>"
[--allowedTools "<same list>"] only when tools are enabled
--permission-mode dontAsk
--output-format json
--model <model>
--system-prompt <systemPrompt>
--json-schema <JSON.stringify(schema)>
--no-session-persistence
--safe-mode
--strict-mcp-config
[--effort <effort>]
Why each flag is there:
-p: print mode. One request, one result, then exit.--tools "": with an empty list the model has no tools; it can only read the prompt and answer. Visual mode passesReadso Claude can open the PDF parts.--allowedTools: pre-approves the enabled tools so no permission prompt blocks the non-interactive run.--permission-mode dontAskcovers the same need.- The variadic flags (
--tools,--allowedTools) come first and are each followed by an ordinary flag, so they can't swallow later arguments. --output-format jsongives a single JSON envelope on stdout.--system-promptreplaces Claude Code's default agent system prompt with the examiner prompt.--json-schemamakes the CLI validate the answer and return it asstructured_output.--no-session-persistence: runs are not saved to the user's Claude Code session history.--safe-modeand--strict-mcp-config(with no MCP config supplied) keep the user's local Claude Code customisations and MCP servers out of these runs.
The prompt goes in on stdin rather than as an argument. Lecture text can run to hundreds of kilobytes, far beyond the Windows command-line limit (about 32K characters).
8.3 Process handling (exec)
spawn(CLAUDE_BIN, args, { cwd, env: childEnv(), windowsHide: true }), with no shell.- stdout and stderr are collected as UTF-8 strings.
- A timer kills the child after
timeoutMsand the promise rejects with "Claude took longer than N minutes and was stopped." ENOENT(executable not found) becomes aClaudeErrorexplaining how to install Claude Code or setCLAUDE_BIN.- stdin errors are swallowed: if the process exits before reading its input, the
closehandler reports the real problem.
8.4 Parsing the result
The CLI prints one JSON object. runClaude checks it in order:
- stdout not JSON →
ClaudeError("Claude Code exited with code N without a result. <first 800 chars of stderr/stdout>"). is_errortrue orsubtype !== "success"→ClaudeError("Claude returned an error: <result>").- Takes
structured_output, or failing that triesJSON.parse(result). - Neither present →
ClaudeError("Claude did not return structured output...").
It returns { output, modelUsed, durationMs }. modelUsed is the first key of modelUsage, which is the concrete model ID the alias resolved to (shown as a badge on the quiz page).
8.5 Status check (getClaudeStatus)
Runs claude auth status (30-second timeout) and parses its JSON:
interface ClaudeStatus {
installed: boolean;
loggedIn: boolean;
authMethod?: string; // "claude.ai" for subscription logins
subscriptionType?: string; // e.g. "pro", "max"
email?: string;
error?: string;
}
Any failure gives loggedIn: false. installed is false only when the error is the "not found" message from ENOENT. The result is cached in module memory for 60 seconds; force = true (from ?refresh) bypasses the cache.
9. Uploading a PDF
Client: UploadDropzone accepts drag-and-drop or a file picker (multiple files). It keeps files whose MIME type is application/pdf or whose name ends in .pdf, then uploads them one at a time as multipart/form-data to POST /api/documents, showing "Processing 2 of 5: name.pdf". Errors are collected and shown together. With exactly one successful upload it navigates to /documents/<id>; otherwise it calls router.refresh() to reload the library.
Server: src/app/api/documents/route.ts
- Reads the
filefield. Missing → 400. - Rejects files over 100 MB → 413.
- Checks the magic bytes: the first five bytes must be
%PDF-→ otherwise 400. - Extracts text with
extractPages(bytes.slice()). pdf.js may take ownership of (detach) the buffer it's given, so it gets a copy and the original is kept for writing to disk. Extraction failure (encrypted, corrupt) → 422. - Writes the bytes to
data/uploads/<uuid>.pdf. - Inserts the document:
nameis the file name without.pdf, plussize,pageCount,charCount(sum of page lengths) and the pages array. - Returns
{ id }.
Text extraction: lib/pdf.ts
const pdf = await getDocumentProxy(data);
const { text } = await extractText(pdf, { mergePages: false }); // string[] per page
// per page: strip trailing spaces/tabs before newlines, collapse 3+ newlines to 2, trim
Scanned-PDF heuristic: isLowText(d) in format.ts flags a document when charCount < max(500, pageCount × 60). Flagged documents get a "Scanned" badge, and the generate form switches its default reading mode to Visual and shows a warning.
10. Generating a question set
10.1 Request flow
GenerateForm ──POST /api/quizzes──▶ validate ─▶ insertQuiz(status=generating)
│ ─▶ startGeneration(id) (not awaited)
│◀──────────── { id } ──────────────────
▼
router.push(/quizzes/<id>) ─▶ QuizPage sees status=generating ─▶ <GeneratingView>
│ every 3 s
GET /api/quizzes/<id> → {status}
│ status ≠ generating
router.refresh()
▼
QuizPage renders QuizRunner or the error card
10.2 Validation in POST /api/quizzes
| Field | Rule |
|---|---|
documentIds | Array, de-duplicated. The legacy single documentId is also accepted. Every id must exist, otherwise 404 "Select at least one document that still exists." |
model | Must match /^[\w.\-\[\]]{2,80}$/ (letters, digits, _, ., -, and brackets so suffixes like [1m] pass). Otherwise 400. |
counts | Each type floored and clamped to 0–40. All zero → 400. |
pageFrom / pageTo | Single-document sets only (forced to 0 otherwise). If either is set: from is clamped to 1…pageCount, to to from…pageCount, and a range covering the whole document is stored as 0/0. |
effort | One of the six values, else default. |
difficulty | One of easy, medium, hard, mixed, else mixed. |
focus | Truncated to 1000 characters. |
mode | visual if exactly "visual", else text. |
title | Trimmed, max 120. When empty: single document → "<name>: Set N" where N is one more than the number of sets already linked to the document; several → "A & B", "A, B & C", or "A … F (6 materials)". |
The route inserts the quiz, calls startGeneration(id) and returns { id } straight away. Material-size limits are not checked here; they are checked inside the background job, so an oversized selection shows up as a failed set on the quiz page.
10.3 The background job
startGeneration(quizId):
- Returns early if the id is already in the process-global
runningset, so double clicks and a retry during a run don't start a second CLI process. - Calls
generate(quizId); any thrown error is saved withfailQuiz(quizId, message); the id is removed fromrunninginfinally.
generate(quizId):
- Loads the quiz and its documents. No documents → "The source documents were deleted."
- Uses the page range only when there is exactly one document.
- Prepares the material in Text or Visual mode (below).
- Calls
runClaudewith the generation schema and the examiner system prompt. - In Visual mode, deletes the temporary folder in a
finally(fire-and-forget). - Writes
{ quizId, output }todata/claude-workdir/last-generation.jsonfor troubleshooting (overwritten every run; errors ignored). - Sanitises the questions. If none survive, it throws: "Claude returned N question(s), but none were in a usable format" or "Claude did not return any questions".
completeQuiz(...)stores the questions, the resolved model and the duration.
10.4 Text mode
For each document, selectPages(pages, from, to) joins the non-empty pages in range as:
--- Page 1 ---
<text>
--- Page 2 ---
<text>
- A document with under 200 characters in range fails the job with advice to use Visual mode (the message names the document for multi-document sets).
- With several documents each section is wrapped in
<document title="Name">…</document>(double quotes in names become single quotes). - If the combined text is over 600,000 characters (about 150k tokens) the job fails, asking for fewer documents or a smaller page range.
- The text goes into the prompt inside
<lecture_material>…</lecture_material>. No tools are enabled.
10.5 Visual mode
Claude reads the PDF pages itself, so it can see diagrams, charts, scanned slides and screenshots of code.
- Counts the selected pages (the range for a single document with a range, otherwise the sum of page counts). Over 200 fails the job.
- Creates
data/claude-workdir/<quizId>/. splitPdf()loads each source with pdf-lib (ignoreEncryption: true) and writes parts of at most 10 pages each, copying pages into a newPDFDocument. Part names arepages-001-010.pdf, prefixeddoc01-,doc02-… when there are several documents.- The prompt lists every part with its document name and original page numbers and tells Claude to read each one whole with the Read tool and to cite the original page numbers.
- Runs with
tools: ["Read"]andcwdset to the quiz folder.
Why split: Claude Code's Read tool reads a PDF of up to 10 pages in one go, while reading page ranges needs Poppler (pdftoppm), which most Windows machines don't have. Pre-cut parts avoid that dependency.
10.6 Prompt construction (buildInput)
The system prompt sets Claude up as a university examiner: test understanding, ground everything in the material, avoid trivia about slide layout, lecturer names, course codes or page numbers, write plain text with no Markdown, and answer only through the structured output.
The user prompt is assembled as:
Create a practice exam of exactly <total> questions from the lecture material.
Question counts (exact):
- 5 × "mcq"
- ...
Rules per type:
- <TYPE_RULES for each requested type>
Rules for every question:
- <difficulty rule>
- Cover the breadth of the material; do not ask the same thing twice.
- "explanation": 1-3 sentences ...
- "topic" ... "source" ... (format differs for multi-document sets)
- Fields that do not apply ... must be empty ...
- [multi-doc] spread questions across N documents ..., connect ideas across documents
- [focus] Focus on these topics where the material allows: ...
<lecture_material>...</lecture_material> or the list of PDF parts
Per-type rules (TYPE_RULES), summarised:
| Type | Rule | Marks |
|---|---|---|
mcq | 4–6 options, 2+ correct, plausible distractors, no "all/none of the above", never refer to options by letter or position (they're shuffled). | 2 |
single | Usually 4 options, exactly one correct; occasionally True/False as ["True","False"]. | 1 |
fill_blank | Sentence with 1–3 [[BLANK]] tokens; one blanks entry per token listing all reasonable accepted answers (synonyms, abbreviations, spellings); each 1–4 words. | 1 per blank |
long_answer | Structured written question; full modelAnswer; 3–6 keyPoints. | 4–10 |
artifact | Concrete artifact in artifact.content (code, pseudo-code, SQL, regex, config, formula, table), plain text with line breaks, no fences. Taken from the material or written to apply it (5–30 lines). Tasks vary: explain, trace, find a bug, analyse complexity, connect to a concept. | 3–8 |
Difficulty: mixed asks for roughly 30% easy, 50% medium, 20% hard; otherwise every question gets the chosen level.
10.7 Output schema
{
"questions": [{
"type": "mcq|single|fill_blank|long_answer|artifact",
"question": "string",
"options": ["string"],
"correctOptions": [0],
"blanks": [{ "answers": ["string"] }],
"artifact": { "language": "string", "content": "string" },
"modelAnswer": "string",
"keyPoints": ["string"],
"marks": 1,
"explanation": "string",
"difficulty": "easy|medium|hard",
"topic": "string",
"source": "string"
}]
}
Every property is required and additionalProperties is false throughout, so the model fills in every field for every question, using empty values where a field doesn't apply.
10.8 Sanitising (sanitize)
The schema guarantees shape, not sense. sanitize() repairs or drops each question:
- All types: drop unknown types and empty questions. Assign a fresh UUID. Trim strings; drop empty options and key points.
marks=max(1, round(marks)). Unknown difficulty becomesmedium. mcq/single: de-duplicatecorrectOptionsand keep integer indices that are in range. Drop the question if there are fewer than 2 options or no valid correct index.singlekeeps only the first correct index;mcqsorts them.fill_blank: count[[BLANK]]tokens. Drop if there are none or fewer non-empty blank entries than tokens; extra entries are trimmed off.long_answer/artifact: drop if there is no model answer or no key points.artifact:stripFences()removes a leading```langline, a trailing```, normalises CRLF, drops leading blank lines and trims the end while keeping indentation. Drop if the content is empty. Language is lowercased, defaulttext.- Then
shuffleOptions()(next section). - Finally the list is sorted by type in
QUESTION_TYPESorder.
10.9 Option shuffling (lib/shuffle.ts)
Models tend to put correct answers first, so each choice question's options are shuffled with Fisher–Yates. shuffleOptions(q) returns the new question and newIndex[old] = new, which is used to remap correctOptions (and, in migration 2, saved answers). Questions with fewer than 2 options, non-choice questions and True/False pairs (exactly ["True","False"], case-insensitive) keep their order.
10.10 Retry and interruption
- Retry (
POST /api/quizzes/[id]/retry): if the quiz isn't currentlygenerating, it resets the row withmarkQuizGeneratingand callsstartGenerationagain with the same stored config. If it is already generating, nothing happens. - Interrupted runs: the job is an in-memory promise. If the server stops, the row stays
generatinguntilexpireStaleGenerations()turns it into an error 20 minutes afterstarted_at.
10.11 The waiting screen
GeneratingView shows a pulsing icon, an indeterminate progress bar, the model and elapsed time, and a stage label based on elapsed time (cosmetic, not real progress):
- under 8 s: "Reading the material" / "Reading the pages"
- under 60 s: "Writing N questions"
- after that: "Still writing. Long or visual sets can take a few minutes"
It polls GET /api/quizzes/<id> every 3 seconds (cache: "no-store") and calls router.refresh() once the status isn't generating. The user can leave; the set shows up in the library when done.
11. Taking a quiz (client side)
QuizRunner is the largest client component. It receives quizId, questions and optionally initialAttempt (when reviewing a past attempt).
11.1 State
| State | Purpose |
|---|---|
mode | exam (submit at the end) or practice (check each answer as you go). Not persisted. |
layout | focus (one question, navigator sidebar) or list (all questions grouped by type). Persisted in localStorage. |
answers | Answers map. |
flagged | Question ids marked for review. |
current | Index of the focused question. |
startedAt | Timer origin; restored drafts set it back by the saved elapsed time. |
checked | Practice mode: per-question local result, or "revealed" for written questions. |
attempt | Set after submission (or from initialAttempt); switches to the results view. |
filter | Results view: all, review (not fully correct), correct. |
restored, hydrated, submitting, error | UI bookkeeping. |
11.2 Drafts and autosave
Unsubmitted work is kept in localStorage["exam-helper:draft:<quizId>"]:
interface Draft { answers: Answers; flagged: string[]; current: number; elapsedMs: number }
- Restore (after mount, so server and client renders match): only when no
initialAttemptis given and the draft contains at least one answer for a question that still exists. Flags are filtered to existing ids,currentis clamped, and a "Welcome back. Your answers from last time were restored." banner offers Start over. - Save: on every change to answers, flags or position, every 10 seconds (so the elapsed time stays current), and on
pagehide. - Clear: after a successful submission. Start over and Retake reset the state, and the next save overwrites the draft with an empty one.
All storage access is wrapped in try/catch, so private windows and blocked storage simply mean no autosave.
11.3 Answering
QuestionCard renders each type:
- Choice: options are labels wrapping visually hidden radio/checkbox inputs, lettered A, B, C…
mcqtoggles indices (kept sorted);singlereplaces the selection. - Fill in the blank: the question is split on
[[BLANK]], with an inline input between the pieces. The answer is always a fullvaluesarray, one entry per blank. - Written: a textarea with a live word count. Artifact questions first show the artifact in a dark, line-numbered
<figure>labelled with its language.
Changing an answer removes any practice-mode check for that question.
11.4 Practice mode
The footer button reads Check answer (objective types, enabled once answered) or Show model answer (written types, always enabled). Checking calls the shared gradeLocally() in the browser:
- Objective questions get a real
QuestionResult: the card shows correct and incorrect options, blank corrections and the status badge, and the navigator square turns green, amber or red. - Written questions return
nulllocally, so they are stored as"revealed": the model answer, marking points and explanation open, with no score.
Practice checks live only in component state. Nothing is saved until the attempt is submitted.
11.5 Navigation and keyboard shortcuts
In focus layout there are Previous/Next buttons (the last question shows Finish and submit) and a Navigator grid. Each square shows answered/flagged/result state with an icon as well as colour, and an aria-label such as "Question 4, answered, marked for review".
Shortcuts (focus layout, before submission; ignored when Ctrl/Meta/Alt is held, when typing in a text field, or while a dialog is open):
| Key | Action |
|---|---|
← / → | Previous / next question |
A… | Pick or toggle that option (letters map up to J) |
M | Toggle mark for review |
Enter | Practice mode: check the current question |
In list layout, clicking a navigator entry scrolls the matching card into view (#q-<id>).
11.6 Submitting
submit() shows a confirmation dialog when any question is unanswered or flagged. It then posts { answers } to /api/quizzes/<id>/attempts. While that's in flight the button reads "Claude is grading…" if any written question has an answer, otherwise "Grading…", and all inputs are locked. On success it:
- stores the returned attempt and switches to the results view,
- clears the draft,
- shows
gradingError, if any, as a banner, - scrolls to the top and calls
router.refresh()so the attempt history above the runner updates.
11.7 Results view
ScoreSummary shows:
- a score ring (green ≥ 80%, amber ≥ 50%, red otherwise) with an animated draw-in, the percentage and
score / max, - a verdict: Excellent (≥ 90), Great work (≥ 75), Good effort (≥ 60), Getting there (≥ 40), Keep practising,
- counts of correct, partly correct, incorrect (which includes
ungraded) and skipped, - a bar per question type,
- Topics to review: results grouped by
topic(case-insensitive), keeping topics that lost marks, weakest ratio first, top 5, - a Retake button that resets the runner to a fresh attempt.
Below it, every question is shown read-only with reveal on: feedback, covered and missed points (written types), the model answer and marking points in a <details>, the explanation and the source. A sticky filter bar switches between All, To review and Correct.
/attempts/[id] renders the same QuizRunner with initialAttempt, so a past attempt opens straight into this view.
12. Grading an attempt
POST /api/quizzes/[id]/attempts (src/app/api/quizzes/[id]/attempts/route.ts):
- 404 unless the quiz exists and is
ready. - Reads
answers(any object; defaults to{}). - For each question:
gradeLocally(q, answer)returns a result for objective types.- For written types: a non-empty answer (trimmed, cut to 20,000 characters) is queued for Claude; an empty one is
unansweredwith score 0.
- Sends the whole queue to
gradeLongAnswers(items, quiz.config.model)in one Claude call. - If that call throws, every queued question is marked
ungradedwith feedback "Could not grade this answer: <error>". The attempt is still saved andgradingErroris returned so the client can show it. - Orders the results by question order, totals
scoreandmaxScore, and inserts the attempt. - Returns
{ attempt, gradingError }.
The server always grades from the stored questions. Practice-mode results from the browser are never trusted or sent.
12.1 Local grading (lib/grade.ts)
isAnswered(q, a): choice has at least one selection; blanks has at least one non-blank value; text is non-empty after trimming.
| Type | Scoring |
|---|---|
single | Full marks if the selected index equals correctOptions[0], else 0. |
mcq | Full marks if every correct option is ticked and no wrong one. Otherwise max(0, (hits − wrong) / correctCount) × marks, rounded to the nearest half mark. Each wrong tick cancels a right one. |
fill_blank | Each blank is checked against its accepted answers; score = (right / blanks) × marks, rounded to the nearest half. blankCorrect records each blank's result. |
Status: score ≥ max → correct, > 0 → partial, otherwise incorrect. Unanswered questions are unanswered.
Worked example for mcq (2 marks, 3 correct options): ticking 2 correct and 1 wrong gives (2 − 1) / 3 × 2 = 0.67, which rounds to 0.5.
Blank matching uses normalizeAnswer() on both sides:
- lowercase, then Unicode NFKD, then strip combining accents (
é→e) - remove quotes and backticks (
“ ” " ' \’`) - replace anything other than letters, digits,
.,+,-,/,%and whitespace with a space - collapse whitespace and trim
- drop a leading
the,aoran, and one trailing.
Two answers match if their normalised forms are equal, or both parse as numbers (after removing commas) and differ by less than 1e-9. So "The Mitochondria." matches mitochondria, and 1,000 matches 1000.
12.2 Claude grading (lib/grade-ai.ts)
The payload is a JSON array, one entry per written answer:
{
"questionId": "...",
"question": "...",
"artifact": { "language": "...", "content": "..." }, // artifact questions only
"marks": 6,
"keyPoints": ["..."],
"modelAnswer": "...",
"studentAnswer": "..."
}
The user prompt is "Mark each student answer below. Return one grade per questionId." followed by that JSON. The system prompt asks for a fair, consistent examiner who:
- awards marks for each key point genuinely shown, even when worded differently,
- gives nothing for vague, irrelevant or wrong statements,
- may use half marks between 0 and the question's marks,
- writes 2–4 sentences of feedback to the student (what was good, what was missing, how to improve),
- quotes or paraphrases key points in
coveredPoints/missedPoints, - checks claims about an artifact against the artifact itself.
Output schema: { grades: [{ questionId, score, feedback, coveredPoints, missedPoints }] }.
The call uses the set's generation model with default effort, no tools, and an 8-minute timeout. Each score is rounded to the nearest half and clamped to [0, marks]. A question missing from the output becomes ungraded ("Claude did not return a grade for this answer.").
13. Onboarding, the proxy and setup checks
13.1 First-run flow
src/proxy.ts runs before every page request. Its matcher /((?!api/|_next/|.*\.[\w]+$).*) skips API routes, Next.js internals and anything with a file extension (icons, images).
- Not onboarded and not on
/welcome→ redirect to/welcome. - Onboarded and on
/welcome→ redirect to/.
isOnboarded() checks for data/onboarded.json. Once it has seen the file it caches true in module memory and stops touching the filesystem. markOnboarded() writes { "completedAt": "<ISO date>" } and sets the cache.
The welcome page (src/app/welcome/page.tsx) has its own header (no app nav) and an EligibilityCard. Its button posts to /api/onboarding and then calls router.replace("/"), replacing the history entry so Back doesn't land on a page the proxy now redirects away from. The button reads Start using Recall when the checks pass and Continue anyway otherwise, so a user without Claude Code can still get in and upload PDFs.
13.2 Eligibility checks (lib/eligibility.ts)
checkEligibility(status) turns a ClaudeStatus into three checks:
| Check | Pass | Fail / Warn | Pending |
|---|---|---|---|
| Claude Code installed | installed | fail | status not loaded yet |
| Signed in to Claude | loggedIn | fail | not installed |
| Claude subscription | authMethod === "claude.ai" and a subscriptionType | warn: the login works but may bill an API account | not signed in |
ready is installed && loggedIn; the plan check never blocks. Failed checks show copyable install commands (PowerShell and curl) or claude auth login.
13.3 Shared status store (useClaudeStatus)
The header badge, the setup banner, the setup checklist, the eligibility card and the generate form all need the CLI status. useClaudeStatus() keeps a single module-level store:
currentholds the last status, and alistenersset is notified on change.useSyncExternalStore(subscribe, () => current, () => null): the server snapshot isnull, so SSR always renders the "checking" state.- The first mount calls
load(false). Concurrent loads share one in-flight promise. refresh()callsload(true), which adds?refresh=1to skip the server's 60-second cache.- A network failure becomes
{ installed: false, loggedIn: false, error: "Could not reach the app server." }.
Where the status shows up:
ClaudeStatusBadge(header): "Claude Pro" / "Claude not signed in" / "Set up Claude", linking to/setup.SetupBanner(home): a prompt to finish setup, hidden while loading or once signed in.GenerateFormfooter: "Claude isn't connected. Set it up" whenloggedInis false.SetupChecklist(/setup): three numbered steps with a Check again button and the raw error under "Details".
14. HTTP API reference
All handlers are in src/app/api. Bodies are JSON unless stated. Errors return { "error": string } with the status shown.
GET /api/claude/status[?refresh]
Returns a ClaudeStatus. Cached for 60 s unless refresh is present.
POST /api/documents
multipart/form-data with a file field.
| Status | When |
|---|---|
200 { id } | Stored |
| 400 | No file, or the file isn't a PDF |
| 413 | Larger than 100 MB |
| 422 | Text extraction failed |
PATCH /api/documents/[id]
Body { name }, trimmed, max 200. 400 if empty, 404 if missing. Returns { ok: true }.
DELETE /api/documents/[id]
Deletes the row (and sets made only from it), then removes the PDF file. 404 if missing.
POST /api/quizzes
{
"documentIds": ["..."], // or legacy "documentId"
"title": "", // optional
"model": "sonnet",
"effort": "default",
"counts": { "mcq": 5, "single": 5, "fill_blank": 5, "long_answer": 2, "artifact": 0 },
"difficulty": "mixed",
"pageFrom": 0, "pageTo": 0, // single document only
"focus": "",
"mode": "text" // or "visual"
}
Returns { id } right away; generation runs in the background. Validation rules are in 10.2.
GET /api/quizzes/[id]
Returns { status, error } only, for cheap polling. 404 if missing.
PATCH /api/quizzes/[id]
Body { title }, trimmed, max 120. 400 if empty, 404 if missing.
DELETE /api/quizzes/[id]
Deletes the set and its attempts. Always returns { ok: true }.
POST /api/quizzes/[id]/retry
Restarts generation unless it is already running. 404 if missing.
POST /api/quizzes/[id]/attempts
Body { answers: Answers }. Returns { attempt: AttemptRecord, gradingError: string | null }. 404 unless the quiz is ready. The request stays open while Claude grades written answers (up to 8 minutes).
POST /api/onboarding
Writes the onboarding marker. Returns { ok: true }.
Route handlers use the typed RouteContext<"/api/..."> helper, so ctx.params is a promise typed from the route path.
15. Pages and routing
| Route | File | Kind | Content |
|---|---|---|---|
/welcome | app/welcome/page.tsx | Static server component | First-run landing page and EligibilityCard. Own chrome. |
/ | app/(app)/page.tsx | Server, dynamic section | Hero, UploadDropzone, then Library: stat tiles, materials grid, the 20 most recent sets. With nothing uploaded it shows a "How it works" panel instead. |
/generate | app/(app)/generate/page.tsx | Server + client form | GenerateForm with a document picker (pickable), oldest document first, all selected. Empty state links back to upload. |
/documents/[id] | app/(app)/documents/[id]/page.tsx | Server | Renameable name, page count, size, upload date, "Scanned" badge, Delete, the document's sets, and a GenerateForm for just this document (page range available). |
/quizzes/[id] | app/(app)/quizzes/[id]/page.tsx | Server + client runner | Renameable title, metadata badges (resolved model, difficulty, generation time, page range), source chips for combined sets, Delete. Then either GeneratingView, an error card with RetryButton, or attempt history plus QuizRunner. |
/attempts/[id] | app/(app)/attempts/[id]/page.tsx | Server + client runner | QuizRunner opened on a saved attempt. |
/setup | app/(app)/setup/page.tsx | Static | SetupChecklist and an FAQ (cost, storage, model choice, Text vs Visual, PATH problems). |
Rendering pattern. With cacheComponents on, a page's static parts are prerendered into a shell and request-time parts stream in. Each data page puts its database work in an async inner component inside <Suspense fallback={<PageLoading />}> and calls await io() before the first synchronous SQLite read:
export default function QuizPage({ params }: PageProps<"/quizzes/[id]">) {
return (
<Suspense fallback={<PageLoading />}>
<QuizView params={params} />
</Suspense>
);
}
async function QuizView({ params }) {
const { id } = await params;
await io(); // per-request data follows
const quiz = getQuiz(id);
if (!quiz) notFound();
...
}
Layouts. The root layout sets <html lang="en">, the font variables and the inline theme script. The (app) route group adds a skip link, a sticky blurred header (logo, NavLinks, ClaudeStatusBadge, ThemeToggle), <main id="main"> and a footer linking to Setup & help.
404s. notFound() inside an app page renders (app)/not-found.tsx inside the app chrome. Unmatched URLs render the root not-found.tsx, which brings its own logo.
Attempt history. On a ready quiz with attempts, a collapsible <details> shows the count, a "Best N%" badge, a mini bar chart of the last 12 scores (oldest to newest, coloured by score) and links to each attempt.
Routes stay mounted. With Cache Components, Next.js keeps recently visited routes mounted but hidden. Components that navigate away (GenerateForm, DeleteButton) reset their busy state after navigating so the form isn't stuck when the user comes back.
16. Component reference
| Component | Kind | Notes |
|---|---|---|
ui.tsx | shared | cx() class joiner; Card; Button / buttonClass() with variants primary, secondary, ghost, danger, danger-solid; Badge with tones; scoreTone() (≥0.8 green, ≥0.5 amber, else red); ToggleGroup segmented control; inputClass; Spinner; PageLoading; Kbd; SectionTitle; BackLink; EmptyState; ErrorText (role="alert"). |
Dialog.tsx | client | Modal on the native <dialog> element (focus trap, Escape and backdrop come from the browser; clicking the backdrop closes it). useConfirm() returns [confirm, element], where confirm(opts) resolves to true or false. |
DeleteButton | client | Confirms, sends DELETE, optionally redirects, then refreshes. |
EditableTitle | client | An <h1> with a pencil button that becomes an input. Enter saves with PATCH {[field]: value}; Escape cancels; an unchanged or empty value just closes. Max length 120 for titles and 200 for names. |
GenerateForm | client | See below. |
GeneratingView | client | Polling waiting screen (10.11). |
QuizList | server | Rows with a ScoreRing (best score, dashed when never attempted), title, "Combined" badge, metadata, Generating/Failed badges. "Source deleted" covers sets with no remaining documents. |
QuizRunner | client | Section 11. |
quiz/QuestionCard | client | Renders every question type plus the review panel. Exports StatusBadge, fmt() (whole numbers or one decimal) and LETTERS. |
quiz/ScoreSummary | client | Section 11.7. |
RetryButton | client | Posts to retry, then refreshes. |
UploadDropzone | client | Section 9. |
NavLinks | client | Library and New set links. usePathname() is request data, so the active highlight sits behind a Suspense and the links first render without it. |
ThemeToggle | client | System / Light / Dark (section 18). |
ClaudeStatusBadge, SetupBanner, SetupChecklist, EligibilityCard | client | Section 13. SetupChecklist also exports CopyCommand (a terminal-style line with a copy button). |
useClaudeStatus | client hook | Section 13.3. |
Logo / LogoMark | shared | Inline SVG mark. Keep in sync with src/app/icon.svg. |
NotFoundCard | shared | 404 empty state. |
GenerateForm in detail
Props: documents: DocumentOption[] (id, name, pageCount, lowText) and pickable.
-
Materials picker (when
pickable): checkboxes with Select all / Clear all and a "N of M selected · P pages" hint. -
Question presets (counts are mcq / single / fill_blank / long_answer / artifact):
Preset Counts Total Quick check 3 / 5 / 2 / 0 / 0 10 Balanced (default) 5 / 5 / 5 / 2 / 0 17 Full exam 10 / 10 / 10 / 5 / 0 35 Written & code 0 / 0 / 0 / 3 / 4 7 Editing any count shows a "Custom" chip. Each type has −/+ steppers and a numeric input clamped to 0–40.
-
Difficulty: Easy, Medium, Hard, Mixed.
-
Model: four preset cards, or "Use a specific model ID" with a free-text input.
-
Reading mode: Text or Visual. Defaults to Visual if any selected document looks scanned, with a warning naming it. For multi-document selections over 200 pages it warns and disables Generate.
-
More options (collapsed): page range (single document only), focus topics, title, effort.
-
Persistence: model, effort, counts and difficulty are saved to
localStorage["exam-helper:generate-prefs"]on every change and restored after mount. Title, page range, focus and mode are not saved. -
Submit is disabled while busy, when the total is 0, with no model, with no documents, or when Visual is over the limit. On success it navigates to
/quizzes/<id>.
17. Browser storage
Per-browser conveniences only. Everything that matters is in SQLite.
| Key | Written by | Value |
|---|---|---|
exam-helper:theme | ThemeToggle | "light" or "dark"; removed for System |
exam-helper:generate-prefs | GenerateForm | { model, effort, counts, difficulty } |
exam-helper:quiz-layout | QuizRunner | "focus" or "list" |
exam-helper:draft:<quizId> | QuizRunner | Draft (11.2) |
Every read and write is wrapped in try/catch. Values are read after mount (in useEffect) so the first client render matches the server HTML; the react-hooks/set-state-in-effect lint rule is disabled on those lines on purpose.
18. Theming, styling and accessibility
Theme
themeScript(lib/theme.ts) is inlined in<head>and runs before first paint. It readsexam-helper:themeand falls back toprefers-color-scheme, then setsdata-theme="dark"|"light"on<html>.suppressHydrationWarningon<html>covers the attribute React didn't render.globals.cssdeclares@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)), so every Tailwinddark:class follows the attribute rather than the media query.- CSS variables
--background,--foreground,--mutedand--subtleare set for light, for[data-theme="dark"], and for system dark when no attribute exists yet (no JS). Muted and subtle text stay above 4.5:1 contrast in both themes. ThemeToggleoffers System, Light and Dark. While System is selected it listens for OS theme changes.- The
viewport.themeColormetadata matches the page background in each scheme.
Tokens
@theme inline maps brand-50…950 to Tailwind's violet scale. The comment in the CSS explains the choice: a violet brand never reads as "correct" next to the green, amber and red used for results. It also exposes background, foreground, muted and subtle as colour utilities and sets the sans and mono font families.
Motion
Keyframes fade-up (180 ms entrance), ring-draw (score rings) and indeterminate (progress bar). A prefers-reduced-motion: reduce rule shortens all animations and transitions to 0.01 ms.
Accessibility features
- "Skip to content" link targeting
#main. - Visible
:focus-visibleoutline in the brand colour; custom radio and checkbox tiles show a ring when their hidden input has keyboard focus. - Native
<dialog>for confirmations. ToggleGroupbuttons usearia-pressedinside a labelledrole="group".- Results never rely on colour alone: the navigator overlays check, minus and cross icons, and option tiles show check and cross marks.
- Navigator buttons have full
aria-labels; the answered-count progress bar hasrole="progressbar"with values. aria-liveon upload progress, generation stage and the eligibility summary; errors userole="alert".pointer-coarse:variants enlarge touch targets.- Cursor rules restore
cursor: pointeron buttons, summaries and enabled checkbox labels (Tailwind v4 resets it).
19. Limits and constants
| Constant | Value | Where |
|---|---|---|
| Max upload size | 100 MB | api/documents/route.ts |
| Questions per type | 0–40 | API and form |
| Max text sent in Text mode | 600,000 chars | MAX_TEXT_CHARS in generate.ts |
| Min text per document (Text mode) | 200 chars | generate.ts |
| Max pages in Visual mode | 200 | MAX_VISUAL_PAGES in generate.ts (form repeats it) |
| Pages per Visual part | 10 | PAGES_PER_PART |
| Generation timeout | 15 min | DEFAULT_TIMEOUT_MS in claude.ts |
| Grading timeout | 8 min | grade-ai.ts |
claude auth status timeout | 30 s | claude.ts |
| Status cache | 60 s | claude.ts |
| Stale generation cut-off | 20 min | STALE_AFTER_MS in repo.ts |
| Generation poll interval | 3 s | GeneratingView |
| Draft autosave interval | 10 s | QuizRunner |
| Written answer sent for grading | 20,000 chars | attempts route |
| Quiz title | 120 chars | API and EditableTitle |
| Document name | 200 chars | API and EditableTitle |
| Focus text | 1000 chars | API |
| Model ID pattern | ^[\w.\-\[\]]{2,80}$ | API |
| Sets on the home page | 20 most recent | listQuizzes() |
| Scores in the history chart | last 12 | quiz page |
| Weak topics shown | 5 | ScoreSummary |
| Scanned heuristic | chars < max(500, pages × 60) | isLowText |
20. Error handling and troubleshooting
Where errors surface
| Failure | What the user sees |
|---|---|
| Upload rejected | Red ErrorText under the drop zone, one message per failed file. |
| Bad generation request | Error inside the form; nothing is created. |
| Generation failed (CLI error, timeout, limits, no usable questions) | The quiz page shows "Generation failed" with the stored message and a Retry button. The library row shows a "Failed" badge. |
| Server restarted mid-generation | After 20 minutes the set becomes failed with an "interrupted" message and can be retried. |
| Written grading failed | The attempt is saved; written questions are "Not graded" with the error as feedback, and a banner explains it. |
| Claude not installed or signed in | Header badge, home banner, generate form footer, and /setup. |
Diagnostics
data/claude-workdir/last-generation.jsonholds the parsed structured output of the most recent generation ({ quizId, output }). It's useful when a set comes back with fewer questions than requested, since sanitising drops malformed ones.- Raw CLI failures:
ClaudeErrormessages include up to 800 characters of the CLI's stderr or stdout. They're stored inquizzes.errorand shown on the quiz page. - Status details:
/setup→ Check again → Details shows the error fromclaude auth status. - Reset onboarding: delete
data/onboarded.jsonand restart the server.isOnboarded()cachestruein memory, so deleting the file alone won't bring the welcome page back. - Start fresh: stop the server and delete
data/.
Common problems
- "Claude Code CLI not found". The server inherited a
PATHwithoutclaude, often because Claude Code was installed after the terminal runningnpm run devwas opened. Restart from a new terminal or setCLAUDE_BINin.env.local. The CLI is spawned without a shell, so on WindowsCLAUDE_BINshould point to an executable (for exampleclaude.exe). A.cmdwrapper may not start that way. - "Not enough text could be extracted". The PDF is scanned or image-only. Use Visual mode.
- "…more than the 600,000 limit". Choose a page range (single document) or fewer documents.
- Generation keeps failing with one model. Try another model or Text mode. Check that the model ID exists for the signed-in account.
21. Security and privacy model
- Single user, local only. There is no authentication. Anyone who can reach the server can read the library, upload files and spend the signed-in user's Claude usage. The README says not to deploy it publicly. On a shared network, consider binding the dev server to localhost (
next dev -H 127.0.0.1). - What leaves the machine. Only what goes to Claude through the CLI: extracted text (Text mode), PDF parts read by the Read tool (Visual mode), and, during grading, the questions, model answers, artifacts and the student's written answers. PDFs, the database and attempts stay in
data/. - Billing.
ANTHROPIC_API_KEYis stripped from the child environment so the CLI uses the subscription login. The eligibility check warns when the login is not aclaude.aisubscription. - CLI sandboxing. Text runs and grading have no tools at all. Visual runs enable only
Read, with the working directory set to a temporary folder holding the PDF parts for that set. Runs skip session persistence and leave MCP servers out. - Input handling. Uploads are checked by size and magic bytes. Generation parameters are validated and clamped. Model IDs are limited to a safe character set, and arguments are passed to
spawnas an array without a shell, so there is no shell interpolation. Document and quiz ids come fromrandomUUID(), and all SQL uses bound parameters. - Trusted client for answers. The attempts endpoint stores the
answersobject as sent and only checks that it is an object. That suits a local single-user tool but is not hardened against hand-crafted requests. - Prompt content. Lecture text is placed in the prompt verbatim. A PDF containing instructions could influence the questions generated for it. The schema limits the effect to the quiz content.
22. Behaviours and edge cases worth knowing
These are behaviours of the current code that are easy to miss when reading it for the first time.
- The home "Question sets" tile counts at most 20.
listQuizzes()without a document id is limited to the 20 most recent sets, and the tile shows the length of that list. Per-document pages list every set. - Size limits are enforced after the set is created. The 600k-character and 200-page limits are checked inside the background job, so a set that's too big appears briefly as "generating" and then as failed. The form only pre-warns for multi-document Visual selections over 200 pages; a single document over 200 pages in Visual mode isn't caught until the job runs.
- A restart can leave a set "generating" for up to 20 minutes. Retry does nothing while the status is
generating, and the status only flips once the stale cut-off passes. - No limit on concurrent generations. Each request spawns its own CLI process.
- Grading uses the generation model but default effort. The effort setting only affects generation.
- Practice-mode checks and the timer are not saved. Attempts record answers and results only, not time taken or practice checks.
- The prompt asks for 2+ correct options in
mcq, but sanitising accepts 1. fill_blankmarks are whatever the model returns (minimum 1). The prompt asks for one mark per blank but the code doesn't enforce it.- Keyboard letters go up to
J(LETTERS = "ABCDEFGHIJ"), although the on-screen hint and README say A–F, which covers the 4–6 options the prompt asks for. last-generation.jsonis overwritten every run and holds the parsed output, not the raw CLI stdout.DELETE /api/quizzes/[id]returnsokeven when the id doesn't exist.- Deleting a document keeps combined sets. Their
documentslist shrinks, but their questions (includingsourcestrings that name the deleted document) are unchanged. Retrying such a set regenerates from the remaining documents only. - Default set numbering ("Set N") counts every set linked to the document, combined ones included.
- Re-shuffling on regeneration. Retry produces new questions with new ids. There are no attempts to remap, because a set can only have attempts once it's
ready, and it can only be retried after it fails.
23. Extending the app
Adding a question type
- Types: add the literal to
QuestionTypeand an entry toQUESTION_TYPES(its position sets the display order). UpdateisWritten()if Claude should grade it. - Generation: add a
TYPE_RULESentry and any new fields toquestionSchema(every property must also be inrequired; tell the model to send empty values when a field doesn't apply). Add repair and drop rules insanitize(). - Grading: handle the type in
gradeLocally(), or rely ongradeLongAnswers()and add any new fields to its payload. - UI: render the input in
QuestionCard, extend theAnswerunion if it needs a new answer shape, and updateisAnswered(). - Form: add a count to every entry in
PRESETSinGenerateForm. Saved prefs merge over the defaults, so older saved prefs pick up the new key automatically.
Adding a database migration
Append a function to MIGRATIONS in db.ts. Never edit or reorder existing entries; user_version records how many have run. Migrations run in one transaction with foreign keys off, followed by foreign_key_check. To rebuild a table, use the create-copy-drop-rename pattern from migration 1.
Adding a Claude call
Use runClaude() with a JSON Schema whose properties are all required and additionalProperties: false. Keep tools empty unless the model must read files, and if it must, give it a dedicated cwd containing only those files. Treat the output as untrusted: validate and clamp it the way sanitize() and gradeLongAnswers() do.
Working with Next.js 16.4
The Next.js version here differs from older releases (proxy.ts instead of middleware, Cache Components, io(), typed PageProps / RouteContext / LayoutProps globals). As AGENTS.md says, read the bundled guides in node_modules/next/dist/docs/ before changing routing or data-loading code. Any new server component that reads SQLite should call await io() first and sit inside a <Suspense> boundary.
Challenges
Use Claude code in a head-less format Artefact question generation
Outcome
Published On GitHub