Skip to content

Automation and CI ​

This page is for writers and maintainers who keep a story project in a git repository and want its checks to run automatically. It covers the three GitHub Actions templates and the issue form that ship with Story Skills, how to run the story CLI from scripts and CI, and how to check a project before each commit.

On this page

What you can automate ​

The story CLI is deterministic: the same project always produces the same findings, and it never asks questions. That makes four kinds of automation practical:

GoalHow
Stop a broken chapter from mergingRun story check, which runs validate, links, and continuity, on every push to main and every pull request. templates/github/story-checks.yml does this.
Draft chapters on a scheduleLet Claude Code draft the next chapter and open a pull request for you to review. templates/github/draft-next-chapter.yml does this.
Give reviewers a current, citable copy of the bookBuild the HTML review copy on every push to main and publish it to GitHub Pages; readers file notes through an issue form. templates/github/review-copy.yml and templates/github/ISSUE_TEMPLATE/manuscript-note.yml do this.
Catch problems before they are committedRun the same checks from a git pre-commit hook. See Checking before each commit.

The checks are the deterministic ones described in Continuity and analysis and the CLI reference, and the review copy is an ordinary story build. The one creative step, drafting, is done by an agent and always arrives as a pull request for you to review.

Running the CLI non-interactively ​

Getting the CLI in CI ​

The CLI needs Node 18 or newer and has no runtime dependencies. In a CI job, use one of these:

SourceCommandNotes
npm, pinnednpx --yes --package story-skills@0.24.1 story <command>Fetches the published package.
npm, installed once per jobnpm install -g story-skills@0.24.1, then story <command>What the templates use. Faster when a job runs several commands.
GitHub tagnpx --yes --package github:danjdewhurst/story-skills#v0.24.1 story <command>Fetches the tagged release from GitHub. Slower, and without npm provenance; use it for an unreleased fix or a fork.
Bundled fallbacknode <skills-dir>/story-maintenance/scripts/story.js <command>No network needed if your repository already contains the skills, for example under .claude/skills/.

Always pin a version. An unpinned npx story-skills can pick up a new release mid-book and start reporting findings your project has never seen. Local installs can track the latest release; CI should pin, because new releases can add checks.

To confirm which version a job is using:

shell
npx --yes --package story-skills@0.24.1 story --version
text
0.24.1

Exit codes ​

Every command exits 0 on success. A failure exits with a code that says what kind of failure it was, so a script can tell a manuscript with errors from a mistyped command line. Warnings do not change the exit code unless a severity entry in story.md promotes them to errors (see Failing on warnings).

CodeMeaningExamples
0Success. For checks, no errors.Project is valid, or only warnings.
1Findings: a check reported at least one error: line.check, validate, links, or continuity found an error; names found a clash.
2Usage error: the command line was wrong.An unknown command or option, a missing option value, an unexpected argument, an unsupported --format, or an id that does not exist (Unknown character nobody).
3Not a usable story project.No story.md at the path, invalid cli-defaults or severity in story.md (every command except validate, report, next, and doctor), a file the command needs cannot be read or parsed (Cannot export: fix this file first) or is a symlink, a project with a newer schema (only migrate exits 3 for it; validate reports schema-too-new and exits 1), or nothing to build (No chapters found to export).
4Refused or failed write. The message says what, if anything, was changed.The target already exists (init without --force, add of an existing id), --out points at project source, inside a .git folder, outside the project, or through a symlink, another story command holds the project lock or the lock cannot be created, a file changed on disk meanwhile, or the file system refused the write (permission denied, a full disk).

Because findings keep 1, story validate "$STORY_DIR" || exit 1 and the GitHub Actions templates fail a job exactly as before. Scripts that test for 1 specifically to mean "any failure" need to accept 2, 3, and 4 too; [ $? -ne 0 ] or || exit works for every code.

Which commands can report findings (exit 1):

CommandExits 1 when
checkvalidate, links, or continuity finds an error. With --strict, a warning fails it too.
validateThe project has a structural, frontmatter, or registry error.
linksA cross-reference points at a missing file, or a required backlink is missing.
continuityA continuity contract is broken, such as a dead character listed in a later chapter or a payoff before its setup. Findings matched by continuity/exemptions.md are dismissed and do not count.
seriesA linked path is not a story project, the chronology has a cycle, two books share a book-number, linked books declare different series, or shared canon contradicts itself, such as a character who died in an earlier book appearing later. A missing series backlink is caught by links, not series.
progress, timeline, mentions, diagram, grid, listA project file cannot be parsed and is reported as an error: line. Their own findings are advisory, unless severity in story.md promotes one to an error.
prose, pacing, clues, voicesA file they read fails to read or parse and is reported as an error: line, or a severity entry in story.md promotes one of their warnings to an error. With prose - or voices -, a broken chapter or scene file does not count, since the passage stands in for those files. A samples entry in style-sheet.md that cannot be read only warns.
comparestyle-sheet.md or progress.md cannot be parsed and is reported as an error: line. Any other project file that fails to parse, or any file of the earlier draft, stops it with exit 3 instead, because it cannot compare without it. Its own findings are advisory, unless severity in story.md promotes one to an error.
namesA candidate name clashes with an existing one, or a project file cannot be parsed and is reported as an error: line.
impactThe change it tests would add an error to story check. With --strict, an added warning, or a warning the change itself gives, fails it too. Findings the project already has do not count, even when the change gives them new ids.
report, next, doctorNever, on a readable project. They summarise the checks but always exit 0.
doctor --fixA check still reports an error after its safe repairs.
similarity, notes, build, export, context, add, rename, move, split, merge, remove, snapshot --restoreOnly when severity in story.md promotes a warning they print; the command's output is still written.
init, import, migrate, reindex, wordcount, knowledge, passes, snapshot, synopsisNever: they succeed, or stop with 2, 3, or 4. snapshot --restore is the exception above.
lspNever. The language server exits 0 when the editor shuts it down and 1 when it exits without a shutdown request, or stops with 2 for an unknown option.

report --actionable, next, and doctor are for reading, not gating. Use check, which runs validate, links, and continuity, when a job must fail, as the story checks workflow does.

Before this split, every failure exited 1. If a script relied on 1 for a usage error, a missing project, or a refused write, update it to the new code.

