PlotWeaver #ai #llm #ai-assisted-development #quality-engineering

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.ts started 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:redistribute is implemented as ipcMain.emit('chapters:generate', ...), which does not invoke an ipcMain.handle handler. It compiles, it ships, it does nothing. A bug only tests for that specific path would catch, and they don't exist yet.
  • RelationshipMap.tsx is a 10-line stub while @xyflow/react sits 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.