Environment variables
Every variable Handbooks reads, the naming rule that generates them, the .env cascade, and which ones must never enter a config file.
The naming rule
Every setting has one camelCase key in the registry. Three names are derived from it by the same transform:
| Surface | From readWorkers | Scoped to generate |
|---|---|---|
| Flag | --read-workers <n> | — |
| Environment | HANDBOOK_READ_WORKERS | HANDBOOK_GENERATE_READ_WORKERS |
| Config-file key | readWorkers | generateReadWorkers, or nested generate: { readWorkers: } |
The scoped form always wins over the flat one. That is what lets you say "narrate in Chinese, but only when generating" without touching anything else.
export HANDBOOK_NARRATE_LANG=en # everywhere
export HANDBOOK_GENERATE_NARRATE_LANG=zh # …except generateA few settings are scoped-only, because their meaning changes per command:
--out (HANDBOOK_RENDER_OUT, HANDBOOK_SKILL_OUT, HANDBOOK_PLAN_OUT),
--handbook (HANDBOOK_SKILL_HANDBOOK, HANDBOOK_PLAN_HANDBOOK),
skill's --lang (HANDBOOK_SKILL_BODY_LANG), and resync's --detail /
--narrate-lang (HANDBOOK_RESYNC_CARD_DETAIL, HANDBOOK_RESYNC_PROSE_LANG).
Vendor aliases
Seven settings also accept the names people already have exported:
| Setting | Alias |
|---|---|
llmApiKey | OPENAI_API_KEY |
llmProvider | OPENAI_PROVIDER |
llmModel | OPENAI_MODEL |
llmBaseUrl | OPENAI_BASE_URL |
llmMaxTokens | OPENAI_MAX_TOKENS |
llmTimeout | OPENAI_TIMEOUT |
llmExtraBody | OPENAI_EXTRA_BODY |
Lookup order is: scoped HANDBOOK_<CMD>_<KEY> → flat HANDBOOK_<KEY> → the vendor alias.
Bootstrap variables
Three settings are resolved before everything else, because everything else depends on
them. None of them can be set by the thing they load — an --env key inside
handbook.config.yaml would have nothing left to read it.
| Variable / flag | What it does |
|---|---|
HANDBOOK_ENV / --env <name> | Selects a per-environment .env cascade and prefers handbook.config.<name>.yaml |
--env-file <path> / HANDBOOK_ENV_FILE | Loads exactly that one file, bypassing the cascade. A missing file is a loud error. Prefer the variable: Node >= 20.6 owns --env-file too and pre-scans for it, so a missing path dies as node: <path>: not found (exit 9) before Handbooks runs. The flag wins over the variable when both are set |
--config <path> | Names one exact config file, bypassing discovery |
The .env cascade
With no --env-file, the CLI loads a cascade of .env* files from the current
directory, highest precedence first:
| # | File | Who | Scope | Committed? |
|---|---|---|---|---|
| 1 | the shell environment | — | — | always wins |
| 2 | .env.<name>.local | personal | this environment only | no (gitignored) |
| 3 | .env.<name> | team | this environment only | yes |
| 4 | .env.local | personal | every environment | no (gitignored) |
| 5 | .env | team | baseline | yes |
Rows 2 and 3 only apply when --env/HANDBOOK_ENV names an environment. With neither
set, only rows 4 and 5 load.
The whole cascade is "call it in this order, first writer wins", because loading a file never overrides a key that is already set. That one rule is what keeps the shell outranking every file, with no extra logic anywhere.
handbook generate --env prod --source ~/code/api --work work/api
# loads .env.prod.local → .env.prod → .env.local → .env
# and prefers handbook.config.prod.yaml over handbook.config.yamlThe cascade is cwd-only
Unlike handbook.config.yaml — which is discovered by walking up to the git root — .env files are read
from the directory you run the command in. .env means "this machine, right now". Run LLM-backed commands
from the repo root, or pass --env-file.
What the .env parser accepts
KEY=value, an optional export prefix, blank lines, # comment lines, single- and
double-quoted values (quotes stripped), and a trailing # inline comment on an unquoted
value. CRLF, LF and bare-CR line endings all work. No multiline values.
An empty value reads as unset — HANDBOOK_TITLE= will not produce an untitled
handbook.
Secrets
Two settings are marked secret in the registry — llmApiKey / OPENAI_API_KEY and
llmExtraBody / OPENAI_EXTRA_BODY. For both that means:
- it is never a command-line flag (flags land in shell history and
psoutput); - it is rejected if it appears in a config file, with a message saying why — config files get committed;
- it is masked in
handbook configoutput.
llmExtraBody is a secret because it is free-form. It merges whatever you put in it
into every request body, and gateways do accept auth in the body — so the tool cannot
enumerate what is in there, and cannot tell a tuning field from a credential. It has no
flag at all; use the environment variable.
llmBaseUrl is deliberately not a secret: a team pointing every checkout at one shared
gateway has a legitimate reason to commit it. Only a URL carrying embedded credentials
(https://user:pass@gw.internal/v1) is refused in a config file — there and nowhere else.
handbook.config.yaml: llmApiKey must not appear in a config file (it gets committed)
— use .env or the shell environment insteadDocker
The image bakes in HANDBOOK_SOURCE=/src and HANDBOOK_WORK=/work, so you only mount
volumes:
docker run --rm -v "$PWD:/src:ro" -v handbook-work:/work handbook:local analyzeDocker's own --env-file layers on top of the toolchain's .env loading — both
apply, and an OPENAI_* variable passed that way is visible exactly as a shell export
would be. .env* files are never baked into the image; see .dockerignore.
Seeing what actually resolved
handbook config --command generateprints the active environment, every .env file the cascade loaded, the config file it
resolved, and one row per setting with its provenance — flag, env, file or
default.
handbook config --check # exit 2 on the first invalid or missing valuePut --check in CI
A typo'd variable used to mean "silently ran at the default". Now it is a failure with the variable named in the message — which is much cheaper to find in CI than forty minutes into a generation run.
The complete list
Every variable, with its type, default and documentation, is on the Configuration reference page — which is generated from the same registry the CLI reads, so it cannot drift.
.env.example in the repo root is generated from that registry too. Every line in it
starts commented out, so copying the whole file is safe.