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:
co-authored by
Claude Opus 5
parent
3c85bab4a4
commit
0d7b7a6f30
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user