Handbooks
Reference

CLI reference

Every subcommand, every flag, its environment variable and its default — plus what each command writes and what it exits with.

handbook [global options] <command> [command options]

Every command writes its result to stdout as JSON and its logs to stderr, so piping works exactly as you would hope:

handbook analyze --source ~/code/api --work work/api | jq .functions

`--help` is generated, not written

Every flag below is derived from one settings registry, so handbook <cmd> --help always lists the flag, its environment variable, its per-command scoped variable and its default. If this page and --help ever disagree, --help is right — and a drift test fails the build.

Global options

FlagEffect
-V, --versionPrint the version
-v, --verboseDebug logging
-q, --quietErrors only — wins over -v
--env <name>Select an environment: loads .env.<name>.local and .env.<name> ahead of .env.local and .env, and prefers handbook.config.<name>.yaml. Same as HANDBOOK_ENV
--env-file <path>Load exactly this file, bypassing the .env cascade. A missing file is a loud error, not a fallback. Prefer HANDBOOK_ENV_FILE — see the warning below
--config <path>Use this config file instead of discovering the nearest handbook.config.yaml

Global options go before the subcommand:

handbook --env prod -v generate --source ~/code/api --work work/api

`--env-file` collides with a Node flag

Node >= 20.6 has its own --env-file, and it pre-scans the whole command line for it — including the part after the script path, where it does not actually apply the file. A path that exists passes through to Handbooks untouched, but a path that does not exist kills the process first:

$ handbook --env-file /gone.env config
node: /gone.env: not found        # node, exit 9, before Handbooks ever runs

So the one case the flag promises to report loudly is the one case it cannot report. HANDBOOK_ENV_FILE does exactly the same thing and cannot be intercepted:

$ HANDBOOK_ENV_FILE=/gone.env handbook config
handbook: error: ENOENT: no such file or directory, open '/gone.env'

The flag still works whenever the file is actually there, and it beats the environment variable when both are set.


analyze

Phase 1 only: build the static call graph. No LLM, no key, free.

handbook analyze --source <dir> --work <dir> [--lang <lang>]
FlagDefaultEnv
--source <dir>requiredHANDBOOK_SOURCE / HANDBOOK_ANALYZE_SOURCE
--work <dir>requiredHANDBOOK_WORK / HANDBOOK_ANALYZE_WORK
--lang <lang>autoHANDBOOK_LANG / HANDBOOK_ANALYZE_LANG

--lang accepts auto or any one of: cpp csharp dart go java kotlin objc ocaml php python ruby rust scala shell solidity swift typescript zig. auto detects and merges every language in one pass and is almost always what you want.

Writes phase1/graph.json, functions.csv, graph.dot, dropped-calls.json, scan-coverage.json.

files counts what was actually read and parsed; filesUnparsed counts what was not, and every one of those is named with a reason in scan-coverage.json. See Artifact formats.

stdout
{
  "language": "multi",
  "files": 412,
  "functions": 3187,
  "edgesKept": 9042,
  "edgesDropped": 611,
  "filesUnparsed": 3
}

generate

The full pipeline. Needs an LLM endpoint for anything past phase 1.

handbook generate --source <dir> --work <dir> [options]

Pipeline options

