A self-hosted campaign manager for D&D 5e, for a DM and their players. Campaign notes are pages built from blocks (like Notion), with NPCs, locations, sessions, quests, factions and player characters as typed pages. The rules (spells, monsters, items, …) come from your own local copy of the 5etools data, for the 2014 or the 2024 rules.
- Campaigns locked to an edition. A campaign uses the 2014 or 2024 rules; the reference, search and
{@tags}follow it. Other-edition content can be allowed per campaign, with a badge. A campaign can later move to the other edition: you see every reference and what it becomes before anything changes. - Pages and blocks. Text, callouts, read-aloud boxes, DM secrets, tables and roll tables, images, columns,
tabs, embedded statblocks and spells, page cards, live queries ("all NPCs in this town"),
[[links]]with backlinks, custom properties. - The reference. Browse and search everything in your 5etools data, with statblocks in the 2014 or 2024
style and hover previews for every
{@tag}. - Homebrew in the 5etools format: form and JSON editors with a live preview, variants of official creatures, revisions, import and export of 5etools homebrew files.
- Templates for blocks and whole pages, with typed fields; linked copies follow template changes.
- Encounters and combat. Difficulty by the campaign's rules, an initiative tracker with death saves, legendary actions and resistance, recharge, lair actions, Ready and Delay, and a live view for the players' screen that never shows hidden creatures. Loot goes to the party's inventory.
- Players. Invite links, a co-DM role, players editing their own character. What the DM hides is never sent to a player's browser: the server leaves it out of every response.
- Campaign export and import as a zip file.
- Accounts and administration. Everyone changes their own name and password on the Account page; the admin manages users, resets passwords with one-time links and can always recover a locked-out account from the command line (Locked out?).
- Page history. The DM sees every earlier version of a page, compares versions and restores one, with an Undo. Edits made by an AI assistant are labelled (Using it).
- Reveal to players. "Reveal now" on a secret or "Share now" on a handout shows up on the players' screens while they play, without reloading.
- Backups and trash. One-click or scheduled instance backups with a tested restore (Backups), and a 30-day trash for deleted pages and templates with Undo and Restore.
- Phone-friendly for players (tested in Chrome's phone emulation). Reading, your own character, the inventory, dice and the combat screen work on phone-width screens (Using it).
- AI assistants (MCP). Connect Claude or another MCP client with a personal token: it can read and write pages, encounters, the inventory, homebrew and templates, with exactly your rights.
Critical Notes ships no game content. It reads two git repositories that you clone yourself: 5etools-src (the data, about 1 GB) and 5etools-img (art and tokens, about 13 GB, optional). Use them only for books you own; see Licensing.
pnpm data:clone # both repos into ./5etools-data/ (SKIP_IMG=1 for the data only)
# or by hand:
git clone --depth 1 https://github.com/5etools-mirror-3/5etools-src.git 5etools-data/5etools-src
git clone --depth 1 https://github.com/5etools-mirror-3/5etools-img.git 5etools-data/5etools-imgdocker compose up -d # pulls the published image the first time; then open http://localhost:3000To build the image from this checkout instead, add the build file:
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build.
The first visit asks you to create the admin account. Players join through invite links that a DM creates in the campaign's settings (there is no open sign-up).
- The 5etools folders are mounted read-only. Your data (database, search index, uploaded images) lives in the
app-datavolume, anddocker compose down -vdeletes it. Scheduled backups are on by default (one every 24 hours, into that same volume), so copy them off the machine. - The app runs as an unprivileged user, on port 3000 inside the container (
PORT=8080 docker compose up -dto publish another port). - Set
PUBLIC_URLto the address people use (for examplehttps://notes.example.orgbehind a reverse proxy that handles TLS). With anhttps://address, session cookies are marked Secure.
Image and versions. Compose uses the published image ghcr.io/merilairon/critical-notes (linux/amd64 and
linux/arm64; the amd64 image was tested; the arm64 image is built by the release workflow and has not been run on arm64 hardware). docker compose pull && docker compose up -d moves to the newest release; database migrations run when the
server starts, so back up first (the Updating section has the steps). Pin a version with CN_VERSION=1.2.0 in .env (the default is latest).
To build from a checkout instead (before the first release, if the pull is denied, or while developing):
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build.
Updating the 5etools data:
docker compose --profile tools run --rm data-update # pulls the latest data into ./5etools-data
docker compose restart app # re-indexes on start (only changed files are read)Requires Node 24.15 or later, pnpm 11 and git.
pnpm install
pnpm build # the server bundle (apps/server/dist) and the web app (apps/web/dist)
NODE_ENV=production pnpm --filter @cn/server start:dist # serves the app on http://localhost:3000
pnpm data:update # later: pull the latest 5etools data and re-indexEnvironment variables, or a .env file in the repository root (see .env.example). Relative paths are
resolved from the repository root.
| Variable | Default | |
|---|---|---|
FIVETOOLS_SRC_PATH |
./5etools-data/5etools-src |
The 5etools data clone |
FIVETOOLS_IMG_PATH |
./5etools-data/5etools-img |
The image clone (optional; missing images show a placeholder) |
DATA_DIR |
./var |
Database, search index and uploads |
BACKUP_KEEP |
7 |
Backups to keep, 1 to 1000 (an invalid value logs a warning and uses 7). Backups made from the web page are protected for 7 days and do not count until then |
BACKUP_EVERY_HOURS |
off (24 in docker-compose.yml) |
Make a backup in the server every N hours, 1 to 720 (0 or unset = off; recommended 24). The shipped docker-compose.yml turns it on with 24; put BACKUP_EVERY_HOURS=0 in the .env next to it to turn it off, see Backups. After downtime one catch-up backup is made about a minute after start; it is skipped while a backup or a restore is running. An invalid value stops the server from starting. The Backups page shows the next run |
BACKUP_DIR |
DATA_DIR/backups |
Where backup zips go; use another disk or mount so the backups survive the data. Must not be inside the web build, the uploads or the 5etools folders, or DATA_DIR itself, and must not be world-writable (the server refuses to start) |
HISTORY_KEEP_ALL_DAYS |
30 |
Page history: every saved version is kept this many days (1 to 3650); older ones are thinned to one per page per day. An invalid value logs a warning and uses the default |
HISTORY_COALESCE_MINUTES |
2 |
Page history: saves by the same person within this many minutes become one version (0 = keep every save, 0 to 1440). A paste you regret can overwrite at most this much earlier state |
HISTORY_MONTHLY_AFTER_DAYS |
365 |
Page history: after this many days only one version per page per month is kept (1 to 36500) |
HISTORY_MAX_PER_PAGE |
200 |
Page history: a page with more versions than this is thinned early, oldest first (10 to 100000) |
HISTORY_MAX_MB_PER_CAMPAIGN |
500 |
Page history: soft size limit per campaign (10 to 1000000 MB); older versions are thinned beyond it, the last 30 days never are. Pruning stops history from growing; it does not shrink the database file |
PORT |
3000 |
|
PUBLIC_URL |
http://localhost:4200 |
The address people open; requests from other origins are refused |
SECURE_COOKIES |
from PUBLIC_URL |
1 or 0 to override (for example behind a TLS-terminating proxy that forwards plain http) |
TRUST_PROXY |
off | 1 behind a reverse proxy that overwrites X-Forwarded-For, so rate limits tell clients apart. Leave off when the port is reachable directly (the header could be spoofed); the app logs one warning if it sees the header while this is off |
EVENTS_MAX_CONNECTIONS |
500 |
Live event streams (reveal notifications) open at once; over the limit a new stream gets 503 with Retry-After and the browser retries. One user keeps at most 6 (the oldest is closed) |
EVENTS_MAX_PER_CAMPAIGN |
100 |
The same limit per campaign |
WEB_DIST_DIR |
./apps/web/dist/web/browser |
The built web app, served in production |
After this section you can move your instance to a new release without losing data. The short version: read the changelog, back up, update, check.
-
Read the changelog for every version between yours and the new one. Entries under Upgrade notes tell you about settings you must change. The same text is on each GitHub release. Write down the version you run now (the Account page shows it, or
curl http://localhost:3000/api/health): you need it to go back. If the changelog saysdocker-compose.ymlchanged, copy the new file from the release (the tag on GitHub) over yours first, and keep your own edits in.envor adocker-compose.override.yml. -
Make a backup and copy it off the machine. Do this with the version that is still running, before you pull anything. Updates change the database, and a migration cannot be undone. Scheduled backups are on by default with Docker (one every 24 hours), but the last one may be hours old, so make a fresh one. The full guide is in Backups.
docker compose exec app node admin.mjs backup # prints the path of the new zip docker compose cp app:/data/app/backups/backup-<time>.zip . # a copy outside the container
Use the name the first command printed. If you set
BACKUP_DIR, the path is different; use the printed one.docker compose cponly copies the file to the same machine, and a backup is readable by its owner only (mode 0600, user id 1000 in the container), so the copy may needsudoor achownto open. A copy on the same disk does not protect you from the disk failing: copy it off the machine, encrypted, because a backup holds password hashes and DM secrets. -
Pin the version you want (recommended) or follow
latest. In the.envfile next todocker-compose.yml:CN_VERSION=1.0.0
Without
CN_VERSION, Compose followslatest, the newest stable release. A pre-release such as1.0.0-rc.1is neverlatest; you ask for it by name. -
Pull and restart:
docker compose pull && docker compose up -dThe container is recreated and database migrations run when the server starts. The first start after some updates also rebuilds the search index and takes longer than usual. Your data stays in the
app-datavolume. -
Check it:
curl http://localhost:3000/api/healthshows the new version ({"ok":true,"version":"..."}), anddocker compose logs appshows no errors. Open a campaign you know.
To update the 5etools data instead, see Updating the 5etools data. That does not change the app.
There is no downgrade. Do not start an older image on a database that a newer one has used: migrations only go forward, and the older code does not know the new tables. If an update goes wrong, go back to the state before it:
docker compose stop app- Set
CN_VERSIONin.envback to the version you had, thendocker compose pull. - Restore the backup you made in step 2, following Restoring:
docker compose run --rm app node admin.mjs restore <file> --yes, thendocker compose up -d.<file>is the path inside the container, like/data/app/backups/backup-<time>.zip(a copy from elsewhere needs the-vmount shown there). Restoring discards everything written after that backup and signs everyone out; the newer data is moved aside topre-restore-<time>/, not deleted.
Page history and rolling back. Version 1.3 adds the page history table (migration 0007). It is created when the server starts, history starts empty (a page's first edit stores its earlier state as a "before history was recorded" version), sessions and logins keep working, and the migration cannot be undone. Starting an older version on a database that 1.3 has used is not refused, but it is not supported. To go back, use the steps above: restore the backup from before the update. That discards everything written since, including all history. The history of a page in the app is a different kind of rollback: it restores one page's content and never touches the app version.
The reverse also holds: a backup made by a newer version is refused by an older one (exit code 66, "update first"), so update the app before you restore such a backup. An older backup restores into a newer version and is migrated on the way.
Without Docker. In the repository folder:
node apps/server/dist/admin.mjs backup # 1. with the current build, before changing anything
# 2. stop the server (Ctrl+C, systemctl stop, ...)
git fetch --tags
git checkout v1.0.0 # the tag you want (see the releases page); `git pull` on main follows development
pnpm install
pnpm build
# 3. start the server again; migrations run on start
NODE_ENV=production pnpm --filter @cn/server start:dist
node apps/server/dist/admin.mjs backup # 4. check: this prints the path of a new backup if the database is healthy
curl http://localhost:3000/api/health # and the version here is the one you checked outOnly commands that open the database to change it (the server, reset-link) apply pending migrations; backup never does.
To go back, check out the old tag, run pnpm install && pnpm build, and restore your backup as described in
Restoring without Docker.
Anyone with shell access to the server (or its container) can issue a one-time password reset link for any enabled user, including the only admin. Admins can also create links from the Admin page.
docker compose exec app node admin.mjs reset-link <username> # with Docker
node apps/server/dist/admin.mjs reset-link <username> # without Docker, after `pnpm build`It prints the link (<PUBLIC_URL>/reset#<token>) on stdout and nothing else, so it can be piped; hints go to
stderr. Open the link, choose a new password; the link works once, expires after 24 hours and replaces any
earlier link for that user. Reading the settings works as for the server: environment variables, plus the
repository .env outside Docker (so run it with the same DATA_DIR and PUBLIC_URL as the server; the
Docker image has them set). It is safe to run while the app is running, but admin.mjs must be the same version as the running server (reset-link applies pending migrations when it opens the database; backup does not). Exit codes: 1 unknown user, 2 disabled
user (enable it first; this command cannot), 3 no database found at DATA_DIR, 64 wrong usage.
Years of campaign notes should not live on one disk. A backup is a single zip file with your whole instance in it, and you can make one in a few seconds while the app is running. This section shows how to make backups, how to get them off the machine, and how to restore one. Restoring is done on the command line, on purpose.
Jump to: Quick start · What is in a backup · Making backups · Copying backups off the machine · Restoring · Restoring onto a new or wiped machine · Exit codes · When something goes wrong · If you think you were compromised · Rehearse a restore · File permissions
With Docker (run these in the folder with docker-compose.yml):
# 1. make a backup; it prints its path
docker compose exec app node admin.mjs backup
# 2. copy it out of the container (use the name printed in step 1)
docker compose cp app:/data/app/backups/backup-<time>.zip .
# 3. prove it would restore; changes nothing
docker compose run --rm app node admin.mjs restore /data/app/backups/backup-<time>.zip --checkWithout Docker (run these in the repository folder, after pnpm build):
# 1. make a backup; it prints the path, by default var/backups/backup-<time>.zip
node apps/server/dist/admin.mjs backup
# 2. a copy somewhere else (see "off the machine" below: encrypt it)
cp var/backups/backup-<time>.zip /some/other/disk/
# 3. prove it would restore; changes nothing
node apps/server/dist/admin.mjs restore var/backups/backup-<time>.zip --checkTo restore, see Restoring. Then do two more things today: check that scheduled
backups are on (they are by default with Docker; a bare install needs BACKUP_EVERY_HOURS) so
you do not depend on remembering, and decide where the copies go (the backup is on the same disk as your data until
you move it).
backup-<UTC date and time>.zip, for example backup-20261017T223817Z.zip (the time is UTC), holds:
| In the zip | What it is |
|---|---|
app.sqlite |
The database: users, password hashes, API-token hashes, sessions, every campaign with its pages, DM secrets, homebrew, templates, encounters, inventory, invites |
uploads/ |
Every uploaded image and its thumbnail |
manifest.json, checksums.json |
The app version, the database version, counts, and a checksum of every file, so a damaged copy is noticed |
It does not hold the 5etools data or the search index: those are rebuilt from your clones (the index on the
first start after a restore). It also does not hold your settings (.env, docker-compose.yml, PUBLIC_URL):
keep those somewhere safe yourself.
Treat a backup like the database. Anyone who gets the file gets every password hash and every DM secret of every campaign, in plain form (the app does not encrypt backups). Do not email it, do not put it in a shared folder, and encrypt every copy that leaves your machine (see below). Only admins can download one from the web page, and only after typing their password again.
A backup next to the data protects you from mistakes, not from a dead disk. By default backups are in
backups/inside the data directory, on the same disk. With Docker that is theapp-datavolume, anddocker compose down -vdeletes the volume together with every backup in it. Until a copy is somewhere else, you have protection against "I deleted the wrong campaign", not against "the disk died".
There are three ways. All of them make the same kind of file, safely while the server is running and people are editing, and each file is read back and checked before it counts.
From the web page. Sign in as an admin and open Admin > Backups (/admin/backups), then choose
Back up now. The page lists every backup with its size, who made it (web page, command line or scheduled) and
what it holds, and has the Download button. You can make at most 3 backups per 10 minutes this way.
From the command line.
# with Docker; the path printed is inside the container: /data/app/backups/backup-<time>.zip
docker compose exec app node admin.mjs backup
# without Docker, from the repository folder
node apps/server/dist/admin.mjs backupIt prints the path of the new zip on stdout and nothing else there; the progress, a short audit line and a reminder
go to stderr. (In a cron job or script use docker compose exec -T app ..., which does not need a terminal.) Exit
code 0 means the backup exists and was verified. If the server is writing constantly, a command-line backup can be
restarted several times and give up after 10 minutes with exit code 70: run it again when the instance is quiet (see
When something goes wrong).
Set BACKUP_EVERY_HOURS and the server makes a backup by itself every N hours (1 to 720). With Docker Compose it
is on by default: the shipped docker-compose.yml uses 24 hours and keeps the newest 7. A bare install
(without Docker) is off by default; we recommend 24 there too. This matters for more than convenience: retention keeps only the newest
backups, so if you only ever make them by hand, the day you finally need one it may be the old one you made last
spring. A schedule keeps fresh backups coming and is also what protects your older ones from being pushed out (see
Retention).
With Docker, put BACKUP_EVERY_HOURS, BACKUP_KEEP or BACKUP_DIR in a .env file next to docker-compose.yml;
Compose passes them into the container (an empty BACKUP_KEEP or BACKUP_DIR counts as unset, an empty
BACKUP_EVERY_HOURS as the default 24). For example, .env with BACKUP_EVERY_HOURS=0
turns scheduled backups off, and BACKUP_EVERY_HOURS=12 makes one every 12 hours. Without Docker, add
BACKUP_EVERY_HOURS=24 to .env in the repository folder (or the server's environment).
Run docker compose up -d after changing .env. The server log says Scheduled backups: every 24 h, keeping 7 at start,
and the Backups page shows Scheduled backups: every 24 hours, next run <time>.
- The next run is the newest backup (from any source) plus the interval, so a backup you make by hand postpones the next scheduled one, and a restart does not reset the clock.
- After downtime, or on the first start, if nothing is newer than the interval, one catch-up backup is made about a minute after the server starts (60 to 90 seconds). Missed runs are not repeated.
- A scheduled run is skipped, not queued, while another backup or a restore is running, and tried again after 5
minutes. If a run fails it retries after 15 minutes, then after longer waits; the Backups page shows the last
error and the server log has a
backup.failedline. - A value that is not a whole number from 1 to 720 (
24h,1.5) stops the server from starting, withRefusing to start: BACKUP_EVERY_HOURS must be a whole number from 1 to 720 .... The command line still works.
After every successful backup the oldest ones are deleted so that the newest BACKUP_KEEP remain (default 7,
allowed 1 to 1000). A failed backup deletes nothing. Only files named like backup-<date>T<time>Z.zip are ever
touched; ordering uses the time in the name, so copying a file does not change its age.
BACKUP_KEEPcounts files, not days. WithBACKUP_EVERY_HOURS=24and the default 7 you have the last 7 days; withBACKUP_EVERY_HOURS=1you would have only the last 7 hours. If you might notice a mistake later than that, raiseBACKUP_KEEP(each backup is about the size of your database plus your uploads) and copy older backups off the machine.- Backups made with Back up now are never removed automatically during their first 7 days and do not count
towards
BACKUP_KEEPuntil then; after that they count like any other (you can still delete them by hand). This stops a stolen admin session from clicking "Back up now" a few times and rotating all your real backups out. In practice: right after a busy week of web-page backups you will see more than 7 files; they join the normal count once they are a week old. At most3 x BACKUP_KEEPof these young web-page backups are allowed (21 by default); beyond that Back up now refuses and says to use the command line, which has no such cap. - Deleting a backup by hand (delete the file) is fine. There is no delete button on purpose.
BACKUP_DIR (default DATA_DIR/backups) is where zips are written and listed. Point it at another disk or mount
so a dead data disk does not take the backups with it. The server refuses to start (and the command line refuses
to run) if it is DATA_DIR itself, inside or around the uploads, the web build or the 5etools folders, or
world-writable, or if the user the app runs as cannot write in it (see When something goes wrong). If you
change it later, backups in the old place are no longer listed or cleaned up.
With Docker, mount a host folder and set BACKUP_DIR in the .env next to docker-compose.yml. The app runs as
the unprivileged user node (uid 1000), so the folder must belong to that user.
Create the folder with the right owner before the first docker compose up; if it does not exist, Docker
creates it owned by root, and the server then refuses to start (see When something goes wrong):
mkdir -m 700 /mnt/backupdisk/critical-notes && sudo chown 1000:1000 /mnt/backupdisk/critical-notes# .env
BACKUP_DIR=/backupsUnder Docker, BACKUP_DIR is a path inside the container (use an absolute path such as /backups; a relative
path would be wrong), not a path on the host. A folder outside the app-data volume therefore has to be mounted there. docker-compose.yml
has no such mount, so add one in a docker-compose.override.yml next to it (Compose reads that file automatically):
# docker-compose.override.yml
services:
app:
volumes:
- /mnt/backupdisk/critical-notes:/backupsInside the container the files are then in /backups/ (use that path in the restore commands below). A host folder
also makes the next section easier: it is a normal folder you can rsync.
When the server gets a stop request (docker compose stop, docker stop, systemctl stop, Ctrl+C), it stops
accepting requests and waits up to 8 seconds for a running backup, whether it is scheduled or from the web page. Then
it closes its databases and exits. If the backup did not finish in time, the log says Shutting down: the running backup did not finish in time; it was stopped, nothing was published and its partial files and lock were removed:
no partial file is ever listed, and a new backup can start right after the restart. Only a forced end (a second stop
request while it waits, kill -9, a power cut) leaves leftovers behind; see "A backup is already running" under
When something goes wrong. An idle server
stops well under a second; a stop during a backup can take up to about 10 seconds (the 8-second wait plus a moment
to finish open requests). Compose allows 15 seconds (stop_grace_period), so docker compose stop is never cut
short. With plain docker run, Docker's default is 10 seconds, so add --stop-timeout 15 if you may stop the
container during a backup.
Do this regularly (right after each scheduled backup is a good rhythm), and encrypt first: only encrypted
files should ever leave the machine. Any of these work: age, gpg, or a backup tool that encrypts, such as
restic or borg. With gpg, which is on most systems:
mkdir -p encrypted-copies
# encrypt (asks for a passphrase)
gpg --symmetric --cipher-algo AES256 --output encrypted-copies/backup-20261017T223817Z.zip.gpg backup-20261017T223817Z.zip
# later: decrypt it again, privately
( umask 077; gpg --output backup-20261017T223817Z.zip --decrypt encrypted-copies/backup-20261017T223817Z.zip.gpg )Keep the passphrase in a password manager, not next to the file: a backup you cannot decrypt is not a backup. Copy
only the .gpg file to a cloud drive, another computer, or a USB disk kept elsewhere, and delete the plain zip you
encrypted from any shared or synced location. (gpg --decrypt writes a world-readable file unless you set umask 077
first, as above, or run chmod 600 on the result.)
Download from the web page. On Admin > Backups, choose Download on a backup and type your password. The
page swaps the password for a single-use link that works for 60 seconds, and your browser saves the file. If it
says "too many downloads", wait a few seconds and choose Download again. If you run the app behind a reverse proxy,
its access log records the full address of the download, which contains the one-time ticket= parameter. The
ticket is useless after one use, but tell your proxy to leave that parameter out of its logs.
With Docker, copy it out of the container. Use the name of the file as listed on the Backups page or printed by
the backup command:
docker compose cp app:/data/app/backups/backup-20261017T223817Z.zip .(Run it in the folder with docker-compose.yml. The copy has mode 0600 and may belong to uid 1000, so if that is
not you, you may need sudo to read it. docker compose cp app:/data/app/backups/ ./all copies the whole
folder.) If you set BACKUP_DIR to a host folder as above, you do not need cp: the files are already there.
Without Docker, or from a host folder, with rsync or scp:
rsync -av --include='*.gpg' --exclude='*' encrypted-copies/ you@otherbox:/srv/critical-notes-backups/
scp encrypted-copies/backup-20261017T223817Z.zip.gpg you@otherbox:/srv/critical-notes-backups/Both send only the encrypted files. rsync without --delete keeps every backup on the other side, including those the app has since
rotated out: good for history, but it grows until you clean it up.
Restoring is only done from the command line, on the machine that runs the app, never from the web page. It replaces all your data, and a restore that anyone with a stolen admin cookie could start would be a way to swap in someone else's database. Whoever can run these commands can already read your data directory anyway.
What you need: the backup file, shell access, and the app stopped. Restore refuses to run while the server is up (exit code 69).
Breathe. Nothing is lost yet if you do these in order and avoid the list at the end.
- List your backups. The newest is last:
docker compose run --rm app ls -1 /data/app/backups(Docker) orls -1 var/backups(without Docker). If you copied backups elsewhere, use the newest of those. - Check the one you will use with
restore <file> --check(recipes below). It prints the file'ssha256; compare it withsha256sum <file>on a copy you kept elsewhere to know the file is the one you saved. - Restore it with the recipe below. Your current data is moved aside, not deleted, so you can still undo.
What not to do:
docker compose down -v,docker volume pruneordocker system prune --volumes: they delete the volume with your data and your backups.- Do not copy
app.sqliteby hand while the server runs: the newest changes are in a second file (app.sqlite-wal), and a copy without it is incomplete or damaged. Usebackup. - Do not start the app (
docker compose up -d) on a new machine before you restore: it would create an empty instance first. - Do not unzip a backup over the data directory, and do not delete
pre-restore-*before you have checked the restored instance.
Last resort, only if the zip is all that survived and --check refuses it (for example exit 65 because of an
extra object in the database): unzip -p backup.zip app.sqlite > app.sqlite and unzip backup.zip 'uploads/*' give you
the raw files. This skips every protection of the restore: no checksum or schema check, and sessions, API
tokens and invite links are not cleared. Put them in an empty data directory with the app stopped, start the app,
then reset your admin password and revoke tokens by hand. Prefer restore whenever it accepts the file.
docker compose stop appCheck the file first. This changes nothing and tells you what is in it:
docker compose run --rm app node admin.mjs restore /data/app/backups/backup-20261017T223817Z.zip --checkYou should see ... is complete and restorable. Nothing was changed., when it was made, how many users, campaigns
and uploads it holds, and the enabled administrators in it. If the backup is not in the volume (it came from
somewhere else), mount it for the command; the -v needs the full path of the file on the host (here the current
folder), and the path after the colon is where it appears inside the container:
docker compose run --rm -v "$PWD/backup-20261017T223817Z.zip:/restore/backup.zip:ro" app node admin.mjs restore /restore/backup.zip --checkNow restore. Without --yes it prints the plan and asks Replace the current data with this backup? [y/N];
with --yes it goes ahead:
docker compose run --rm app node admin.mjs restore /data/app/backups/backup-20261017T223817Z.zip --yes
# starts the app again; the first start rebuilds the search index and takes longer than usual
docker compose up -dThe report ends with Docker: docker compose up -d (works whether or not the container exists), which is the command
to use.
Verify: open the app, sign in (everyone has to sign in again), open a campaign you know and look at the newest page and an image. Then delete the safety copy (below). If it is not what you wanted, restore another backup the same way.
You need a build (pnpm build) and the same settings as the server: the commands read the repository .env
and the environment, so run them from the repository folder and with the same DATA_DIR (and BACKUP_DIR) the
server uses. If you start the server from a service file that sets variables, set the same ones for these commands.
# stop the server first (however you run it: Ctrl+C, systemctl stop, ...)
node apps/server/dist/admin.mjs restore var/backups/backup-20261017T223817Z.zip --check # verify only
node apps/server/dist/admin.mjs restore var/backups/backup-20261017T223817Z.zip --yes # replace the data
# start the server againRun it as the same user as the server. If you run it as root, the restored files are handed to the owner of the data directory.
- Replaces the database and the uploads with the backup. Everything done after the backup was made is gone from the running instance, and the report says when that was.
- Moves your previous data aside, it does not copy or delete it. It goes to
pre-restore-<time>/inside the data directory (in Docker/data/app/pre-restore-<time>/) and stays until you remove it. It still contains the old password hashes and tokens, so delete it once you have verified the restored instance. With Docker:docker compose exec app sh -c 'rm -rf /data/app/pre-restore-*'(this removes every such folder; look at the names in the report first). If you restored something by mistake, the files you need are in that folder. - Signs everyone out. All sessions and password-reset links are cleared. Make a new reset link for yourself if
needed:
node admin.mjs reset-link <username>(see Locked out?). - Revokes API tokens and invite links (MCP tokens, player invites), because they may have been made or revoked
after the backup. People make new ones.
--keep-tokens --yeskeeps them as they were in the backup; read its warning first, because it brings back tokens that were revoked later, even after a compromise. - Carries over restrictions you made after the backup: users you disabled, and tokens and invites you revoked,
stay disabled and revoked, so a restore does not undo a lock-out.
--no-carry-restrictionsturns that off. The report lists what was carried over. Users created after the backup are not in the restored instance (the safety copy still holds them) and the report names them. If the report saysEnabled administrators: none, a carried-over restriction locked out every admin (reset-linkwill say the user is disabled, exit 2). Run the restore again with--no-carry-restrictions(and--yes), then review Admin > Users. Running restore again is allowed after a finished restore: it treats the just-restored state as the live data, moves it aside to a newpre-restore-<time>/folder (the earlierpre-restore-*folder stays; the report lists it as an older folder), and installs the backup again. Delete thepre-restore-*folders once you are done. - Prints a report on stdout (the plan and any prompt go to stderr). Read the line
Enabled administrators:: a name you do not recognise means the backup is compromised. A block startingCHECK THESE NOWlists admins who were demoted, and users who were deleted, in the live data since the backup; they are back as they were in the backup. Disable or demote them in Admin > Users, or reset their passwords. Restoring onto an instance that had no users (started once and left empty) prints the same block with the backup's admins, and a wiped machine prints a note that the live datacould not be compared: both are harmless. - Rebuilds the search index on the next start (the first start is slower). This needs your 5etools data mounted as usual.
- Migrates an older backup. A backup from an older version is upgraded to the current database version as part
of the restore (the report says
Migrated N steps). A backup made by a newer version is refused (exit 66): update the app first, then restore. A backup whose migration history does not match (a fork, an edited database) is refused (exit 67); restore it with the same checkout or Docker image that made the backup (check your git history from around the date in the file name). - Does not touch the 5etools data,
.env, or the compose file.
Every restore writes a line to restore-log.jsonl in the data directory (who, when, which file).
- Get the app ready as in Running it with Docker (or without Docker), but stop before the
start command: clone the 5etools data, restore your settings (
.envor the compose file, especiallyPUBLIC_URL), and with Docker the image is pulled bydocker compose up(docker compose pullfetches it ahead of time; from a checkout use-f docker-compose.yml -f docker-compose.build.yml build). - Do not run
docker compose upyet (or start the server). With Docker,docker compose runcreates the volume for you. - Get the backup file onto the machine (decrypt it first if you encrypted it) and run the
--checkand the--yesrestore from above. There is no previous data, so the report says nothing was moved aside. - Start the app and sign in with your old password. With Docker use
docker compose up -d. The restore ran in a temporary container, sodocker compose start appfinds nothing to start on a machine where the app never ran.
The commands use these exit codes (backup and restore share them; 1 to 3 are also used by reset-link):
| Code | Meaning |
|---|---|
| 0 | Done (for restore --check: the backup is complete and would restore) |
| 1 / 2 | reset-link: unknown user / disabled user |
| 3 | No database found at DATA_DIR (backup), or the backup file does not exist or cannot be read (restore) |
| 64 | Wrong usage, an unknown option, --keep-tokens without --yes, or no terminal to ask on and no --yes |
| 65 | Not a valid backup (damaged, incomplete, or not one of ours) |
| 66 | Made by a newer version: update first |
| 67 | Different migration history: restore with the same checkout or Docker image that made the backup |
| 69 | The server is running: stop it first |
| 70 | Failed (the message says why; for example a database copy that could not finish in 10 minutes) |
| 71 | The restore was rolled back: your data is exactly as before |
| 72 | You answered no at the prompt: nothing was changed |
| 73 | The backup folder (BACKUP_DIR) is not writable by the user running the command: see When something goes wrong |
| 74 | Not enough disk space |
| 75 | A restore is running or was interrupted: finish it first |
| 76 | A backup is already running |
| 77 | The rollback itself failed: follow the instructions it printed |
| 78 | Cannot lock app.sqlite: see below |
The restore was interrupted (power cut, killed terminal, Ctrl+C). The data directory holds a marker file
(.restore-in-progress) and the app refuses to start until the restore is finished. With Docker and the default
restart: unless-stopped, the app container then restarts over and over and prints the same message each time;
this is harmless. Finish it:
docker compose stop app
docker compose run --rm app node admin.mjs restore /data/app/backups/<the same file> <the same flags>
docker compose up -dUse the same backup file and the same flags (--yes, --keep-tokens...). If the file was not in the volume and you
gave it with -v ...:/restore/backup.zip:ro, use the same -v again. The command says
Continuing the interrupted restore of ... and carries on, or starts over if the swap had not begun. If a rollback
was under way, running it again finishes the rollback and brings your old data back (exit 71); you can then start
the restore again. Every command except restore is refused with exit code 75 until then.
The message printed gives a short version of this ("abort by hand"); this is the complete list: your previous
data is in pre-restore-<time>/ inside the data directory; move app.sqlite (and app.sqlite-wal and
app.sqlite-shm, if they are there) and uploads/ back to their old places (remove a newly installed
app.sqlite first if one stands there), remove the .restore-* folders, then delete the file
.restore-in-progress. Do that only if you cannot run the restore again. If a rollback fails (exit 77), the
message lists these steps and running the restore command again retries the rollback.
A backup is already running (exit 76, or "Back up now" is refused). Only one backup runs at a time. If the
previous one was killed (a reboot, kill -9), it looks like it is still running for 2 minutes: wait and try
again. Half-written files left by a killed backup are cleaned up after an hour and are never listed. (A normal stop
removes its own unfinished backup, so this only applies after a forced end such as kill -9, a power cut or a second
stop request: a backup started within 2 minutes of that says "already running".)
The command-line backup takes forever or fails with "did not finish within 10 minutes". A command-line backup copies the database in steps and has to start a step again whenever the server writes to it in between. On a very busy server it can be starved. Run it when nobody is editing, or make the backup from the web page or schedule it (those run inside the server).
Not enough free space (exit 74, or HTTP 507 "Not enough free disk space for a backup" on the page). A backup
needs room for about two copies of the database, your uploads, and 64 MiB. Free space, lower BACKUP_KEEP, or
point BACKUP_DIR at a bigger disk. A restore needs the larger of three times the zip's size and its unpacked
size, plus 64 MiB, in the data directory; the message gives the numbers, and nothing has been changed.
Cannot lock app.sqlite (...) (exit 78). The restore could not get the exclusive lock it needs on the database
and refuses to go on in case the server is running. It also appears when app.sqlite cannot be read by the user
running the command (for example it became owned by root after a command was run as root). Check that the app is
really stopped (docker compose ps), and that you run the command as a user that can write the data directory
(inside Docker: use docker compose run --rm app ..., not a different image or --user). On network drives and
some odd mounts file locking does not work at all; restore on a local disk.
The server is running (exit 69). Stop the app first. With Docker, docker compose stop app.
The backup is refused.
- Exit 65,
This is not a complete Critical Notes backup: the file is damaged, cut short (an interrupted copy), or not a backup. Get the copy again and compare its size with the original. The checks apply to the content, so a file that unzips fine can still be refused if it was changed. - Exit 66,
made by a newer Critical Notes: update the app (docker compose pull && docker compose up -d, or-f docker-compose.yml -f docker-compose.build.yml up -d --buildfrom a checkout) and run the restore again. - Exit 67,
The migration history of this backup differs: it was made by another branch, a fork or with an edited database. Restore it with the same checkout or Docker image that made the backup (check your git history from around the date in the file name). - Exit 65,
has an extra schema object (...): the restore only accepts a database that contains exactly this app's own tables. A leftover fromANALYZE(sqlite_stat1) is tolerated; anything else that was added to the database by hand (an index, a view, a trigger) is refused, and the message names it. Remove it from the live database, make a new backup, and restore that. The new backup failed its verification pass: the backup was made, read back and did not pass, so nothing was kept (and no older backup was deleted). It happens, rarely, with a database full of repeated blocks that compresses more than 200 to 1 (the limit was 50 to 1 before version 1.3). Try again; if it keeps happening, report it.
Refusing to start: The backup folder ... is not writable by this user (the command-line backup prints the same
sentence and exits 73). The app user cannot write in BACKUP_DIR (or the mount is read-only: remove :ro), usually because the host folder did not exist before
docker compose up and Docker created it owned by root. The server checks this at start (it creates a missing folder,
mode 700, then writes and removes a small test file), so you see the problem at once instead of at the first backup. The
message gives the Docker and bare-install fixes and shows the path as the app sees it, so with a bind mount
(/backups above) fix the host folder: sudo chown 1000:1000 /mnt/backupdisk/critical-notes, mode 700. A bare install
needs a folder its own user can write. With restart: unless-stopped a container that refuses to start restarts over
and over until you fix the folder; run docker compose stop app first if you want a quiet log. If a backup fails later
because the folder became unwritable, the Backups page shows The last backup failed: the server may not write in the backup folder (BACKUP_DIR). Fix the folder owner (Docker: chown 1000:1000 on the folder); the server log has its path.
The server will not start and says Refusing to start: BACKUP_DIR ... The folder is not allowed (see
BACKUP_DIR) or BACKUP_EVERY_HOURS is invalid. The message names the
variable. (A folder the app user cannot write in has its own message, just above.)
The web download ends with "too many downloads" or the link was refused. Three downloads run at a time: wait a few seconds and press Download again. A link works once and for 60 seconds; press Download again for a new one. The password prompt allows 5 attempts a minute.
Opening a backup by hand. A backup is a normal zip. To look inside without restoring (a copy of the file on another machine is enough):
unzip -l backup-20261017T223817Z.zip # lists manifest.json, app.sqlite, uploads/..., checksums.json
unzip -p backup-20261017T223817Z.zip manifest.json # when it was made, counts, database versionThis only reads the file; never restore by unpacking it over the data directory. Use restore, which checks the
file first and handles the rest. If your copy of the app is gone, a backup is still a plain zip whose uploads/
folder holds your images and app.sqlite is a SQLite database.
If unzip stops with "overlapped components ... zip bomb" on a backup that has a single entry over 4 GiB (a very
big database or image collection), that is a false alarm of the Info-ZIP unzip 6.0 checker. The file is valid:
use 7z x, Python's zipfile, or UNZIP_DISABLE_ZIPBOMB_DETECTION=TRUE unzip ....
Restore from a backup made before the incident, not the newest one: a backup of a compromised instance brings
the attacker's admin account back with it. Then: reset every admin password, review Admin > Users, leave the
tokens revoked (do not use --keep-tokens), restore with --no-carry-restrictions if the live data may have been
tampered with (an attacker may have disabled the real admins, and restore keeps that by default), delete the
pre-restore-* folder (it holds the old state, including what the attacker left; wipe it securely or keep it on an
encrypted disk), and rotate anything else that was reachable from the server. Scheduled backups help here: they make
sure a pre-incident backup exists.
An untested backup is a hope, not a backup. Once, and again after you update, restore one into a scratch copy that cannot hurt your real data, on another port. Use a recent backup and your own copy of it, not the live files.
With Docker, using a throwaway volume (rehearsal) and the image your compose file runs (docker compose images shows it; below ghcr.io/merilairon/critical-notes:latest, or critical-notes:local when built from a checkout):
docker run --rm -v "$PWD/backup-20261017T223817Z.zip:/restore/backup.zip:ro" -v rehearsal:/data/app ghcr.io/merilairon/critical-notes:latest node admin.mjs restore /restore/backup.zip --yes
docker run -d --rm --name rehearsal-app -p 3999:3000 -e PUBLIC_URL=http://localhost:3999 -v rehearsal:/data/app ghcr.io/merilairon/critical-notes:latest
# open http://localhost:3999, sign in with your old password, look at a campaign and an image; then:
docker stop rehearsal-app
docker volume rm rehearsal(The restore report ends with docker compose up -d; that line is for compose installs. For the rehearsal you start
the container with docker run as shown. docker stop ends the container within a second. If volume rm says "in use", wait a moment and run it again.)
(Add -v "$PWD/5etools-data/5etools-src:/data/5etools-src:ro" to the second command if you want the rules reference
in the rehearsal.) Without Docker, from the repository folder, with a scratch data directory:
mkdir /tmp/rehearsal
DATA_DIR=/tmp/rehearsal node apps/server/dist/admin.mjs restore backup-20261017T223817Z.zip --yes
DATA_DIR=/tmp/rehearsal PORT=3999 PUBLIC_URL=http://localhost:3999 NODE_ENV=production pnpm --filter @cn/server start:dist
# check it in the browser, stop it with Ctrl+C, then: rm -rf /tmp/rehearsalEnvironment variables take precedence over .env, so the scratch directory does not touch the real one. If you set
BACKUP_DIR in your .env, set it to something outside the scratch directory as well, or leave it unset.
Developers can run the automated drill, which starts a server on a temporary folder, makes a backup, wipes the folder, restores, and checks the data over HTTP (about 15 seconds, no 5etools data or network needed):
pnpm --filter @cn/server build && pnpm --filter @cn/server drill # or: pnpm drillBackups and the data directory contain secrets, so keep them private. The app creates the backups/ folder with
mode 0700 and every backup zip with 0600, whatever your umask is, and the restore log is 0600. It does not change
the mode of DATA_DIR itself, so on a shared machine run chmod 700 var (or your DATA_DIR) yourself. In Docker
the app runs as the unprivileged user node (uid 1000) and the data lives in a Docker volume; if you put
BACKUP_DIR on a host folder, make it chown 1000:1000 and chmod 700. Copies you make (docker compose cp,
rsync) belong to you: keep them as private as the originals, and encrypted.
Editions. Pick 2014 or 2024 rules when you create a campaign. The campaign's reference shows the printing for its edition (a 2014 campaign shows the PHB Fireball, a 2024 one the XPHB Fireball); "Also printed as …" links reach the other. Turn on "Allow 2024 content" (or "Allow legacy (2014) content") in the campaign's settings to mix them. "Change edition" (owner only) lists every reference in the campaign and what it maps to before it changes anything.
What players see. New pages are visible to the DM only. A page can be shared with everyone or with chosen
players; a player always sees their own character. Inside a shared page, "DM secret" blocks and blocks marked
DM-only (Ctrl+Shift+D) stay hidden until you mark them revealed (Ctrl+Shift+R). "Preview as player" shows a
page exactly as a player receives it. A player can never mark a block as revealed.
Revealing during play. Press Ctrl+. in a secret or DM-only block and choose Reveal now: the block is
revealed at once, and a notice offers Undo for 15 seconds. On a handout page, Share now shares the page with
everyone and Share with chosen players picks who. Both are ordinary saves, so a failed save says "Not saved"
and changes nothing. Players who have the campaign open see a short message ("New: ...", "New details on this
page") and a New label on the page and the block, with no reload and no automatic scrolling; a player who is
in the middle of typing on that page gets a Reload button instead, so nothing they typed is replaced. The
message only tells the player's browser to look again, so it carries no title or text, and the player's browser
gets the page like any other request: what the player may not see stays out. A player who was offline when it
happened sees the new page or block when they open it, but is not told about a reveal on a page they did not
have open. Live messages need one server process (what docker-compose.yml runs); with several processes
sharing one database, players would not get them. See EVENTS_MAX_CONNECTIONS in Configuration.
Page history. The DM (and a co-DM) can open History in a page's toolbar (players and "Preview as player"
do not have it). Every change to the page is a version: a web edit, a player's edit of their character, an edit by an AI
assistant (marked via AI assistant with the token's name), a restore, a template, an import or an edition
change. Saves by the same person within 2 minutes fold into one version (HISTORY_COALESCE_MINUTES; a paste you
regret can overwrite at most that much earlier state). Tick one version to compare it with the current page, or
two to compare them: added and removed text is marked with + and -, underline and strike-through, and
changes to the title, type, details and properties are listed as "Other changes". Restore brings an earlier
version back as a new version, without asking, and Undo restore undoes it. A restore brings back content only
(title, type, text, details, properties), not who can see the page, its parent, tags, icon or who owns a character.
Players never see history, and a campaign export does not contain it. Old versions are thinned: all are kept for
30 days, then one per day, then one per month after a year, and a page or campaign over its limit is thinned
early (see the HISTORY_* settings in Configuration). Thinning stops history from growing; it
does not make the database file smaller. History starts when you update to 1.3: a page's first edit after that
stores its earlier state as a "before history was recorded" version. History is part of the database, so it is
in every backup.
Combat tracker. In the initiative tracker (press ? on the page for the keys and a short rules note):
- Death saves for player characters: marks for successes and failures, a d20 field or Roll for me, Natural 20 (up again at 1 HP) and Natural 1 (two failures), Stable, Dead, and extra failures for damage at 0 HP (two for a critical hit). Damage past 0 HP of at least the character's maximum HP kills at once. Damage that temporary HP absorbs adds no failure: that is a table convention, not printed rules text. The table sees these marks for player characters only.
- Legendary actions (spend and undo; they refill when that creature's turn starts) and Legendary Resistance (use and give back; it is never reset by a new round), filled in from the statblock when you add the creature.
- Recharge: mark an ability used, and when the creature's turn starts the tracker rolls the d6 and shows the roll to you only.
- Lair actions: a hidden row on initiative 20 (it loses ties), and a switch for creatures that are in their lair. While a hidden row has the turn, players see no highlighted turn.
- Ready (a trigger, shown to players for characters only) and Move after, the tracker's way to Delay: a combatant takes its place just after another one. When the active creature does this, the turn passes on at once and its conditions do not tick. Delay is a table convention, not a rule from the core books.
- Players never see legendary actions, resistance, recharge, lair rows that are hidden, or any of these for monsters. If someone loses access while the live combat view is open (removed from the campaign, disabled, signed out, password changed), the view closes within about 25 seconds.
Queries. A query block ("all NPCs in this town") shows a list, a table or cards. In a table or cards, the column picker chooses and orders the columns (type, tags, dates, details and properties). Anyone, players included, can sort a table by clicking a column header; that sort is not saved. Players get only the rows and columns they may see. On a phone the table scrolls sideways with the title column kept in view; choose cards for a phone-first block.
Writing. Type / for blocks, [[ to link (or create) a page, {@ to insert a rules reference such as
{@spell fireball|XPHB}, and dice like {@dice 2d6+3}.
Keyboard. Ctrl+. (also on a Mac) opens the block options menu for the block that holds the caret (the same menu as the
handle next to a block and the "Block options" button in the toolbar): turn into, wrap, move up or down, duplicate, delete.
Arrow keys, Home and End move in the menu, Esc closes it and returns focus, Tab closes it. Ctrl+Shift+D marks the block
DM-only, Ctrl+Shift+R reveals it, Ctrl+K searches.
Homebrew. Under Homebrew, create a source (its name, abbreviation and edition), then add creatures, spells, items, species, backgrounds, feats and more, in the form editor or as 5etools JSON. "Copy to homebrew" on any reference entry starts a variant that stores only your changes. Import 5etools homebrew files (or a URL); export a source as a 5etools homebrew file. Share a source with chosen campaigns, or keep quick one-off creatures in a campaign's own homebrew.
Accounts and users. Under Account (click your name at the top) you change your display name and your password; changing the password signs out your other sessions, and Sign out other sessions does the same without changing the password (this browser stays signed in). Revoking an AI token asks for confirmation first, in the app's own dialog (as does every other delete or discard in the app). The account page also shows the app version and your AI tokens. Admins get Admin > Users: disable and enable accounts (a disabled user cannot sign in and their tokens stop working), grant or revoke the admin role, create a one-time password reset link, and delete a user. For each campaign the user owns, you choose whether to transfer it to a member or archive it, so campaign content is never lost with the account. These actions ask for the admin's own password again. Admin > Backups is described under Backups.
Trash. Deleting a page moves it, with its subpages, to the campaign's Trash; the editor shows Deleted · Undo instead of asking first. DMs and co-DMs open Trash in the campaign menu to restore a page (links, backlinks and search come back with it) or delete it permanently. Deleted templates are listed at the bottom of the template library with the same two choices. Both kinds are removed for good after 30 days. AI assistants can list the page trash and restore a page, but cannot delete permanently.
On a phone. Below 1024 px wide the header is one row with a menu, and the campaign navigation opens as a drawer. Players can join by invite, read shared pages, edit their own character, use the inventory and the dice log, and follow the combat screen at 320 to 430 px, with 44 px tap targets and 16 px form text (so iOS does not zoom into fields). Exceptions: inline dice rolls in text (about 29 px high) and the inline "Sign in" link on the invite page. The DM's prep screens (settings, encounters, the tracker controls, the admin pages) stay desktop-first. The phone layout was tested in Chrome's phone emulation, not yet on real devices.
Templates. Write placeholders in a template's text: {{name}}, {{dc: number = 13}},
{{creature: reference<monster>}}, {{mood: select(calm|angry)}}; {{rules.race}} becomes "Race" or
"Species" by the campaign's edition. Insert a template with / → Template…, as a copy or linked (linked
copies update when the template changes). Page templates create whole pages, optionally with subpages, and a
campaign can use one by default for each page type.
Critical Notes has an MCP endpoint at /mcp, so an AI assistant can prep
with you: "add the innkeeper as an NPC with a secret", "plan an encounter for session 4", "write a homebrew
potion".
-
Under Account (click your name at the top), create a token: read only, or read and write, with an expiry. It is shown once, with the setup for common clients.
-
Add the server to your client. For Claude Code:
claude mcp add --transport http critical-notes https://notes.example.org/mcp --header "Authorization: Bearer cn_…"Clients that only start local programs (such as Claude Desktop) can reach it through mcp-remote; the Account page shows that setup too.
The assistant acts as you: as a DM it can do what you can; as a player it sees only what is shared with you and edits only your own character. It writes page text as Markdown with a few extras (the server explains them to the model when it connects):
| Markdown | Becomes |
|---|---|
{@spell fireball|xphb}, {@dice 2d6} |
rules references, as typed in the editor |
[[Stonehill Inn]] |
a link to that page |
:::secret … ::: |
a DM secret (:::secret{revealed} once revealed) |
:::dm … ::: |
blocks only game masters see |
:::callout{tone=warning}, :::read-aloud, :::details[Title] |
callouts, read-aloud boxes, collapsible sections |
:::roll-table{dice=d6} around a table |
a roll table |
:::columns / :::column, :::tabs / :::tab[Title] |
layouts |
::embed{uid="monster:goblin|mm"} |
an embedded statblock |
<!-- cn:block … --> |
a block Markdown can't show (an encounter, loot, a query…), kept as is |
Edits through Markdown change only what changed: everything else, including blocks Markdown can't show and blocks a player can't see, stays exactly as it was. Revoke a token on the Account page at any time.
Deleting a custom property. delete_property removes the property and its value on every page, and there is no undo, so it
refuses unless the call has confirm: true. The refusal names the property and tells the assistant to ask you first.
Page history for assistants. The server offers 34 tools (13 that only read, 21 that also change things; a read
only token gets the 13). Two of them work with page history, for a game master's token only:
get_page_history lists the saved versions of a page without their text, or reads one version in full, and marks the
edits an assistant made; restore_page_version brings an earlier version back as a new version (it needs a read and
write token, and the page's current revision, so it refuses if the page changed since the assistant read it). An
assistant can therefore undo its own edit. Every edit an assistant makes is recorded in the history as "via AI
assistant" with the token's name, so you can compare it and restore the page.
pnpm dev # server on :3000 (tsx watch), web on :4200 (proxies /api and /img)
pnpm -s typecheck && pnpm -s lint && pnpm -s test # the gate before every change goes in
pnpm e2e # builds, starts the server on a fresh data folder, runs the browser tests (desktop and phone)
pnpm e2e:phone # only the phone project (390 x 844, touch) for the player flows
pnpm drill # builds the server and rehearses a backup and restore
pnpm index [--force] # rebuild the reference index by hand
pnpm --filter @cn/server db:generate # migrations after a schema change (both dialects)- The browser tests (Playwright, in
e2e/) drive the system Chrome (CHROME_PATHfor another one) and need the real 5etools data.E2E_BASE=http://localhost:3000 pnpm exec playwright testruns them against a server that is already running, which must be a fresh instance. - Unit and API tests use a small invented fixture instead of 5etools content.
- The app database is SQLite.
apps/server/src/db/schema.pg.tsmirrors the schema for Postgres, with its own migrations inapps/server/drizzle-pg/; a test applies both and compares them. The services still use the synchronous SQLite driver, so running on Postgres needs a port of the data layer first. - docs/ARCHITECTURE.md explains how the pieces fit together, with the data model. docs/PLAN.md is the original plan with its deviations; docs/STATUS.md the current state; docs/RELEASE.md how a release is cut; DATA_NOTES.md what the 5etools data looks like.
- This project's code. Critical Notes is licensed under the Apache License 2.0
(Copyright (c) 2026 Merilairon). The license covers only this repository's own code and documents. It does
not cover or grant any right in the game content below. Contributions are accepted under the same license,
with a DCO sign-off (
git commit -s) and no CLA; the details are in CONTRIBUTING.md. - Game content and art. The rules text, statblocks and other content in 5etools, and the art in
5etools-img, belong to Wizards of the Coast, the credited artists or their rights holders. Only the text of
the System Reference Documents 5.1 and 5.2 is available under
CC-BY-4.0, and reuse needs the attribution statement from the
SRD. This repository contains none of the content. The app builds a local index (
DATA_DIR/index.sqlite, a derived copy of the entities in your clone) and shows the content and images to the players you invite, and over MCP to the AI service you connect. The host is responsible for that use. Use it for books you own; this is advice, not a license from Wizards. Do not publish a server with this content to the public. - 5etools. The 5etools code is MIT-licensed (Copyright (c) 2017 TheGiddyLimit and contributors). Parts of
packages/fivetools(the edition table, the{@tag}registry, the source-name maps) are adapted from it, and the MIT notice is kept in THIRD_PARTY_NOTICES.md. The 5etools-utils package (MIT) supplies the homebrew schemas and the edition source lists at run time; its npm package ships no license file. - Third-party code (dependencies, the bundled AI agent definitions, the 5etools license notice) is listed in THIRD_PARTY_NOTICES.md.
- Your data stays in your
DATA_DIR. Your notes and homebrew are yours. An export that contains 5etools text or art stays under the terms of that content. - Trademarks. Critical Notes is an unofficial fan project. It is not affiliated with, endorsed or sponsored by Wizards of the Coast. "Dungeons & Dragons" and "D&D" are trademarks of Wizards of the Coast LLC, used here only to describe compatibility; the project uses no Wizards logos.