Here is a passing check and a failing one, using the examples in this repository:

shell
story continuity examples/the-last-ember; echo "exit=$?"
text
Continuity is consistent: 0 errors, 0 warnings, 0 dismissed
exit=0
shell
story continuity examples/the-unraveled-thread; echo "exit=$?"
text
Continuity check failed: 4 errors, 3 warnings, 0 dismissed
error: chapters/chapter-04.md lists edran-vale, who died in chapter-02; move posthumous appearances to mentions
error: continuity/promises/the-broken-compass.md pays off in chapter-02 before it is planted in chapter-03
error: continuity/questions/who-burned-the-mill.md resolves in chapter-02 before it is introduced in chapter-03
error: continuity/state.md knowledge-state[0] references missing chapter chapter-05
warning: chapters/chapter-03.md POV character nessa-thorn is not listed in characters [pov-not-in-cast]
warning: continuity/promises/the-sealed-letter.md was planted in chapter-01, 3 chapters ago, and has no payoff yet [promise-unpaid]
warning: continuity/state.md object-state[0] status active conflicts with worldbuilding/artifacts/vales-compass.md status destroyed [state-status-conflict]
exit=1

Output streams ​

By default, output is plain text with stable line prefixes, so you can filter it with standard tools. For a machine-readable result, see JSON output.

  • validate, links, continuity, check, and mentions with no entity write only to stderr: one summary line, then one line per finding, prefixed error:, warning:, or dismissed:. Nothing goes to stdout.
  • compare, notes, similarity, progress, timeline, prose, pacing, clues, voices, names, mentions <kind> <id>, and series write their report to stdout and the same summary and finding lines to stderr.
  • Every other command writes its report or confirmation to stdout.
  • A command that cannot run, for example because of an unknown option or a missing argument, prints one error line to stderr and exits 2. An unknown command also prints the full help text after the error.
  • Pointing any command at a directory without story.md prints <path> is not a story project: missing story.md to stderr and exits 3. In a project that has story.md, validate reports each other missing required file as an error: finding and exits 1.

To capture findings, redirect stderr:

shell
story validate . 2> validate.log

The CLI reference has the full rules.

JSON output ​

--json works on every command: the check and analysis commands (validate, links, continuity, check, series, report, next, doctor, knowledge, context, list, progress, timeline, prose, pacing, clues, voices, similarity, names, mentions, compare, and notes), diagram, grid, synopsis, and passes, the commands that change the project in place (add, rename, move, split, merge, remove, reindex, migrate, wordcount, doctor --fix, and snapshot), impact, and export, build, init, and import. It prints one JSON object to stdout and nothing to stderr, so a script can parse the result instead of the text:

json
{
  "apiVersion": "story/v2",
  "command": "validate",
  "ok": true,
  "data": { "errors": 0, "warnings": 1, "dismissed": 0 },
  "diagnostics": [
    {
      "severity": "warning",
      "file": "chapters/chapter-01.md",
      "chapter": null,
      "message": "chapters/chapter-01.md declares 900 words but contains 993",
      "code": "stale-word-count",
      "check": "validate"
    }
  ],
  "writes": []
}

ok is true exactly when the command exits 0, so a job can read either. diagnostics holds every finding with its severity, file, message, rule (code, from Finding codes), and the check that raised it (check). A command that cannot run, such as one pointed at a folder without story.md, still prints an envelope, with ok: false, data: null, and the error as its diagnostic, and exits with the same code as a text run (2, 3, or 4; see Exit codes). schemas/result.schema.json describes the envelope and each command's data; apiVersion changes when a field is renamed, removed, or retyped. The CLI reference has the full rules.

For example, to fail a job on any warning with jq:

shell
story validate . --json > validate.json
jq -e '[.diagnostics[] | select(.severity == "warning")] | length == 0' validate.json

jq -e exits 1 when the expression is false, and story validate has already exited 1 if there were errors. Filter on code to fail on one kind of warning, such as select(.code == "todo-markers"), or promote it in story.md as below.

To act on a set of files without parsing their YAML, ask story list for them. This prints the file of every chapter still in draft that Ilse narrates:

shell
story list chapters --where status=draft --where pov=ilse --json | jq -r '.data.items[].file'

To keep the filters with the manuscript rather than in the script, save them in story.md as a query and run it by name: story list --query ilse-drafts --json. story validate checks saved queries, so a broken one fails the CI job that checks the project.

A build job can read the file it made from writes, or from data.outFile, rather than work out its name from the story id. With --pdf, data.engine names the engine that rendered it:

shell
story build --format epub --json | jq -r '.data.outFile'

Failing on warnings ​

Warnings are advisory, so a project can pass with stale data. For example, a chapter whose word-count frontmatter is out of date passes validate:

text
Project is valid: 0 errors, 1 warnings, 0 dismissed
warning: chapters/chapter-01.md declares 900 words but contains 993 [stale-word-count]

To fail on particular warnings, promote them to errors in story.md. The setting lives with the manuscript, so a local run and CI fail the same way:

yaml
severity:
  - warning: stale-word-count
    level: error
  - warning: todo-markers
    level: error
  - warning: prose-avoided-spelling
    level: error
text
$ story validate; echo "exit=$?"
Project validation failed: 1 errors, 0 warnings, 0 dismissed
error: chapters/chapter-01.md declares 900 words but contains 993 [stale-word-count]
exit=1

Every warning line ends with its code in brackets, so the name to put in severity is on the line you want to change. level: off does the opposite and reports a warning as dismissed:. Any warning can be overridden, in whichever command reports it; errors cannot. The CLI reference lists every code, by command.

The same place holds default flags, such as stricter prose thresholds for every run, local or in CI. A flag on the command line still wins:

yaml
cli-defaults:
  - command: prose
    max-filter-words: 8
    max-adverbs: 10

The same holds with --json: a promoted warning is a diagnostic with "severity": "error", ok is false, and the run exits 1. Run story validate after editing either field: it rejects an unknown command, flag, warning code, or level, and while either field is invalid the other commands refuse to run, exiting 3, rather than silently skip a severity your job relies on.

To fail on every warning from validate, links, and continuity, run story check --strict, which reports each warning as an error and exits 1:

shell
story check . --strict

Other commands have no --strict flag, but you can fail on any warning: line:

