Services now return entities; endpoints (and the agent toolsets) map to *Response records instead of services building wire DTOs themselves. Also brings in the outline-import agent, MCP tool, ledger and web dialog that were already in progress on disk.
257 lines
12 KiB
Markdown
257 lines
12 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.
|
|
|
|
### Importing an existing outline
|
|
|
|
`.claude/agents/outline-importer.md` is a Claude Code subagent that reads an author's outline
|
|
already on disk — chapter files and character dossiers in the folder shape described in that
|
|
file — and writes it into a new Novelly project over the MCP server above. It's read-only against
|
|
the source files and keeps a resumable ledger (`.novelly-import.json`) next to them, since a full
|
|
book is more tool calls than fit in one turn. Invoke it with the source folder path once the API is
|
|
running and `.mcp.json` is set up.
|
|
|
|
## 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.
|