James WamplerandClaude Opus 5 7678cc7275 Replace the outline tree with chapter beat tables and tags
The self-nesting outline tree was more structure than chapter outlining needs.
A chapter outline is now a paragraph plus a flat, ordered table of beats, and
tags do the cross-referencing that nesting was doing badly.

A beat is one row: a three-to-five word title, an optional character, what
happened, and what's next. Ordering is a SortOrder column within the chapter —
no parent pointers, no cycle guards, no recursive tree building. Reordering is
one call taking beat ids in the order wanted; ids left out keep their relative
position at the end rather than jumping to the front.

Beats plan, scenes carry prose. The two layers stay separate and a beat's
SceneId is the optional link between them, nullable in both directions —
deleting a scene ungroups its beats rather than deleting the plan, since that
is a decision about prose and not about the outline.

Tags are project-scoped, unique by name case-insensitively, and attach to
characters, chapters and beats through three join tables so cascade deletes are
the database's job rather than ours. Applying an unknown tag by name creates it,
which keeps tagging a single action; GET /api/tags/{id}/references returns
everything carrying a tag across all three kinds at once.

Removed: OutlineNode, OutlineService, its endpoints, agent and MCP tools, and
the Outline tab. Added: Beat and Tag with their services, endpoints, 5 agent
tools and 10 MCP tools, a beat table on the chapter page, a tag editor used in
three places, and a Tags tab for cross-referencing.

Migration drops OutlineNodes — the scaffolder's data-loss warning is the
intended removal, not an accident.

44 tests, up from 31.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
2026-08-06 12:11:20 -07:00

Novel Software

Software for planning and writing a novel. You outline the book, keep character dossiers, break chapters into scenes, and draft prose — with a Claude-powered agent embedded in the app that can read and edit the same data you can, and an MCP server that exposes that data to Claude Code, Claude Desktop, or any other MCP client.

The point of the three-way arrangement is that there is exactly one source of truth. The React UI, the embedded agent, and the MCP server all go through the same REST API, so an edit made from a chat in Claude Code and an edit made by typing in the browser are the same edit.

Stack

Piece Built with
NovelSoftware.Api ASP.NET Core 10 minimal APIs, OpenAPI
NovelSoftware.Application Services, DTOs, the agent tool-use loop
NovelSoftware.Domain Entities and enums, no dependencies
NovelSoftware.Infrastructure EF Core 10 + SQLite, Anthropic SDK client
NovelSoftware.Mcp MCP stdio server (ModelContextProtocol)
NovelSoftware.Web React 19, TypeScript, Vite, TanStack Query, Tailwind v4

Running it

Prerequisites: .NET 10 SDK and Node 20+.

# 1. API — creates and migrates novel.db on first run, listens on :5080
ASPNETCORE_URLS=http://localhost:5080 dotnet run --project src/NovelSoftware.Api

# 2. Web — dev server on :5173, proxies /api to :5080
cd src/NovelSoftware.Web && npm install && npm run dev

Open http://localhost:5173.

The app is fully usable without an Anthropic key — only the Agent tab needs one. To turn the agent on:

export ANTHROPIC_API_KEY=sk-ant-...

Without it, agent endpoints return 503 Agent unavailable with an explanatory message and everything else keeps working.

Tests

dotnet test                                 # 44 tests
cd src/NovelSoftware.Web && npm run build   # typecheck + bundle

Tests run against real in-memory SQLite rather than the EF in-memory provider, so they exercise the cascade deletes and query translation the app actually ships with.

Configuration

src/NovelSoftware.Api/appsettings.json:

{
  "ConnectionStrings": { "Novel": "Data Source=novel.db" },
  "Cors": { "Origins": [ "http://localhost:5173" ] },
  "Agent": {
    "Model": "claude-opus-5",
    "MaxTokens": 16000,
    "Effort": "high",        // low | medium | high | max
    "MaxIterations": 12      // tool-call ceiling per user turn
  }
}

The API key is read from ANTHROPIC_API_KEY or, if you prefer, Agent:ApiKey — keep it out of appsettings.json and use user-secrets or the environment.

The data model

Project ──┬── Character ── CharacterRelationship
          ├── Chapter ──┬── Beat   (the outline: flat, ordered)
          │             └── Scene  (the prose)
          ├── Tag  (applied to characters, chapters and beats)
          └── AgentConversation ── AgentMessage

A chapter outline is a paragraph plus a table. The paragraph is the chapter's Summary; the table is its beats. Each beat is one row:

Column What goes in it
Beat A three-to-five word handle — "she burns the atlas", not a sentence
Character Whose beat it is. Optional; not every beat belongs to one person
What happened The event itself
What's next What it sets in motion — the hook into the following beat
Scene Optional grouping: which scene will carry this beat's prose