shell
set -o pipefail
story prose . 2>&1 | tee prose.log
if grep -q '^warning:' prose.log; then
  echo "story prose reported warnings"
  exit 1
fi

set -o pipefail needs bash, which GitHub's Linux runners use for run: steps. It keeps the command's own exit code when its output is piped through tee.

Another approach is to check that the generated data is current. story reindex and story wordcount --write rewrite registry tables and word counts from the markdown, so if they change anything, someone forgot to run them:

shell
story reindex .
story wordcount . --write
git diff --exit-code

git diff --exit-code exits 1 and shows the difference when either command changed a file. See Core concepts for why registries are generated rather than edited by hand.

The GitHub Actions templates ​

Story Skills ships three workflows and one issue form in templates/github/. Copy them into the repository that holds your story project; they do not run in the Story Skills repository itself.

TemplateCopy toRuns onNeedsWhat it does
story-checks.yml.github/workflows/Push to main, every pull requestNothingRuns check (validate, links, and continuity) and report --actionable.
draft-next-chapter.yml.github/workflows/Weekday schedule, manual dispatchANTHROPIC_API_KEY secret, the skills committed to the repositoryDrafts the next chapter with Claude Code in a read-only job, then checks the commit and opens a pull request from a second job.
review-copy.yml.github/workflows/Push to main, manual dispatchGitHub Pages set to deploy from GitHub ActionsRuns the checks, builds the HTML review copy, and publishes it to GitHub Pages.
ISSUE_TEMPLATE/manuscript-note.yml.github/ISSUE_TEMPLATE/A reader opening an issueA manuscript-note labelGives readers a form for a note on one paragraph of the review copy.

Together they let you write a book through pull requests and review it in the open: the draft workflow proposes a chapter, the checks keep contradictions out, you review and merge, and the review copy puts the merged book in front of readers, whose notes come back as issues.

All three workflows:

  • install the CLI once per job from npm, at the release in STORY_VERSION, into the runner's temporary folder, and run plain story commands, using Node 24 from actions/setup-node;
  • read the project from STORY_DIR, which defaults to the repository root (.);
  • pin every action to a commit SHA, with the tag it corresponds to in a comment;
  • set timeout-minutes on every job (15 minutes, and 60 for the draft job that runs the agent), so a run that hangs, or a project that makes the CLI slow, cannot hold a runner for GitHub's six-hour default.

Story checks workflow ​

story-checks.yml is plain CI for a story project. It needs no secrets and only reads the repository (permissions: contents: read). Its checkout sets persist-credentials: false, so the GITHUB_TOKEN is not left in .git/config while the CLI fetched at run time executes.

Install it ​

  1. In your story repository, create .github/workflows/.
  2. Copy templates/github/story-checks.yml into it.
  3. If the project is not at the repository root, set STORY_DIR (see Customising the workflows).
  4. Commit and push. The workflow runs on the push to main and on every pull request after that.

To make the checks block merging, add the story-checks job as a required status check in a branch protection rule or ruleset for main.

What it runs ​

StepCommandFails the job when
Check structure, references, and continuitystory check "$STORY_DIR"validate, links, or continuity reports an error.
Report project healthstory report "$STORY_DIR" --actionableNever. It prints inventory, check results, and next actions to the job log.

story check runs all three checks over one scan and lists every finding before it fails, so a validation error does not hide link and continuity results. Warnings show in the log but do not fail the job; add --strict to the story check line to fail on them too.

If a continuity finding is intentional, record it in continuity/exemptions.md rather than weakening the workflow. The finding is then reported as dismissed. See Exemptions and Project format reference.

Draft the next chapter workflow ​

draft-next-chapter.yml runs Claude Code on a schedule to draft one chapter per run. The agent only commits. The workflow checks the commit, pushes the branch, and opens the pull request, so a chapter that fails the checks arrives as a draft pull request that lists the failures, never as a ready one. You review the chapter like any other change.

Install it ​

  1. Copy templates/github/draft-next-chapter.yml to .github/workflows/ in your story repository. Install story-checks.yml too.
  2. Add a repository secret named ANTHROPIC_API_KEY (Settings, then Secrets and variables, then Actions).
  3. Commit the Story Skills skills into the repository so the agent has the workflows it is told to follow, such as chapter-writing. Either run npx skills add danjdewhurst/story-skills in the repository and commit the result, or copy this repository's skills/ directory to .claude/skills/. See Skills catalogue.
  4. In Settings, then Actions, then General, under Workflow permissions, turn on Allow GitHub Actions to create and approve pull requests. Without this, the built-in GITHUB_TOKEN cannot open the chapter PR.
  5. Protect your default branch: in Settings, then Rules or Branches, require pull requests before merging into main.
  6. Adjust STORY_DIR, the budgets, and the cron schedule, then commit.

To try it without waiting for the schedule, open the Actions tab, pick Draft the next chapter, and choose Run workflow. The form takes the budgets for that run.

Budgets ​

BudgetDefaultSet byWhen it is exceeded
Words of prose in the drafted chapter1500 to 5000MIN_WORDS, MAX_WORDS, or the min_words and max_words inputsThe agent is told the range. A chapter outside it still gets a pull request, but as a draft that names its word count, and the run fails.
Agent turns80MAX_TURNS, or the max_turns inputPassed to Claude Code as --max-turns. The agent stops, nothing is pushed, and the run fails with a message naming the budget.
API spend per run$5MAX_BUDGET_USD, or the max_budget_usd inputPassed to Claude Code as --max-budget-usd. As for turns.
Time60 minutestimeout-minutes on the draft jobGitHub cancels the job; nothing is pushed.

Change the defaults in the env block at the top of the file: each is ${{ inputs.<name> || '<default>' }}, so a scheduled run uses the default after || and a manual run can override it. The first step of the run rejects a budget that is not a plain number, since the turn and dollar limits are passed on the agent's command line.

Triggers, permissions, and secrets ​

