Build and check D&D character documents from a D&D Beyond sheet.
- Each character gets a personal guide (player only), a party handout (safe to share), a DM brief (DM only) and a party workbook (a fillable PDF).
- They're built from Python scripts into A4 PDFs, then checked:
- spoilers: nothing from the character's
secrets.txtreaches a party document; - spells: nothing the character can't cast yet, unless it's labelled with its level;
- layout: golden baselines catch drift.
- spoilers: nothing from the character's
- It reads D&D Beyond (read-only) and never writes to it.
- Want a campaign repo? Use the template: dnd-campaign-template. Its README walks you through it, with or without Claude.
Python 3.12 or later, and DejaVu fonts (sudo apt-get install fonts-dejavu-core).
pip install "ddtools @ git+https://github.com/modem7/ddtools.git@v0.2.0"
ddtools --versionEvery command has --help. Commands that report data take --json.
ddtools init my-campaign --owner <your GitHub name>: the repo's files, filled in for you (seeddtools initbelow).cd my-campaign, thenddtools new-character "Name" --new-campaign "Campaign".- Set
dndbeyond_idinName/character.yaml, make the character Public on D&D Beyond, and runddtools fetch Name. ddtools workbook Name: fill in the PDF, thenddtools import-workbook filled.pdf Name.- Write the documents (
ddtools blueprintssays what each one holds), thenddtools build Nameandddtools check Name.
| Command | What it does |
|---|---|
ddtools build <dir> [--out DIR] |
Runs each document script listed in <dir>/character.yaml. Writes the PDFs (and a .txt copy of each) to <dir>/pdf/, or to --out. Stops at the first failing script, names it and prints its error. |
ddtools check <dir> |
Checks every audience: party document. Spoilers: any entry from secrets.txt, ignoring case and curly quotes, even if it's split across lines. Spells: any spell not on the latest snapshot, unless its sentence has a level label ("at level 4", "(level 8+)"), it sits under a future_sections heading, or it's in spell_ignore. Exits 1 if it finds anything. |
ddtools preview <dir> [--pdf NAME] [--pages A-B] |
Renders pages to tmp/preview-<name>.png so the layout can be checked by eye. |
ddtools golden update <dir> |
Rebuilds and records a new golden baseline in tests/golden/<dir>/. Only use it when a change was intended, and commit it with that change. |
ddtools golden check <dir> |
Rebuilds and compares with the baseline. Exits 1 on any difference. |
ddtools golden capture <dir> --from-committed |
Records a baseline from the PDFs already in <dir>/pdf/, without rebuilding. |
Characters must be set to Public on D&D Beyond (Character Settings → Character Privacy).
| Command | What it does |
|---|---|
ddtools fetch <dir> |
Saves the sheet to <dir>/snapshots/YYYY-MM-DD.json. With a bare id or URL, it prints the sheet instead. |
ddtools summary <dir | id | url> |
Shows level, classes, stats, spells by source, options, feats, magic items and gold. Use it to check that the documents match the sheet. |
ddtools party <dir> |
Fetches every party member live, with name, level and classes. |
ddtools diff <dir> [old new] |
Shows what changed between the two latest snapshots, with the level-up checklist when the level changed. |
ddtools watch [dirs…] [--issue-dir DIR] |
Compares live sheets (the character's and the party's) with the committed snapshots and party.json. Exits 0 for no changes, 3 for changes, 1 for an error. Gold and consumables don't count as changes. |
ddtools sheet-text <dir> |
Writes the text for the sheet's description and notes fields to <dir>/notes/dndbeyond-text.md, from <dir>/dndbeyond.yaml. Allies come from the party list. Says which fields need pasting, lists [blanks], and shows where the live sheet gives a secret away. Refuses, writing nothing, if the new text would give one away or a detail is over 50 characters. |
| Command | What it does |
|---|---|
ddtools new-character "Name" --new-campaign "Campaign" |
Creates Name/ (spaces become underscores) with character.yaml, secrets.txt, notes and starter build scripts for every document. |
ddtools new-character "Name" --campaign-from <dir> |
The same, copying the campaign (facts, rules, party) from a character in the same campaign. The new character is dropped from their own party list. |
ddtools workbook <dir> |
Builds the party workbook: a fillable PDF each player fills in about their character. What's specific to the campaign comes from character.yaml (campaign.facts, campaign.workbook, party_summary). |
ddtools import-workbook <pdf> <dir> |
Reads a filled workbook: every answer goes into notes/sources/workbook-answers.md, and blanks in character.yaml (D&D Beyond id, class summary) are filled. Never overwrites: differences are listed. |
ddtools blueprints [name] |
Lists the blueprints, or prints one: what each document contains, and the rules learned so far. |
ddtools retire <dir> --reason death|tpk|retired|campaign-end [--note "…"] |
Marks the character retired and writes RETIRED.md (unrevealed secrets, open DM questions). Moves the folder to archive/. |
ddtools list [--json] |
Lists active and archived characters. |
ddtools init <dir> --owner NAME [--name --public --template --extends --funding HANDLE] |
Makes a folder a campaign repo (see below). Safe to re-run: it never touches a file someone edited, and lists what it left alone. |
It writes a campaign repo, filled in for its owner:
| File | What |
|---|---|
CLAUDE.md, README.md |
How to work in the repo, for people and for Claude |
requirements.txt |
ddtools, pinned to this version (Renovate proposes updates) |
.github/workflows/ci.yml |
Build, check and golden-compare every active character; secret scan |
.github/workflows/dndbeyond-watch.yml |
A daily ddtools watch that opens an issue when a sheet changes |
.github/ |
Settings, CODEOWNERS, issue and PR templates, auto-assign |
| Others | renovate.json, .editorconfig, .gitattributes, .gitignore, .claude/settings.json, MIT licence, CONTRIBUTING |
It records what it was run with in .ddtools-init.yaml. Run it again with other values
(say, on your copy of the template repo) and it updates the files still exactly as it
wrote them, removes ones no longer wanted (FUNDING without --funding), and keeps
anything you edited.
Options: --public (default private), --template (a GitHub template repository),
--extends (settings.yml extends your .github repo), --funding HANDLE (a Buy Me a
Coffee link).
| Path | What |
|---|---|
character.yaml |
Name, level, D&D Beyond id, the campaign (facts, rules, party) and the documents to build |
secrets.txt |
Words that must never reach the party. One per line; case-insensitive, whole words; "quoted" for an exact match |
src/ |
One build script per document, using ddtools.pdfstyle |
notes/ |
Decisions, rulings, open questions, sources |
snapshots/ |
D&D Beyond sheets (ddtools fetch) and party.json |
pdf/ |
The built documents |
Usually triggered by the daily watch's dndbeyond-sync issue.
ddtools fetch <dir>, thenddtools diff <dir>: read the checklist it prints.- Update the cheat sheet (current level only), the handout's "What They Can Do for You Right Now" and "Coming Soon", the DM brief's numbers and level plan, and the guide's level table.
- Bump
levelandupdatedincharacter.yaml. - If the party's levels changed, also run
ddtools party <dir> --json > <dir>/snapshots/party.json. - Run Before sending a document, then open a PR whose body says
Closes #<issue>.
- Add it to
campaign.rulesincharacter.yaml(status: confirmedoropen; useshow_into keep a row out of a document), and quote the DM innotes/rulings.md. - Update any section that relied on the old assumption (search the
.txtcopies). - Run Before sending a document.
- Someone leaves: set their
status: goneincampaign.party. They drop off the workbook, the allies text andwatch. - Someone joins: add them to
campaign.partywith theirdndbeyond_id, then runddtools party <dir> --json > <dir>/snapshots/party.json. - Update the documents that name teammates, then run Before sending a document.
ddtools retire <dir> --reason death --note "How it happened".- Read
archive/<dir>/RETIRED.md: unrevealed secrets and open questions are story threads the next character can pick up. ddtools new-character "New Name" --campaign-from archive/<dir>(same campaign).- Set
dndbeyond_id, runddtools fetch, and write the documents following the blueprints (ddtools blueprints). - Tell the party: their handouts and the workbook may mention the old character.
ddtools new-character "Name" --new-campaign "Campaign".- Fill in
campaign.facts,campaign.rulesandcampaign.partyincharacter.yaml, and put the DM's campaign guide inreference/. - For the workbook, fill in
campaign.workbook(who it's for, the campaign line, what's not allowed, an example place, combat notes), thenddtools workbook <dir>.
The party can read the D&D Beyond sheet, so its description and notes follow the same rules as a party document.
- Change the text in
<dir>/dndbeyond.yaml. A new teammate needs a line underallies:. ddtools fetch <dir>, so the comparison uses today's sheet.ddtools sheet-text <dir>. If it refuses, it names the field and the word: reword it.- Open
<dir>/notes/dndbeyond-text.md. Paste each field marked needs updating or missing into D&D Beyond (Description tab, then Notes). Leave the rest. - Done when:
ddtools fetch <dir>, thenddtools sheet-text <dir>, shows every field as up to date or leave as is.
ddtools build <dir>andddtools check <dir>.ddtools preview <dir>, and look at every page.- Reread every party document as a teammate would: could anything hint at a secret?
- If the change was intended, run
ddtools golden update <dir>and commit it with the change.
A campaign repo can add its own commands and D&D Beyond text blocks with a small package
of its own. Register a module in the ddtools.plugins entry-point group:
[project.entry-points."ddtools.plugins"]
my_plugin = "my_plugin.plugin"def register(api):
def configure(parser):
parser.add_argument("dir")
@api.command("weather", "Today's weather for the party.", configure)
def weather(args):
print("Sunny")
return 0
# Fills {{weather}} in dndbeyond.yaml's text fields.
api.sheet_text_block("weather", lambda ch, names: "Sunny, for " + ", ".join(names))A plugin that fails to load prints one warning and is skipped; ddtools still runs.
Unknown top-level keys in character.yaml are kept in Character.extra for plugins.
pip install -r requirements-dev.txt
pytest
ruff check . && ruff format --check .- Tests use made-up characters (
tests/conftest.py) andtests/fixtures/. They never call D&D Beyond. ddtools.pdfstylevalues set the look of every document; changing one changes every campaign's PDFs, and their golden tests will say so.- To release: bump
__version__insrc/ddtools/__init__.py, add aCHANGELOG.mdsection and merge. Then either press Run workflow under Actions → Release, or push the tagvX.Y.Z. The release workflow tags master if needed, checks the tag matches and publishes the GitHub release with that section as its notes.
MIT. See LICENSE.