Files
novelly/README.md
T
James WamplerandClaude Opus 5 4f396bb5f9 Surface arcs, character beats and open questions in the web client
The character page gains three sections under the dossier: the arc as an
editable ordered table with each stage pinnable to a chapter, every beat the
character appears in across the book (each row linking into that chapter's
outline), and the character's open questions. The sidebar groups main
characters above supporting ones, and both the sheet and the add dialog let you
set importance.

The arc section shows for main characters, and also for supporting ones that
already have stages — demoting someone should not hide work they thought they
had lost.

The outline page gains a notes section and an open-questions section at the
bottom. Beat rows are now anchored so the character page can link straight to a
row. Raising a question from either page attaches it to what that page is
about, and the section hides the association it is already scoped to rather
than repeating "Landfall" on every row.

Also fixes an ordering wart the browser run exposed: both CharacterRole and
CharacterImportance are stored as text, so ordering them in SQL ordered the
spelling — "Deuteragonist" beat "Protagonist" and the sidebar put the second
lead above the character the book is about. Listing now sorts after
materialising, which uses the enums' declaration order. The test for it was
checked both ways: it fails on the SQL ordering and passes on the fix.

73 tests pass, the web client builds and lints clean. Driven in a browser
end to end: resolving a question with "also add to notes" drops it off the open
list and appends the decision under the chapter's existing note, "show
resolved" brings it back with a Reopen button, and a beat link on the character
page lands on the right chapter outline at that beat's anchor.

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

248 lines
11 KiB
Markdown

# Novelly
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 |
|---|---|
| `Novelly.Api` | ASP.NET Core 10 minimal APIs, EF Core 10 + SQLite, Anthropic SDK, OpenAPI |
| `Novelly.AppHost` | .NET Aspire orchestration for the API and the web client |
| `Novelly.ServiceDefaults` | Shared OpenTelemetry, health checks and service discovery |
| `Novelly.Mcp` | MCP stdio server (`ModelContextProtocol`) |
| `Novelly.Web` | React 19, TypeScript, Vite, TanStack Query, Tailwind v4 |
The back end is one project organised by feature, not by layer. Each feature folder —
`Projects/`, `Characters/`, `Chapters/`, `Beats/`, `Scenes/`, `Tags/`, `Agent/` — holds its
entity, DTOs, service and endpoints together, so adding a capability means touching one
folder rather than four. `Common/` holds what genuinely crosses features and `Data/` holds
the `DbContext` and migrations.
## Running it
Prerequisites: .NET 10 SDK and Node 20+.
Everything at once, through Aspire:
```bash
cd src/Novelly.Web && npm install && cd -
dotnet run --project src/Novelly.AppHost
```
That starts the API on :5080 and the Vite dev server on :5173, and opens the Aspire
dashboard for logs, traces and metrics across both.
Or run the two halves separately:
```bash
# 1. API — creates and migrates novel.db on first run, listens on :5080
ASPNETCORE_URLS=http://localhost:5080 dotnet run --project src/Novelly.Api
# 2. Web — dev server on :5173, proxies /api to :5080
cd src/Novelly.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 # 73 tests
cd src/Novelly.Web && npm run build # typecheck + bundle
```
`git push` runs both through a Husky pre-push hook (`scripts/ci/prepush.sh`). Install the
hook once with `npm install` at the repo root.
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/Novelly.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
│ └── CharacterArcStage (the arc: flat, ordered)
├── Chapter ──┬── Beat (the outline: flat, ordered)
│ └── Scene (the prose)
├── Tag (applied to characters, chapters and beats)
├── OpenQuestion (attached to a chapter and/or a character)
└── 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.
**Main characters carry the book; supporting characters hold it up.** A character's
`Importance` (`Main` or `Supporting`) is separate from their `Role` — role is the part they
play in the story, importance is how much of it they take, and a mentor can be either.
Characters start supporting and get promoted. The two together drive the ordering, so the
protagonist is always the first name on the list.
**A main character's arc is a table, not a paragraph.** `ArcSummary` still holds the
sentence version; `CharacterArcStage` breaks the same change into ordered steps, each
optionally pinned to the chapter it lands in. It is the same flat-and-ordered shape as the
beat table, for the same reason. Nothing refuses an arc on a supporting character —
demoting someone should not delete their work.
**The character page reads the outlines back.** `GET /api/characters/{id}/beats` returns
every beat a character appears in, in manuscript order, each carrying its chapter so the
page links straight into that chapter's outline. It is the dossier's reality check: what
they actually do on the page, as against what the sheet claims about them.
**Open questions are what you have not decided.** A question hangs off a chapter outline, a
character, both, or neither. It can be resolved, reopened or deleted, and resolving can
append the decision to the notes of whatever it was attached to — so a settled question ends
up where you re-read it rather than in a list you have stopped looking at. Resolved
questions drop off the list unless you ask for them.
**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 29 tools covering the brief, characters and their arcs, chapter outlines
(beats), scenes, tags and open questions — 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 38 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/Novelly.Mcp -c Release -o ./mcp-server
```
`.mcp.json` (or Claude Desktop's config):
```jsonc
{
"mcpServers": {
"novelly": {
"command": "/absolute/path/to/mcp-server/Novelly.Mcp",
"env": { "NOVELLY_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}` |
| Arcs | `GET\|POST /api/characters/{id}/arc`, `POST /api/characters/{id}/arc/reorder`, `GET\|PATCH\|DELETE /api/arc-stages/{id}` |
| Questions | `GET\|POST /api/projects/{id}/questions`, `GET\|PATCH\|DELETE /api/questions/{id}`, `POST /api/questions/{id}/resolve`, `POST /api/questions/{id}/reopen` |
| 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](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 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.