Add novel-writing app: .NET 10 API, React front end, agent and MCP server

Builds out the vertical slice for planning and writing a novel. Three front
ends — the React UI, an embedded Claude agent, and an MCP stdio server — all go
through one REST API, so an edit made from Claude Code and one made in the
browser are the same edit.

Layout:
  Domain          entities and enums, no dependencies
  Application     services, DTOs, the agent tool-use loop and its 15 tools
  Infrastructure  EF Core 10 + SQLite, Anthropic SDK client
  Api             ASP.NET Core 10 minimal APIs, OpenAPI, ProblemDetails
  Mcp             MCP stdio server, 21 tools over the same REST API
  Web             React 19 + Vite + TanStack Query + Tailwind v4

Data model is Project > Characters / OutlineNodes / Chapters > Scenes, plus
agent conversations. The outline is a self-nesting tree so acts, sequences and
beats can be arranged however the book wants; scenes carry goal/conflict/outcome
because that is what the agent drafts prose from.

Notes on a few choices:

- Conversation history replays to the model as text only. The agent re-reads
  current state through its tools rather than trusting a record of edits that
  may since have changed in the UI.
- The user's turn is persisted before the tool loop runs, so a question is
  recorded even when the model call fails. Turn order uses an explicit sequence
  column; timestamps tie when a turn completes inside one tick.
- Tool failures return is_error results rather than throwing, so the model can
  read the message and correct itself. MCP tools do the same via CallToolResult,
  which keeps the API's own message instead of a generic SDK error.
- The Anthropic client is constructed lazily. It is injected into the agent
  service, which also serves read-only endpoints, and those should keep working
  on an install with no key. Sending without one returns 503, not 400.
- DateTimeOffset is stored as UTC ticks. SQLite refuses to ORDER BY the default
  text form, which every "recently updated first" listing depends on.

Tests run against real in-memory SQLite rather than the EF in-memory provider so
they exercise the cascade deletes and query translation that actually ship.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
James Wampler
2026-08-06 12:11:20 -07:00
co-authored by Claude Opus 5
parent 3c85bab4a4
commit 0d7b7a6f30
91 changed files with 9935 additions and 1 deletions
+174 -1
View File
@@ -1 +1,174 @@
# novel-software
# 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+.
```bash
# 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:
```bash
export ANTHROPIC_API_KEY=sk-ant-...
```
Without it, agent endpoints return `503 Agent unavailable` with an explanatory message
and everything else keeps working.
### Tests
```bash
dotnet test # 31 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`:
```jsonc
{
"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
├── OutlineNode (self-nesting: Part > Act > Sequence > Beat)
├── Chapter ── Scene (goal / conflict / outcome, prose, word count)
└── AgentConversation ── AgentMessage
```
The outline tree is deliberately loose — nest acts under parts, beats under sequences, or
keep a flat list of beats. An outline node can link to the chapter that realises it.
Scenes carry the goal/conflict/outcome trio because that is the unit the agent works from
when turning an outline into prose. Word counts are recomputed on every save.
## 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 15 tools covering the brief, characters, the outline tree, chapters
and scenes — 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 21 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:
```bash
dotnet publish src/NovelSoftware.Mcp -c Release -o ./mcp-server
```
`.mcp.json` (or Claude Desktop's config):
```jsonc
{
"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` |
| Outline | `GET\|POST /api/projects/{id}/outline`, `GET\|PATCH\|DELETE /api/outline/{id}`, `POST /api/outline/{id}/move` |
| Chapters | `GET\|POST /api/projects/{id}/chapters`, `GET\|PATCH\|DELETE /api/chapters/{id}` |
| Scenes | `GET\|POST /api/chapters/{id}/scenes`, `GET\|PATCH\|DELETE /api/scenes/{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.
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](https://github.com/advisories/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 reordering in the outline (the `move` endpoint is already there).
- 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.