Three things the outline could not express before:
Main vs supporting. A new CharacterImportance sits alongside CharacterRole
rather than inside it — role is the part a character plays (protagonist,
mentor, foil), importance is how much of the book they carry, and a mentor can
be either. Characters start Supporting and get promoted. Listings put main
characters first.
Character arcs. A main character's arc is a flat ordered list of stages, the
same shape as a chapter's beats and for the same reason: an arc is a sequence
of changes, not a tree. A stage can be pinned to the chapter where it lands.
Nothing refuses an arc on a supporting character — demoting someone should not
delete their work.
Open questions. What the writer has not decided yet, hanging off a chapter
outline, a character, both, or neither. They can be resolved, reopened or
deleted, and resolving can append the decision to the notes of whatever the
question was attached to, so it lands where the writer will re-read it.
Resolved questions drop off the list unless asked for.
Also adds GET /api/characters/{id}/beats — every beat a character appears in,
in manuscript order, carrying each beat's chapter so the character page can
link straight into that chapter's outline.
Deletes are deliberately asymmetric: deleting a chapter unpins arc stages and
detaches questions rather than taking them, because a plan outlives a decision
about where the chapter break falls. Deleting a character or project does take
their arcs and questions.
All three capabilities are surfaced in the REST API, the agent toolset and the
MCP server, per the one-source-of-truth rule.
Two things worth flagging in the migration: EF's generated default for the new
Importance column was an empty string, which does not parse back to a
CharacterImportance and would have faulted every read of an existing dossier —
it now defaults to Supporting, verified by migrating a database seeded on the
old schema and reading the row back through the API. And the earlier migrations
were renamed to the namespace EF derives from the output folder, so future
`migrations add` runs stop drifting.
72 tests pass (28 new). The endpoints were also exercised over curl end to end:
arc stages resolving their chapter, a character's beats across chapters, and a
question attached to both a chapter and a character resolving into both sets of
notes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
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:
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:
# 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:
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/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:
{
"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_errorresults, not exceptions — the model reads the message and corrects itself. MaxIterationscaps 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/Novelly.Mcp -c Release -o ./mcp-server
.mcp.json (or Claude Desktop's config):
{
"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} |
| 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-dom7.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 — butnpm auditwill 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.