Tutor is one Electron desktop app for one reader on one machine. It generates a book chapter by chapter, and each chapter is shaped by the feedback and quiz results of the one before it.
Everything below follows from those two facts. There is exactly one writer, and there is no cloud.
Start here, then follow the links.
- Domain words are defined in CONTEXT.md.
- Decisions and what each one cost are in docs/adr/.
- The HTTP surface is in docs/api-routes.md, generated from the route registry, never written by hand.
flowchart LR
reader([Reader])
subgraph app["Tutor.app"]
electron["electron/<br/>main and preload"]
client["client/<br/>React renderer"]
server["server/<br/>embedded Fastify"]
end
library[("On-disk library<br/>Markdown and YAML")]
providers["AI providers<br/>Anthropic, OpenAI, Google"]
local["Kokoro TTS and ffmpeg<br/>on this machine"]
reader --> client
electron --> client
electron --> server
client -->|"HTTP and SSE on 127.0.0.1"| server
server --> library
server -->|"the reader's own API key"| providers
server --> local
The Fastify server runs inside the Electron main process. It is not deployed anywhere. It binds 127.0.0.1 on a free port at launch, or port 3147 when run standalone with pnpm dev:server.
The library is plain Markdown and YAML under the OS data directory (ADR 0001). Narration is synthesized locally instead of by a metered cloud service (ADR 0003).
The core never does I/O. It talks to 15 small interfaces, the ports, and each port has one or more adapters that do the real work.
flowchart LR
subgraph core["server core"]
direction TB
routes["routes/<br/>parse and delegate"]
services["services/<br/>application logic"]
domain["domain/<br/>pure rules"]
routes --> services
services --> domain
end
subgraph ports["ports/ (15 interfaces)"]
direction TB
pStore["books and files<br/>BookRepository, ArtifactStore,<br/>LibraryMigrator, JobJournal, KeyVault"]
pAi["AI<br/>TextGeneration, ImageGeneration"]
pMedia["media<br/>SpeechSynthesis, AudioAssembly,<br/>DiagramRenderer, EpubImport, EpubExport"]
pSys["system<br/>BackgroundTasks, Clock, OsFileManager"]
end
subgraph adapters["adapters/ (the only I/O)"]
direction TB
aStore["fs-*.ts<br/>file-key-vault.ts"]
aAi["ai-sdk-text-generation.ts<br/>http-image-generation.ts"]
aMedia["kokoro, ffmpeg, kroki,<br/>electron, epub2, epub-gen"]
aSys["in-memory and journalled tasks,<br/>system-clock, os-file-manager"]
end
services --> pStore
services --> pAi
services --> pMedia
services --> pSys
pStore --> aStore
pAi --> aAi
pMedia --> aMedia
pSys --> aSys
The boxes inside ports/ are just themes to keep the diagram readable. They are not layers, and they do not exist in the code. What each port actually is, and why it exists, in one line each.
Books and files
| Port | What it does | Why it is a port |
|---|---|---|
| BookRepository | Reads and writes the library itself, book metadata, chapters, quizzes, feedback | Services never touch fs, and tests run against an in-memory library |
| ArtifactStore | The binary files that belong to a book, covers, audio, EPUBs | Big blobs with their own lifecycle, kept apart from the YAML the repository owns |
| LibraryMigrator | Upgrades an older on-disk library to the current schema at boot | Works on raw YAML that may not validate yet, so it sits below the repository |
| JobJournal | One file per long-running job, so a restart can resume it | Added as a decorator, the in-memory task adapter never had to change |
| KeyVault | Stores the reader's API keys | Keys live in one place and never end up in logs or the journal |
AI
| Port | What it does | Why it is a port |
|---|---|---|
| TextGeneration | Every prompt to a language model, streaming and structured | The only doorway to AI in the whole app. One fake makes everything testable without a key |
| ImageGeneration | Generates book cover images | Same idea as TextGeneration with a smaller surface |
Media
| Port | What it does | Why it is a port |
|---|---|---|
| SpeechSynthesis | Turns chapter text into narration audio | The real model is a 100MB download. The fake keeps tests instant |
| AudioAssembly | Stitches chapter audio into one M4B audiobook | ffmpeg is a separate binary with its own failure modes |
| DiagramRenderer | Renders Mermaid blocks to images for EPUB export | The app renders offscreen in Electron, dev uses kroki. One interface hides which |
| EpubImport | Parses an uploaded EPUB into plain book data | Returns data only. Saving it is the service's job |
| EpubExport | Builds an EPUB file from rendered chapters | Wraps a CJS library with awkward packaging, quarantined here |
System
| Port | What it does | Why it is a port |
|---|---|---|
| BackgroundTasks | The task tray. Start a job, report progress, cancel | Long jobs outlive a request, and the UI watches them over SSE |
| Clock | The current time and fresh ids | Tests pin time instead of sleeping |
| OsFileManager | Reveals a file in Finder | The one place the app shells out to open |
Three rules hold all of this together.
- Nothing in the core names an adapter.
server/composition-root.tsis the one place a real adapter is chosen, andbuildServer(overrides)lets a test swap any of them out. - Every port ships an in-memory fake and a shared contract test.
- Every adapter that can run without spending money or downloading a model runs that same contract test. That is what keeps a fake honest about how the real adapter behaves.
See server/ports/README.md, server/adapters/README.md, and ADR 0005 for why the AI SDK sits behind a port.
flowchart LR
components["features/<br/>components"] --> hooks["features/<br/>hooks"]
hooks --> store["store/<br/>Redux slices"]
hooks --> api["api/<br/>the one HTTP client"]
api -->|"HTTP and SSE"| routes["routes/"]
routes --> services["services/"]
services --> ports["ports/"]
ports --> adapters["adapters/"]
Components render. Hooks decide.
Every call to the server goes through client/api/. A raw fetch or new EventSource anywhere else is an ESLint error, not a convention. The client used to hold 84 scattered fetch calls and two competing reconnect policies, and the lint rule is what keeps that from coming back.
sequenceDiagram
actor Reader
participant Client as client/
participant Server as server/
participant AI as TextGeneration
participant Disk as library
Reader->>Client: topic and prompt
Client->>Server: POST /api/books
Server->>AI: draft the table of contents
Server->>Disk: meta.yml, toc.yml
Server-->>Client: SSE, status toc_review
Reader->>Client: approve the TOC
Client->>Server: PUT /api/books/:id/toc
Server->>AI: generate chapter 1
Server-->>Client: SSE chunks as they stream
Server->>Disk: chapters/01.md
Reader->>Client: read, then submit feedback
Client->>Server: POST /api/books/:id/chapters/1/feedback
Server->>Disk: feedback/01.yml
Server->>AI: generate chapter 2 in the background
Reader->>Client: answer the quiz while it generates
Note over Server,AI: chapter 2 is shaped by chapter 1's feedback and quiz result
Chapters are generated one at a time, not up front, and the quiz exists partly to cover the generation wait (ADR 0002).
Closing the app mid-generation loses nothing. Jobs are journalled to disk and resumed at the next boot (ADR 0008).
flowchart TD
client["client/"] --> shared["shared/"]
server["server/"] --> shared
electron["electron/"] --> shared
client -. "ESLint error" .-> server
shared -. "ESLint error" .-> client
shared/ is the root. It imports neither side. It holds the Zod schemas, the status predicates, the HTTP contract types, and the SSE event unions, so both halves of the app validate against the same definitions.
This is one package shaped like a monorepo, not real workspaces (ADR 0004). The folders are already package-shaped if that ever needs to change.
Observability, a security-hardening pass, and release engineering were considered and declined. The app runs on one machine, holds one reader's data, and has no cloud component, so each of those would add machinery with nothing to protect or measure. The reasoning is in ADR 0004 so the gap reads as a decision, not an oversight.
| Area | Start at |
|---|---|
| Server, routes, services, ports, adapters | server/README.md |
| React renderer and feature slices | client/README.md |
| Types both sides depend on | shared/README.md |
| Electron shell and packaging | electron/README.md |
| End-to-end journeys | e2e/README.md |
| Every decision and what it cost | docs/adr/ |
| Domain vocabulary | CONTEXT.md |
| Generated HTTP surface | docs/api-routes.md |