PlotTwist.ai: a novel-writing app in one day
Today I started — and in most senses that matter, finished — a desktop app for writing novels with AI assistance. It's called PlotTwist.ai, and day one closed with 112 files, 25,498 insertions, a 23-table database, working LLM chapter generation, AI image generation, and a test suite with exactly 130 passing tests. The commit message says "Full implementation across 8 phases", which is what it feels like when you point modern AI coding agents at a plan and keep up.
The plan is the part I'd defend hardest. The first commit contains
more than the app: specs/01_initial_spec.md (664 lines) and
specs/plan.md (410 lines). The app was written from those documents,
and the day's speed is mostly their speed: every hour spent arguing with
myself in the spec was an hour the agents didn't spend guessing.
The stack, briefly
Electron + React 19 + TypeScript, Tailwind 4 and shadcn/ui for the chrome, TipTap for prose editing, Zustand for state, better-sqlite3 + Drizzle for storage, the OpenAI SDK for LLM calls, pdf-lib for output. Nothing exotic; the exotic part is what it's for.
A data model with opinions about stories
The schema commits to a story structure: a book has a Beginning, then N story blocks — each a build-up, a problem, and a resolution — then a Conclusion. Four section tables, all sharing the same shape:
summary: text('summary'),
prose_content: text('prose_content', { mode: 'json' }),
word_count: integer('word_count').notNull().default(0),
status: text('status').$type<SectionStatus>().notNull().default('draft'),
author_intent: text('author_intent'),
active_characters: text('active_characters', { mode: 'json' }).$type<number[]>().default([]),
author_intent is the field I care about most: what this section is
supposed to do, kept separate from what it currently says. It's the
difference between an outline tool and a drafting tool: the AI gets told
the intent, not just shown the words.
The LLM layer: no favourites
Every LLM call goes through the OpenAI SDK pointed at a user-configured
base_url — no hardcoded models, no blessed provider. The UI placeholder
suggests Fireworks, OpenAI, or Ollama and leaves it at that. Prose
generation streams token-by-token over IPC with an AbortController for
cancel; chapter planning is a non-streaming call that asks for a JSON
array of chapters given a markdown table of sections, with instructions
like "end chapters at moments of suspense" and the eternal plea to
"Return ONLY the JSON array, no other text."
Since models ignore that plea on principle, parsing is a fallback chain:
strip code fences, try JSON.parse, regex out the first array-shaped
substring, and, if all else fails, distribute sections into chapters
evenly by arithmetic. The LLM proposes; the fallback disposes.
Beyond chapters, seventeen form fields across Atmosphere, Setting, and Character each got a pair of AI buttons (generate from context, or refine what's there), plus six refine presets for prose (expand, condense, dialogue, tension, voice, pacing). The pattern repeats everywhere: AI on tap at the point of friction, never AI in charge.
Images, and the two-stage prompt
Cover and chapter art run on Fireworks image models, and the prompts are written by... another model. Stage one: an LLM turns the book's atmosphere, setting, and characters into an image prompt ("Keep the total prompt under 250 words"). Stage two: the image model renders it. By evening the chapter-artwork path fed in the actual rendered chapter text with the instruction "ONLY use details that actually appear in the chapter text", because the first version invented scenes with an enthusiasm that would embarrass the novelist.
The Flux-style models answer asynchronously, so there's a polling loop:
every second, up to maxAttempts = 600 // 10 minutes, with progress
logs every thirty and explicit handling for Request Moderated. Image
APIs have opinions about fiction, and the user deserves to be told when
their battle scene trips a filter rather than watching a spinner die.
130 tests on day one
The same day the app was born it got Vitest (two configs: node for the
main process, happy-dom for components), Playwright for Electron E2E, and
integration tests that run the real IPC handlers against in-memory
SQLite. I counted the it() blocks: exactly 130, the largest suites
covering prompts and the outline IPC. Day-one tests are unfashionable for
a prototype, but with AI writing most of the code, the test suite isn't
ceremony. It's the leash. Thirty years of test engineering distils to
one rule that AI has only sharpened: the faster the code arrives, the
earlier the harness has to exist.
Honest ledger, day one
src/main/ipc/llm.tsstarted the day at 208 lines and ended it at 1,372. It 7×'d in a single day, +927 lines in the final commit alone. It is already the file the refactor gods have marked.chapters:redistributeis implemented asipcMain.emit('chapters:generate', ...), which does not invoke anipcMain.handlehandler. It compiles, it ships, it does nothing. A bug only tests for that specific path would catch, and they don't exist yet.RelationshipMap.tsxis a 10-line stub while@xyflow/reactsits in the dependencies like a gym membership.- The whole tree contains exactly one TODO comment. This says less about completeness than about how AI-generated code apologises: it doesn't.
The thing I want to record about today is that the line count was downstream of a document. A 664-line spec produced a 25,000-line day. The spec was the work; the day was the printout. Whether the shape it printed is the right one, the next few weeks of actually writing a novel in it will decide.