Dual-engine workflow (.github/workflows/ci.yml, read by both GitHub Actions and Gitea Actions): build, test, coverage badge on every push; on Gitea main pushes only, build+push API/web images to the Gitea registry and redeploy the persistent LAN stack via the shared [self-hosted, qa] runner. Replaces the ad hoc docker-compose.deploy.yml manual workflow with deploy/qa/docker-compose.qa.yml, pulled by CI — data volume preserved across deploys (no -v on down), unlike mic-check's throwaway QA stack.
282 lines
13 KiB
Markdown
282 lines
13 KiB
Markdown
# Novelly
|
|
|
|
[](https://github.com/wamplerj/novelly/actions/workflows/ci.yml)
|
|
[](https://git.wampler.us/wamplerj/novelly/actions?workflow=ci.yml)
|
|

|
|
|
|
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 —
|
|
`Novels/`, `Characters/`, `Chapters/`, `Beats/`, `Tags/`, `Locations/`, `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 # 174 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
|
|
|
|
```
|
|
Novel ──┬── Character ──┬── CharacterRelationship
|
|
│ └── CharacterArcStage (the arc: flat, ordered)
|
|
├── Chapter ──── Beat (the outline: flat, ordered)
|
|
├── Tag (applied to characters, chapters and beats)
|
|
├── Location (applied to chapters)
|
|
├── 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 |
|
|
| Characters | Who's in the beat. Optional; not every beat belongs to anyone, and a beat can name more than one |
|
|
| What happened | The event itself |
|
|
| What's next | What it sets in motion — the hook into the following beat |
|
|
|
|
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. The outline
|
|
tab also rolls up the distinct set of characters across a chapter's beats under the beat
|
|
and word counts, each linking to that character's page — a quick cast list without
|
|
opening every beat.
|
|
|
|
**Beats plan; the chapter's `Prose` carries the draft.** A chapter has one prose field,
|
|
written and redrafted in place; `WordCount` is recomputed from it whenever it changes.
|
|
There is no separate scene entity — the outline (beats) and the draft (prose) are the
|
|
two views of the same chapter.
|
|
|
|
**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 novel, 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.
|
|
|
|
**A chapter can have several locations.** Locations replaced the old single free-text
|
|
`Setting` field: a chapter now carries a multi-select list of locations (where and when
|
|
it takes place), each a novel-scoped, name-deduped entity — applying an unknown location
|
|
by name creates it, same as tags. A "Locations" tab on the novel lists every location
|
|
with its chapter count, and `GET /api/locations/{id}/references` lists every chapter set
|
|
there.
|
|
|
|
## 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 33 tools covering the brief, characters and their arcs, chapter
|
|
outlines (beats), tags, locations 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 45 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
|
|
./scripts/publish-mcp.sh
|
|
```
|
|
|
|
Aspire does not run or manage this process — it's a separate stdio subprocess your MCP
|
|
client spawns directly, so nothing rebuilds it automatically. Re-run the script (and
|
|
reconnect your MCP client) after pulling changes that touch `src/Novelly.Mcp`, or it keeps
|
|
serving whatever was published last, including against a stale auth contract.
|
|
|
|
Copy `.mcp.json.example` to `.mcp.json` (gitignored, since it carries your API key) and
|
|
fill in the key:
|
|
|
|
```jsonc
|
|
{
|
|
"mcpServers": {
|
|
"novelly": {
|
|
"command": "/absolute/path/to/mcp-server/Novelly.Mcp",
|
|
"env": {
|
|
"NOVELLY_API_URL": "http://localhost:5080",
|
|
"NOVELLY_API_KEY": "<matches the API's Auth:ServiceApiKey user secret>"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
The API must be running, with `Auth:ServiceApiKey` set (e.g. via
|
|
`dotnet user-secrets set Auth:ServiceApiKey <key> -p src/Novelly.Api`) to the same value
|
|
as `NOVELLY_API_KEY` above. If the API is not running, or the key is missing or mismatched,
|
|
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 |
|
|
|---|---|
|
|
| Novels | `GET\|POST /api/novels`, `GET\|PATCH\|DELETE /api/novels/{id}` |
|
|
| Characters | `GET\|POST /api/novels/{id}/characters`, `GET\|PATCH\|DELETE /api/characters/{id}`, `POST /api/characters/{id}/relationships` |
|
|
| Chapters | `GET\|POST /api/novels/{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}` |
|
|
| Tags | `GET\|POST /api/novels/{id}/tags`, `GET /api/tags/{id}/references`, `PATCH\|DELETE /api/tags/{id}` |
|
|
| Locations | `GET\|POST /api/novels/{id}/locations`, `GET /api/locations/{id}/references`, `PATCH\|DELETE /api/locations/{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/novels/{id}/questions`, `GET\|PATCH\|DELETE /api/questions/{id}`, `POST /api/questions/{id}/resolve`, `POST /api/questions/{id}/reopen` |
|
|
| Agent | `GET /api/novels/{id}/agent/conversations`, `POST /api/novels/{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 novel has not
|
|
seen; the same is true of a chapter's `locations` array. Omitting either leaves it
|
|
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.
|