The layered split into Domain/Application/Infrastructure/Api was forcing organisation by layer: adding one capability meant touching four projects and four folders that each held a slice of it. Those four projects are now one feature-organised Novelly.Api, where each folder — Projects, Characters, Chapters, Beats, Scenes, Tags, Agent — holds its entity, DTOs, service and endpoints together. Common/ holds what genuinely crosses features (the patch semantics, the two exception types, DraftStatus) and Data/ holds the DbContext and migrations. Six .NET projects become five: the three layer projects are gone, and Novelly.AppHost and Novelly.ServiceDefaults are new. - Namespaces move from NovelSoftware.* to Novelly.*, including the entity type names recorded in the EF model snapshots. The migration ids are untouched, so an existing novel.db still migrates cleanly — verified against a fresh file. - Aspire orchestration mirrors the mic-check setup: the AppHost starts the API on :5080 and the Vite dev server on :5173, and the API picks up OpenTelemetry, health checks and service discovery from ServiceDefaults. /health and /alive now answer in development. - A Husky pre-push hook runs scripts/ci/prepush.sh: build, test, then a web build. The scripts are plain bash so CI can run the same steps. - The MCP server's env var is now NOVELLY_API_URL. Verified beyond the build: 44 tests pass, the web client builds, the API was exercised over curl (project/chapter/beat/tag round trip, tag cross-reference, 503 on the agent without a key while conversation listing still returns 200), the MCP server was driven over stdio JSON-RPC (26 tools, errors still surface the API's own message rather than being flattened), and the AppHost was run to confirm both resources come up and Vite proxies /api through to the API. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
220 lines
9.0 KiB
Markdown
220 lines
9.0 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 # 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`:
|
|
|
|
```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
|
|
├── 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_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 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:
|
|
|
|
```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}` |
|
|
| 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.
|