SettingValueWhy
Triggersschedule (0 6 * * 1-5, 06:00 UTC on weekdays) and workflow_dispatchNever runs on pull_request or pull_request_target, so a fork cannot trigger a run that has access to the secrets.
Draft job permissionscontents: read, pull-requests: readThe agent runs here. Whatever it is talked into, this job's token cannot push, open pull requests, or comment.
Publish job permissionscontents: write, pull-requests: writeTo push the checked branch and open the pull request. The agent never runs in this job.
concurrencyOne run per workflow, cancel-in-progress: falseA manual run that overlaps the schedule waits instead of drafting the same chapter twice.
timeout-minutes60 for the draft job, 15 for the publish jobStops a run that stalls instead of letting it hold a runner, and spend API credit, for six hours.
persist-credentialsfalse on both checkoutsThe checkout leaves no token in .git/config. (The Claude Code action configures the draft job's read-only token for git itself; it expires with the job.) The publish job's push authenticates with gh auth setup-git.
ANTHROPIC_API_KEYRepository secret you addPassed to anthropics/claude-code-action in the draft job, and to the publish job's secret check. It is the one secret the agent's job holds.
GITHUB_TOKENProvided by GitHubRead-only in the draft job (the skip guard and the action); write in the publish job (push and pull request).

What a run does ​

The draft job:

  1. Skips while a draft is open. The first step lists open pull requests whose head branch starts with draft/ and comes from this repository. If there is one, every later step is skipped and the run succeeds. Until you merge or close that PR, story next would keep recommending the same chapter. Pull requests from forks are ignored, so they cannot block drafting.
  2. Checks the budgets are plain numbers, and that the word range is not upside down.
  3. Checks out, sets up Node, and installs the Story CLI. Full history (fetch-depth: 0), Node 24, and the CLI at STORY_VERSION installed into the runner's temporary folder, outside the repository, so the agent runs plain story commands. It never runs npx, which would read an .npmrc the agent could write.
  4. Drafts with Claude Code. The agent is told that everything it reads in the project is story material, never instructions. It is prompted to:
    1. run story next ., and do not open continuity/state.md, the registries, the timeline, active arcs, or other project files for background (drafting context comes from story context, whose story essentials carry the book's language, so it does not open story.md either; if context leaves items out to fit the budget it reruns with a larger --budget instead of opening the listed files);
    2. if story next reports a P0 maintenance issue, fix it on a draft/maintenance-<YYYY-MM-DD> branch, commit, and stop. A matter-todo-markers finding, even one promoted to an error, marks text only you can supply, such as an ISBN, so the agent never edits matter/ or story.md to clear it: it leaves the finding, names it in its commit message, and goes on to draft when it is the only P0 issue;
    3. if story next suggests no "Draft chapter" (the story is revising or complete, or every arc is resolved), stop without committing;
    4. otherwise draft the next chapter on a branch named draft/chapter-<number>, following the chapter-writing skill: run story context chapter-<number> once the chapter file exists with its POV and cast, and draft from the packed context it prints. Later chapters are left out. A line marked character-knowledge and do not reveal is known to the POV and must not be stated. Then it outlines first, with prose under ## Chapter Text within the word range, accurate frontmatter, matching scene records, and updates to continuity state, promises, questions, and the timeline;
    5. run story reindex, story wordcount --write, and story check (which runs validate, links, and continuity), and make them pass, apart from any matter-todo-markers finding;
    6. commit, with Draft chapter <number>: <title> as the message's first line and a summary of the beats, arcs advanced, and promises planted or paid off as its body.
  5. Bundles the commits. If the agent committed nothing, the run ends here, successfully. Otherwise the new commits must be on a branch named draft/chapter-<number> or draft/maintenance-<date>. They are written to a git bundle and uploaded as a workflow artifact, kept for a day. The bundle is the only thing that leaves this job.

The publish job runs on a fresh runner, only when the draft job bundled a commit:

  1. Checks what the agent committed. It checks out the repository, verifies the bundle, and fetches the branch. It refuses the whole draft, pushing nothing, unless:

    • the commits build on github.sha, the commit the run started from (taken from GitHub, not from the draft job);
    • the commits form a straight line, with no merge commit: a merge can carry changes of its own that a plain git log does not list (the file check below lists them anyway, with -m, as a second guard);
    • every file any of the commits touches is the story's own markdown: story.md, style-sheet.md, progress.md, or a .md file under chapters/, scenes/, characters/, worldbuilding/, plot/, continuity/, glossary/, matter/, or research/ in STORY_DIR.

    So no dotfiles or hidden folders (.github/, .claude/, .npmrc), no scripts, no symlinks or submodules, and no file named CLAUDE.md, AGENTS.md, GEMINI.md, or SKILL.md, which would carry instructions into every later agent run. A file added in one commit and removed in the next is refused too, since the push would carry it.

  2. Checks for secrets. It refuses the draft if any commit's changes (a merge's own changes and files git would call binary included) or message contain the value of ANTHROPIC_API_KEY. GitHub masks secrets in logs, not in commits. The check finds the key as written, not encoded or split, so it is a last line of defence. Rotate the key if this ever fires.

  3. Runs the story checks. It runs story validate, story links, and story continuity with --json and reads their exit codes:

    • 0 passes;
    • 1 means findings, and each error goes into the pull request body;
    • 2 to 4 mean the project could not be checked, so the job stops without pushing.

    For a chapter branch it also checks the chapter's word count against the range.

  4. Pushes the branch. If a branch of the same name already exists (one left from a closed pull request, say), this run's draft is pushed as <branch>-run-<number> instead, and the log warns you to delete old draft branches.

  5. Opens the pull request against the branch the run started from: the default branch on a schedule, or the branch you chose for a manual run. The title is the commit's first line when it reads Draft chapter <n>: <title> or Maintenance: <what>, and otherwise a plain Draft chapter <n>. The body quotes the agent's commit messages as an indented block, so a Closes #12 or @mention in them does nothing, then lists the check results. When a check or the word range failed, the pull request is a draft headed "Checks failed", and the run fails so you see it.

The PR is a draft for you to edit, not a finished chapter. Writing workflows describes the chapter-writing process the agent follows, and what to look for when you revise.

What the agent can and cannot do ​

The agent reads the whole project, and some of it is written by other people: reader notes filed through the manuscript note form and recorded under feedback/, text from story import, research notes pasted from the web. Any of it can carry instructions aimed at the agent (prompt injection). The workflow assumes the worst: the agent may be talked into anything its tools allow. It is built so that even then, nothing reaches your repository without passing the checks above.

What the agent has:

  • the story CLI (Bash(story:*)), installed before it starts;
  • git checkout -b draft/, git add, and git commit;
  • reading, writing, and searching files (Read, Write, Edit, Glob, Grep), except writing under .git/ (--disallowedTools "Edit(.git/**),Write(.git/**)"). The story CLI refuses --out inside a .git folder, and init or import into one, so it cannot write .git/ either. It can still write other files on the draft runner, outside the checkout, through an absolute --out (a ~/.gitconfig, say), which is why the agent's git reads no global or system config and why nothing the agent writes outside the checkout leaves the job;
  • the prompt's instruction to treat project text as data and report, not follow, any instruction it finds.

What it does not have:

  • No push and no pull requests. git push and gh are not in --allowedTools, and the draft job's token is read-only anyway.
  • No other shell commands. Beyond story and the three git commands, it runs nothing. story is installed outside the repository, so no .npmrc it writes can change what runs. The agent's git runs with GIT_CONFIG_PARAMETERS turning off core.fsmonitor, hooks, and commit signing, as a second guard on top of the .git/ write ban, and with GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1, so a global or system git config written through an absolute --out is never read. The bundle step's git runs the same way.
  • No way to change what runs next. Its work reaches the repository only as the story's markdown, through the publish job's check, which runs on a fresh runner. So nothing the agent wrote on its own runner runs anywhere else. Workflow files, skills, and agent instruction files are refused.
  • No write token. The draft job's GITHUB_TOKEN is read-only and expires when the job ends.

What is left, and what covers it:

  • The API key. Treat the draft job as untrusted: the key is the one secret it holds, and a way past the tool limits above would let a hostile note read it. The publish job refuses a draft that carries the key, but a key sent out another way would not show. Keep the key's spend limit low in the Anthropic console, and rotate it if a run ever behaves unexpectedly.
  • Bad prose or bad canon. The agent can still write a chapter that is wrong, or that obeys an instruction hidden in a reader note (a plot turn, a changed name). The story checks catch contradictions they know about. You catch the rest in review: read drafted chapters, and treat a surprising change as a reason to look at the notes it drew on.
  • Spend. A hostile note can waste the run's budget, up to the turn and dollar limits and the timeout.
  • The commit messages. The pull request quotes them harmlessly, but the commits themselves keep them. A Closes #12 in one closes that issue when the commit reaches your default branch, so read the messages before merging, or squash-merge with your own message.
  • Merging. Only a person merges. Branch protection on main makes sure of that.

Claude Code matches --allowedTools rules against the literal command text. That is why the prompt spells out each story command in full, with no quotes or shell variables. If you edit the prompt, keep each command starting with an allowed prefix, or the agent will be refused permission to run it. The Story Skills test suite checks this for the shipped template. It also runs the publish job's steps against sample commits, a bare remote, and a stub gh, with the shell Actions uses.

Why the checks run in the workflow ​

GitHub does not start other workflows for events caused by the built-in GITHUB_TOKEN. A pull request the publish job opens therefore does not trigger story-checks.yml. The publish job runs the same three checks itself, and they decide whether the pull request opens ready or as a draft.

If you want story-checks.yml to run on drafted PRs as well, for example because it is a required status check, replace GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} in the publish job's push and pull request steps with a personal access token or GitHub App token stored as a secret. Keep it out of the draft job.

