Add main/supporting characters, character arcs and open questions

Three things the outline could not express before:

Main vs supporting. A new CharacterImportance sits alongside CharacterRole
rather than inside it — role is the part a character plays (protagonist,
mentor, foil), importance is how much of the book they carry, and a mentor can
be either. Characters start Supporting and get promoted. Listings put main
characters first.

Character arcs. A main character's arc is a flat ordered list of stages, the
same shape as a chapter's beats and for the same reason: an arc is a sequence
of changes, not a tree. A stage can be pinned to the chapter where it lands.
Nothing refuses an arc on a supporting character — demoting someone should not
delete their work.

Open questions. What the writer has not decided yet, hanging off a chapter
outline, a character, both, or neither. They can be resolved, reopened or
deleted, and resolving can append the decision to the notes of whatever the
question was attached to, so it lands where the writer will re-read it.
Resolved questions drop off the list unless asked for.

Also adds GET /api/characters/{id}/beats — every beat a character appears in,
in manuscript order, carrying each beat's chapter so the character page can
link straight into that chapter's outline.

Deletes are deliberately asymmetric: deleting a chapter unpins arc stages and
detaches questions rather than taking them, because a plan outlives a decision
about where the chapter break falls. Deleting a character or project does take
their arcs and questions.

All three capabilities are surfaced in the REST API, the agent toolset and the
MCP server, per the one-source-of-truth rule.

Two things worth flagging in the migration: EF's generated default for the new
Importance column was an empty string, which does not parse back to a
CharacterImportance and would have faulted every read of an existing dossier —
it now defaults to Supporting, verified by migrating a database seeded on the
old schema and reading the row back through the API. And the earlier migrations
were renamed to the namespace EF derives from the output folder, so future
`migrations add` runs stop drifting.

72 tests pass (28 new). The endpoints were also exercised over curl end to end:
arc stages resolving their chapter, a character's beats across chapters, and a
question attached to both a chapter and a character resolving into both sets of
notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
James Wampler
2026-08-06 12:11:20 -07:00
co-authored by Claude Opus 5
parent 96021c5fee
commit 0358667679
34 changed files with 2638 additions and 13 deletions
+70
View File
@@ -33,6 +33,8 @@ public static class CharacterTools
CancellationToken ct,
[Description("Protagonist, Antagonist, Deuteragonist, Supporting, Minor, Mentor, LoveInterest or Foil.")]
string? role = null,
[Description("Main or Supporting. Main characters are the few the story is about and are worth tracking an arc for.")]
string? importance = null,
[Description("Age, exact or approximate.")] string? age = null,
[Description("The pronouns this character uses.")] string? pronouns = null,
[Description("What they do.")] string? occupation = null,
@@ -51,6 +53,7 @@ public static class CharacterTools
{
name,
role = role ?? "Supporting",
importance = importance ?? "Supporting",
age,
pronouns,
occupation,
@@ -76,6 +79,8 @@ public static class CharacterTools
[Description("New name.")] string? name = null,
[Description("Protagonist, Antagonist, Deuteragonist, Supporting, Minor, Mentor, LoveInterest or Foil.")]
string? role = null,
[Description("Main or Supporting. Main characters are the few the story is about and are worth tracking an arc for.")]
string? importance = null,
[Description("Age, exact or approximate.")] string? age = null,
[Description("The pronouns this character uses.")] string? pronouns = null,
[Description("What they do.")] string? occupation = null,
@@ -94,6 +99,7 @@ public static class CharacterTools
{
name,
role,
importance,
age,
pronouns,
occupation,
@@ -110,6 +116,70 @@ public static class CharacterTools
tags
}, ct);
[McpServerTool(Name = "get_character_beats")]
[Description("Every beat this character appears in, across the whole book, in manuscript order. "
+ "This is what the character actually does on the page, as opposed to what the "
+ "dossier claims about them — read it before revising a character.")]
public static Task<CallToolResult> GetCharacterBeats(
NovelApiClient api,
[Description("The character's id.")] Guid characterId,
CancellationToken ct) =>
api.GetAsync($"/api/characters/{characterId}/beats", ct);
[McpServerTool(Name = "get_character_arc")]
[Description("Read a main character's arc: the ordered stages of how they change, each "
+ "optionally pinned to the chapter where it lands.")]
public static Task<CallToolResult> GetCharacterArc(
NovelApiClient api,
[Description("The character's id.")] Guid characterId,
CancellationToken ct) =>
api.GetAsync($"/api/characters/{characterId}/arc", ct);
[McpServerTool(Name = "add_arc_stage")]
[Description("Add a stage to a character's arc. Arcs are kept for main characters — promote "
+ "the character with update_character first if they are still Supporting.")]
public static Task<CallToolResult> AddArcStage(
NovelApiClient api,
[Description("Id of the character whose arc to add to.")] Guid characterId,
[Description("A short handle for the change, three to five words.")] string title,
CancellationToken ct,
[Description("What shifts in the character here, and what it costs them.")] string? description = null,
[Description("Id of the chapter where this stage lands, if it is pinned to one.")] Guid? chapterId = null,
[Description("Position in the arc. Appended to the end when omitted.")] int? sortOrder = null) =>
api.PostAsync($"/api/characters/{characterId}/arc",
new { title, sortOrder, description, chapterId }, ct);
[McpServerTool(Name = "update_arc_stage")]
[Description("Revise a stage of a character's arc. Only the fields you supply change.")]
public static Task<CallToolResult> UpdateArcStage(
NovelApiClient api,
[Description("The arc stage's id.")] Guid arcStageId,
CancellationToken ct,
[Description("New title for the stage.")] string? title = null,
[Description("What shifts in the character here.")] string? description = null,
[Description("Id of the chapter where this stage lands.")] Guid? chapterId = null,
[Description("Position in the arc.")] int? sortOrder = null) =>
api.PatchAsync($"/api/arc-stages/{arcStageId}",
new { title, sortOrder, description, chapterId }, ct);
[McpServerTool(Name = "delete_arc_stage")]
[Description("Remove a stage from a character's arc.")]
public static Task<CallToolResult> DeleteArcStage(
NovelApiClient api,
[Description("The arc stage's id.")] Guid arcStageId,
CancellationToken ct) =>
api.DeleteAsync($"/api/arc-stages/{arcStageId}", ct);
[McpServerTool(Name = "reorder_arc_stages")]
[Description("Renumber a character's arc to match the order given. Stages left out keep their "
+ "relative position after the ones listed.")]
public static Task<CallToolResult> ReorderArcStages(
NovelApiClient api,
[Description("Id of the character whose arc to reorder.")] Guid characterId,
[Description("Arc stage ids in the order wanted.")] string[] stageIds,
CancellationToken ct) =>
api.PostAsync($"/api/characters/{characterId}/arc/reorder", new { stageIds }, ct);
[McpServerTool(Name = "relate_characters")]
[Description("Record a relationship from one character to another in the same project.")]
public static Task<CallToolResult> RelateCharacters(