diff --git a/.gitignore b/.gitignore
index d5a18de..aefa0f0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -427,3 +427,11 @@ FodyWeavers.xsd
*.msix
*.msm
*.msp
+
+## Novel Software
+node_modules/
+dist/
+*.db
+*.db-shm
+*.db-wal
+mcp-server/
diff --git a/.mcp.json.example b/.mcp.json.example
new file mode 100644
index 0000000..e526305
--- /dev/null
+++ b/.mcp.json.example
@@ -0,0 +1,10 @@
+{
+ "mcpServers": {
+ "novel-software": {
+ "command": "./mcp-server/NovelSoftware.Mcp",
+ "env": {
+ "NOVELSOFTWARE_API_URL": "http://localhost:5080"
+ }
+ }
+ }
+}
diff --git a/NovelSoftware.slnx b/NovelSoftware.slnx
new file mode 100644
index 0000000..d2c7a3f
--- /dev/null
+++ b/NovelSoftware.slnx
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index 1a92238..8115a19 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,174 @@
-# novel-software
\ No newline at end of file
+# Novel Software
+
+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 |
+|---|---|
+| `NovelSoftware.Api` | ASP.NET Core 10 minimal APIs, OpenAPI |
+| `NovelSoftware.Application` | Services, DTOs, the agent tool-use loop |
+| `NovelSoftware.Domain` | Entities and enums, no dependencies |
+| `NovelSoftware.Infrastructure` | EF Core 10 + SQLite, Anthropic SDK client |
+| `NovelSoftware.Mcp` | MCP stdio server (`ModelContextProtocol`) |
+| `NovelSoftware.Web` | React 19, TypeScript, Vite, TanStack Query, Tailwind v4 |
+
+## Running it
+
+Prerequisites: .NET 10 SDK and Node 20+.
+
+```bash
+# 1. API — creates and migrates novel.db on first run, listens on :5080
+ASPNETCORE_URLS=http://localhost:5080 dotnet run --project src/NovelSoftware.Api
+
+# 2. Web — dev server on :5173, proxies /api to :5080
+cd src/NovelSoftware.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 # 31 tests
+cd src/NovelSoftware.Web && npm run build # typecheck + bundle
+```
+
+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/NovelSoftware.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
+ ├── OutlineNode (self-nesting: Part > Act > Sequence > Beat)
+ ├── Chapter ── Scene (goal / conflict / outcome, prose, word count)
+ └── AgentConversation ── AgentMessage
+```
+
+The outline tree is deliberately loose — nest acts under parts, beats under sequences, or
+keep a flat list of beats. An outline node can link to the chapter that realises it.
+
+Scenes carry the goal/conflict/outcome trio because that is the unit the agent works from
+when turning an outline into prose. Word counts are recomputed on every save.
+
+## 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 15 tools covering the brief, characters, the outline tree, chapters
+and scenes — 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 21 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/NovelSoftware.Mcp -c Release -o ./mcp-server
+```
+
+`.mcp.json` (or Claude Desktop's config):
+
+```jsonc
+{
+ "mcpServers": {
+ "novel-software": {
+ "command": "/absolute/path/to/mcp-server/NovelSoftware.Mcp",
+ "env": { "NOVELSOFTWARE_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` |
+| Outline | `GET\|POST /api/projects/{id}/outline`, `GET\|PATCH\|DELETE /api/outline/{id}`, `POST /api/outline/{id}/move` |
+| Chapters | `GET\|POST /api/projects/{id}/chapters`, `GET\|PATCH\|DELETE /api/chapters/{id}` |
+| Scenes | `GET\|POST /api/chapters/{id}/scenes`, `GET\|PATCH\|DELETE /api/scenes/{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.
+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 reordering in the outline (the `move` endpoint is already there).
+- 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.
diff --git a/src/NovelSoftware.Api/Endpoints/AgentEndpoints.cs b/src/NovelSoftware.Api/Endpoints/AgentEndpoints.cs
new file mode 100644
index 0000000..f268191
--- /dev/null
+++ b/src/NovelSoftware.Api/Endpoints/AgentEndpoints.cs
@@ -0,0 +1,40 @@
+using NovelSoftware.Application.Agent;
+using NovelSoftware.Application.Dtos;
+
+namespace NovelSoftware.Api.Endpoints;
+
+public static class AgentEndpoints
+{
+ public static IEndpointRouteBuilder MapAgentEndpoints(this IEndpointRouteBuilder app)
+ {
+ var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/agent").WithTags("Agent");
+
+ projectScoped.MapGet("/conversations", async (
+ Guid projectId, NovelAgentService agent, CancellationToken ct) =>
+ Results.Ok(await agent.ListConversationsAsync(projectId, ct)))
+ .WithSummary("List the project's agent conversations.");
+
+ projectScoped.MapPost("/messages", async (
+ Guid projectId,
+ SendAgentMessageRequest request,
+ NovelAgentService agent,
+ CancellationToken ct) =>
+ Results.Ok(await agent.SendMessageAsync(projectId, request, ct)))
+ .WithSummary("Send a message to the writing agent and run it to completion.");
+
+ var conversations = app.MapGroup("/api/conversations").WithTags("Agent");
+
+ conversations.MapGet("/{id:guid}", async (Guid id, NovelAgentService agent, CancellationToken ct) =>
+ Results.Ok(await agent.GetConversationAsync(id, ct)))
+ .WithSummary("Read a conversation's full transcript.");
+
+ conversations.MapDelete("/{id:guid}", async (Guid id, NovelAgentService agent, CancellationToken ct) =>
+ {
+ await agent.DeleteConversationAsync(id, ct);
+ return Results.NoContent();
+ })
+ .WithSummary("Delete a conversation.");
+
+ return app;
+ }
+}
diff --git a/src/NovelSoftware.Api/Endpoints/ChapterEndpoints.cs b/src/NovelSoftware.Api/Endpoints/ChapterEndpoints.cs
new file mode 100644
index 0000000..0140c44
--- /dev/null
+++ b/src/NovelSoftware.Api/Endpoints/ChapterEndpoints.cs
@@ -0,0 +1,44 @@
+using NovelSoftware.Application.Dtos;
+using NovelSoftware.Application.Services;
+
+namespace NovelSoftware.Api.Endpoints;
+
+public static class ChapterEndpoints
+{
+ public static IEndpointRouteBuilder MapChapterEndpoints(this IEndpointRouteBuilder app)
+ {
+ var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/chapters").WithTags("Chapters");
+
+ projectScoped.MapGet("/", async (Guid projectId, ChapterService service, CancellationToken ct) =>
+ Results.Ok(await service.ListAsync(projectId, ct)))
+ .WithSummary("List a project's chapters in manuscript order.");
+
+ projectScoped.MapPost("/", async (
+ Guid projectId, CreateChapterRequest request, ChapterService service, CancellationToken ct) =>
+ {
+ var created = await service.CreateAsync(projectId, request, ct);
+ return Results.Created($"/api/chapters/{created.Id}", created);
+ })
+ .WithSummary("Add a chapter.");
+
+ var chapters = app.MapGroup("/api/chapters").WithTags("Chapters");
+
+ chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
+ Results.Ok(await service.GetAsync(id, ct)))
+ .WithSummary("Read a chapter with all of its scenes.");
+
+ chapters.MapPatch("/{id:guid}", async (
+ Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) =>
+ Results.Ok(await service.UpdateAsync(id, request, ct)))
+ .WithSummary("Update a chapter.");
+
+ chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
+ {
+ await service.DeleteAsync(id, ct);
+ return Results.NoContent();
+ })
+ .WithSummary("Delete a chapter and its scenes.");
+
+ return app;
+ }
+}
diff --git a/src/NovelSoftware.Api/Endpoints/CharacterEndpoints.cs b/src/NovelSoftware.Api/Endpoints/CharacterEndpoints.cs
new file mode 100644
index 0000000..0994eb2
--- /dev/null
+++ b/src/NovelSoftware.Api/Endpoints/CharacterEndpoints.cs
@@ -0,0 +1,57 @@
+using NovelSoftware.Application.Dtos;
+using NovelSoftware.Application.Services;
+
+namespace NovelSoftware.Api.Endpoints;
+
+public static class CharacterEndpoints
+{
+ public static IEndpointRouteBuilder MapCharacterEndpoints(this IEndpointRouteBuilder app)
+ {
+ var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/characters").WithTags("Characters");
+
+ projectScoped.MapGet("/", async (Guid projectId, CharacterService service, CancellationToken ct) =>
+ Results.Ok(await service.ListAsync(projectId, ct)))
+ .WithSummary("List a project's character dossiers.");
+
+ projectScoped.MapPost("/", async (
+ Guid projectId, CreateCharacterRequest request, CharacterService service, CancellationToken ct) =>
+ {
+ var created = await service.CreateAsync(projectId, request, ct);
+ return Results.Created($"/api/characters/{created.Id}", created);
+ })
+ .WithSummary("Add a character dossier.");
+
+ var characters = app.MapGroup("/api/characters").WithTags("Characters");
+
+ characters.MapGet("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
+ Results.Ok(await service.GetAsync(id, ct)))
+ .WithSummary("Read a character dossier.");
+
+ characters.MapPatch("/{id:guid}", async (
+ Guid id, UpdateCharacterRequest request, CharacterService service, CancellationToken ct) =>
+ Results.Ok(await service.UpdateAsync(id, request, ct)))
+ .WithSummary("Update a character dossier.");
+
+ characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
+ {
+ await service.DeleteAsync(id, ct);
+ return Results.NoContent();
+ })
+ .WithSummary("Delete a character.");
+
+ characters.MapPost("/{id:guid}/relationships", async (
+ Guid id, CreateRelationshipRequest request, CharacterService service, CancellationToken ct) =>
+ Results.Ok(await service.AddRelationshipAsync(id, request, ct)))
+ .WithSummary("Relate this character to another in the same project.");
+
+ characters.MapDelete("/relationships/{relationshipId:guid}", async (
+ Guid relationshipId, CharacterService service, CancellationToken ct) =>
+ {
+ await service.RemoveRelationshipAsync(relationshipId, ct);
+ return Results.NoContent();
+ })
+ .WithSummary("Remove a relationship.");
+
+ return app;
+ }
+}
diff --git a/src/NovelSoftware.Api/Endpoints/OutlineEndpoints.cs b/src/NovelSoftware.Api/Endpoints/OutlineEndpoints.cs
new file mode 100644
index 0000000..6f567a9
--- /dev/null
+++ b/src/NovelSoftware.Api/Endpoints/OutlineEndpoints.cs
@@ -0,0 +1,49 @@
+using NovelSoftware.Application.Dtos;
+using NovelSoftware.Application.Services;
+
+namespace NovelSoftware.Api.Endpoints;
+
+public static class OutlineEndpoints
+{
+ public static IEndpointRouteBuilder MapOutlineEndpoints(this IEndpointRouteBuilder app)
+ {
+ var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/outline").WithTags("Outline");
+
+ projectScoped.MapGet("/", async (Guid projectId, OutlineService service, CancellationToken ct) =>
+ Results.Ok(await service.GetTreeAsync(projectId, ct)))
+ .WithSummary("Read the project's outline as a nested tree.");
+
+ projectScoped.MapPost("/", async (
+ Guid projectId, CreateOutlineNodeRequest request, OutlineService service, CancellationToken ct) =>
+ {
+ var created = await service.CreateAsync(projectId, request, ct);
+ return Results.Created($"/api/outline/{created.Id}", created);
+ })
+ .WithSummary("Add an outline node.");
+
+ var nodes = app.MapGroup("/api/outline").WithTags("Outline");
+
+ nodes.MapGet("/{id:guid}", async (Guid id, OutlineService service, CancellationToken ct) =>
+ Results.Ok(await service.GetAsync(id, ct)))
+ .WithSummary("Read one outline node and its subtree.");
+
+ nodes.MapPatch("/{id:guid}", async (
+ Guid id, UpdateOutlineNodeRequest request, OutlineService service, CancellationToken ct) =>
+ Results.Ok(await service.UpdateAsync(id, request, ct)))
+ .WithSummary("Update an outline node.");
+
+ nodes.MapPost("/{id:guid}/move", async (
+ Guid id, MoveOutlineNodeRequest request, OutlineService service, CancellationToken ct) =>
+ Results.Ok(await service.MoveAsync(id, request, ct)))
+ .WithSummary("Reparent or reorder an outline node.");
+
+ nodes.MapDelete("/{id:guid}", async (Guid id, OutlineService service, CancellationToken ct) =>
+ {
+ await service.DeleteAsync(id, ct);
+ return Results.NoContent();
+ })
+ .WithSummary("Delete an outline node and everything beneath it.");
+
+ return app;
+ }
+}
diff --git a/src/NovelSoftware.Api/Endpoints/ProjectEndpoints.cs b/src/NovelSoftware.Api/Endpoints/ProjectEndpoints.cs
new file mode 100644
index 0000000..e093d4b
--- /dev/null
+++ b/src/NovelSoftware.Api/Endpoints/ProjectEndpoints.cs
@@ -0,0 +1,41 @@
+using NovelSoftware.Application.Dtos;
+using NovelSoftware.Application.Services;
+
+namespace NovelSoftware.Api.Endpoints;
+
+public static class ProjectEndpoints
+{
+ public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/projects").WithTags("Projects");
+
+ group.MapGet("/", async (ProjectService service, CancellationToken ct) =>
+ Results.Ok(await service.ListAsync(ct)))
+ .WithSummary("List all novel projects.");
+
+ group.MapGet("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) =>
+ Results.Ok(await service.GetAsync(id, ct)))
+ .WithSummary("Read a project's brief.");
+
+ group.MapPost("/", async (CreateProjectRequest request, ProjectService service, CancellationToken ct) =>
+ {
+ var created = await service.CreateAsync(request, ct);
+ return Results.Created($"/api/projects/{created.Id}", created);
+ })
+ .WithSummary("Create a novel project.");
+
+ group.MapPatch("/{id:guid}", async (
+ Guid id, UpdateProjectRequest request, ProjectService service, CancellationToken ct) =>
+ Results.Ok(await service.UpdateAsync(id, request, ct)))
+ .WithSummary("Update a project's brief.");
+
+ group.MapDelete("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) =>
+ {
+ await service.DeleteAsync(id, ct);
+ return Results.NoContent();
+ })
+ .WithSummary("Delete a project and everything in it.");
+
+ return app;
+ }
+}
diff --git a/src/NovelSoftware.Api/Endpoints/SceneEndpoints.cs b/src/NovelSoftware.Api/Endpoints/SceneEndpoints.cs
new file mode 100644
index 0000000..13ca5a3
--- /dev/null
+++ b/src/NovelSoftware.Api/Endpoints/SceneEndpoints.cs
@@ -0,0 +1,44 @@
+using NovelSoftware.Application.Dtos;
+using NovelSoftware.Application.Services;
+
+namespace NovelSoftware.Api.Endpoints;
+
+public static class SceneEndpoints
+{
+ public static IEndpointRouteBuilder MapSceneEndpoints(this IEndpointRouteBuilder app)
+ {
+ var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/scenes").WithTags("Scenes");
+
+ chapterScoped.MapGet("/", async (Guid chapterId, SceneService service, CancellationToken ct) =>
+ Results.Ok(await service.ListAsync(chapterId, ct)))
+ .WithSummary("List a chapter's scenes in order.");
+
+ chapterScoped.MapPost("/", async (
+ Guid chapterId, CreateSceneRequest request, SceneService service, CancellationToken ct) =>
+ {
+ var created = await service.CreateAsync(chapterId, request, ct);
+ return Results.Created($"/api/scenes/{created.Id}", created);
+ })
+ .WithSummary("Add a scene to a chapter.");
+
+ var scenes = app.MapGroup("/api/scenes").WithTags("Scenes");
+
+ scenes.MapGet("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) =>
+ Results.Ok(await service.GetAsync(id, ct)))
+ .WithSummary("Read a scene, including its prose.");
+
+ scenes.MapPatch("/{id:guid}", async (
+ Guid id, UpdateSceneRequest request, SceneService service, CancellationToken ct) =>
+ Results.Ok(await service.UpdateAsync(id, request, ct)))
+ .WithSummary("Update a scene. Sending prose recomputes the word count.");
+
+ scenes.MapDelete("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) =>
+ {
+ await service.DeleteAsync(id, ct);
+ return Results.NoContent();
+ })
+ .WithSummary("Delete a scene.");
+
+ return app;
+ }
+}
diff --git a/src/NovelSoftware.Api/NovelSoftware.Api.csproj b/src/NovelSoftware.Api/NovelSoftware.Api.csproj
new file mode 100644
index 0000000..0f65328
--- /dev/null
+++ b/src/NovelSoftware.Api/NovelSoftware.Api.csproj
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/src/NovelSoftware.Api/Program.cs b/src/NovelSoftware.Api/Program.cs
new file mode 100644
index 0000000..f1c39dd
--- /dev/null
+++ b/src/NovelSoftware.Api/Program.cs
@@ -0,0 +1,78 @@
+using System.Text.Json.Serialization;
+using Microsoft.AspNetCore.Diagnostics;
+using Microsoft.EntityFrameworkCore;
+using NovelSoftware.Api.Endpoints;
+using NovelSoftware.Application;
+using NovelSoftware.Infrastructure;
+using NovelSoftware.Infrastructure.Persistence;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddNovelSoftware(builder.Configuration);
+builder.Services.AddOpenApi();
+builder.Services.AddProblemDetails();
+
+// Enums travel as their names, so the React client and the MCP server both read
+// "Protagonist" rather than an ordinal that shifts whenever the enum is reordered.
+builder.Services.ConfigureHttpJsonOptions(options =>
+ options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
+
+var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get()
+ ?? ["http://localhost:5173"];
+
+builder.Services.AddCors(options => options.AddDefaultPolicy(policy => policy
+ .WithOrigins(corsOrigins)
+ .AllowAnyHeader()
+ .AllowAnyMethod()));
+
+var app = builder.Build();
+
+// Local-first tool: bring the SQLite file up to date on boot rather than making the
+// writer run a migration command before they can open the app.
+using (var scope = app.Services.CreateScope())
+{
+ await scope.ServiceProvider.GetRequiredService().Database.MigrateAsync();
+}
+
+app.UseExceptionHandler(handler => handler.Run(async context =>
+{
+ var exception = context.Features.Get()?.Error;
+
+ var (status, title) = exception switch
+ {
+ NotFoundException => (StatusCodes.Status404NotFound, "Not found"),
+ AgentNotConfiguredException => (StatusCodes.Status503ServiceUnavailable, "Agent unavailable"),
+ ArgumentException or InvalidOperationException => (StatusCodes.Status400BadRequest, "Invalid request"),
+ _ => (StatusCodes.Status500InternalServerError, "Unexpected error")
+ };
+
+ if (status == StatusCodes.Status500InternalServerError)
+ {
+ app.Logger.LogError(exception, "Unhandled exception on {Path}", context.Request.Path);
+ }
+
+ await Results
+ .Problem(title: title, detail: exception?.Message, statusCode: status)
+ .ExecuteAsync(context);
+}));
+
+app.UseCors();
+
+if (app.Environment.IsDevelopment())
+{
+ app.MapOpenApi();
+}
+
+app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health");
+
+app.MapProjectEndpoints()
+ .MapCharacterEndpoints()
+ .MapOutlineEndpoints()
+ .MapChapterEndpoints()
+ .MapSceneEndpoints()
+ .MapAgentEndpoints();
+
+app.Run();
+
+/// Exposed so the tests can spin the API up with WebApplicationFactory.
+public partial class Program;
diff --git a/src/NovelSoftware.Api/Properties/launchSettings.json b/src/NovelSoftware.Api/Properties/launchSettings.json
new file mode 100644
index 0000000..74e737a
--- /dev/null
+++ b/src/NovelSoftware.Api/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5266",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7123;http://localhost:5266",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/src/NovelSoftware.Api/appsettings.Development.json b/src/NovelSoftware.Api/appsettings.Development.json
new file mode 100644
index 0000000..36ce91e
--- /dev/null
+++ b/src/NovelSoftware.Api/appsettings.Development.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "NovelSoftware": "Debug",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/src/NovelSoftware.Api/appsettings.json b/src/NovelSoftware.Api/appsettings.json
new file mode 100644
index 0000000..72510ac
--- /dev/null
+++ b/src/NovelSoftware.Api/appsettings.json
@@ -0,0 +1,22 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Microsoft.EntityFrameworkCore.Database.Command": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "ConnectionStrings": {
+ "Novel": "Data Source=novel.db"
+ },
+ "Cors": {
+ "Origins": [ "http://localhost:5173" ]
+ },
+ "Agent": {
+ "Model": "claude-opus-5",
+ "MaxTokens": 16000,
+ "Effort": "high",
+ "MaxIterations": 12
+ }
+}
diff --git a/src/NovelSoftware.Application/Agent/AgentContracts.cs b/src/NovelSoftware.Application/Agent/AgentContracts.cs
new file mode 100644
index 0000000..6018d38
--- /dev/null
+++ b/src/NovelSoftware.Application/Agent/AgentContracts.cs
@@ -0,0 +1,60 @@
+using System.Text.Json;
+
+namespace NovelSoftware.Application.Agent;
+
+/// A tool the model may call, described in the shape the Messages API expects.
+public record AgentToolDefinition(string Name, string Description, JsonElement InputSchema);
+
+/// One content block in a model turn.
+public abstract record AgentContentBlock;
+
+public record AgentTextBlock(string Text) : AgentContentBlock;
+
+public record AgentToolUseBlock(string Id, string Name, JsonElement Input) : AgentContentBlock;
+
+public record AgentToolResultBlock(string ToolUseId, string Content, bool IsError = false) : AgentContentBlock;
+
+/// A full turn in the conversation sent to or received from the model.
+public record AgentChatMessage(string Role, IReadOnlyList Content)
+{
+ public static AgentChatMessage User(params AgentContentBlock[] content) => new("user", content);
+ public static AgentChatMessage Assistant(IReadOnlyList content) => new("assistant", content);
+}
+
+public record AgentModelResponse(IReadOnlyList Content, string? StopReason);
+
+///
+/// The model-facing seam. Infrastructure implements this against the Anthropic SDK;
+/// tests substitute a scripted stand-in so the agent loop can be exercised offline.
+///
+public interface IAgentModelClient
+{
+ Task CompleteAsync(
+ string systemPrompt,
+ IReadOnlyList messages,
+ IReadOnlyList tools,
+ CancellationToken ct = default);
+}
+
+/// Configuration for the embedded writing agent.
+public class AgentOptions
+{
+ public const string SectionName = "Agent";
+
+ /// Anthropic model id. Defaults to the current Opus.
+ public string Model { get; set; } = "claude-opus-5";
+
+ public int MaxTokens { get; set; } = 16000;
+
+ /// Thinking depth: low | medium | high | xhigh | max.
+ public string Effort { get; set; } = "high";
+
+ ///
+ /// Ceiling on model round-trips per user turn. Each tool call costs one; without a
+ /// cap a confused model could loop indefinitely.
+ ///
+ public int MaxIterations { get; set; } = 12;
+
+ /// Falls back to the ANTHROPIC_API_KEY environment variable when unset.
+ public string? ApiKey { get; set; }
+}
diff --git a/src/NovelSoftware.Application/Agent/JsonSchema.cs b/src/NovelSoftware.Application/Agent/JsonSchema.cs
new file mode 100644
index 0000000..3c7a181
--- /dev/null
+++ b/src/NovelSoftware.Application/Agent/JsonSchema.cs
@@ -0,0 +1,102 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace NovelSoftware.Application.Agent;
+
+///
+/// Small builder for the JSON Schema objects tool definitions need. Hand-writing these
+/// as string literals is where tool definitions usually rot, so build them structurally.
+///
+public sealed class JsonSchemaBuilder
+{
+ private readonly JsonObject _properties = [];
+ private readonly JsonArray _required = [];
+
+ public JsonSchemaBuilder Str(string name, string description, bool required = false) =>
+ Add(name, "string", description, required);
+
+ public JsonSchemaBuilder Int(string name, string description, bool required = false) =>
+ Add(name, "integer", description, required);
+
+ public JsonSchemaBuilder Bool(string name, string description, bool required = false) =>
+ Add(name, "boolean", description, required);
+
+ public JsonSchemaBuilder Enum(string name, string description, IEnumerable values, bool required = false)
+ {
+ var node = new JsonObject
+ {
+ ["type"] = "string",
+ ["description"] = description,
+ ["enum"] = new JsonArray([.. values.Select(v => JsonValue.Create(v))])
+ };
+
+ _properties[name] = node;
+ if (required)
+ {
+ _required.Add(name);
+ }
+
+ return this;
+ }
+
+ private JsonSchemaBuilder Add(string name, string type, string description, bool required)
+ {
+ _properties[name] = new JsonObject { ["type"] = type, ["description"] = description };
+ if (required)
+ {
+ _required.Add(name);
+ }
+
+ return this;
+ }
+
+ public JsonElement Build()
+ {
+ var schema = new JsonObject
+ {
+ ["type"] = "object",
+ ["properties"] = _properties,
+ ["required"] = _required
+ };
+
+ return JsonSerializer.Deserialize(schema.ToJsonString());
+ }
+}
+
+/// Lenient readers for tool input, which arrives as untyped JSON.
+public static class JsonInput
+{
+ public static string? String(JsonElement input, string name) =>
+ input.ValueKind == JsonValueKind.Object
+ && input.TryGetProperty(name, out var value)
+ && value.ValueKind is JsonValueKind.String
+ ? value.GetString()
+ : null;
+
+ public static string RequiredString(JsonElement input, string name) =>
+ String(input, name) ?? throw new ArgumentException($"Missing required argument '{name}'.");
+
+ public static Guid? Guid(JsonElement input, string name) =>
+ System.Guid.TryParse(String(input, name), out var id) ? id : null;
+
+ public static Guid RequiredGuid(JsonElement input, string name) =>
+ Guid(input, name) ?? throw new ArgumentException($"Missing or malformed id argument '{name}'.");
+
+ public static int? Int(JsonElement input, string name)
+ {
+ if (input.ValueKind != JsonValueKind.Object || !input.TryGetProperty(name, out var value))
+ {
+ return null;
+ }
+
+ return value.ValueKind switch
+ {
+ JsonValueKind.Number when value.TryGetInt32(out var n) => n,
+ JsonValueKind.String when int.TryParse(value.GetString(), out var n) => n,
+ _ => null
+ };
+ }
+
+ public static TEnum? Enum(JsonElement input, string name) where TEnum : struct, System.Enum =>
+ System.Enum.TryParse(String(input, name), ignoreCase: true, out var parsed) ? parsed : null;
+}
diff --git a/src/NovelSoftware.Application/Agent/NovelAgentService.cs b/src/NovelSoftware.Application/Agent/NovelAgentService.cs
new file mode 100644
index 0000000..f23cb19
--- /dev/null
+++ b/src/NovelSoftware.Application/Agent/NovelAgentService.cs
@@ -0,0 +1,265 @@
+using System.Text;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using NovelSoftware.Application.Dtos;
+using NovelSoftware.Domain;
+using NovelSoftware.Domain.Entities;
+
+namespace NovelSoftware.Application.Agent;
+
+///
+/// The embedded writing agent. Runs the tool-use loop against the model, persists the
+/// conversation, and returns the finished turn together with a record of what it changed.
+///
+public class NovelAgentService(
+ INovelDbContext db,
+ IAgentModelClient model,
+ NovelAgentToolset toolset,
+ IOptions options,
+ ILogger logger)
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
+ };
+
+ private readonly AgentOptions _options = options.Value;
+
+ public async Task> ListConversationsAsync(
+ Guid projectId, CancellationToken ct = default) =>
+ await db.Conversations
+ .Where(c => c.ProjectId == projectId)
+ .OrderByDescending(c => c.UpdatedAt)
+ .Select(c => new ConversationSummaryDto(c.Id, c.ProjectId, c.Title, c.Messages.Count, c.UpdatedAt))
+ .ToListAsync(ct);
+
+ public async Task GetConversationAsync(Guid conversationId, CancellationToken ct = default)
+ {
+ var conversation = await LoadConversationAsync(conversationId, ct);
+
+ return new ConversationDto(
+ conversation.Id,
+ conversation.ProjectId,
+ conversation.Title,
+ [.. conversation.Messages.OrderBy(m => m.Sequence).Select(ToDto)],
+ conversation.UpdatedAt);
+ }
+
+ public async Task DeleteConversationAsync(Guid conversationId, CancellationToken ct = default)
+ {
+ var conversation = await LoadConversationAsync(conversationId, ct);
+ db.Conversations.Remove(conversation);
+ await db.SaveChangesAsync(ct);
+ }
+
+ ///
+ /// Sends a message to the agent and runs it to completion, executing any tools it
+ /// calls along the way. Returns the assistant's final turn.
+ ///
+ public async Task SendMessageAsync(
+ Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
+ {
+ var conversation = request.ConversationId is { } id
+ ? await LoadConversationAsync(id, ct)
+ : await StartConversationAsync(projectId, request.Message, ct);
+
+ // Persist the user's turn before running the loop. The tools save through the
+ // same DbContext, so leaving this pending would entangle it with their writes —
+ // and recording the question even if the model call fails is the behaviour we want.
+ await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct);
+
+ var systemPrompt = await BuildSystemPromptAsync(projectId, ct);
+ var transcript = BuildTranscript(conversation);
+ var toolCalls = new List();
+ var text = new StringBuilder();
+
+ for (var iteration = 0; iteration < _options.MaxIterations; iteration++)
+ {
+ var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct);
+
+ foreach (var block in response.Content.OfType())
+ {
+ if (!string.IsNullOrWhiteSpace(block.Text))
+ {
+ text.AppendLine(block.Text.Trim());
+ }
+ }
+
+ var requestedTools = response.Content.OfType().ToList();
+ if (requestedTools.Count == 0)
+ {
+ break;
+ }
+
+ // Echo the assistant's turn back verbatim, then answer every tool_use block in a
+ // single user turn — splitting the results would train the model out of
+ // requesting tools in parallel.
+ transcript.Add(AgentChatMessage.Assistant(response.Content));
+
+ var results = new List();
+ foreach (var call in requestedTools)
+ {
+ var (result, isError) = await toolset.ExecuteAsync(call.Name, projectId, call.Input, ct);
+
+ logger.LogInformation(
+ "Agent tool {Tool} on project {ProjectId} {Outcome}",
+ call.Name, projectId, isError ? "failed" : "succeeded");
+
+ toolCalls.Add(new ToolCallDto(call.Name, call.Input.ToString(), result));
+ results.Add(new AgentToolResultBlock(call.Id, result, isError));
+ }
+
+ transcript.Add(AgentChatMessage.User([.. results]));
+
+ if (iteration == _options.MaxIterations - 1)
+ {
+ logger.LogWarning(
+ "Agent hit the {Max}-iteration ceiling on project {ProjectId}",
+ _options.MaxIterations, projectId);
+
+ text.AppendLine(
+ "_I reached my tool-call limit for this turn. Ask me to continue if there's more to do._");
+ }
+ }
+
+ var reply = await AppendMessageAsync(
+ conversation,
+ AgentRole.Assistant,
+ text.ToString().TrimEnd(),
+ toolCalls.Count > 0 ? JsonSerializer.Serialize(toolCalls, JsonOptions) : null,
+ ct);
+
+ return new AgentTurnDto(conversation.Id, ToDto(reply));
+ }
+
+ ///
+ /// Appends a turn and commits it. Messages are added to the set directly rather than
+ /// through the parent's collection so their insert never depends on EF discovering
+ /// the graph change at an inconvenient moment.
+ ///
+ private async Task AppendMessageAsync(
+ AgentConversation conversation, AgentRole role, string content, string? toolCallsJson, CancellationToken ct)
+ {
+ var message = new AgentMessage
+ {
+ ConversationId = conversation.Id,
+ Role = role,
+ Sequence = conversation.Messages.Count == 0 ? 0 : conversation.Messages.Max(m => m.Sequence) + 1,
+ Content = content,
+ ToolCallsJson = toolCallsJson
+ };
+
+ db.AgentMessages.Add(message);
+ conversation.UpdatedAt = DateTimeOffset.UtcNow;
+
+ await db.SaveChangesAsync(ct);
+
+ // EF's relationship fixup normally puts the message into the parent's collection
+ // once both are tracked. Guard rather than assume, since the sequence number of
+ // the next turn is derived from it.
+ if (!conversation.Messages.Contains(message))
+ {
+ conversation.Messages.Add(message);
+ }
+
+ return message;
+ }
+
+ private async Task StartConversationAsync(
+ Guid projectId, string firstMessage, CancellationToken ct)
+ {
+ if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
+ {
+ throw new NotFoundException(nameof(Project), projectId);
+ }
+
+ var conversation = new AgentConversation
+ {
+ ProjectId = projectId,
+ Title = Summarise(firstMessage)
+ };
+
+ db.Conversations.Add(conversation);
+ return conversation;
+ }
+
+ private async Task LoadConversationAsync(Guid conversationId, CancellationToken ct) =>
+ await db.Conversations
+ .Include(c => c.Messages)
+ .FirstOrDefaultAsync(c => c.Id == conversationId, ct)
+ ?? throw new NotFoundException(nameof(AgentConversation), conversationId);
+
+ ///
+ /// Replays the stored conversation as plain text turns. Tool calls are not replayed —
+ /// the agent re-reads current state through its tools, which is more reliable than
+ /// trusting a transcript of edits that may since have been changed in the UI.
+ ///
+ private static List BuildTranscript(AgentConversation conversation) =>
+ [
+ .. conversation.Messages
+ .Where(m => !string.IsNullOrWhiteSpace(m.Content))
+ .OrderBy(m => m.Sequence)
+ .Select(m => new AgentChatMessage(
+ m.Role == AgentRole.User ? "user" : "assistant",
+ [new AgentTextBlock(m.Content)]))
+ ];
+
+ private async Task BuildSystemPromptAsync(Guid projectId, CancellationToken ct)
+ {
+ var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct)
+ ?? throw new NotFoundException(nameof(Project), projectId);
+
+ var brief = new StringBuilder();
+ brief.AppendLine($"Title: {project.Title}");
+ if (!string.IsNullOrWhiteSpace(project.Genre)) brief.AppendLine($"Genre: {project.Genre}");
+ if (!string.IsNullOrWhiteSpace(project.Logline)) brief.AppendLine($"Logline: {project.Logline}");
+ if (project.TargetWordCount is { } target) brief.AppendLine($"Target length: {target:N0} words");
+
+ return $"""
+ You are a developmental editor and writing partner embedded in the software the
+ writer is using to plan their novel. You have tools that read and write the
+ project's real data: the brief, character dossiers, the outline tree, chapters
+ and scenes.
+
+ The project you are working on:
+ {brief}
+ Working principles:
+
+ - Read before you write. Call get_project_brief, get_outline, or list_characters
+ to ground yourself rather than assuming what is already there.
+ - The book is the writer's. Ask about the choices that define the story — what a
+ character wants, what the ending costs them — instead of deciding for them.
+ - Do not invent biographical detail to fill an empty field. An unanswered
+ question in a dossier is more useful than a plausible-sounding fabrication.
+ - When you do have enough to act, act. Make the edit and say what you changed in
+ a sentence; do not narrate every tool call or ask permission for routine work.
+ - Prefer structural help — where a beat lands, whether a want and a need are
+ genuinely in tension, what the outline is missing — over line-level polish,
+ unless the writer asks for prose.
+ - When drafting prose into a scene, match the voice already established in the
+ project. Write the scene, then stop; do not append notes about your choices.
+ - Destructive operations (deleting outline nodes) need the writer's explicit
+ go-ahead first.
+
+ Keep replies short. Lead with the outcome, then the reasoning if it earns its place.
+ """;
+ }
+
+ private static AgentMessageDto ToDto(AgentMessage message) => new(
+ message.Id,
+ message.Role,
+ message.Content,
+ message.ToolCallsJson is null
+ ? []
+ : JsonSerializer.Deserialize>(message.ToolCallsJson, JsonOptions) ?? [],
+ message.CreatedAt);
+
+ /// Derives a conversation title from its opening message.
+ private static string Summarise(string message)
+ {
+ var trimmed = message.Trim().ReplaceLineEndings(" ");
+ return trimmed.Length <= 60 ? trimmed : string.Concat(trimmed.AsSpan(0, 57), "...");
+ }
+}
diff --git a/src/NovelSoftware.Application/Agent/NovelAgentToolset.cs b/src/NovelSoftware.Application/Agent/NovelAgentToolset.cs
new file mode 100644
index 0000000..6b0b182
--- /dev/null
+++ b/src/NovelSoftware.Application/Agent/NovelAgentToolset.cs
@@ -0,0 +1,360 @@
+using System.Text.Json;
+using NovelSoftware.Application.Dtos;
+using NovelSoftware.Application.Services;
+using NovelSoftware.Domain;
+
+namespace NovelSoftware.Application.Agent;
+
+/// A tool the agent can call, bound to a handler that runs against the project's data.
+public sealed record AgentTool(
+ string Name,
+ string Description,
+ JsonElement InputSchema,
+ Func> Handler);
+
+///
+/// The tools the writing agent can reach for. Everything here goes through the same
+/// application services the REST API uses, so an edit made by the agent is
+/// indistinguishable from one made in the UI.
+///
+public class NovelAgentToolset(
+ ProjectService projects,
+ CharacterService characters,
+ OutlineService outlines,
+ ChapterService chapters,
+ SceneService scenes)
+{
+ private static readonly JsonSerializerOptions SerializerOptions = new()
+ {
+ WriteIndented = false,
+ Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
+ };
+
+ private Dictionary? _byName;
+
+ public IReadOnlyList Tools => [.. ByName.Values];
+
+ public IReadOnlyList Definitions =>
+ [.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
+
+ ///
+ /// Runs a tool and serialises its result. Failures come back as text rather than
+ /// exceptions so the model can read the message and correct itself.
+ ///
+ public async Task<(string Result, bool IsError)> ExecuteAsync(
+ string name, Guid projectId, JsonElement input, CancellationToken ct = default)
+ {
+ if (!ByName.TryGetValue(name, out var tool))
+ {
+ return ($"No such tool: '{name}'.", true);
+ }
+
+ try
+ {
+ var result = await tool.Handler(projectId, input, ct);
+ return (JsonSerializer.Serialize(result, SerializerOptions), false);
+ }
+ catch (NotFoundException ex)
+ {
+ return (ex.Message, true);
+ }
+ catch (ArgumentException ex)
+ {
+ return (ex.Message, true);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return (ex.Message, true);
+ }
+ }
+
+ private Dictionary ByName => _byName ??= Build().ToDictionary(t => t.Name);
+
+ private IEnumerable Build()
+ {
+ yield return new AgentTool(
+ "get_project_brief",
+ "Read the project's title, logline, synopsis, genre, notes and word-count target. "
+ + "Call this first in a conversation to ground yourself in what the book is.",
+ new JsonSchemaBuilder().Build(),
+ async (projectId, _, ct) => await projects.GetAsync(projectId, ct));
+
+ yield return new AgentTool(
+ "update_project_brief",
+ "Revise the project's top-level fields. Only the fields you supply change; "
+ + "pass an empty string to clear a field.",
+ new JsonSchemaBuilder()
+ .Str("title", "New title.")
+ .Str("author", "Author name.")
+ .Str("genre", "Genre or category.")
+ .Str("logline", "One-sentence pitch.")
+ .Str("synopsis", "Paragraph-length summary of the whole book.")
+ .Str("notes", "Free-form notes on theme, tone, comparable titles.")
+ .Int("target_word_count", "Target manuscript length in words.")
+ .Build(),
+ async (projectId, input, ct) => await projects.UpdateAsync(projectId, new UpdateProjectRequest(
+ JsonInput.String(input, "title"),
+ JsonInput.String(input, "author"),
+ JsonInput.String(input, "genre"),
+ JsonInput.String(input, "logline"),
+ JsonInput.String(input, "synopsis"),
+ JsonInput.String(input, "notes"),
+ JsonInput.Int(input, "target_word_count")), ct));
+
+ yield return new AgentTool(
+ "list_characters",
+ "List every character in the project with their full dossiers.",
+ new JsonSchemaBuilder().Build(),
+ async (projectId, _, ct) => await characters.ListAsync(projectId, ct));
+
+ yield return new AgentTool(
+ "create_character",
+ "Add a character dossier. Name is the only requirement — leave fields blank when "
+ + "the writer has not decided them yet rather than inventing detail.",
+ CharacterSchema(includeName: true, nameRequired: true).Build(),
+ async (projectId, input, ct) => await characters.CreateAsync(projectId, new CreateCharacterRequest(
+ JsonInput.RequiredString(input, "name"),
+ JsonInput.Enum(input, "role") ?? CharacterRole.Supporting,
+ JsonInput.String(input, "age"),
+ JsonInput.String(input, "pronouns"),
+ JsonInput.String(input, "occupation"),
+ JsonInput.String(input, "appearance"),
+ JsonInput.String(input, "personality"),
+ JsonInput.String(input, "backstory"),
+ JsonInput.String(input, "want"),
+ JsonInput.String(input, "need"),
+ JsonInput.String(input, "internal_conflict"),
+ JsonInput.String(input, "external_conflict"),
+ JsonInput.String(input, "arc_summary"),
+ JsonInput.String(input, "voice"),
+ JsonInput.String(input, "notes")), ct));
+
+ yield return new AgentTool(
+ "update_character",
+ "Revise an existing character dossier. Only the fields you supply change.",
+ CharacterSchema(includeName: true, nameRequired: false)
+ .Str("character_id", "Id of the character to update.", required: true)
+ .Build(),
+ async (_, input, ct) => await characters.UpdateAsync(
+ JsonInput.RequiredGuid(input, "character_id"),
+ new UpdateCharacterRequest(
+ JsonInput.String(input, "name"),
+ JsonInput.Enum(input, "role"),
+ JsonInput.String(input, "age"),
+ JsonInput.String(input, "pronouns"),
+ JsonInput.String(input, "occupation"),
+ JsonInput.String(input, "appearance"),
+ JsonInput.String(input, "personality"),
+ JsonInput.String(input, "backstory"),
+ JsonInput.String(input, "want"),
+ JsonInput.String(input, "need"),
+ JsonInput.String(input, "internal_conflict"),
+ JsonInput.String(input, "external_conflict"),
+ JsonInput.String(input, "arc_summary"),
+ JsonInput.String(input, "voice"),
+ JsonInput.String(input, "notes")), ct));
+
+ yield return new AgentTool(
+ "get_outline",
+ "Read the project's outline as a nested tree of parts, acts, sequences and beats.",
+ new JsonSchemaBuilder().Build(),
+ async (projectId, _, ct) => await outlines.GetTreeAsync(projectId, ct));
+
+ yield return new AgentTool(
+ "create_outline_node",
+ "Add a node to the outline. Pass parent_id to nest it; omit it for a top-level node.",
+ new JsonSchemaBuilder()
+ .Str("title", "Short label for the node.", required: true)
+ .Enum("node_type", "Structural level of the node.", System.Enum.GetNames())
+ .Str("parent_id", "Id of the parent node, if nesting.")
+ .Str("summary", "What happens here, in a sentence or two.")
+ .Int("sort_order", "Position among siblings. Appended to the end when omitted.")
+ .Str("chapter_id", "Id of the chapter that realises this node, if one exists.")
+ .Build(),
+ async (projectId, input, ct) => await outlines.CreateAsync(projectId, new CreateOutlineNodeRequest(
+ JsonInput.RequiredString(input, "title"),
+ JsonInput.Enum(input, "node_type") ?? OutlineNodeType.Beat,
+ JsonInput.Guid(input, "parent_id"),
+ JsonInput.String(input, "summary"),
+ JsonInput.Int(input, "sort_order"),
+ JsonInput.Guid(input, "chapter_id")), ct));
+
+ yield return new AgentTool(
+ "update_outline_node",
+ "Revise an outline node's title, type, summary, position or linked chapter.",
+ new JsonSchemaBuilder()
+ .Str("node_id", "Id of the node to update.", required: true)
+ .Str("title", "New title.")
+ .Enum("node_type", "Structural level of the node.", System.Enum.GetNames())
+ .Str("summary", "What happens here.")
+ .Int("sort_order", "Position among siblings.")
+ .Str("chapter_id", "Id of the chapter that realises this node.")
+ .Build(),
+ async (_, input, ct) => await outlines.UpdateAsync(
+ JsonInput.RequiredGuid(input, "node_id"),
+ new UpdateOutlineNodeRequest(
+ JsonInput.String(input, "title"),
+ JsonInput.Enum(input, "node_type"),
+ JsonInput.String(input, "summary"),
+ JsonInput.Int(input, "sort_order"),
+ JsonInput.Guid(input, "chapter_id")), ct));
+
+ yield return new AgentTool(
+ "delete_outline_node",
+ "Remove an outline node and everything nested beneath it. This cannot be undone, "
+ + "so confirm with the writer before calling it.",
+ new JsonSchemaBuilder()
+ .Str("node_id", "Id of the node to delete.", required: true)
+ .Build(),
+ async (_, input, ct) =>
+ {
+ await outlines.DeleteAsync(JsonInput.RequiredGuid(input, "node_id"), ct);
+ return new { deleted = true };
+ });
+
+ yield return new AgentTool(
+ "list_chapters",
+ "List the project's chapters in manuscript order with scene and word counts.",
+ new JsonSchemaBuilder().Build(),
+ async (projectId, _, ct) => await chapters.ListAsync(projectId, ct));
+
+ yield return new AgentTool(
+ "get_chapter",
+ "Read one chapter in full, including all of its scenes and any drafted prose.",
+ new JsonSchemaBuilder()
+ .Str("chapter_id", "Id of the chapter to read.", required: true)
+ .Build(),
+ async (_, input, ct) => await chapters.GetAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct));
+
+ yield return new AgentTool(
+ "create_chapter",
+ "Add a chapter. Its number is appended to the end of the manuscript unless you supply one.",
+ new JsonSchemaBuilder()
+ .Str("title", "Chapter title.", required: true)
+ .Int("number", "Position in the manuscript, 1-based.")
+ .Str("summary", "What the chapter covers.")
+ .Str("pov_character_id", "Id of the point-of-view character.")
+ .Str("setting", "Where and when the chapter takes place.")
+ .Str("notes", "Anything else worth recording.")
+ .Enum("status", "Drafting status.", System.Enum.GetNames())
+ .Int("target_word_count", "Target length in words.")
+ .Build(),
+ async (projectId, input, ct) => await chapters.CreateAsync(projectId, new CreateChapterRequest(
+ JsonInput.RequiredString(input, "title"),
+ JsonInput.Int(input, "number"),
+ JsonInput.String(input, "summary"),
+ JsonInput.Guid(input, "pov_character_id"),
+ JsonInput.String(input, "setting"),
+ JsonInput.String(input, "notes"),
+ JsonInput.Enum(input, "status") ?? DraftStatus.Planned,
+ JsonInput.Int(input, "target_word_count")), ct));
+
+ yield return new AgentTool(
+ "update_chapter",
+ "Revise a chapter's title, number, summary, POV, setting, notes or status.",
+ new JsonSchemaBuilder()
+ .Str("chapter_id", "Id of the chapter to update.", required: true)
+ .Str("title", "New title.")
+ .Int("number", "Position in the manuscript.")
+ .Str("summary", "What the chapter covers.")
+ .Str("pov_character_id", "Id of the point-of-view character.")
+ .Str("setting", "Where and when the chapter takes place.")
+ .Str("notes", "Anything else worth recording.")
+ .Enum("status", "Drafting status.", System.Enum.GetNames())
+ .Int("target_word_count", "Target length in words.")
+ .Build(),
+ async (_, input, ct) => await chapters.UpdateAsync(
+ JsonInput.RequiredGuid(input, "chapter_id"),
+ new UpdateChapterRequest(
+ JsonInput.String(input, "title"),
+ JsonInput.Int(input, "number"),
+ JsonInput.String(input, "summary"),
+ JsonInput.Guid(input, "pov_character_id"),
+ JsonInput.String(input, "setting"),
+ JsonInput.String(input, "notes"),
+ JsonInput.Enum(input, "status"),
+ JsonInput.Int(input, "target_word_count")), ct));
+
+ yield return new AgentTool(
+ "create_scene",
+ "Add a scene to a chapter. The goal/conflict/outcome trio is what makes a scene "
+ + "draftable later, so fill those in when the writer has given you enough to work with.",
+ SceneSchema()
+ .Str("chapter_id", "Id of the chapter the scene belongs to.", required: true)
+ .Str("title", "Scene title.", required: true)
+ .Build(),
+ async (_, input, ct) => await scenes.CreateAsync(
+ JsonInput.RequiredGuid(input, "chapter_id"),
+ new CreateSceneRequest(
+ JsonInput.RequiredString(input, "title"),
+ JsonInput.Int(input, "sort_order"),
+ JsonInput.String(input, "summary"),
+ JsonInput.String(input, "goal"),
+ JsonInput.String(input, "conflict"),
+ JsonInput.String(input, "outcome"),
+ JsonInput.Guid(input, "pov_character_id"),
+ JsonInput.String(input, "location"),
+ JsonInput.String(input, "prose"),
+ JsonInput.Enum(input, "status") ?? DraftStatus.Planned), ct));
+
+ yield return new AgentTool(
+ "update_scene",
+ "Revise a scene. Use the 'prose' argument to write or replace the scene's draft text; "
+ + "the word count is recomputed automatically.",
+ SceneSchema()
+ .Str("scene_id", "Id of the scene to update.", required: true)
+ .Str("title", "New title.")
+ .Build(),
+ async (_, input, ct) => await scenes.UpdateAsync(
+ JsonInput.RequiredGuid(input, "scene_id"),
+ new UpdateSceneRequest(
+ JsonInput.String(input, "title"),
+ JsonInput.Int(input, "sort_order"),
+ JsonInput.String(input, "summary"),
+ JsonInput.String(input, "goal"),
+ JsonInput.String(input, "conflict"),
+ JsonInput.String(input, "outcome"),
+ JsonInput.Guid(input, "pov_character_id"),
+ JsonInput.String(input, "location"),
+ JsonInput.String(input, "prose"),
+ JsonInput.Enum(input, "status")), ct));
+ }
+
+ private static JsonSchemaBuilder CharacterSchema(bool includeName, bool nameRequired)
+ {
+ var schema = new JsonSchemaBuilder();
+
+ if (includeName)
+ {
+ schema.Str("name", "The character's name.", nameRequired);
+ }
+
+ return schema
+ .Enum("role", "The part they play in the story.", System.Enum.GetNames())
+ .Str("age", "Age, exact or approximate.")
+ .Str("pronouns", "The pronouns this character uses.")
+ .Str("occupation", "What they do.")
+ .Str("appearance", "How they look.")
+ .Str("personality", "Temperament, habits, how they treat people.")
+ .Str("backstory", "History that shapes who they are now.")
+ .Str("want", "What they consciously pursue.")
+ .Str("need", "What they actually need, usually at odds with what they want.")
+ .Str("internal_conflict", "The war inside them.")
+ .Str("external_conflict", "What in the world opposes them.")
+ .Str("arc_summary", "How they change over the course of the book.")
+ .Str("voice", "Speech patterns and register that make their dialogue theirs.")
+ .Str("notes", "Anything else worth recording.");
+ }
+
+ private static JsonSchemaBuilder SceneSchema() =>
+ new JsonSchemaBuilder()
+ .Int("sort_order", "Position within the chapter. Appended to the end when omitted.")
+ .Str("summary", "What happens in the scene.")
+ .Str("goal", "What the POV character is trying to achieve.")
+ .Str("conflict", "What stands in the way.")
+ .Str("outcome", "How it lands, and what it costs.")
+ .Str("pov_character_id", "Id of the point-of-view character.")
+ .Str("location", "Where the scene takes place.")
+ .Str("prose", "The drafted prose for this scene.")
+ .Enum("status", "Drafting status.", System.Enum.GetNames());
+}
diff --git a/src/NovelSoftware.Application/AgentNotConfiguredException.cs b/src/NovelSoftware.Application/AgentNotConfiguredException.cs
new file mode 100644
index 0000000..fb72f0d
--- /dev/null
+++ b/src/NovelSoftware.Application/AgentNotConfiguredException.cs
@@ -0,0 +1,8 @@
+namespace NovelSoftware.Application;
+
+///
+/// Thrown when the agent is asked to run but has no model credentials. This is a
+/// deployment problem rather than a bad request, so the API reports it as 503 — the rest
+/// of the app works fine without a key.
+///
+public class AgentNotConfiguredException(string message) : Exception(message);
diff --git a/src/NovelSoftware.Application/Dtos/AgentDtos.cs b/src/NovelSoftware.Application/Dtos/AgentDtos.cs
new file mode 100644
index 0000000..4e771b1
--- /dev/null
+++ b/src/NovelSoftware.Application/Dtos/AgentDtos.cs
@@ -0,0 +1,31 @@
+using NovelSoftware.Domain;
+
+namespace NovelSoftware.Application.Dtos;
+
+public record ConversationSummaryDto(
+ Guid Id,
+ Guid ProjectId,
+ string Title,
+ int MessageCount,
+ DateTimeOffset UpdatedAt);
+
+public record ConversationDto(
+ Guid Id,
+ Guid ProjectId,
+ string Title,
+ IReadOnlyList Messages,
+ DateTimeOffset UpdatedAt);
+
+public record AgentMessageDto(
+ Guid Id,
+ AgentRole Role,
+ string Content,
+ IReadOnlyList ToolCalls,
+ DateTimeOffset CreatedAt);
+
+/// A record of one tool the agent invoked, surfaced so the writer can audit changes.
+public record ToolCallDto(string Name, string Input, string Result);
+
+public record SendAgentMessageRequest(string Message, Guid? ConversationId = null);
+
+public record AgentTurnDto(Guid ConversationId, AgentMessageDto Message);
diff --git a/src/NovelSoftware.Application/Dtos/ChapterDtos.cs b/src/NovelSoftware.Application/Dtos/ChapterDtos.cs
new file mode 100644
index 0000000..566ba9c
--- /dev/null
+++ b/src/NovelSoftware.Application/Dtos/ChapterDtos.cs
@@ -0,0 +1,68 @@
+using NovelSoftware.Domain;
+using NovelSoftware.Domain.Entities;
+
+namespace NovelSoftware.Application.Dtos;
+
+public record ChapterSummaryDto(
+ Guid Id,
+ Guid ProjectId,
+ int Number,
+ string Title,
+ string? Summary,
+ Guid? PovCharacterId,
+ string? PovCharacterName,
+ string? Setting,
+ DraftStatus Status,
+ int? TargetWordCount,
+ int SceneCount,
+ int WordCount);
+
+public record ChapterDto(
+ Guid Id,
+ Guid ProjectId,
+ int Number,
+ string Title,
+ string? Summary,
+ Guid? PovCharacterId,
+ string? PovCharacterName,
+ string? Setting,
+ string? Notes,
+ DraftStatus Status,
+ int? TargetWordCount,
+ IReadOnlyList Scenes,
+ DateTimeOffset UpdatedAt);
+
+public record CreateChapterRequest(
+ string Title,
+ int? Number = null,
+ string? Summary = null,
+ Guid? PovCharacterId = null,
+ string? Setting = null,
+ string? Notes = null,
+ DraftStatus Status = DraftStatus.Planned,
+ int? TargetWordCount = null);
+
+public record UpdateChapterRequest(
+ string? Title = null,
+ int? Number = null,
+ string? Summary = null,
+ Guid? PovCharacterId = null,
+ string? Setting = null,
+ string? Notes = null,
+ DraftStatus? Status = null,
+ int? TargetWordCount = null);
+
+public static class ChapterMapping
+{
+ public static ChapterDto ToDto(this Chapter c) => new(
+ c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
+ c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Notes,
+ c.Status, c.TargetWordCount,
+ [.. c.Scenes.OrderBy(s => s.SortOrder).Select(s => s.ToDto())],
+ c.UpdatedAt);
+
+ public static ChapterSummaryDto ToSummaryDto(this Chapter c) => new(
+ c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
+ c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Status, c.TargetWordCount,
+ c.Scenes.Count, c.Scenes.Sum(s => s.WordCount));
+}
diff --git a/src/NovelSoftware.Application/Dtos/CharacterDtos.cs b/src/NovelSoftware.Application/Dtos/CharacterDtos.cs
new file mode 100644
index 0000000..001812a
--- /dev/null
+++ b/src/NovelSoftware.Application/Dtos/CharacterDtos.cs
@@ -0,0 +1,86 @@
+using NovelSoftware.Domain;
+using NovelSoftware.Domain.Entities;
+
+namespace NovelSoftware.Application.Dtos;
+
+public record CharacterDto(
+ Guid Id,
+ Guid ProjectId,
+ string Name,
+ CharacterRole Role,
+ string? Age,
+ string? Pronouns,
+ string? Occupation,
+ string? Appearance,
+ string? Personality,
+ string? Backstory,
+ string? Want,
+ string? Need,
+ string? InternalConflict,
+ string? ExternalConflict,
+ string? ArcSummary,
+ string? Voice,
+ string? Notes,
+ IReadOnlyList Relationships,
+ DateTimeOffset UpdatedAt);
+
+public record RelationshipDto(
+ Guid Id,
+ Guid RelatedCharacterId,
+ string RelatedCharacterName,
+ string RelationshipType,
+ string? Description);
+
+public record CreateCharacterRequest(
+ string Name,
+ CharacterRole Role = CharacterRole.Supporting,
+ string? Age = null,
+ string? Pronouns = null,
+ string? Occupation = null,
+ string? Appearance = null,
+ string? Personality = null,
+ string? Backstory = null,
+ string? Want = null,
+ string? Need = null,
+ string? InternalConflict = null,
+ string? ExternalConflict = null,
+ string? ArcSummary = null,
+ string? Voice = null,
+ string? Notes = null);
+
+public record UpdateCharacterRequest(
+ string? Name = null,
+ CharacterRole? Role = null,
+ string? Age = null,
+ string? Pronouns = null,
+ string? Occupation = null,
+ string? Appearance = null,
+ string? Personality = null,
+ string? Backstory = null,
+ string? Want = null,
+ string? Need = null,
+ string? InternalConflict = null,
+ string? ExternalConflict = null,
+ string? ArcSummary = null,
+ string? Voice = null,
+ string? Notes = null);
+
+public record CreateRelationshipRequest(
+ Guid RelatedCharacterId,
+ string RelationshipType,
+ string? Description = null);
+
+public static class CharacterMapping
+{
+ public static CharacterDto ToDto(this Character c) => new(
+ c.Id, c.ProjectId, c.Name, c.Role, c.Age, c.Pronouns, c.Occupation,
+ c.Appearance, c.Personality, c.Backstory, c.Want, c.Need,
+ c.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes,
+ [.. c.Relationships.Select(r => new RelationshipDto(
+ r.Id,
+ r.RelatedCharacterId,
+ r.RelatedCharacter?.Name ?? "(unknown)",
+ r.RelationshipType,
+ r.Description))],
+ c.UpdatedAt);
+}
diff --git a/src/NovelSoftware.Application/Dtos/OutlineDtos.cs b/src/NovelSoftware.Application/Dtos/OutlineDtos.cs
new file mode 100644
index 0000000..7b283ab
--- /dev/null
+++ b/src/NovelSoftware.Application/Dtos/OutlineDtos.cs
@@ -0,0 +1,33 @@
+using NovelSoftware.Domain;
+
+namespace NovelSoftware.Application.Dtos;
+
+/// An outline node with its subtree inlined — the shape the outline view renders.
+public record OutlineNodeDto(
+ Guid Id,
+ Guid ProjectId,
+ Guid? ParentId,
+ OutlineNodeType NodeType,
+ string Title,
+ string? Summary,
+ int SortOrder,
+ Guid? ChapterId,
+ IReadOnlyList Children);
+
+public record CreateOutlineNodeRequest(
+ string Title,
+ OutlineNodeType NodeType = OutlineNodeType.Beat,
+ Guid? ParentId = null,
+ string? Summary = null,
+ int? SortOrder = null,
+ Guid? ChapterId = null);
+
+public record UpdateOutlineNodeRequest(
+ string? Title = null,
+ OutlineNodeType? NodeType = null,
+ string? Summary = null,
+ int? SortOrder = null,
+ Guid? ChapterId = null);
+
+/// Moves a node to a new parent and/or position. A null means root level.
+public record MoveOutlineNodeRequest(Guid? ParentId, int SortOrder);
diff --git a/src/NovelSoftware.Application/Dtos/ProjectDtos.cs b/src/NovelSoftware.Application/Dtos/ProjectDtos.cs
new file mode 100644
index 0000000..ebe4d59
--- /dev/null
+++ b/src/NovelSoftware.Application/Dtos/ProjectDtos.cs
@@ -0,0 +1,56 @@
+using NovelSoftware.Domain.Entities;
+
+namespace NovelSoftware.Application.Dtos;
+
+public record ProjectSummaryDto(
+ Guid Id,
+ string Title,
+ string? Author,
+ string? Genre,
+ string? Logline,
+ int? TargetWordCount,
+ int CharacterCount,
+ int ChapterCount,
+ int WordCount,
+ DateTimeOffset UpdatedAt);
+
+public record ProjectDto(
+ Guid Id,
+ string Title,
+ string? Author,
+ string? Genre,
+ string? Logline,
+ string? Synopsis,
+ string? Notes,
+ int? TargetWordCount,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset UpdatedAt);
+
+public record CreateProjectRequest(
+ string Title,
+ string? Author = null,
+ string? Genre = null,
+ string? Logline = null,
+ string? Synopsis = null,
+ string? Notes = null,
+ int? TargetWordCount = null);
+
+///
+/// Patch-style update: every field is optional and null means "leave alone".
+/// Clearing a field is done by sending an empty string.
+///
+public record UpdateProjectRequest(
+ string? Title = null,
+ string? Author = null,
+ string? Genre = null,
+ string? Logline = null,
+ string? Synopsis = null,
+ string? Notes = null,
+ int? TargetWordCount = null);
+
+public static class ProjectMapping
+{
+ public static ProjectDto ToDto(this Project p) => new(
+ p.Id, p.Title, p.Author, p.Genre, p.Logline, p.Synopsis, p.Notes,
+ p.TargetWordCount, p.CreatedAt, p.UpdatedAt);
+}
diff --git a/src/NovelSoftware.Application/Dtos/SceneDtos.cs b/src/NovelSoftware.Application/Dtos/SceneDtos.cs
new file mode 100644
index 0000000..6aa873c
--- /dev/null
+++ b/src/NovelSoftware.Application/Dtos/SceneDtos.cs
@@ -0,0 +1,63 @@
+using NovelSoftware.Domain;
+using NovelSoftware.Domain.Entities;
+
+namespace NovelSoftware.Application.Dtos;
+
+public record SceneDto(
+ Guid Id,
+ Guid ChapterId,
+ int SortOrder,
+ string Title,
+ string? Summary,
+ string? Goal,
+ string? Conflict,
+ string? Outcome,
+ Guid? PovCharacterId,
+ string? PovCharacterName,
+ string? Location,
+ string? Prose,
+ int WordCount,
+ DraftStatus Status,
+ DateTimeOffset UpdatedAt);
+
+public record CreateSceneRequest(
+ string Title,
+ int? SortOrder = null,
+ string? Summary = null,
+ string? Goal = null,
+ string? Conflict = null,
+ string? Outcome = null,
+ Guid? PovCharacterId = null,
+ string? Location = null,
+ string? Prose = null,
+ DraftStatus Status = DraftStatus.Planned);
+
+public record UpdateSceneRequest(
+ string? Title = null,
+ int? SortOrder = null,
+ string? Summary = null,
+ string? Goal = null,
+ string? Conflict = null,
+ string? Outcome = null,
+ Guid? PovCharacterId = null,
+ string? Location = null,
+ string? Prose = null,
+ DraftStatus? Status = null);
+
+public static class SceneMapping
+{
+ public static SceneDto ToDto(this Scene s) => new(
+ s.Id, s.ChapterId, s.SortOrder, s.Title, s.Summary,
+ s.Goal, s.Conflict, s.Outcome,
+ s.PovCharacterId, s.PovCharacter?.Name, s.Location,
+ s.Prose, s.WordCount, s.Status, s.UpdatedAt);
+
+ ///
+ /// Whitespace-delimited word count. Good enough for progress tracking, and it costs
+ /// nothing to recompute on every save.
+ ///
+ public static int CountWords(string? prose) =>
+ string.IsNullOrWhiteSpace(prose)
+ ? 0
+ : prose.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
+}
diff --git a/src/NovelSoftware.Application/INovelDbContext.cs b/src/NovelSoftware.Application/INovelDbContext.cs
new file mode 100644
index 0000000..b2266d0
--- /dev/null
+++ b/src/NovelSoftware.Application/INovelDbContext.cs
@@ -0,0 +1,22 @@
+using Microsoft.EntityFrameworkCore;
+using NovelSoftware.Domain.Entities;
+
+namespace NovelSoftware.Application;
+
+///
+/// The persistence surface the application services depend on. Infrastructure supplies
+/// the EF Core implementation; tests can point it at an in-memory SQLite connection.
+///
+public interface INovelDbContext
+{
+ DbSet Projects { get; }
+ DbSet Characters { get; }
+ DbSet CharacterRelationships { get; }
+ DbSet OutlineNodes { get; }
+ DbSet Chapters { get; }
+ DbSet Scenes { get; }
+ DbSet Conversations { get; }
+ DbSet AgentMessages { get; }
+
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/NovelSoftware.Application/NotFoundException.cs b/src/NovelSoftware.Application/NotFoundException.cs
new file mode 100644
index 0000000..3130b07
--- /dev/null
+++ b/src/NovelSoftware.Application/NotFoundException.cs
@@ -0,0 +1,12 @@
+namespace NovelSoftware.Application;
+
+///
+/// Thrown when a service is asked for an entity that does not exist. The API translates
+/// this into a 404 so services never have to know about HTTP.
+///
+public class NotFoundException(string entity, Guid id)
+ : Exception($"{entity} '{id}' was not found.")
+{
+ public string Entity { get; } = entity;
+ public Guid Id { get; } = id;
+}
diff --git a/src/NovelSoftware.Application/NovelSoftware.Application.csproj b/src/NovelSoftware.Application/NovelSoftware.Application.csproj
new file mode 100644
index 0000000..f614db7
--- /dev/null
+++ b/src/NovelSoftware.Application/NovelSoftware.Application.csproj
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/src/NovelSoftware.Application/Services/ChapterService.cs b/src/NovelSoftware.Application/Services/ChapterService.cs
new file mode 100644
index 0000000..90b0191
--- /dev/null
+++ b/src/NovelSoftware.Application/Services/ChapterService.cs
@@ -0,0 +1,90 @@
+using Microsoft.EntityFrameworkCore;
+using NovelSoftware.Application.Dtos;
+using NovelSoftware.Domain.Entities;
+
+namespace NovelSoftware.Application.Services;
+
+public class ChapterService(INovelDbContext db)
+{
+ public async Task> ListAsync(Guid projectId, CancellationToken ct = default)
+ {
+ var chapters = await db.Chapters
+ .Include(c => c.PovCharacter)
+ .Include(c => c.Scenes)
+ .Where(c => c.ProjectId == projectId)
+ .OrderBy(c => c.Number)
+ .ToListAsync(ct);
+
+ return [.. chapters.Select(c => c.ToSummaryDto())];
+ }
+
+ public async Task GetAsync(Guid id, CancellationToken ct = default) =>
+ (await FindAsync(id, ct)).ToDto();
+
+ public async Task CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default)
+ {
+ if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
+ {
+ throw new NotFoundException(nameof(Project), projectId);
+ }
+
+ var chapter = new Chapter
+ {
+ ProjectId = projectId,
+ Title = request.Title,
+ Number = request.Number ?? await NextChapterNumberAsync(projectId, ct),
+ Summary = request.Summary,
+ PovCharacterId = request.PovCharacterId,
+ Setting = request.Setting,
+ Notes = request.Notes,
+ Status = request.Status,
+ TargetWordCount = request.TargetWordCount
+ };
+
+ db.Chapters.Add(chapter);
+ await db.SaveChangesAsync(ct);
+ return (await FindAsync(chapter.Id, ct)).ToDto();
+ }
+
+ public async Task UpdateAsync(Guid id, UpdateChapterRequest request, CancellationToken ct = default)
+ {
+ var chapter = await FindAsync(id, ct);
+
+ chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
+ chapter.Number = request.Number ?? chapter.Number;
+ chapter.Summary = Patch.Apply(chapter.Summary, request.Summary);
+ chapter.PovCharacterId = request.PovCharacterId ?? chapter.PovCharacterId;
+ chapter.Setting = Patch.Apply(chapter.Setting, request.Setting);
+ chapter.Notes = Patch.Apply(chapter.Notes, request.Notes);
+ chapter.Status = request.Status ?? chapter.Status;
+ chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount;
+ chapter.UpdatedAt = DateTimeOffset.UtcNow;
+
+ await db.SaveChangesAsync(ct);
+ return (await FindAsync(id, ct)).ToDto();
+ }
+
+ public async Task DeleteAsync(Guid id, CancellationToken ct = default)
+ {
+ var chapter = await FindAsync(id, ct);
+ db.Chapters.Remove(chapter);
+ await db.SaveChangesAsync(ct);
+ }
+
+ private async Task NextChapterNumberAsync(Guid projectId, CancellationToken ct)
+ {
+ var max = await db.Chapters
+ .Where(c => c.ProjectId == projectId)
+ .MaxAsync(c => (int?)c.Number, ct);
+
+ return (max ?? 0) + 1;
+ }
+
+ private async Task FindAsync(Guid id, CancellationToken ct) =>
+ await db.Chapters
+ .Include(c => c.PovCharacter)
+ .Include(c => c.Scenes)
+ .ThenInclude(s => s.PovCharacter)
+ .FirstOrDefaultAsync(c => c.Id == id, ct)
+ ?? throw new NotFoundException(nameof(Chapter), id);
+}
diff --git a/src/NovelSoftware.Application/Services/CharacterService.cs b/src/NovelSoftware.Application/Services/CharacterService.cs
new file mode 100644
index 0000000..5b68c57
--- /dev/null
+++ b/src/NovelSoftware.Application/Services/CharacterService.cs
@@ -0,0 +1,136 @@
+using Microsoft.EntityFrameworkCore;
+using NovelSoftware.Application.Dtos;
+using NovelSoftware.Domain.Entities;
+
+namespace NovelSoftware.Application.Services;
+
+public class CharacterService(INovelDbContext db)
+{
+ public async Task> ListAsync(Guid projectId, CancellationToken ct = default)
+ {
+ var characters = await Query()
+ .Where(c => c.ProjectId == projectId)
+ .OrderBy(c => c.Role)
+ .ThenBy(c => c.Name)
+ .ToListAsync(ct);
+
+ return [.. characters.Select(c => c.ToDto())];
+ }
+
+ public async Task GetAsync(Guid id, CancellationToken ct = default) =>
+ (await FindAsync(id, ct)).ToDto();
+
+ public async Task CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default)
+ {
+ await EnsureProjectExists(projectId, ct);
+
+ var character = new Character
+ {
+ ProjectId = projectId,
+ Name = request.Name,
+ Role = request.Role,
+ Age = request.Age,
+ Pronouns = request.Pronouns,
+ Occupation = request.Occupation,
+ Appearance = request.Appearance,
+ Personality = request.Personality,
+ Backstory = request.Backstory,
+ Want = request.Want,
+ Need = request.Need,
+ InternalConflict = request.InternalConflict,
+ ExternalConflict = request.ExternalConflict,
+ ArcSummary = request.ArcSummary,
+ Voice = request.Voice,
+ Notes = request.Notes
+ };
+
+ db.Characters.Add(character);
+ await db.SaveChangesAsync(ct);
+ return character.ToDto();
+ }
+
+ public async Task UpdateAsync(Guid id, UpdateCharacterRequest request, CancellationToken ct = default)
+ {
+ var character = await FindAsync(id, ct);
+
+ character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name;
+ character.Role = request.Role ?? character.Role;
+ character.Age = Patch.Apply(character.Age, request.Age);
+ character.Pronouns = Patch.Apply(character.Pronouns, request.Pronouns);
+ character.Occupation = Patch.Apply(character.Occupation, request.Occupation);
+ character.Appearance = Patch.Apply(character.Appearance, request.Appearance);
+ character.Personality = Patch.Apply(character.Personality, request.Personality);
+ character.Backstory = Patch.Apply(character.Backstory, request.Backstory);
+ character.Want = Patch.Apply(character.Want, request.Want);
+ character.Need = Patch.Apply(character.Need, request.Need);
+ character.InternalConflict = Patch.Apply(character.InternalConflict, request.InternalConflict);
+ character.ExternalConflict = Patch.Apply(character.ExternalConflict, request.ExternalConflict);
+ character.ArcSummary = Patch.Apply(character.ArcSummary, request.ArcSummary);
+ character.Voice = Patch.Apply(character.Voice, request.Voice);
+ character.Notes = Patch.Apply(character.Notes, request.Notes);
+ character.UpdatedAt = DateTimeOffset.UtcNow;
+
+ await db.SaveChangesAsync(ct);
+ return character.ToDto();
+ }
+
+ public async Task DeleteAsync(Guid id, CancellationToken ct = default)
+ {
+ var character = await FindAsync(id, ct);
+ db.Characters.Remove(character);
+ await db.SaveChangesAsync(ct);
+ }
+
+ public async Task AddRelationshipAsync(
+ Guid characterId, CreateRelationshipRequest request, CancellationToken ct = default)
+ {
+ var character = await FindAsync(characterId, ct);
+
+ var related = await db.Characters
+ .FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct)
+ ?? throw new NotFoundException(nameof(Character), request.RelatedCharacterId);
+
+ if (related.ProjectId != character.ProjectId)
+ {
+ throw new InvalidOperationException("Characters must belong to the same project to be related.");
+ }
+
+ db.CharacterRelationships.Add(new CharacterRelationship
+ {
+ CharacterId = characterId,
+ RelatedCharacterId = request.RelatedCharacterId,
+ RelationshipType = request.RelationshipType,
+ Description = request.Description
+ });
+
+ await db.SaveChangesAsync(ct);
+ return (await FindAsync(characterId, ct)).ToDto();
+ }
+
+ public async Task RemoveRelationshipAsync(Guid relationshipId, CancellationToken ct = default)
+ {
+ var relationship = await db.CharacterRelationships
+ .FirstOrDefaultAsync(r => r.Id == relationshipId, ct)
+ ?? throw new NotFoundException(nameof(CharacterRelationship), relationshipId);
+
+ db.CharacterRelationships.Remove(relationship);
+ await db.SaveChangesAsync(ct);
+ }
+
+ private IQueryable Query() =>
+ db.Characters
+ .Include(c => c.Relationships)
+ .ThenInclude(r => r.RelatedCharacter);
+
+ private async Task FindAsync(Guid id, CancellationToken ct) =>
+ await Query().FirstOrDefaultAsync(c => c.Id == id, ct)
+ ?? throw new NotFoundException(nameof(Character), id);
+
+ private async Task EnsureProjectExists(Guid projectId, CancellationToken ct)
+ {
+ if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
+ {
+ throw new NotFoundException(nameof(Project), projectId);
+ }
+ }
+}
diff --git a/src/NovelSoftware.Application/Services/OutlineService.cs b/src/NovelSoftware.Application/Services/OutlineService.cs
new file mode 100644
index 0000000..3f3a5b6
--- /dev/null
+++ b/src/NovelSoftware.Application/Services/OutlineService.cs
@@ -0,0 +1,166 @@
+using Microsoft.EntityFrameworkCore;
+using NovelSoftware.Application.Dtos;
+using NovelSoftware.Domain.Entities;
+
+namespace NovelSoftware.Application.Services;
+
+public class OutlineService(INovelDbContext db)
+{
+ /// Returns the project's outline as a tree of root nodes with children inlined.
+ public async Task> GetTreeAsync(Guid projectId, CancellationToken ct = default)
+ {
+ var nodes = await db.OutlineNodes
+ .Where(n => n.ProjectId == projectId)
+ .ToListAsync(ct);
+
+ return BuildTree(nodes, parentId: null);
+ }
+
+ public async Task GetAsync(Guid id, CancellationToken ct = default)
+ {
+ var node = await FindAsync(id, ct);
+ var siblings = await db.OutlineNodes.Where(n => n.ProjectId == node.ProjectId).ToListAsync(ct);
+ return BuildNode(node, siblings);
+ }
+
+ public async Task CreateAsync(
+ Guid projectId, CreateOutlineNodeRequest request, CancellationToken ct = default)
+ {
+ if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
+ {
+ throw new NotFoundException(nameof(Project), projectId);
+ }
+
+ if (request.ParentId is { } parentId && !await db.OutlineNodes.AnyAsync(n => n.Id == parentId, ct))
+ {
+ throw new NotFoundException(nameof(OutlineNode), parentId);
+ }
+
+ var node = new OutlineNode
+ {
+ ProjectId = projectId,
+ ParentId = request.ParentId,
+ NodeType = request.NodeType,
+ Title = request.Title,
+ Summary = request.Summary,
+ ChapterId = request.ChapterId,
+ SortOrder = request.SortOrder ?? await NextSortOrderAsync(projectId, request.ParentId, ct)
+ };
+
+ db.OutlineNodes.Add(node);
+ await db.SaveChangesAsync(ct);
+ return BuildNode(node, []);
+ }
+
+ public async Task UpdateAsync(
+ Guid id, UpdateOutlineNodeRequest request, CancellationToken ct = default)
+ {
+ var node = await FindAsync(id, ct);
+
+ node.Title = Patch.Apply(node.Title, request.Title) ?? node.Title;
+ node.NodeType = request.NodeType ?? node.NodeType;
+ node.Summary = Patch.Apply(node.Summary, request.Summary);
+ node.SortOrder = request.SortOrder ?? node.SortOrder;
+ node.ChapterId = request.ChapterId ?? node.ChapterId;
+ node.UpdatedAt = DateTimeOffset.UtcNow;
+
+ await db.SaveChangesAsync(ct);
+ return await GetAsync(id, ct);
+ }
+
+ ///
+ /// Reparents a node. Refuses to move a node under one of its own descendants, which
+ /// would detach the subtree from the tree entirely.
+ ///
+ public async Task MoveAsync(Guid id, MoveOutlineNodeRequest request, CancellationToken ct = default)
+ {
+ var node = await FindAsync(id, ct);
+
+ if (request.ParentId == id)
+ {
+ throw new InvalidOperationException("An outline node cannot be its own parent.");
+ }
+
+ if (request.ParentId is { } newParentId)
+ {
+ var allNodes = await db.OutlineNodes
+ .Where(n => n.ProjectId == node.ProjectId)
+ .ToListAsync(ct);
+
+ if (!allNodes.Any(n => n.Id == newParentId))
+ {
+ throw new NotFoundException(nameof(OutlineNode), newParentId);
+ }
+
+ if (DescendantIds(allNodes, id).Contains(newParentId))
+ {
+ throw new InvalidOperationException("An outline node cannot be moved beneath its own descendant.");
+ }
+ }
+
+ node.ParentId = request.ParentId;
+ node.SortOrder = request.SortOrder;
+ node.UpdatedAt = DateTimeOffset.UtcNow;
+
+ await db.SaveChangesAsync(ct);
+ return await GetAsync(id, ct);
+ }
+
+ /// Deletes a node and its entire subtree.
+ public async Task DeleteAsync(Guid id, CancellationToken ct = default)
+ {
+ var node = await FindAsync(id, ct);
+
+ var allNodes = await db.OutlineNodes
+ .Where(n => n.ProjectId == node.ProjectId)
+ .ToListAsync(ct);
+
+ var doomed = DescendantIds(allNodes, id).Append(id).ToHashSet();
+ db.OutlineNodes.RemoveRange(allNodes.Where(n => doomed.Contains(n.Id)));
+
+ await db.SaveChangesAsync(ct);
+ }
+
+ private async Task NextSortOrderAsync(Guid projectId, Guid? parentId, CancellationToken ct)
+ {
+ var max = await db.OutlineNodes
+ .Where(n => n.ProjectId == projectId && n.ParentId == parentId)
+ .MaxAsync(n => (int?)n.SortOrder, ct);
+
+ return (max ?? 0) + 1;
+ }
+
+ private async Task FindAsync(Guid id, CancellationToken ct) =>
+ await db.OutlineNodes.FirstOrDefaultAsync(n => n.Id == id, ct)
+ ?? throw new NotFoundException(nameof(OutlineNode), id);
+
+ private static IReadOnlyList BuildTree(List all, Guid? parentId) =>
+ [
+ .. all
+ .Where(n => n.ParentId == parentId)
+ .OrderBy(n => n.SortOrder)
+ .ThenBy(n => n.Title)
+ .Select(n => new OutlineNodeDto(
+ n.Id, n.ProjectId, n.ParentId, n.NodeType, n.Title, n.Summary,
+ n.SortOrder, n.ChapterId, BuildTree(all, n.Id)))
+ ];
+
+ private static OutlineNodeDto BuildNode(OutlineNode node, List all) => new(
+ node.Id, node.ProjectId, node.ParentId, node.NodeType, node.Title, node.Summary,
+ node.SortOrder, node.ChapterId, BuildTree(all, node.Id));
+
+ private static IEnumerable DescendantIds(List all, Guid rootId)
+ {
+ var frontier = new Queue([rootId]);
+
+ while (frontier.Count > 0)
+ {
+ var current = frontier.Dequeue();
+ foreach (var child in all.Where(n => n.ParentId == current))
+ {
+ yield return child.Id;
+ frontier.Enqueue(child.Id);
+ }
+ }
+ }
+}
diff --git a/src/NovelSoftware.Application/Services/ProjectService.cs b/src/NovelSoftware.Application/Services/ProjectService.cs
new file mode 100644
index 0000000..213dc71
--- /dev/null
+++ b/src/NovelSoftware.Application/Services/ProjectService.cs
@@ -0,0 +1,87 @@
+using Microsoft.EntityFrameworkCore;
+using NovelSoftware.Application.Dtos;
+using NovelSoftware.Domain.Entities;
+
+namespace NovelSoftware.Application.Services;
+
+public class ProjectService(INovelDbContext db)
+{
+ public async Task> ListAsync(CancellationToken ct = default) =>
+ await db.Projects
+ .OrderByDescending(p => p.UpdatedAt)
+ .Select(p => new ProjectSummaryDto(
+ p.Id,
+ p.Title,
+ p.Author,
+ p.Genre,
+ p.Logline,
+ p.TargetWordCount,
+ p.Characters.Count,
+ p.Chapters.Count,
+ p.Chapters.SelectMany(c => c.Scenes).Sum(s => (int?)s.WordCount) ?? 0,
+ p.UpdatedAt))
+ .ToListAsync(ct);
+
+ public async Task GetAsync(Guid id, CancellationToken ct = default) =>
+ (await FindAsync(id, ct)).ToDto();
+
+ public async Task CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
+ {
+ var project = new Project
+ {
+ Title = request.Title,
+ Author = request.Author,
+ Genre = request.Genre,
+ Logline = request.Logline,
+ Synopsis = request.Synopsis,
+ Notes = request.Notes,
+ TargetWordCount = request.TargetWordCount
+ };
+
+ db.Projects.Add(project);
+ await db.SaveChangesAsync(ct);
+ return project.ToDto();
+ }
+
+ public async Task UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
+ {
+ var project = await FindAsync(id, ct);
+
+ project.Title = Patch.Apply(project.Title, request.Title) ?? project.Title;
+ project.Author = Patch.Apply(project.Author, request.Author);
+ project.Genre = Patch.Apply(project.Genre, request.Genre);
+ project.Logline = Patch.Apply(project.Logline, request.Logline);
+ project.Synopsis = Patch.Apply(project.Synopsis, request.Synopsis);
+ project.Notes = Patch.Apply(project.Notes, request.Notes);
+ project.TargetWordCount = request.TargetWordCount ?? project.TargetWordCount;
+ project.UpdatedAt = DateTimeOffset.UtcNow;
+
+ await db.SaveChangesAsync(ct);
+ return project.ToDto();
+ }
+
+ public async Task DeleteAsync(Guid id, CancellationToken ct = default)
+ {
+ var project = await FindAsync(id, ct);
+ db.Projects.Remove(project);
+ await db.SaveChangesAsync(ct);
+ }
+
+ private async Task FindAsync(Guid id, CancellationToken ct) =>
+ await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct)
+ ?? throw new NotFoundException(nameof(Project), id);
+}
+
+///
+/// Patch semantics shared by every update endpoint: a null value leaves the field
+/// untouched, an empty string clears it.
+///
+internal static class Patch
+{
+ public static string? Apply(string? current, string? incoming) => incoming switch
+ {
+ null => current,
+ "" => null,
+ _ => incoming
+ };
+}
diff --git a/src/NovelSoftware.Application/Services/SceneService.cs b/src/NovelSoftware.Application/Services/SceneService.cs
new file mode 100644
index 0000000..3b49ba8
--- /dev/null
+++ b/src/NovelSoftware.Application/Services/SceneService.cs
@@ -0,0 +1,97 @@
+using Microsoft.EntityFrameworkCore;
+using NovelSoftware.Application.Dtos;
+using NovelSoftware.Domain.Entities;
+
+namespace NovelSoftware.Application.Services;
+
+public class SceneService(INovelDbContext db)
+{
+ public async Task> ListAsync(Guid chapterId, CancellationToken ct = default)
+ {
+ var scenes = await Query()
+ .Where(s => s.ChapterId == chapterId)
+ .OrderBy(s => s.SortOrder)
+ .ToListAsync(ct);
+
+ return [.. scenes.Select(s => s.ToDto())];
+ }
+
+ public async Task GetAsync(Guid id, CancellationToken ct = default) =>
+ (await FindAsync(id, ct)).ToDto();
+
+ public async Task CreateAsync(Guid chapterId, CreateSceneRequest request, CancellationToken ct = default)
+ {
+ if (!await db.Chapters.AnyAsync(c => c.Id == chapterId, ct))
+ {
+ throw new NotFoundException(nameof(Chapter), chapterId);
+ }
+
+ var scene = new Scene
+ {
+ ChapterId = chapterId,
+ Title = request.Title,
+ SortOrder = request.SortOrder ?? await NextSortOrderAsync(chapterId, ct),
+ Summary = request.Summary,
+ Goal = request.Goal,
+ Conflict = request.Conflict,
+ Outcome = request.Outcome,
+ PovCharacterId = request.PovCharacterId,
+ Location = request.Location,
+ Prose = request.Prose,
+ WordCount = SceneMapping.CountWords(request.Prose),
+ Status = request.Status
+ };
+
+ db.Scenes.Add(scene);
+ await db.SaveChangesAsync(ct);
+ return (await FindAsync(scene.Id, ct)).ToDto();
+ }
+
+ public async Task UpdateAsync(Guid id, UpdateSceneRequest request, CancellationToken ct = default)
+ {
+ var scene = await FindAsync(id, ct);
+
+ scene.Title = Patch.Apply(scene.Title, request.Title) ?? scene.Title;
+ scene.SortOrder = request.SortOrder ?? scene.SortOrder;
+ scene.Summary = Patch.Apply(scene.Summary, request.Summary);
+ scene.Goal = Patch.Apply(scene.Goal, request.Goal);
+ scene.Conflict = Patch.Apply(scene.Conflict, request.Conflict);
+ scene.Outcome = Patch.Apply(scene.Outcome, request.Outcome);
+ scene.PovCharacterId = request.PovCharacterId ?? scene.PovCharacterId;
+ scene.Location = Patch.Apply(scene.Location, request.Location);
+ scene.Status = request.Status ?? scene.Status;
+
+ if (request.Prose is not null)
+ {
+ scene.Prose = Patch.Apply(scene.Prose, request.Prose);
+ scene.WordCount = SceneMapping.CountWords(scene.Prose);
+ }
+
+ scene.UpdatedAt = DateTimeOffset.UtcNow;
+
+ await db.SaveChangesAsync(ct);
+ return (await FindAsync(id, ct)).ToDto();
+ }
+
+ public async Task DeleteAsync(Guid id, CancellationToken ct = default)
+ {
+ var scene = await FindAsync(id, ct);
+ db.Scenes.Remove(scene);
+ await db.SaveChangesAsync(ct);
+ }
+
+ private async Task NextSortOrderAsync(Guid chapterId, CancellationToken ct)
+ {
+ var max = await db.Scenes
+ .Where(s => s.ChapterId == chapterId)
+ .MaxAsync(s => (int?)s.SortOrder, ct);
+
+ return (max ?? 0) + 1;
+ }
+
+ private IQueryable Query() => db.Scenes.Include(s => s.PovCharacter);
+
+ private async Task FindAsync(Guid id, CancellationToken ct) =>
+ await Query().FirstOrDefaultAsync(s => s.Id == id, ct)
+ ?? throw new NotFoundException(nameof(Scene), id);
+}
diff --git a/src/NovelSoftware.Domain/Entities/AgentConversation.cs b/src/NovelSoftware.Domain/Entities/AgentConversation.cs
new file mode 100644
index 0000000..d4c7070
--- /dev/null
+++ b/src/NovelSoftware.Domain/Entities/AgentConversation.cs
@@ -0,0 +1,47 @@
+namespace NovelSoftware.Domain.Entities;
+
+/// A chat thread between the writer and the embedded agent, scoped to one project.
+public class AgentConversation
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public Guid ProjectId { get; set; }
+ public Project? Project { get; set; }
+
+ public string Title { get; set; } = "New conversation";
+
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ public List Messages { get; set; } = [];
+}
+
+///
+/// One turn in an agent conversation. Assistant turns may carry a record of the tools
+/// the agent called, so the UI can show what it changed and the next request can replay
+/// the turn back to the model.
+///
+public class AgentMessage
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public Guid ConversationId { get; set; }
+ public AgentConversation? Conversation { get; set; }
+
+ public AgentRole Role { get; set; }
+
+ ///
+ /// Position in the conversation, 0-based. Timestamps are not enough to order a
+ /// transcript: a fast turn can produce two messages inside the same tick.
+ ///
+ public int Sequence { get; set; }
+
+ /// The visible text of the turn.
+ public string Content { get; set; } = string.Empty;
+
+ ///
+ /// JSON array of { name, input, result } objects describing tool calls made
+ /// during this turn. Null on user turns and on assistant turns that used no tools.
+ ///
+ public string? ToolCallsJson { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
diff --git a/src/NovelSoftware.Domain/Entities/Chapter.cs b/src/NovelSoftware.Domain/Entities/Chapter.cs
new file mode 100644
index 0000000..e31108c
--- /dev/null
+++ b/src/NovelSoftware.Domain/Entities/Chapter.cs
@@ -0,0 +1,30 @@
+namespace NovelSoftware.Domain.Entities;
+
+/// A chapter: an ordered container of scenes plus its own planning fields.
+public class Chapter
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public Guid ProjectId { get; set; }
+ public Project? Project { get; set; }
+
+ /// Position in the manuscript, 1-based.
+ public int Number { get; set; }
+
+ public string Title { get; set; } = string.Empty;
+ public string? Summary { get; set; }
+
+ /// Whose head we are in for this chapter.
+ public Guid? PovCharacterId { get; set; }
+ public Character? PovCharacter { get; set; }
+
+ public string? Setting { get; set; }
+ public string? Notes { get; set; }
+
+ public DraftStatus Status { get; set; } = DraftStatus.Planned;
+ public int? TargetWordCount { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ public List Scenes { get; set; } = [];
+}
diff --git a/src/NovelSoftware.Domain/Entities/Character.cs b/src/NovelSoftware.Domain/Entities/Character.cs
new file mode 100644
index 0000000..709e8b2
--- /dev/null
+++ b/src/NovelSoftware.Domain/Entities/Character.cs
@@ -0,0 +1,62 @@
+namespace NovelSoftware.Domain.Entities;
+
+///
+/// A character dossier. Every field beyond is optional so a writer can
+/// start with a name and fill the sheet in as the character comes into focus.
+///
+public class Character
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public Guid ProjectId { get; set; }
+ public Project? Project { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+ public CharacterRole Role { get; set; } = CharacterRole.Supporting;
+
+ public string? Age { get; set; }
+ public string? Pronouns { get; set; }
+ public string? Occupation { get; set; }
+
+ public string? Appearance { get; set; }
+ public string? Personality { get; set; }
+ public string? Backstory { get; set; }
+
+ /// What the character consciously wants.
+ public string? Want { get; set; }
+
+ /// What the character actually needs — usually at odds with .
+ public string? Need { get; set; }
+
+ public string? InternalConflict { get; set; }
+ public string? ExternalConflict { get; set; }
+
+ /// How the character changes over the course of the book.
+ public string? ArcSummary { get; set; }
+
+ /// Speech patterns, verbal tics, register — anything that makes dialogue sound like them.
+ public string? Voice { get; set; }
+
+ public string? Notes { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ public List Relationships { get; set; } = [];
+}
+
+/// A directed relationship from one character to another.
+public class CharacterRelationship
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ public Guid CharacterId { get; set; }
+ public Character? Character { get; set; }
+
+ public Guid RelatedCharacterId { get; set; }
+ public Character? RelatedCharacter { get; set; }
+
+ /// e.g. "sister", "rival", "former mentor".
+ public string RelationshipType { get; set; } = string.Empty;
+
+ public string? Description { get; set; }
+}
diff --git a/src/NovelSoftware.Domain/Entities/OutlineNode.cs b/src/NovelSoftware.Domain/Entities/OutlineNode.cs
new file mode 100644
index 0000000..a5200f7
--- /dev/null
+++ b/src/NovelSoftware.Domain/Entities/OutlineNode.cs
@@ -0,0 +1,31 @@
+namespace NovelSoftware.Domain.Entities;
+
+///
+/// A node in the project's outline tree. Nodes are self-nesting, so the same structure
+/// serves a three-act skeleton, a beat sheet, or a loose pile of scene ideas.
+///
+public class OutlineNode
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public Guid ProjectId { get; set; }
+ public Project? Project { get; set; }
+
+ public Guid? ParentId { get; set; }
+ public OutlineNode? Parent { get; set; }
+ public List Children { get; set; } = [];
+
+ public OutlineNodeType NodeType { get; set; } = OutlineNodeType.Beat;
+
+ public string Title { get; set; } = string.Empty;
+ public string? Summary { get; set; }
+
+ /// Position among siblings. Gaps are allowed; ordering is by this value then title.
+ public int SortOrder { get; set; }
+
+ /// Optional link to the chapter that realises this outline node.
+ public Guid? ChapterId { get; set; }
+ public Chapter? Chapter { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
diff --git a/src/NovelSoftware.Domain/Entities/Project.cs b/src/NovelSoftware.Domain/Entities/Project.cs
new file mode 100644
index 0000000..8ec6a55
--- /dev/null
+++ b/src/NovelSoftware.Domain/Entities/Project.cs
@@ -0,0 +1,30 @@
+namespace NovelSoftware.Domain.Entities;
+
+/// A single novel and everything that belongs to it.
+public class Project
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ public string Title { get; set; } = string.Empty;
+ public string? Author { get; set; }
+ public string? Genre { get; set; }
+
+ /// One-sentence pitch.
+ public string? Logline { get; set; }
+
+ /// Paragraph-length summary of the whole book.
+ public string? Synopsis { get; set; }
+
+ /// Free-form notes on theme, tone, comparable titles, etc.
+ public string? Notes { get; set; }
+
+ public int? TargetWordCount { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ public List Characters { get; set; } = [];
+ public List Chapters { get; set; } = [];
+ public List OutlineNodes { get; set; } = [];
+ public List Conversations { get; set; } = [];
+}
diff --git a/src/NovelSoftware.Domain/Entities/Scene.cs b/src/NovelSoftware.Domain/Entities/Scene.cs
new file mode 100644
index 0000000..9acaba9
--- /dev/null
+++ b/src/NovelSoftware.Domain/Entities/Scene.cs
@@ -0,0 +1,41 @@
+namespace NovelSoftware.Domain.Entities;
+
+///
+/// A scene inside a chapter. The goal/conflict/outcome trio is the unit the agent
+/// works with when turning an outline into prose.
+///
+public class Scene
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public Guid ChapterId { get; set; }
+ public Chapter? Chapter { get; set; }
+
+ /// Position within the chapter, 1-based.
+ public int SortOrder { get; set; }
+
+ public string Title { get; set; } = string.Empty;
+ public string? Summary { get; set; }
+
+ /// What the POV character is trying to achieve.
+ public string? Goal { get; set; }
+
+ /// What stands in the way.
+ public string? Conflict { get; set; }
+
+ /// How it lands — and what it costs.
+ public string? Outcome { get; set; }
+
+ public Guid? PovCharacterId { get; set; }
+ public Character? PovCharacter { get; set; }
+
+ public string? Location { get; set; }
+
+ /// The drafted prose, if any.
+ public string? Prose { get; set; }
+
+ public int WordCount { get; set; }
+ public DraftStatus Status { get; set; } = DraftStatus.Planned;
+
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
diff --git a/src/NovelSoftware.Domain/Enums.cs b/src/NovelSoftware.Domain/Enums.cs
new file mode 100644
index 0000000..22f5b5d
--- /dev/null
+++ b/src/NovelSoftware.Domain/Enums.cs
@@ -0,0 +1,45 @@
+namespace NovelSoftware.Domain;
+
+/// The role a character plays in the story.
+public enum CharacterRole
+{
+ Protagonist,
+ Antagonist,
+ Deuteragonist,
+ Supporting,
+ Minor,
+ Mentor,
+ LoveInterest,
+ Foil
+}
+
+///
+/// The kind of node in a project's outline tree. The tree is intentionally loose:
+/// a writer can nest an Act under a Part, or skip straight to Beats.
+///
+public enum OutlineNodeType
+{
+ Part,
+ Act,
+ Sequence,
+ Chapter,
+ Beat,
+ Note
+}
+
+/// How far along a chapter or scene is in the drafting pipeline.
+public enum DraftStatus
+{
+ Planned,
+ Outlined,
+ Drafted,
+ Revised,
+ Final
+}
+
+/// Who produced a message in an agent conversation.
+public enum AgentRole
+{
+ User,
+ Assistant
+}
diff --git a/src/NovelSoftware.Domain/NovelSoftware.Domain.csproj b/src/NovelSoftware.Domain/NovelSoftware.Domain.csproj
new file mode 100644
index 0000000..b760144
--- /dev/null
+++ b/src/NovelSoftware.Domain/NovelSoftware.Domain.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/src/NovelSoftware.Infrastructure/Anthropic/AnthropicAgentModelClient.cs b/src/NovelSoftware.Infrastructure/Anthropic/AnthropicAgentModelClient.cs
new file mode 100644
index 0000000..f106c8a
--- /dev/null
+++ b/src/NovelSoftware.Infrastructure/Anthropic/AnthropicAgentModelClient.cs
@@ -0,0 +1,162 @@
+using System.Text.Json;
+using Anthropic;
+using Anthropic.Models.Messages;
+using Microsoft.Extensions.Options;
+using NovelSoftware.Application;
+using NovelSoftware.Application.Agent;
+
+namespace NovelSoftware.Infrastructure.Anthropic;
+
+///
+/// Talks to the Anthropic Messages API. Translates between the application's
+/// model-agnostic block types and the SDK's request/response shapes; the tool-use loop
+/// itself lives in .
+///
+public class AnthropicAgentModelClient(IOptions options) : IAgentModelClient
+{
+ private readonly AgentOptions _options = options.Value;
+ private AnthropicClient? _client;
+
+ ///
+ /// Built on first use rather than at construction. This type is injected into the
+ /// agent service, which also serves read-only endpoints like listing conversations —
+ /// those should keep working on an install that has not set up a key yet.
+ ///
+ private AnthropicClient Client => _client ??= new AnthropicClient
+ {
+ ApiKey = _options.ApiKey
+ ?? Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")
+ ?? throw new AgentNotConfiguredException(
+ "No Anthropic API key configured. Set the ANTHROPIC_API_KEY environment "
+ + "variable or the Agent:ApiKey setting, then restart the API.")
+ };
+
+ public async Task CompleteAsync(
+ string systemPrompt,
+ IReadOnlyList messages,
+ IReadOnlyList tools,
+ CancellationToken ct = default)
+ {
+ var parameters = new MessageCreateParams
+ {
+ Model = _options.Model,
+ MaxTokens = _options.MaxTokens,
+ System = new List
+ {
+ // The system prompt is stable across a conversation, so cache it: every
+ // turn after the first reads it back at a tenth of the input price.
+ new() { Text = systemPrompt, CacheControl = new CacheControlEphemeral() }
+ },
+ OutputConfig = new OutputConfig { Effort = ParseEffort(_options.Effort) },
+ Tools = [.. tools.Select(ToSdkTool)],
+ Messages = [.. messages.Select(ToSdkMessage)]
+ };
+
+ var response = await Client.Messages.Create(parameters, cancellationToken: ct);
+
+ return new AgentModelResponse(
+ [.. response.Content.Select(FromSdkBlock).OfType()],
+ response.StopReason?.ToString());
+ }
+
+ private static Effort ParseEffort(string effort) => effort.ToLowerInvariant() switch
+ {
+ "low" => Effort.Low,
+ "medium" => Effort.Medium,
+ "high" => Effort.High,
+ "max" => Effort.Max,
+ _ => Effort.High
+ };
+
+ private static ToolUnion ToSdkTool(AgentToolDefinition definition)
+ {
+ var properties = new Dictionary();
+ if (definition.InputSchema.TryGetProperty("properties", out var props)
+ && props.ValueKind == JsonValueKind.Object)
+ {
+ foreach (var property in props.EnumerateObject())
+ {
+ properties[property.Name] = property.Value;
+ }
+ }
+
+ List required = [];
+ if (definition.InputSchema.TryGetProperty("required", out var req)
+ && req.ValueKind == JsonValueKind.Array)
+ {
+ required = [.. req.EnumerateArray().Select(r => r.GetString()!).Where(r => r is not null)];
+ }
+
+ return new Tool
+ {
+ Name = definition.Name,
+ Description = definition.Description,
+ InputSchema = new()
+ {
+ Properties = properties,
+ Required = required
+ }
+ };
+ }
+
+ private static MessageParam ToSdkMessage(AgentChatMessage message) => new()
+ {
+ Role = message.Role == "assistant" ? Role.Assistant : Role.User,
+ Content = new List([.. message.Content.Select(ToSdkBlock)])
+ };
+
+ private static ContentBlockParam ToSdkBlock(AgentContentBlock block) => block switch
+ {
+ AgentTextBlock text => new TextBlockParam { Text = text.Text },
+
+ AgentToolUseBlock toolUse => new ToolUseBlockParam
+ {
+ ID = toolUse.Id,
+ Name = toolUse.Name,
+ Input = ToInputDictionary(toolUse.Input)
+ },
+
+ AgentToolResultBlock result => new ToolResultBlockParam
+ {
+ ToolUseID = result.ToolUseId,
+ Content = result.Content,
+ IsError = result.IsError
+ },
+
+ _ => throw new NotSupportedException($"Unsupported content block: {block.GetType().Name}")
+ };
+
+ private static AgentContentBlock? FromSdkBlock(ContentBlock block)
+ {
+ if (block.TryPickText(out TextBlock? text))
+ {
+ return new AgentTextBlock(text!.Text);
+ }
+
+ if (block.TryPickToolUse(out ToolUseBlock? toolUse))
+ {
+ return new AgentToolUseBlock(
+ toolUse!.ID,
+ toolUse.Name,
+ JsonSerializer.SerializeToElement(toolUse.Input));
+ }
+
+ // Thinking blocks and any future block types carry nothing the loop acts on.
+ return null;
+ }
+
+ private static Dictionary ToInputDictionary(JsonElement input)
+ {
+ var dictionary = new Dictionary();
+
+ if (input.ValueKind == JsonValueKind.Object)
+ {
+ foreach (var property in input.EnumerateObject())
+ {
+ dictionary[property.Name] = property.Value;
+ }
+ }
+
+ return dictionary;
+ }
+}
diff --git a/src/NovelSoftware.Infrastructure/DependencyInjection.cs b/src/NovelSoftware.Infrastructure/DependencyInjection.cs
new file mode 100644
index 0000000..4e82b90
--- /dev/null
+++ b/src/NovelSoftware.Infrastructure/DependencyInjection.cs
@@ -0,0 +1,35 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using NovelSoftware.Application;
+using NovelSoftware.Application.Agent;
+using NovelSoftware.Application.Services;
+using NovelSoftware.Infrastructure.Anthropic;
+using NovelSoftware.Infrastructure.Persistence;
+
+namespace NovelSoftware.Infrastructure;
+
+public static class DependencyInjection
+{
+ public static IServiceCollection AddNovelSoftware(this IServiceCollection services, IConfiguration configuration)
+ {
+ var connectionString = configuration.GetConnectionString("Novel")
+ ?? "Data Source=novel.db";
+
+ services.AddDbContext(options => options.UseSqlite(connectionString));
+ services.AddScoped(sp => sp.GetRequiredService());
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ services.Configure(configuration.GetSection(AgentOptions.SectionName));
+ services.AddScoped();
+
+ return services;
+ }
+}
diff --git a/src/NovelSoftware.Infrastructure/NovelSoftware.Infrastructure.csproj b/src/NovelSoftware.Infrastructure/NovelSoftware.Infrastructure.csproj
new file mode 100644
index 0000000..d3bc0df
--- /dev/null
+++ b/src/NovelSoftware.Infrastructure/NovelSoftware.Infrastructure.csproj
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806023249_InitialSchema.Designer.cs b/src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806023249_InitialSchema.Designer.cs
new file mode 100644
index 0000000..2c8809c
--- /dev/null
+++ b/src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806023249_InitialSchema.Designer.cs
@@ -0,0 +1,532 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using NovelSoftware.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace NovelSoftware.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(NovelDbContext))]
+ [Migration("20260806023249_InitialSchema")]
+ partial class InitialSchema
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
+
+ modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentConversation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("ProjectId")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProjectId");
+
+ b.ToTable("Conversations");
+ });
+
+ modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ConversationId")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("Sequence")
+ .HasColumnType("INTEGER");
+
+ b.Property("ToolCallsJson")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ConversationId", "Sequence")
+ .IsUnique();
+
+ b.ToTable("AgentMessages");
+ });
+
+ modelBuilder.Entity("NovelSoftware.Domain.Entities.Chapter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Notes")
+ .HasColumnType("TEXT");
+
+ b.Property("Number")
+ .HasColumnType("INTEGER");
+
+ b.Property("PovCharacterId")
+ .HasColumnType("TEXT");
+
+ b.Property("ProjectId")
+ .HasColumnType("TEXT");
+
+ b.Property("Setting")
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("Summary")
+ .HasColumnType("TEXT");
+
+ b.Property("TargetWordCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PovCharacterId");
+
+ b.HasIndex("ProjectId", "Number");
+
+ b.ToTable("Chapters");
+ });
+
+ modelBuilder.Entity("NovelSoftware.Domain.Entities.Character", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Age")
+ .HasColumnType("TEXT");
+
+ b.Property("Appearance")
+ .HasColumnType("TEXT");
+
+ b.Property("ArcSummary")
+ .HasColumnType("TEXT");
+
+ b.Property("Backstory")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("ExternalConflict")
+ .HasColumnType("TEXT");
+
+ b.Property("InternalConflict")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("Need")
+ .HasColumnType("TEXT");
+
+ b.Property("Notes")
+ .HasColumnType("TEXT");
+
+ b.Property("Occupation")
+ .HasColumnType("TEXT");
+
+ b.Property("Personality")
+ .HasColumnType("TEXT");
+
+ b.Property("ProjectId")
+ .HasColumnType("TEXT");
+
+ b.Property("Pronouns")
+ .HasColumnType("TEXT");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Voice")
+ .HasColumnType("TEXT");
+
+ b.Property("Want")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProjectId");
+
+ b.ToTable("Characters");
+ });
+
+ modelBuilder.Entity("NovelSoftware.Domain.Entities.CharacterRelationship", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CharacterId")
+ .HasColumnType("TEXT");
+
+ b.Property("Description")
+ .HasColumnType("TEXT");
+
+ b.Property("RelatedCharacterId")
+ .HasColumnType("TEXT");
+
+ b.Property("RelationshipType")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CharacterId");
+
+ b.HasIndex("RelatedCharacterId");
+
+ b.ToTable("CharacterRelationships");
+ });
+
+ modelBuilder.Entity("NovelSoftware.Domain.Entities.OutlineNode", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterId")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("NodeType")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("ParentId")
+ .HasColumnType("TEXT");
+
+ b.Property("ProjectId")
+ .HasColumnType("TEXT");
+
+ b.Property("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property("Summary")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChapterId");
+
+ b.HasIndex("ParentId");
+
+ b.HasIndex("ProjectId", "ParentId", "SortOrder");
+
+ b.ToTable("OutlineNodes");
+ });
+
+ modelBuilder.Entity("NovelSoftware.Domain.Entities.Project", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Author")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Genre")
+ .HasColumnType("TEXT");
+
+ b.Property("Logline")
+ .HasColumnType("TEXT");
+
+ b.Property("Notes")
+ .HasColumnType("TEXT");
+
+ b.Property("Synopsis")
+ .HasColumnType("TEXT");
+
+ b.Property("TargetWordCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("Projects");
+ });
+
+ modelBuilder.Entity("NovelSoftware.Domain.Entities.Scene", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterId")
+ .HasColumnType("TEXT");
+
+ b.Property("Conflict")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Goal")
+ .HasColumnType("TEXT");
+
+ b.Property("Location")
+ .HasColumnType("TEXT");
+
+ b.Property("Outcome")
+ .HasColumnType("TEXT");
+
+ b.Property("PovCharacterId")
+ .HasColumnType("TEXT");
+
+ b.Property("Prose")
+ .HasColumnType("TEXT");
+
+ b.Property("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("Summary")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("WordCount")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PovCharacterId");
+
+ b.HasIndex("ChapterId", "SortOrder");
+
+ b.ToTable("Scenes");
+ });
+
+ modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentConversation", b =>
+ {
+ b.HasOne("NovelSoftware.Domain.Entities.Project", "Project")
+ .WithMany("Conversations")
+ .HasForeignKey("ProjectId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Project");
+ });
+
+ modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentMessage", b =>
+ {
+ b.HasOne("NovelSoftware.Domain.Entities.AgentConversation", "Conversation")
+ .WithMany("Messages")
+ .HasForeignKey("ConversationId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Conversation");
+ });
+
+ modelBuilder.Entity("NovelSoftware.Domain.Entities.Chapter", b =>
+ {
+ b.HasOne("NovelSoftware.Domain.Entities.Character", "PovCharacter")
+ .WithMany()
+ .HasForeignKey("PovCharacterId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("NovelSoftware.Domain.Entities.Project", "Project")
+ .WithMany("Chapters")
+ .HasForeignKey("ProjectId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("PovCharacter");
+
+ b.Navigation("Project");
+ });
+
+ modelBuilder.Entity("NovelSoftware.Domain.Entities.Character", b =>
+ {
+ b.HasOne("NovelSoftware.Domain.Entities.Project", "Project")
+ .WithMany("Characters")
+ .HasForeignKey("ProjectId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Project");
+ });
+
+ modelBuilder.Entity("NovelSoftware.Domain.Entities.CharacterRelationship", b =>
+ {
+ b.HasOne("NovelSoftware.Domain.Entities.Character", "Character")
+ .WithMany("Relationships")
+ .HasForeignKey("CharacterId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("NovelSoftware.Domain.Entities.Character", "RelatedCharacter")
+ .WithMany()
+ .HasForeignKey("RelatedCharacterId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Character");
+
+ b.Navigation("RelatedCharacter");
+ });
+
+ modelBuilder.Entity("NovelSoftware.Domain.Entities.OutlineNode", b =>
+ {
+ b.HasOne("NovelSoftware.Domain.Entities.Chapter", "Chapter")
+ .WithMany()
+ .HasForeignKey("ChapterId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("NovelSoftware.Domain.Entities.OutlineNode", "Parent")
+ .WithMany("Children")
+ .HasForeignKey("ParentId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("NovelSoftware.Domain.Entities.Project", "Project")
+ .WithMany("OutlineNodes")
+ .HasForeignKey("ProjectId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Chapter");
+
+ b.Navigation("Parent");
+
+ b.Navigation("Project");
+ });
+
+ modelBuilder.Entity("NovelSoftware.Domain.Entities.Scene", b =>
+ {
+ b.HasOne("NovelSoftware.Domain.Entities.Chapter", "Chapter")
+ .WithMany("Scenes")
+ .HasForeignKey("ChapterId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("NovelSoftware.Domain.Entities.Character", "PovCharacter")
+ .WithMany()
+ .HasForeignKey("PovCharacterId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.Navigation("Chapter");
+
+ b.Navigation("PovCharacter");
+ });
+
+ modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentConversation", b =>
+ {
+ b.Navigation("Messages");
+ });
+
+ modelBuilder.Entity("NovelSoftware.Domain.Entities.Chapter", b =>
+ {
+ b.Navigation("Scenes");
+ });
+
+ modelBuilder.Entity("NovelSoftware.Domain.Entities.Character", b =>
+ {
+ b.Navigation("Relationships");
+ });
+
+ modelBuilder.Entity("NovelSoftware.Domain.Entities.OutlineNode", b =>
+ {
+ b.Navigation("Children");
+ });
+
+ modelBuilder.Entity("NovelSoftware.Domain.Entities.Project", b =>
+ {
+ b.Navigation("Chapters");
+
+ b.Navigation("Characters");
+
+ b.Navigation("Conversations");
+
+ b.Navigation("OutlineNodes");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806023249_InitialSchema.cs b/src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806023249_InitialSchema.cs
new file mode 100644
index 0000000..026b6e6
--- /dev/null
+++ b/src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806023249_InitialSchema.cs
@@ -0,0 +1,339 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace NovelSoftware.Infrastructure.Persistence.Migrations
+{
+ ///
+ public partial class InitialSchema : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Projects",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ Title = table.Column(type: "TEXT", maxLength: 300, nullable: false),
+ Author = table.Column(type: "TEXT", nullable: true),
+ Genre = table.Column(type: "TEXT", nullable: true),
+ Logline = table.Column(type: "TEXT", nullable: true),
+ Synopsis = table.Column(type: "TEXT", nullable: true),
+ Notes = table.Column(type: "TEXT", nullable: true),
+ TargetWordCount = table.Column(type: "INTEGER", nullable: true),
+ CreatedAt = table.Column(type: "INTEGER", nullable: false),
+ UpdatedAt = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Projects", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Characters",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ ProjectId = table.Column(type: "TEXT", nullable: false),
+ Name = table.Column(type: "TEXT", maxLength: 200, nullable: false),
+ Role = table.Column(type: "TEXT", maxLength: 32, nullable: false),
+ Age = table.Column(type: "TEXT", nullable: true),
+ Pronouns = table.Column(type: "TEXT", nullable: true),
+ Occupation = table.Column(type: "TEXT", nullable: true),
+ Appearance = table.Column(type: "TEXT", nullable: true),
+ Personality = table.Column(type: "TEXT", nullable: true),
+ Backstory = table.Column(type: "TEXT", nullable: true),
+ Want = table.Column(type: "TEXT", nullable: true),
+ Need = table.Column(type: "TEXT", nullable: true),
+ InternalConflict = table.Column(type: "TEXT", nullable: true),
+ ExternalConflict = table.Column(type: "TEXT", nullable: true),
+ ArcSummary = table.Column(type: "TEXT", nullable: true),
+ Voice = table.Column(type: "TEXT", nullable: true),
+ Notes = table.Column(type: "TEXT", nullable: true),
+ CreatedAt = table.Column(type: "INTEGER", nullable: false),
+ UpdatedAt = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Characters", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Characters_Projects_ProjectId",
+ column: x => x.ProjectId,
+ principalTable: "Projects",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Conversations",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ ProjectId = table.Column(type: "TEXT", nullable: false),
+ Title = table.Column(type: "TEXT", maxLength: 200, nullable: false),
+ CreatedAt = table.Column(type: "INTEGER", nullable: false),
+ UpdatedAt = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Conversations", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Conversations_Projects_ProjectId",
+ column: x => x.ProjectId,
+ principalTable: "Projects",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Chapters",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ ProjectId = table.Column(type: "TEXT", nullable: false),
+ Number = table.Column(type: "INTEGER", nullable: false),
+ Title = table.Column(type: "TEXT", maxLength: 300, nullable: false),
+ Summary = table.Column(type: "TEXT", nullable: true),
+ PovCharacterId = table.Column(type: "TEXT", nullable: true),
+ Setting = table.Column