Review copy workflow ​

review-copy.yml gives beta readers, critique partners, and editors a link instead of a terminal. On every push to main it checks the project, builds the HTML review copy with story build --format html, and publishes it to GitHub Pages. Every paragraph in the copy carries a label such as ch03-p12 (chapter 3, paragraph 12), and the manuscript note issue form asks readers for that label, so each note points at an exact paragraph.

The published page prints raw HTML in the manuscript as text. A comment between a stray fence line and the next fence line can therefore show on the Pages site, so close or remove that fence line before you push. See HTML review copy.

Install it ​

  1. Copy templates/github/review-copy.yml to .github/workflows/ in your story repository.
  2. In Settings, then Pages, set Source to GitHub Actions.
  3. Decide who may read the book (see Keeping the manuscript private) before the first push.
  4. If the project is not at the repository root, set STORY_DIR.
  5. Check STORY_VERSION. The html format is newer than Story Skills 0.8.2, so STORY_VERSION must name a later release. A template copied from a release that includes it already does, because the release process sets STORY_VERSION to its own version. With an older release, the build step fails with Unsupported build format: html.
  6. Commit and push to main, or run Review copy from the Actions tab.

story build needs at least one chapter, so a project fresh from story init has nothing to publish. Until chapters/ holds a chapter file, the workflow still runs the checks, then skips the build, upload, and deploy steps and passes with a notice. The first push that adds a chapter publishes the first copy.

The address of the site is shown on the run's deploy job, on the github-pages environment, and in Settings, then Pages. For a project site it is usually https://<owner>.github.io/<repository>/. A link to a paragraph adds its label, such as https://<owner>.github.io/<repository>/#ch03-p12, which opens the copy at that paragraph and highlights it.

Triggers, permissions, and jobs ​

SettingValueWhy
Triggerspush to main and workflow_dispatchReaders see the merged book, not work in progress on other branches. Never runs on pull requests.
permissionscontents: read for the workflow; pages: write and id-token: write for the deploy job onlyThe build only reads the repository. Deploying to Pages needs write access to Pages and an OIDC token, and only the job that deploys gets them.
concurrencyGroup review-copy, cancel-in-progress: falseA deployment that has started finishes instead of being cancelled mid-upload by the next push.
SecretsNoneThe built-in token is enough.

The build job:

  1. Checks out the repository without persisting the token (persist-credentials: false) and sets up Node 24.
  2. Runs story check, which runs validate, links, and continuity. If any of them fails, nothing is built or published, so readers never get a copy with broken references or a contradicted continuity contract. Readers keep the last good copy.
  3. Looks for matter waiting for permission: it warns in the job log about each matter/ file with a line such as permission: pending (any key that starts with perm, in any case). This step reads the files itself, so it warns whatever STORY_VERSION installs; see Pages waiting for permission.
  4. Looks for a chapter file in $STORY_DIR/chapters. With none, the remaining steps and the deploy job are skipped.
  5. Builds the review copy with story build "$STORY_DIR" --format html --stamp "$(date -u +%Y-%m-%d) ${GITHUB_SHA::7}" --out "$GITHUB_WORKSPACE/review-site/index.html". The stamp prints the build date and short commit at the top of the copy, so readers can say which build a note refers to. --note-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/issues/new?template=manuscript-note.yml" puts a Note link beside every paragraph label that opens the issue form with the label, the build, and the paragraph's first few words already filled in. The --out path is absolute because a relative --out is resolved against the project root and may not leave it; see Output paths. A matter page whose permission is still pending is left out; see Pages waiting for permission.
  6. Uploads index.html as a workflow artifact named review-copy, which you can download from the run page.
  7. Uploads the review-site folder as the Pages site.