Beats are flat and ordered by SortOrder within their chapter. There is no nesting and no tree — reordering is one call that takes the beat ids in the order wanted.

Beats plan; scenes carry prose. The two layers are deliberately separate: an outline is for working out what happens, and a scene is where you write it. A beat's SceneId is the optional link between them, and it is nullable in both directions — deleting a scene ungroups its beats rather than deleting the plan.

Tags cross-reference the book. A tag is scoped to one project, unique by name (case-insensitively), and can be attached to any character, chapter or beat. Applying an unknown tag by name creates it, so tagging is one action rather than two. GET /api/tags/{id}/references returns everything carrying a tag, which is how you trace a motif or a thread across all three kinds at once.

The embedded agent

NovelAgentService runs the tool-use loop: it calls the Messages API, executes any tools Claude asks for, feeds every result back in a single user turn, and repeats until Claude stops asking. It has 18 tools covering the brief, characters, chapter outlines (beats), scenes and tags — all of them going through the same application services the REST API uses.

A few deliberate choices worth knowing about:

  • Conversation history replays as text only. Tool calls are not replayed into the transcript. The agent re-reads current state through its tools instead, which is more reliable than trusting a record of edits that may since have changed in the UI.
  • The user's turn is persisted before the loop runs, so a question is recorded even if the model call fails.
  • Tool failures come back as is_error results, not exceptions — the model reads the message and corrects itself.
  • MaxIterations caps tool calls per turn. On hitting it the agent says so rather than silently truncating.
  • The system prompt is cached (cache_control: ephemeral), so every turn after the first reads it back at a fraction of the input price.

The MCP server

A stdio MCP server exposing 26 tools over the same REST API. It holds no domain logic of its own — it is a second front end, not a second implementation.

Build it, then point your MCP client at the produced binary:

dotnet publish src/NovelSoftware.Mcp -c Release -o ./mcp-server

.mcp.json (or Claude Desktop's config):

{
  "mcpServers": {
    "novel-software": {
      "command": "/absolute/path/to/mcp-server/NovelSoftware.Mcp",
      "env": { "NOVELSOFTWARE_API_URL": "http://localhost:5080" }
    }
  }
}

The API must be running. If it is not, the tools say so in a message the model can act on rather than failing opaquely.

API surface

GET /api/health, plus:

Resource Routes
Projects GET|POST /api/projects, GET|PATCH|DELETE /api/projects/{id}
Characters GET|POST /api/projects/{id}/characters, GET|PATCH|DELETE /api/characters/{id}, POST /api/characters/{id}/relationships
Chapters GET|POST /api/projects/{id}/chapters, GET|PATCH|DELETE /api/chapters/{id}
Beats GET|POST /api/chapters/{id}/beats, POST /api/chapters/{id}/beats/reorder, GET|PATCH|DELETE /api/beats/{id}
Scenes GET|POST /api/chapters/{id}/scenes, GET|PATCH|DELETE /api/scenes/{id}
Tags GET|POST /api/projects/{id}/tags, GET /api/tags/{id}/references, PATCH|DELETE /api/tags/{id}
Agent GET /api/projects/{id}/agent/conversations, POST /api/projects/{id}/agent/messages, GET|DELETE /api/conversations/{id}

PATCH bodies are partial: an omitted field is left alone, an empty string clears it. A tags array replaces that item's tags outright and creates any names the project has not seen; omitting it leaves tags untouched. Enums travel as names ("Protagonist", "Drafted"), never ordinals. In development the OpenAPI document is at /openapi/v1.json.

Known issues

  • react-router-dom 7.18.2 carries GHSA-qwww-vcr4-c8h2 (CSRF bypass in RSC mode). No patched release exists yet, and every version below the affected range carries 14 worse advisories. This app is a client-only SPA and does not use RSC mode, so the advisory does not apply — but npm audit will flag it until a fix ships. Upgrade when one does.

Where this could go next

The vertical slice is complete but thin in places. The obvious next steps:

  • Stream agent responses over SSE instead of returning the finished turn.
  • Drag-and-drop beat reordering (the reorder endpoint is already there; the UI uses up/down buttons).
  • Filter chapters and characters by tag from the list views, not just the Tags tab.
  • A manuscript export (Markdown, DOCX) built from chapters and scenes in order.
  • Revision history for scene prose.
  • Authentication, if this is ever going to run anywhere but localhost.
S
Description
Software plan + write novel — ASP.NET Core, React, embedded Claude agent
Readme
1.1 MiB
Languages
C# 71.3%
TypeScript 26.8%
Shell 1.4%
CSS 0.4%
Dockerfile 0.1%