01Claude AI

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
Recall — image 1

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

Claude Code CLISQLiteArtefact QuestionsPDF text only readsPDF with Vision reads

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

  1. What the app does
  2. Technology stack
  3. Repository layout
  4. Architecture overview
  5. Configuration
  6. Persistence: files and SQLite
  7. Domain model
  8. The Claude Code bridge (lib/claude.ts)
  9. Uploading a PDF
  10. Generating a question set
  11. Taking a quiz (client side)
  12. Grading an attempt
  13. Onboarding, the proxy and setup checks
  14. HTTP API reference
  15. Pages and routing
  16. Component reference
  17. Browser storage
  18. Theming, styling and accessibility
  19. Limits and constants
  20. Error handling and troubleshooting
  21. Security and privacy model
  22. Behaviours and edge cases worth knowing
  23. Extending the app

1. What the app does

Recall turns lecture PDFs into practice exams.

  1. The user uploads one or more PDFs. The server extracts the text of each page and stores the file locally.
  2. The user picks question types, counts, difficulty, a Claude model and a reading mode, then asks for a question set.
  3. 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.
  4. 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.
  5. 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

LayerChoiceNotes
FrameworkNext.js 16.4 (App Router, Turbopack)cacheComponents and partialPrefetching enabled. Uses proxy.ts (the successor to middleware.ts) and io() from next/cache.
UIReact 19.3, Tailwind CSS 4, lucide-react iconsTailwind is wired through @tailwindcss/turbopack.
FontsPlus Jakarta Sans (body), Geist Mono (code)Loaded with next/font/google.
Databasebetter-sqlite3Synchronous, native, single file in data/.
PDF textunpdf (pdf.js)Per-page text extraction on upload.
PDF splittingpdf-libCuts PDFs into 10-page parts for Visual mode.
AIClaude Code CLISpawned as a child process in print mode with --json-schema.
LanguageTypeScript 5 (strict)Path alias @/* → src/*.
LintESLint 9 with eslint-config-nextnpm 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

FileRuns onResponsibility
types.tsbothAll shared types, question type metadata, model presets, BLANK_TOKEN.
db.tsserverOpens SQLite, creates the schema, runs migrations, exports data paths.
repo.tsserverEvery SQL query. Maps rows to typed records.
claude.tsserverSpawns the Claude Code CLI; status check.
generate.tsserverBuilds prompts, splits PDFs, runs generation, sanitises output.
grade.tsbothLocal grading of objective questions; answer normalisation.
grade-ai.tsserverBatch grading of written answers through Claude.
pdf.tsserverPer-page text extraction with unpdf.
shuffle.tsbothFisher–Yates shuffle of answer options with an index map.
eligibility.tsbothTurns a Claude status into pass/warn/fail checks.
onboarding.tsserverReads/writes the data/onboarded.json marker.
format.tsbothDate, size, duration and percentage formatting; scanned-PDF heuristic.
theme.tsbothTheme 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 claude is signed into. ANTHROPIC_API_KEY is 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 in structured_output. The app never parses free-form model text.
  • Background generation with polling. POST /api/quizzes inserts a row with status generating, 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 on globalThis so 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

VariableDefaultEffect
CLAUDE_BINclaudePath 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, when npm run dev was 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

ScriptCommand
npm run devnext dev
npm run buildnext build
npm startnext start
npm run linteslint

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():

ConstantPathContents
DATA_DIRdata/Database and marker file
UPLOAD_DIRdata/uploads/<documentId>.pdf
WORK_DIRdata/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:

VersionChange
1Adds 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.
2Shuffles 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():

  1. Reads user_version; returns if it's already current.
  2. Turns foreign_keys off. Dropping quizzes while it's on would fire ON DELETE CASCADE and wipe all attempts. The pragma can only be changed outside a transaction, so this happens first.
  3. Runs every pending migration inside one transaction.
  4. Runs PRAGMA foreign_key_check and throws (rolling back) if any reference is broken.
  5. Sets user_version to the number of migrations.
  6. Turns foreign_keys back on in a finally.

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

FunctionBehaviour
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

FunctionBehaviour
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

FunctionBehaviour
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";
TypeUIAnswer kindGraded by
mcqCheckboxes, select all that applychoiceCode (partial credit)
singleRadio buttonschoiceCode
fill_blankInline text inputs in the sentenceblanksCode (normalised match)
long_answerTextareatextClaude
artifactCode/artifact block + textareatextClaude

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 passes Read so Claude can open the PDF parts.
  • --allowedTools: pre-approves the enabled tools so no permission prompt blocks the non-interactive run. --permission-mode dontAsk covers 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 json gives a single JSON envelope on stdout.
  • --system-prompt replaces Claude Code's default agent system prompt with the examiner prompt.
  • --json-schema makes the CLI validate the answer and return it as structured_output.
  • --no-session-persistence: runs are not saved to the user's Claude Code session history.
  • --safe-mode and --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 timeoutMs and the promise rejects with "Claude took longer than N minutes and was stopped."
  • ENOENT (executable not found) becomes a ClaudeError explaining how to install Claude Code or set CLAUDE_BIN.
  • stdin errors are swallowed: if the process exits before reading its input, the close handler reports the real problem.

8.4 Parsing the result

The CLI prints one JSON object. runClaude checks it in order:

  1. stdout not JSON → ClaudeError("Claude Code exited with code N without a result. <first 800 chars of stderr/stdout>").
  2. is_error true or subtype !== "success" → ClaudeError("Claude returned an error: <result>").
  3. Takes structured_output, or failing that tries JSON.parse(result).
  4. 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

  1. Reads the file field. Missing → 400.
  2. Rejects files over 100 MB → 413.
  3. Checks the magic bytes: the first five bytes must be %PDF- → otherwise 400.
  4. 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.
  5. Writes the bytes to data/uploads/<uuid>.pdf.
  6. Inserts the document: name is the file name without .pdf, plus size, pageCount, charCount (sum of page lengths) and the pages array.
  7. 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

FieldRule
documentIdsArray, de-duplicated. The legacy single documentId is also accepted. Every id must exist, otherwise 404 "Select at least one document that still exists."
modelMust match /^[\w.\-\[\]]{2,80}$/ (letters, digits, _, ., -, and brackets so suffixes like [1m] pass). Otherwise 400.
countsEach type floored and clamped to 0–40. All zero → 400.
pageFrom / pageToSingle-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.
effortOne of the six values, else default.
difficultyOne of easy, medium, hard, mixed, else mixed.
focusTruncated to 1000 characters.
modevisual if exactly "visual", else text.
titleTrimmed, 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 running set, so double clicks and a retry during a run don't start a second CLI process.
  • Calls generate(quizId); any thrown error is saved with failQuiz(quizId, message); the id is removed from running in finally.

generate(quizId):

  1. Loads the quiz and its documents. No documents → "The source documents were deleted."
  2. Uses the page range only when there is exactly one document.
  3. Prepares the material in Text or Visual mode (below).
  4. Calls runClaude with the generation schema and the examiner system prompt.
  5. In Visual mode, deletes the temporary folder in a finally (fire-and-forget).
  6. Writes { quizId, output } to data/claude-workdir/last-generation.json for troubleshooting (overwritten every run; errors ignored).
  7. 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".
  8. 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.

  1. Counts the selected pages (the range for a single document with a range, otherwise the sum of page counts). Over 200 fails the job.
  2. Creates data/claude-workdir/<quizId>/.
  3. splitPdf() loads each source with pdf-lib (ignoreEncryption: true) and writes parts of at most 10 pages each, copying pages into a new PDFDocument. Part names are pages-001-010.pdf, prefixed doc01-, doc02-… when there are several documents.
  4. 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.
  5. Runs with tools: ["Read"] and cwd set 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:

TypeRuleMarks
mcq4–6 options, 2+ correct, plausible distractors, no "all/none of the above", never refer to options by letter or position (they're shuffled).2
singleUsually 4 options, exactly one correct; occasionally True/False as ["True","False"].1
fill_blankSentence 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_answerStructured written question; full modelAnswer; 3–6 keyPoints.4–10
artifactConcrete 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 becomes medium.
  • mcq / single: de-duplicate correctOptions and keep integer indices that are in range. Drop the question if there are fewer than 2 options or no valid correct index. single keeps only the first correct index; mcq sorts 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 ```lang line, a trailing ```, normalises CRLF, drops leading blank lines and trims the end while keeping indentation. Drop if the content is empty. Language is lowercased, default text.
  • Then shuffleOptions() (next section).
  • Finally the list is sorted by type in QUESTION_TYPES order.

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 currently generating, it resets the row with markQuizGenerating and calls startGeneration again 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 generating until expireStaleGenerations() turns it into an error 20 minutes after started_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

StatePurpose
modeexam (submit at the end) or practice (check each answer as you go). Not persisted.
layoutfocus (one question, navigator sidebar) or list (all questions grouped by type). Persisted in localStorage.
answersAnswers map.
flaggedQuestion ids marked for review.
currentIndex of the focused question.
startedAtTimer origin; restored drafts set it back by the saved elapsed time.
checkedPractice mode: per-question local result, or "revealed" for written questions.
attemptSet after submission (or from initialAttempt); switches to the results view.
filterResults view: all, review (not fully correct), correct.
restored, hydrated, submitting, errorUI 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 initialAttempt is given and the draft contains at least one answer for a question that still exists. Flags are filtered to existing ids, current is 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… mcq toggles indices (kept sorted); single replaces the selection.
  • Fill in the blank: the question is split on [[BLANK]], with an inline input between the pieces. The answer is always a full values array, 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 null locally, 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):

KeyAction
← / →Previous / next question
A…Pick or toggle that option (letters map up to J)
MToggle mark for review
EnterPractice 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):

  1. 404 unless the quiz exists and is ready.
  2. Reads answers (any object; defaults to {}).
  3. 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 unanswered with score 0.
  4. Sends the whole queue to gradeLongAnswers(items, quiz.config.model) in one Claude call.
  5. If that call throws, every queued question is marked ungraded with feedback "Could not grade this answer: <error>". The attempt is still saved and gradingError is returned so the client can show it.
  6. Orders the results by question order, totals score and maxScore, and inserts the attempt.
  7. 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.

TypeScoring
singleFull marks if the selected index equals correctOptions[0], else 0.
mcqFull 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_blankEach 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:

  1. lowercase, then Unicode NFKD, then strip combining accents (é → e)
  2. remove quotes and backticks (“ ” " ' \ ’`)
  3. replace anything other than letters, digits, ., +, -, /, % and whitespace with a space
  4. collapse whitespace and trim
  5. drop a leading the, a or an, 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:

CheckPassFail / WarnPending
Claude Code installedinstalledfailstatus not loaded yet
Signed in to ClaudeloggedInfailnot installed
Claude subscriptionauthMethod === "claude.ai" and a subscriptionTypewarn: the login works but may bill an API accountnot 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:

  • current holds the last status, and a listeners set is notified on change.
  • useSyncExternalStore(subscribe, () => current, () => null): the server snapshot is null, so SSR always renders the "checking" state.
  • The first mount calls load(false). Concurrent loads share one in-flight promise.
  • refresh() calls load(true), which adds ?refresh=1 to 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.
  • GenerateForm footer: "Claude isn't connected. Set it up" when loggedIn is 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.

StatusWhen
200 { id }Stored
400No file, or the file isn't a PDF
413Larger than 100 MB
422Text 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

RouteFileKindContent
/welcomeapp/welcome/page.tsxStatic server componentFirst-run landing page and EligibilityCard. Own chrome.
/app/(app)/page.tsxServer, dynamic sectionHero, UploadDropzone, then Library: stat tiles, materials grid, the 20 most recent sets. With nothing uploaded it shows a "How it works" panel instead.
/generateapp/(app)/generate/page.tsxServer + client formGenerateForm with a document picker (pickable), oldest document first, all selected. Empty state links back to upload.
/documents/[id]app/(app)/documents/[id]/page.tsxServerRenameable 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.tsxServer + client runnerRenameable 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.tsxServer + client runnerQuizRunner opened on a saved attempt.
/setupapp/(app)/setup/page.tsxStaticSetupChecklist 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

ComponentKindNotes
ui.tsxsharedcx() 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.tsxclientModal 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.
DeleteButtonclientConfirms, sends DELETE, optionally redirects, then refreshes.
EditableTitleclientAn <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.
GenerateFormclientSee below.
GeneratingViewclientPolling waiting screen (10.11).
QuizListserverRows with a ScoreRing (best score, dashed when never attempted), title, "Combined" badge, metadata, Generating/Failed badges. "Source deleted" covers sets with no remaining documents.
QuizRunnerclientSection 11.
quiz/QuestionCardclientRenders every question type plus the review panel. Exports StatusBadge, fmt() (whole numbers or one decimal) and LETTERS.
quiz/ScoreSummaryclientSection 11.7.
RetryButtonclientPosts to retry, then refreshes.
UploadDropzoneclientSection 9.
NavLinksclientLibrary and New set links. usePathname() is request data, so the active highlight sits behind a Suspense and the links first render without it.
ThemeToggleclientSystem / Light / Dark (section 18).
ClaudeStatusBadge, SetupBanner, SetupChecklist, EligibilityCardclientSection 13. SetupChecklist also exports CopyCommand (a terminal-style line with a copy button).
useClaudeStatusclient hookSection 13.3.
Logo / LogoMarksharedInline SVG mark. Keep in sync with src/app/icon.svg.
NotFoundCardshared404 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):

    PresetCountsTotal
    Quick check3 / 5 / 2 / 0 / 010
    Balanced (default)5 / 5 / 5 / 2 / 017
    Full exam10 / 10 / 10 / 5 / 035
    Written & code0 / 0 / 0 / 3 / 47

    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.

KeyWritten byValue
exam-helper:themeThemeToggle"light" or "dark"; removed for System
exam-helper:generate-prefsGenerateForm{ model, effort, counts, difficulty }
exam-helper:quiz-layoutQuizRunner"focus" or "list"
exam-helper:draft:<quizId>QuizRunnerDraft (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 reads exam-helper:theme and falls back to prefers-color-scheme, then sets data-theme="dark"|"light" on <html>. suppressHydrationWarning on <html> covers the attribute React didn't render.
  • globals.css declares @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)), so every Tailwind dark: class follows the attribute rather than the media query.
  • CSS variables --background, --foreground, --muted and --subtle are 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.
  • ThemeToggle offers System, Light and Dark. While System is selected it listens for OS theme changes.
  • The viewport.themeColor metadata 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-visible outline in the brand colour; custom radio and checkbox tiles show a ring when their hidden input has keyboard focus.
  • Native <dialog> for confirmations.
  • ToggleGroup buttons use aria-pressed inside a labelled role="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 has role="progressbar" with values.
  • aria-live on upload progress, generation stage and the eligibility summary; errors use role="alert".
  • pointer-coarse: variants enlarge touch targets.
  • Cursor rules restore cursor: pointer on buttons, summaries and enabled checkbox labels (Tailwind v4 resets it).

19. Limits and constants

ConstantValueWhere
Max upload size100 MBapi/documents/route.ts
Questions per type0–40API and form
Max text sent in Text mode600,000 charsMAX_TEXT_CHARS in generate.ts
Min text per document (Text mode)200 charsgenerate.ts
Max pages in Visual mode200MAX_VISUAL_PAGES in generate.ts (form repeats it)
Pages per Visual part10PAGES_PER_PART
Generation timeout15 minDEFAULT_TIMEOUT_MS in claude.ts
Grading timeout8 mingrade-ai.ts
claude auth status timeout30 sclaude.ts
Status cache60 sclaude.ts
Stale generation cut-off20 minSTALE_AFTER_MS in repo.ts
Generation poll interval3 sGeneratingView
Draft autosave interval10 sQuizRunner
Written answer sent for grading20,000 charsattempts route
Quiz title120 charsAPI and EditableTitle
Document name200 charsAPI and EditableTitle
Focus text1000 charsAPI
Model ID pattern^[\w.\-\[\]]{2,80}$API
Sets on the home page20 most recentlistQuizzes()
Scores in the history chartlast 12quiz page
Weak topics shown5ScoreSummary
Scanned heuristicchars < max(500, pages × 60)isLowText

20. Error handling and troubleshooting

Where errors surface

FailureWhat the user sees
Upload rejectedRed ErrorText under the drop zone, one message per failed file.
Bad generation requestError 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-generationAfter 20 minutes the set becomes failed with an "interrupted" message and can be retried.
Written grading failedThe attempt is saved; written questions are "Not graded" with the error as feedback, and a banner explains it.
Claude not installed or signed inHeader badge, home banner, generate form footer, and /setup.

Diagnostics

  • data/claude-workdir/last-generation.json holds 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: ClaudeError messages include up to 800 characters of the CLI's stderr or stdout. They're stored in quizzes.error and shown on the quiz page.
  • Status details: /setup → Check again → Details shows the error from claude auth status.
  • Reset onboarding: delete data/onboarded.json and restart the server. isOnboarded() caches true in 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 PATH without claude, often because Claude Code was installed after the terminal running npm run dev was opened. Restart from a new terminal or set CLAUDE_BIN in .env.local. The CLI is spawned without a shell, so on Windows CLAUDE_BIN should point to an executable (for example claude.exe). A .cmd wrapper 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_KEY is stripped from the child environment so the CLI uses the subscription login. The eligibility check warns when the login is not a claude.ai subscription.
  • 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 spawn as an array without a shell, so there is no shell interpolation. Document and quiz ids come from randomUUID(), and all SQL uses bound parameters.
  • Trusted client for answers. The attempts endpoint stores the answers object 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.

  1. 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.
  2. 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.
  3. 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.
  4. No limit on concurrent generations. Each request spawns its own CLI process.
  5. Grading uses the generation model but default effort. The effort setting only affects generation.
  6. Practice-mode checks and the timer are not saved. Attempts record answers and results only, not time taken or practice checks.
  7. The prompt asks for 2+ correct options in mcq, but sanitising accepts 1.
  8. fill_blank marks are whatever the model returns (minimum 1). The prompt asks for one mark per blank but the code doesn't enforce it.
  9. 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.
  10. last-generation.json is overwritten every run and holds the parsed output, not the raw CLI stdout.
  11. DELETE /api/quizzes/[id] returns ok even when the id doesn't exist.
  12. Deleting a document keeps combined sets. Their documents list shrinks, but their questions (including source strings that name the deleted document) are unchanged. Retrying such a set regenerates from the remaining documents only.
  13. Default set numbering ("Set N") counts every set linked to the document, combined ones included.
  14. 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

  1. Types: add the literal to QuestionType and an entry to QUESTION_TYPES (its position sets the display order). Update isWritten() if Claude should grade it.
  2. Generation: add a TYPE_RULES entry and any new fields to questionSchema (every property must also be in required; tell the model to send empty values when a field doesn't apply). Add repair and drop rules in sanitize().
  3. Grading: handle the type in gradeLocally(), or rely on gradeLongAnswers() and add any new fields to its payload.
  4. UI: render the input in QuestionCard, extend the Answer union if it needs a new answer shape, and update isAnswered().
  5. Form: add a count to every entry in PRESETS in GenerateForm. 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