The deploy job then publishes that site with actions/deploy-pages to the github-pages environment.

The workflow builds from the markdown on every run and commits nothing, so dist/ stays out of the repository.

Keeping the manuscript private ​

A GitHub Pages site is public, even when the repository is private, unless your GitHub plan supports private Pages with access control. Publishing an unpublished book on the open web can matter to publishers and to some contests. If the manuscript must stay private:

  • delete the deploy job and the Upload the Pages site step, and
  • share the review-copy workflow artifact instead. Anyone with read access to the repository can download it from the run page; readers then open index.html in a browser.

The feedback-triage skill sets this up for a round of reader notes. It warns about Pages visibility and confirms it with you first, then asks before creating files in .github/.

Pages waiting for permission ​

An epigraph, song lyric, or poem quoted on a matter page may need the rights holder's permission before anyone else reads it. While its permission is pending, or misspelt, or any value but not-needed, granted, or public-domain, story build leaves the page out of the review copy, so the workflow never publishes it, and the build step's log names each page it left out:

text
warning: matter/epigraph.md permission is still pending, so it is left out; pass --include-pending to include it [permission-pending-left-out]

The rest of the book is published as usual. When the permission arrives, set permission: granted and rights-holder on the page and push, and the next copy includes it. Do not add --include-pending to the build step: it would publish the page. cli-defaults in story.md cannot set it either.

To stop the workflow instead, so readers keep the last copy until the page is cleared, promote the warning to an error in story.md:

yaml
severity:
  - warning: permission-pending-left-out
    level: error

The build step then fails, and nothing is uploaded or deployed. A page with no text yet is never printed, so it is not reported and cannot fail the build.

STORY_VERSION must name a release after 0.22.1 for the build to leave the page out; 0.22.1 and earlier publish it. The template's Look for matter waiting for permission step does not depend on the release: it greps matter/ itself and warns in the job log about each page whose permission line says pending:

text
Warning: ./matter/epigraph.md has permission pending. Story Skills releases after 0.22.1 leave the page out of the review copy; 0.22.1 and earlier publish it.

Review rounds and changing labels ​

A paragraph label is the paragraph's position in its chapter, so it changes when you add or remove paragraphs earlier in that chapter. Because this workflow republishes on every push, a note made last week may point at a paragraph that has since moved. Readers therefore quote the build stamp printed at the top of the copy and the paragraph's first few words along with the label; the issue form asks for both. Tag the commit when you send readers the link for a round, for example beta-round-1. To find where an old label's paragraph is now, run story compare . --ref beta-round-1 --anchor ch03-p12 (repeat --anchor for several notes): it prints the paragraph's current label, whether its text changed, or its first few words when it is gone. A build stamp names a commit, so --ref can also take the short commit from the stamp. See Import, export, and builds for how labels are numbered.

Story bible on GitHub Pages ​

The same workflow can publish the story bible site beside the review copy, so readers can look up who someone is or when something happened. Add a step after Build the review copy that writes the codex into the Pages folder:

yaml
      - name: Build the story bible
        if: steps.chapters.outputs.has-chapters == 'true'
        run: story build "$STORY_DIR" --format codex --out "$GITHUB_WORKSPACE/review-site/codex"

STORY_VERSION must name a release that has the codex format. The site then serves the bible at https://<owner>.github.io/<repository>/codex/. Leave out --spoilers here: without it the codex holds no entity notes, deaths, clues, or resolutions, so it is safe for readers who have not finished the book. A --spoilers build belongs in the workflow artifact or on your own machine, never on a public site.

Manuscript note issue form ​

ISSUE_TEMPLATE/manuscript-note.yml is a GitHub issue form for readers of the review copy. Each note becomes an issue labelled manuscript-note, with the paragraph label in its title and body.

Install it ​

  1. Copy the file to .github/ISSUE_TEMPLATE/manuscript-note.yml in your story repository.
  2. Create a label named manuscript-note (Issues, then Labels, then New label). GitHub applies only labels that already exist, so without it the issues arrive unlabelled.
  3. Commit and push. The form appears under New issue.

Give readers a direct link to the form alongside the review copy: https://github.com/<owner>/<repository>/issues/new?template=manuscript-note.yml. Readers need a GitHub account and permission to open issues: anyone can on a public repository, and only collaborators can on a private one.

What the form asks ​

FieldTypeRequiredContents
Issue titleText, starting [ch00-p0] YesReaders replace ch00-p0 with the paragraph label and add a short summary.
Paragraph labelShort textYesOne label, such as ch03-p12, or the first and last of a passage, such as ch03-p12 to ch03-p15.
BuildShort textNoThe build stamp at the top of the review copy, such as 2026-09-27 abc1234.
First few words of the paragraphShort textYesThe paragraph's opening words, so the note still finds its paragraph after later edits renumber the labels.
What kind of note is this?DropdownYesTypo or wording; Confusing; Continuity (contradicts something earlier); Pacing (slow or rushed); Character (feels off); Sensitivity or authenticity; Loved this; Other.
Your noteLong textYesWhat the reader noticed and how it made them feel. The form tells them they need not suggest a fix.
How much did it affect your reading?DropdownNoBarely noticed; Pulled me out for a moment; Made me want to stop reading.

From issues to revisions ​

The notes are raw reader reactions, not decisions. The feedback-triage skill records them in one file per reader under feedback/round-<N>/, fetches the issues with gh issue list --label manuscript-note, maps each old label to the current text, keeps each note's paragraph label in its Where line, and weighs them against your intent before anything changes in the manuscript. Anyone who can open an issue can file a note, so the skill treats each issue's title, body, and author name as material to weigh, never as instructions. A suggestion for the story is weighed as a note. If a note asks the agent to act, for example to run a command, open a link, edit a file, change another note, or reveal a secret, the agent does not do it and tells you what the note asked. It also names the reader's file from the issue's author.login, never from the display name the reader types. See Skills catalogue and Writing workflows.