FlagDefaultWhat it does
--phase <spec>allall · 1 · 2 (=2a+2b+2c) · 2a · 2b · 2c · 3, or a comma list
--strategy <s>(work dir's recorded one, else file)file or member
--skeleton <path>Your own skeleton.yaml. Required for --strategy member
--detail <d>briefbrief or deep card depth
--synth-mode <m>oneshotoneshot, or doctor for the actor–critic repair loop
--narrate-lang <l>enen or zh
--max-doctor-rounds <n>6Doctor convergence rounds
--resumefalseSkip files that already have a completed card
--refreshfalseIgnore phase-3 caches
--llm-cachefalseCache raw LLM replies under <work>/phase3/cache

Throughput options

FlagDefaultWhat it does
--read-workers <n>12Concurrent card batches
--read-batch-size <n>(1 for deep, 8 for brief)Files per card batch
--max-chars-per-file <n>0Truncate each file at n chars; 0 = no limit
--assign-batch-size <n>25Cards per assignment batch
--assign-workers <n>12Concurrent assignment batches
--organize-workers <n>8Concurrent stage-organize calls
--narrate-workers <n>8Concurrent narration calls

LLM options (shared by generate, plan, resync, studio)

FlagDefaultEnv alias
--provider <name>openaiOPENAI_PROVIDER
--model <id>gpt-4o-miniOPENAI_MODEL
--base-url <url>https://api.openai.com/v1OPENAI_BASE_URL
--max-tokens <n>16000OPENAI_MAX_TOKENS
--timeout <sec>300OPENAI_TIMEOUT
--llm-retries <n>6
--llm-retry-backoff <sec>3
--llm-concurrency <n>16

The API key is never a flag. Set OPENAI_API_KEY (or HANDBOOK_LLM_API_KEY) in the environment or a .env file. It is rejected in a config file, because config files get committed.

The extra request body is never a flag either, and is rejected in a config file for the same reason. Set OPENAI_EXTRA_BODY (or HANDBOOK_LLM_EXTRA_BODY) in the environment: it merges vendor fields into every request body — {"thinking":{"type":"disabled"}}, for instance — and because it is free-form, there is no way to tell a tuning field from an auth field inside it. Model, message and token fields cannot be overridden through it.

--base-url is a flag and is welcome in a config file — a team pointing every checkout at one shared gateway is exactly what that file is for. A URL that embeds credentials (https://user:pass@gw.internal/v1) is refused there, and only there; keep the credential in the environment.

--provider selects the wire format, not the vendor: openai (the default) speaks to every OpenAI-compatible endpoint, which is most of them; anthropic and gemini are there for the two that are not.

stdout
{
  "phasesRun": ["1", "2a", "2b", "2c", "3"],
  "nCards": 412,
  "nStages": 9,
  "nUnassignedFiles": 0,
  "nRegisters": 6,
  "usage": { "promptTokens": 1840221, "completionTokens": 214880, "totalTokens": 2055101 }
}

render

Work dir → markdown, and optionally more. No LLM.

handbook render --work <dir> [--title <t>] [--html] [--html-single] [--agent-site] [--llms-txt]
FlagDefaultWhat it does
--work <dir>requiredThe work directory to render
--title <title>System HandbookHandbooks title in the output
--out <dir><work>/handbookWhere to write
--htmlfalseAlso the multi-page HTML site, under <out>/html
--html-singlefalseAlso one self-contained <out>/handbook.html
--agent-sitefalseAlso the agent index + fact tables, under <out>/agent
--llms-txtfalseAlso llms.txt and llms-full.txt
--source-base-url <url>Link every file card to <url>/<relative path>

Without --source-base-url the output contains no external URLs at all, which matters if you are shipping a handbook for a private codebase.

--out is scoped only: its environment variable is HANDBOOK_RENDER_OUT, not a flat HANDBOOK_OUT, because --out means something different on plan and skill.


skill

Rendered handbook → agent SKILL package. No LLM.

handbook skill --handbook <dir> --out <dir> --name <slug> [options]
FlagDefaultWhat it does
--handbook <dir>requiredThe rendered handbook directory
--out <dir>requiredWhere the SKILL package goes
--name <slug>requiredLowercase-hyphen slug; produces <slug>-handbook
--project <name>(--name)Human project name used in the prose
--work <dir>Adds coverage.json from the phase-2 assignment
--source <dir>With --work, adds a content hash per file
--agent-dir <dir>Ships the agent index and its fact tables under references/agent/
--lang <l>enSKILL.md body language. Frontmatter stays English

Two refusals worth knowing

--out must not be the handbook directory, or an ancestor of it: the build starts by wiping --out, which would delete the very thing being packaged. And --lang zh gives you a Chinese body with English frontmatter — agent runtimes route on the description text, so translating it would silently break skill selection.


validate

Check a SKILL package. No LLM. Exits 2 on failure.

handbook validate --skill <dir> [--source <dir>]
FlagDefaultWhat it does
--skill <dir>requiredThe skill directory to validate
--source <dir>Re-hash the live source to detect drift

Checks structure, the frontmatter contract, index ↔ stage-page consistency, coverage.json schema, and hash freshness. Errors and warnings go to stderr.


plan

Handbooks-guided change localization. Needs an LLM endpoint. Read-only.

handbook plan --source <dir> --request "<text>" [--handbook <dir>] [--out <file>]
FlagDefaultWhat it does
--source <dir>requiredThe codebase to plan against (never written to)
--request <text>requiredThe natural-language change request
--handbook <dir>Rendered handbook or skills/<x>/references. Strongly recommended
--out <file>(stdout)Write the plan here
--max-turns <n>30Agent turn budget

Plus the shared LLM options.

Exits non-zero if the planner gave up — it invented tool results, ran out of turns, or finished with nothing usable — rather than writing an apology that a script would feed into apply.


apply

Apply a plan's EDIT blocks. No LLM. Exits 2 if anything did not land.

handbook apply --source <dir> --plan <file> [--dry-run] [--backup-root <dir>]
FlagDefaultWhat it does
--source <dir>requiredThe tree to edit
--plan <file>requiredThe plan from handbook plan
--dry-runfalseVerify only — never writes
--backup-root <dir><source>/.handbook-patchesWhere backups go
stdout
{
  "ok": true,
  "dryRun": false,
  "outcomes": [
    { "index": 1, "file": "src/upload.py", "where": "Uploader.send (~88)", "status": "applied", "line": 88 }
  ],
  "changedFiles": ["src/upload.py"],
  "backupDir": "/repo/.handbook-patches/2026-08-08T14-05-11-204Z",
  "problems": []
}

Statuses: applied · created · no-match · ambiguous · file-missing · not-a-file · unsafe-path · undecodable · skipped.


rollback

Restore a source tree from a patch backup. No LLM.

handbook rollback --backup <dir> [--source <dir>] [--force]
FlagDefaultWhat it does
--backup <dir>requiredBackup directory containing manifest.json
--source <dir>Refuse a backup that belongs to a different tree
--forcefalseRestore even files that changed after the patch

Without --force, a file whose current hash does not match the post-patch hash is refused — restoring it would silently destroy whatever was done since.


resync

Roll a handbook forward after a code change.

handbook resync --case <dir> --work <dir> [options]
FlagDefaultWhat it does
--case <dir>requiredCase directory: edited/ + optional plan.md + optional change.diff
--work <dir>requiredThe work directory to roll forward
--title <title>System HandbookTitle used when re-rendering
--no-llm(LLM on)Structural refresh only; prose is marked stale
--no-render(render on)Skip refreshing already-rendered outputs
--corrections <file>corrections.jsonl; its files widen the refresh set
--detail <d>(match the existing handbook)brief or deep for regenerated cards
--narrate-lang <l>(match the existing handbook)en or zh

Plus the shared LLM options.

Leaving --detail and --narrate-lang unset is the right default: unset means "match what this handbook already is", so a resync never silently downgrades a deep handbook to brief.


studio

The local web UI. Runs until Ctrl-C.

handbook studio [--port <n>] [--host <addr>] [--state-dir <dir>]
FlagDefaultWhat it does
--port <n>4860Port to listen on
--host <addr>127.0.0.1Bind address. Containers need 0.0.0.0
--state-dir <dir>$HOME/.handbook-studioRegistry and managed work dirs

Plus the shared LLM options — Studio resolves them from the same layers as every other command, so --model and a config-file llm: block both reach its jobs.

Setting --host 0.0.0.0 does not make Studio remotely reachable in any useful sense: the CSRF guard checks the Host header, so a request naming a LAN IP is refused with 403. See Studio.


config

Print the resolved configuration and where every value came from. No LLM.

handbook config [--command <name>] [--json] [--check]
FlagDefaultWhat it does
--command <name>generateShow only the settings that apply to this subcommand
--jsonfalseMachine-readable output
--checkfalseValidate only; exit 2 if anything is invalid or missing
handbook config --command generate      # a table, with provenance per row
handbook config --json | jq '.settings[] | select(.source.kind == "env")'
handbook config --check                 # put this one in CI

It shows broken configuration on purpose

Unlike every other command, config does not abort on an invalid value. A missing --source renders as a visible — unset (required) row rather than taking down the one tool you would use to debug that exact problem.


Exit codes

CodeMeaning
0Success
1An error — invalid configuration, a missing artifact, a failed run. Message on stderr, prefixed handbook: error:
2A check failed: validate found problems, apply did not fully land, or config --check found something invalid

2 means "the tool worked, and the answer is no". Scripts should treat it differently from 1.

The pnpm shortcuts

From a clone, each of these builds first and forwards flags straight through:

pnpm analyze  --source ~/code/proj --work work/proj
pnpm generate --source ~/code/proj --work work/proj
pnpm render   --work work/proj --html --agent-site --llms-txt
pnpm skill    --handbook work/proj/handbook --out skills/proj --name proj
pnpm validate --skill skills/proj --source ~/code/proj
pnpm plan     --source ~/code/proj --request "…" --out plan.md
pnpm apply    --source ~/code/proj --plan plan.md --dry-run
pnpm rollback --backup ~/code/proj/.handbook-patches/<stamp>
pnpm resync   --case cases/mycase --work work/proj
pnpm studio
pnpm config:show --command generate
pnpm handbook --help

On this page