novel-characters
Input a novel/short story, and output for each character: character portrait, visual prompts, voice prompts, and layout instructions for the character design sheet.
**This skill does not generate images; it only produces prompts.** Generating images is a downstream matter—it requires choosing a model, an art style, and an aspect ratio, none of which can be answered here.
{baseDir} = the directory where this file is located. The script {baseDir}/scripts/novel-characters.mjs, zero dependencies,node runs directly.
Runtime environment: Both Claude Code and codex can run it, with no difference—this pipeline is entirely node scripts plus model reads and writes, and does not touch any local executables.
Step 0 — Determine the report language
The user can specify a language, for example "in English", "--lang en", "日本語で".If not specified, it is Chinese (zh)。
This lang is passed all the way down: on the second pass, when generating character cards, it determines what language is used for human-readable fields, andvalidate and render must also carry it.
UI copy falls into two cases:
-
zh/en/ja— built in, no need to handle -
Any other language — you need to translate a copy on the spot. Run
node {baseDir}/scripts/novel-characters.mjs ui-template <lang>It prints an English skeleton; translate each value into the target language, and put the whole block into the top-level
cast.jsonfield ofui. During rendering it is merged into the built-in table.If
uiis not provided,validatewill directly report an error—otherwise the report will be in a half-baked state of "character content in French, UI labels in English".
Supported languages are not limited by the built-in table; French, Korean, and Spanish can all produce complete reports.
Step 1 — Locate the input
If the user gives a file path, use it directly. For pasted body text,first save it to a temporary .txt—later, verifying whether "quotations are verbatim" requires comparing against the original text, and without an original text file this step cannot be done.
Determine the output directory: if the user specifies one, use it; if not, use the directory at the same level as the original book.
If there is outline.json(the output of novel-outline), bring it along too and go through seed—the outline is upstream of the character settings, and its characters block has already fixed the character list:
node {baseDir}/scripts/novel-characters.mjs seed <outline.json> > <workdir>/seed.json
What is carried over are facts the outline has already decided; what is left blank is the design that should be done at this layer:
| outline field | after seed |
|---|---|
id |
Kept as-is as the character code—downstream script / storyboard uses it to reference the character |
name |
The character list follows this one,no longer deciding on its own who should be included |
tier |
mapped to importance:lead → protagonist、support → supporting、functional → minor |
arc |
directly placed into persona.arc |
role / from |
goes into seedNote—positioning (female lead / villain) and "merged from whom in the original work", so that when scanning the original text you know which storylines' scenes to gather |
Left blank to be filled:aliases(requires reading the original text to know),oneLiner、persona the remaining items,image、voice。what seed produces is a skeleton, not a finished product, directly run validate It will report a bunch of missing fields; that's expected—Steps 2–6 later exist to fill them in.
Two standards must be upheld:
- Do not overturn the tiering set by the outline. Who is important is a decision made during the adaptation stage; this layer is only responsible for fleshing out the people who have been decided on. If you really think the tiering is wrong, go back and change the outline; don't quietly change it to something different here
- The protagonist group can be subdivided internally. The outline's
leadis the whole group of "male and female leads + main villain", corresponding to theprotagonistandmajortwo tiers; seed always givesprotagonist; according to the positioning inseedNote, change those other than the protagonists tomajor, this does not count as overturning the tiering
Without outline.json it also runs as usual, this skill does not depend on it—skip seed and start from Step 2 by extracting the character table from the original text yourself.
Step 2 — Chunking
node {baseDir}/scripts/novel-characters.mjs chunk <book.txt> <workdir>
Print {"chunks": N, ...}。
- N == 1: skip Step 3, directly read the original text in the current session for the first pass, and write the result yourself as
<workdir>/roster-00.json - N > 1: go to Step 3
truncated: true: clearly tell the user the tail was not scanned, don't keep quiet
Step 3 — First-pass scan (only when N > 1)
If the current environment supports subagents, run them concurrently(Claude Code's Task, codex's subagent): one subagent per chunk,all calls placed in the same messageis what makes it truly concurrent. If not supported, read chunk by chunk serially; the result is the same, just slower.
Each subagent's task:
- Read
{baseDir}/references/roster-pass.md, execute according to it - Read
<workdir>/chunk-NN.txt - Write the roster JSON to
<workdir>/roster-NN.json - Reply only with "done NN, extracted X characters"
Step 4 — Merge + review
node {baseDir}/scripts/novel-characters.mjs merge <workdir> | tee <workdir>/merged.json
Falling into merged.json is not just for keeping records: Step 6's assemble relies on it to get the scene order of characters in the same tier.
Converge precisely by name + aliases (if one chunk lists "陆" as an alias of "陆行远", the two entries merge into one person), accumulate notes, deduplicate quotes, sort descending by number of chunks appeared in—the more chunks a character appears in, the heavier their role.
The output is { "characters": [...], "mergeCandidates": [...] }。mergeCandidates must be reviewed item by item: exact matching can only converge cases where two chunks happen to write the same form of address; the rest are semantic judgments that a script cannot do. Candidates come from name containment relationships (「陆」⊂「陆行远」)—it is a strong signal, not a verdict; fathers and sons or brothers with the same surname cannot be merged. Identical people you notice yourself outside the candidates ("陆先生" and "行远" have no containment relationship, so they won't enter the candidates) must also be merged.
If you want to merge, write a merges.json and then apply it:
{ "merges": [{ "keep": "陆行远", "absorb": ["陆", "陆先生"] }] }
node {baseDir}/scripts/novel-characters.mjs merge <workdir> --apply merges.json | tee <workdir>/merged.json
keep/absorb You can locate by name or any alias; if not found it will directly error. The output still carries mergeCandidates, and only after all the rest are confirmed to be different people (or cleared) should you proceed to the next step. If there is nothing to merge, just go straight on—but merged.json must be kept.
Step 5 — Casting
Take the top N. Default 30; if the user says otherwise, follow the user. For the remaining characters, mention in the final report, "X more were identified but not profiled."
Step 6 — Second pass to produce cards
One per character; likewise run concurrently when possible.
Each task gets:
{baseDir}/references/profile-pass.mdand{baseDir}/references/schema.md(read them, follow them)- Report language
lang(set in Step 0) - The merged
name/aliases/notes/quotes - Names of other characters in the same batch(Avoid lookalike faces and overlapping voice types)
Follow profile-pass.md to complete the identity—appearance semantic self-check before delivering cards: the character profile, image prompt, local translation, and reference sheet subject must not each describe a different identity. The outline determines the roster and adaptation choices, while original-text observation supplies the physical-appearance basis; do not just provide the outline and skip the Step 2–4 original-text scan.
Write the character card JSON to <workdir>/card-<slug>.json。Resume from breakpoint:card-<slug>.json Existing characters do not need to be rerun.
Also write a story summary: in lang the specified language, 3–5 sentences, covering the time-space background, core situation, and the reason these people are together. For short works, write directly from the original text; for long works, summarize from the roster note of each block. Do not spoil the ending, and do not write it as a recommendation blurb. Write to <workdir>/summary.txt. For non-built-in languages, save the entire translated ui from Step 0 as <workdir>/ui.json。
Then assemble cast.json—use assemble, do not hand-build it(hand-building will lose fields and write wrong top-level keys):
node {baseDir}/scripts/novel-characters.mjs assemble <workdir> \
--source <书名> --lang <lang> \
--out <输出目录>/<书名>-cast.json
Bad cards will be called out one by one—whichever card-*.json is bad, rerun only that character; the others do not need to be touched.
The order of characters in the same tier is screen-time order, from the <workdir>/merged.jsonleft in Step 4 (assemble reads it automatically; you can also use --order to point to another file). The numbering in the left column of the report, "sorted by screen time," depends on it—seeing the warning "characters in the same tier will be ordered by filename" means merged.json is missing, so go back to Step 4 and regenerate it.
Step 7 — Validation ⛔ cannot be skipped
node {baseDir}/scripts/novel-characters.mjs validate <cast.json> <book.txt>
Remember to include --lang(set in Step 0). Check: structure,importance enum,quotes verbatim、image prompts contain no personal names、language division of labor(human-facing fields follow lang, image/TTS prompts are always English), andnon-built-in languages must include ui。
If there are violations, fix them one by one according to the errors, and rerun after fixing until it passes. These four types of errors really do occur in models—this validation suite was forged from real outputs.
Step 8 — Output
cd <输出目录>
node {baseDir}/scripts/novel-characters.mjs render <cast.json> --md > <书名>-cast.md
node {baseDir}/scripts/novel-characters.mjs render <cast.json> --html > report.html
The language is taken from cast.json in lang, to temporarily override add --lang <code>。
render By default it looks for images in images/<slug>-sheet.png next to cast.json; if the images are elsewhere, use --images <目录>
to point there (any path).This skill does not produce these files—after the downstream image generation is done, rerun render once to embed the images into the report.
For the style conventions of report.html, see {baseDir}/references/report-style.md—read it before changing styles, and do not change it back into a generic card wall.
Final output:
<输出目录>/
├── <书名>-cast.json
├── <书名>-cast.md
├── report.html ← 双击就能开
└── images/ ← 本 skill 不写这个目录
└── <slug>-sheet.png ← 下游出完图放这儿,render 会捡起来
Step 9 — Reporting
State clearly in one sentence: number of characters and report path. If validation failed once, explain what was fixed. If the original text was truncated, say so explicitly.
Do not say "images generated" or "reference sheets generated"—this step no longer exists; what is delivered is the prompts.
Boundaries
- Single-run limit of 24 blocks (net coverage about 930,000 characters); if exceeded, it will explicitly report
truncated, not silently truncate - Human-readable fields follow
--lang(default Chinese); image and TTS promptsare always English, those engines handle English most reliably - This skill does not generate images.
image.sheetare layout instructions for downstream; the places where layout most easily breaks (one image with two appearances, squashing characters to cram in details, closing off the left column) are held down by a few hardcoded lines in the prompt, seereferences/sheet.md - If you want an interactive interface that can be edited in real time and viewed while running, that is a different thing and is not part of this skill
Self-test
node {baseDir}/scripts/selftest.mjs
329 assertions, no model calls, no quota spent, covering all deterministic logic for chunking / merging / synthesis / multilingual / validation / rendering. Run this first after changing the script.
Built-in sample
{baseDir}/examples/渡口.txt is a short story with 4 characters, among whom the peddler is only ever referred to by a nickname and the boatman is only ever called “Uncle”—specifically used to verify alias merging. Corresponding output 渡口-cast.json / 渡口-cast.md can serve as a quality baseline and is also a self-check fixture for validation.