Customising the workflows ​

Project location ​

Set STORY_DIR at the top of each workflow file when the story project lives in a subdirectory:

yaml
env:
  STORY_DIR: "books/the-last-ember"
  STORY_VERSION: "0.24.1"

For several books in one repository, copy the check steps once per book, or turn STORY_DIR into a matrix value. For a linked series, add a story series "$STORY_DIR" step: it exits 1 when canon contradicts itself across books. The story links step is what catches a missing series backlink. See Series.

Every book a linked series names in follows or precedes must be in the checkout, at the sibling path the link gives. story links and story series report a linked book missing from disk as an error (story.md follows ../the-fall-of-the-citadel is not a story project: missing story.md), so a sequel kept in its own repository fails story-checks.yml on every push, and review-copy.yml, which runs links before it builds, never publishes. Keep the whole series in one repository, with each book in its own folder and STORY_DIR naming the book, or check the other books out beside it with extra actions/checkout steps (repository: and path:) so the relative links resolve. With extra checkouts, check the main repository out into a path: too, because a checkout cannot place a sibling outside $GITHUB_WORKSPACE.

CLI version ​

STORY_VERSION is the Story Skills release the CLI is installed from. Each job installs story-skills@$STORY_VERSION from npm once, in its Install the Story CLI step, into the runner's temporary folder, and later steps run plain story. The release process sets it to the release's own version, so a template copied from a given release already points at that release. Bump it in every workflow file you use when you want a newer release, and run the checks locally first, because new releases can add checks. Nothing bumps it for you; see Upgrading the workflows. Releases are listed on the GitHub releases page and on npm.

To install from GitHub instead, for an unreleased fix or a fork, uncomment STORY_PACKAGE in the workflow's env block and set it to any npm install spec, such as github:danjdewhurst/story-skills#main, a tag, or a commit. When it is set, every install step uses it in place of story-skills@$STORY_VERSION. A git install is slower and has no npm provenance, so switch back once the fix is released.

Schedule ​

Edit the cron expression. GitHub cron times are UTC.

yaml
on:
  schedule:
    - cron: "0 6 * * 1-5" # weekday mornings
  workflow_dispatch:

Delete the schedule block to draft only when you run the workflow by hand.

Branches ​

story-checks.yml runs on pushes to main and on all pull requests, and review-copy.yml on pushes to main. If your default branch has another name, change branches: [main] in both. To publish the review copy from a different branch, such as a beta branch you merge into when a round starts, name that branch instead.

Extra steps ​

Useful steps to add to story-checks.yml:

StepCommandEffect
Prose lint in the logstory prose "$STORY_DIR"Prints per-chapter prose statistics. Exits 0 on a readable project unless severity in story.md promotes a prose warning.
Timeline in the logstory timeline "$STORY_DIR"Prints scene chronology, POV balance, and character presence.
Fail on stale generated datastory reindex "$STORY_DIR", story wordcount "$STORY_DIR" --write, then git diff --exit-codeSee Failing on warnings.
Build a reading copystory build "$STORY_DIR" --format epubWrites dist/<story-id>.epub, which you can upload with actions/upload-artifact.

For example, to attach an EPUB to every run:

yaml
      - name: Build EPUB
        run: story build "$STORY_DIR" --format epub

      - name: Upload EPUB
        uses: actions/upload-artifact@v4
        with:
          name: epub
          path: ${{ env.STORY_DIR }}/dist/*.epub

Pin actions/upload-artifact to a commit SHA to match the rest of the file. Import, export, and builds covers build formats and options.

Action pins ​

The uses: lines are pinned to commit SHAs, so a moved or compromised tag cannot change what runs. The templates ship with the same pins as this repository's own CI, and a test keeps them in step, so a fresh copy starts current. Dependabot does not update templates/, only the workflows in a repository's .github/workflows/, so once you have copied the files there, add a Dependabot config for the github-actions ecosystem to your story repository to keep the pins current:

yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

Upgrading the workflows ​

A copied workflow keeps installing the release in its STORY_VERSION until you change it. Dependabot updates the uses: action pins (see Action pins) but not an env value, so nothing reminds you when a newer Story Skills release is out. Behaviour the workflows rely on can change between releases, such as the review copy's compare --anchor labels, so upgrade deliberately.

Bumping STORY_VERSION ​

  1. Pick the release from the GitHub releases page and read its changelog entries since your current pin.

  2. Run that release locally on the project, so new findings show up on your machine rather than in CI:

    shell
    npx --yes --package story-skills@0.24.1 story check .
  3. Change the pin in every workflow file you copied, such as .github/workflows/story-checks.yml, draft-next-chapter.yml, and review-copy.yml. Each has one line to change, without a v:

    yaml
    env:
      STORY_VERSION: "0.24.1"
  4. Compare each file with the template from the same release in templates/github/. A release can change steps as well as the version.

Migrating from STORY_REF ​

Templates from 0.21.0 and earlier pinned a git tag with STORY_REF: "v0.20.0" and fetched the CLI from GitHub with github:danjdewhurst/story-skills#$STORY_REF. The current templates install from npm with STORY_VERSION, which is faster and carries npm provenance. To migrate a copied workflow:

  1. Replace the STORY_REF line with STORY_VERSION and drop the v from the value, so v0.20.0 becomes 0.20.0, or name the release you are moving to.
  2. Replace each npx --yes --package "github:danjdewhurst/story-skills#$STORY_REF" story ... step, or the npm install ... "github:danjdewhurst/story-skills#$STORY_REF" line, with the template's Install the Story CLI step and plain story commands. Copying the current template and reapplying your STORY_DIR, schedule, and extra steps is usually quickest.
  3. If STORY_REF named a branch or commit rather than a release, set STORY_PACKAGE to github:danjdewhurst/story-skills#<ref> instead (see CLI version).

Getting reminders ​

story doctor reads the workflows in the project's .github/workflows/, and in the git repository root above it when the project lives in a subdirectory. It adds a low-priority action for each STORY_VERSION older or newer than the CLI you are running, and for each legacy STORY_REF, naming the file, the line, and what to change, where <old> and <new> are the pinned release and <cli> the one you are running:

text
- [P3] Update workflow CLI version: .github/workflows/story-checks.yml:30 installs story-skills <old>, older than this CLI (<cli>); after story check passes locally, change the line to STORY_VERSION: "<cli>".
- [P3] Update local CLI version: .github/workflows/story-checks.yml:30 installs story-skills <new>, newer than this CLI (<cli>), so CI can report findings that story check here does not; update it with npm install -g story-skills@<new>.
- [P3] Rename workflow STORY_REF: .github/workflows/review-copy.yml:47 sets the legacy STORY_REF; change the line to STORY_VERSION: "<cli>" and copy the install step from the current template (see Upgrading the workflows in docs/automation.md).

A newer pin means CI runs checks your CLI does not have yet, so the note asks you to update the CLI rather than the workflow. All the newer pins share one note, which lists each line and names the newest release (with each line's release in brackets when they differ). It names the command for the way you installed the CLI, judged from where its code runs:

Installed withThe note says
npm install -gupdate it with npm install -g story-skills@<new>
A package.json dependencyupdate the story-skills dependency in <folder>/package.json to <new>
npxinstall that release with npm install -g story-skills@<new>
bunxinstall that release with bun add -g story-skills@<new>
bun add -gupdate it with bun add -g story-skills@<new>
Homebrewupdate it with brew upgrade story-skills
A downloaded binarydownload the <new> binary for your system from the release page
The bundled scripts/story.js in the skillsupdate the Story Skills plugin or skills, which carry this bundled CLI
A clone of this repositoryupdate the clone in <folder> with git pull, or, with a release tag checked out, with git fetch --tags and git checkout v<new>

npx and bunx users are told to install globally because npx reads the .npmrc in the folder it runs in, and a cloned story repository can carry one that points at another registry; a global npm or Bun install ignores the project's .npmrc and bunfig.toml. Any other layout, such as pnpm, Yarn Plug'n'Play, or story-skills as a dependency of another package, gets update it to <new> and a pointer to Update, pin, or remove.

Versions compare in semver order: 1.0.0-rc.1 is older than 1.0.0 and newer than 0.9.0, and build metadata such as +ci.7 does not count. A STORY_VERSION that is not one exact release gets no version note, since doctor cannot tell which release CI installs. That covers latest, a range like ^0.22.0, 0.22, a number with a leading zero or too large to compare exactly, and a prerelease or build label over 64 characters.

A legacy STORY_REF that names a release newer than the CLI keeps that release, prerelease included, in the suggested line, and one that names a branch or commit gets a STORY_PACKAGE line instead. The note repeats the ref only when it is at most 100 letters, digits, and _./@+-, and says <ref> otherwise. A non-empty, uncommented STORY_PACKAGE silences the STORY_VERSION notes in its own env block and in the jobs and steps inside its owner, since their install step does not use that pin. So one at the top of the file covers the whole workflow, one in a job covers that job, and one in a step covers that step. An empty STORY_PACKAGE: "" does not count, as the templates' ${STORY_PACKAGE:-...} treats it as unset. When there is a workflow note, the Project is mechanically healthy action is left out.

It reads only the STORY_VERSION: and STORY_REF: lines, ignores commented-out ones, and says nothing when there is no workflows folder. A workflow file that is a symlink, a FIFO, or larger than 5 MiB is skipped without being read. story next and story report leave it out. Keeping your local CLI current and running story doctor now and then is enough to notice a stale pin.

To get a pull request instead, Renovate can bump STORY_VERSION with a regex manager that reads the line as an npm version. Add this to renovate.json in your story repository:

json
{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:recommended"],
  "customManagers": [
    {
      "customType": "regex",
      "managerFilePatterns": ["/^\\.github/workflows/[^/]+\\.ya?ml$/"],
      "matchStrings": ["STORY_VERSION:\\s*\"(?<currentValue>[^\"]+)\""],
      "depNameTemplate": "story-skills",
      "datasourceTemplate": "npm"
    }
  ]
}

Renovate then opens a pull request that changes the pin, and story-checks.yml runs on it with the new release, so you see what it reports before merging. Read the changelog before you merge it. This tests the new release only while STORY_PACKAGE is commented out: a workflow with STORY_PACKAGE set keeps installing that package, so the pull request changes a pin nothing uses. Comment out or remove STORY_PACKAGE once the fix it tracked is released.

Checking before each commit ​

A git pre-commit hook catches errors before they reach CI. Save this as .git/hooks/pre-commit in your story repository and make it executable with chmod +x .git/hooks/pre-commit:

shell
#!/bin/sh
# Block the commit when the story project has errors.
STORY_DIR=.
story validate "$STORY_DIR" || exit 1
story links "$STORY_DIR" || exit 1
story continuity "$STORY_DIR" || exit 1

The hook assumes story is on your PATH, for example after npm install -g story-skills. If it is not, replace story with npx --yes story-skills@0.24.1 or with node <skills-dir>/story-maintenance/scripts/story.js. Warnings do not block the commit.

The hook only checks. It does not run story reindex or story wordcount --write, because a hook that rewrites files leaves those changes unstaged. Run those yourself, or ask your agent to, after changing chapters or entities; the skills already direct agents to do this. See Writing workflows.

To skip the hook for one commit, for example to save a half-finished chapter, use git commit --no-verify.

If you use the pre-commit framework, the equivalent is a set of local hooks:

yaml
# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: story-validate
        name: story validate
        entry: story validate .
        language: system
        pass_filenames: false
      - id: story-links
        name: story links
        entry: story links .
        language: system
        pass_filenames: false
      - id: story-continuity
        name: story continuity
        entry: story continuity .
        language: system
        pass_filenames: false

pass_filenames: false matters: each command takes the project root, not a list of changed files.

Other CI systems ​

Nothing in the checks is specific to GitHub. Any CI system with Node 18 or newer can run them:

shell
npm install -g story-skills@0.24.1
story validate .
story links .
story continuity .

Each command exits 1 on errors, and 2, 3, or 4 when it cannot run at all, so any failure fails the job. Add story report . --actionable at the end if you want a readable summary in the log.

See also ​

Released under the MIT License.