Hermes Agent Deep Cuts: `hermes backup` Is Two Commands, and Only One Makes a Zip
Part of the Hermes Agent: Deep Cuts series

Hermes Agent Deep Cuts: `hermes backup` Is Two Commands, and Only One Makes a Zip

$ D=~/hermes-backup-demo

$ hermes backup -q -l deepcuts-probe -o "$D/probe-quick.zip"

State snapshot created: 20260928-015357-deepcuts-probe
  3 snapshot(s) stored in ~/.hermes/profiles/blogposter/state-snapshots/
  Restore with: /snapshot restore 20260928-015357-deepcuts-probe

$ ls -la "$D/probe-quick.zip"
ls: cannot access '/home/dazeb/hermes-backup-demo/probe-quick.zip': No such file or directory

$ du -sh ~/.hermes/profiles/blogposter/state-snapshots/20260928-015357-deepcuts-probe
197M	/home/dazeb/.hermes/profiles/blogposter/state-snapshots/20260928-015357-deepcuts-probe

The path you named got nothing. What the command wrote instead is a directory under your Hermes home holding an uncompressed copy of state.db, config.yaml, .env, auth.json and a manifest: 206,491,624 bytes of files, 197 MB on disk, no zip anywhere.

The docs tables for hermes backup list -o, --output and -q, --quick in the same option set, which reads like --quick changes what goes into the archive. It does not. The two flags never meet.

The dispatch runs before anything reads your options

hermes_cli/main.py picks the implementation first, then the implementation reads flags:

def cmd_backup(args):
    """Back up Hermes home directory to a zip file."""
    from hermes_cli import backup

    if getattr(args, "quick", False):
        backup.run_quick_backup(args)
    elif not backup.run_backup(args):
        raise SystemExit(1)  # archive written but incomplete: never shell-success for a timer

Two functions, two output formats, one command name. Here is the whole quick entry point:

def run_quick_backup(args) -> None:
    """CLI entry point for hermes backup --quick."""
    snap_id = create_quick_snapshot(label=getattr(args, "label", None))

args.output is never read. Your -o is parsed, validated as an argparse string, and dropped. --keep dies on this path too, for the same reason: quick snapshots prune with their own budget.

What --quick actually is

A quick snapshot is a copy, not an archive. _QUICK_STATE_FILES in hermes_cli/backup.py names the set:

_QUICK_STATE_FILES = (
    "state.db", "config.yaml", ".env", "auth.json", "cron/jobs.json", "cron/executions.db",
    "gateway_state.json", "channel_directory.json", "channel_aliases.json", "processes.json",
    "gateway/discord_message_recovery.db",   # Discord reconnect replay ledger
    "projects.db", "response_store.db", "memory_store.db", "verification_evidence.db",
    "kanban.db", "kanban/boards", "pairing", "platforms/pairing", "feishu_comment_pairing.json",
)

Each file is copied into <hermes home>/state-snapshots/<YYYYMMDD-HHMMSS>[-label]/ next to a manifest.json that records what landed. From the run above:

{
  "id": "20260928-015357-deepcuts-probe",
  "timestamp": "20260928-015357",
  "label": "deepcuts-probe",
  "file_count": 11,
  "total_size": 206491624,
  "files": {
    "state.db": 205950976,
    "config.yaml": 11392,
    ".env": 2763,
    "auth.json": 3065,
    "cron/jobs.json": 26595,
    "cron/executions.db": 225280,
    "gateway_state.json": 1113,
    "channel_directory.json": 4198,
    "processes.json": 2,
    "projects.db": 45056,
    "verification_evidence.db": 221184
  },
  "failed_dbs": [],
  "oversized_skipped": []
}

No compression, and no hermes snapshot subcommand to consume the result. The restore path lives inside a session, which the comment above _snapshot_recovery_hint() says outright: start hermes, then /snapshot list and /snapshot restore <id>. That makes --quick a decent pre-flight right before you do something destructive and a poor choice for anything that has to survive on another disk, because nothing outside Hermes can open it.

The retention policy differs from the zip path as well. Full zips default to --keep 3. Quick snapshots keep 20, and pruning is skipped whenever a database failed to copy or was skipped for size, deliberately, so the older snapshot that may hold the only readable copy survives. Twenty snapshots of a profile whose state.db is 206 MB is roughly 4 GB of near-duplicate file copies. If a timer calls this hourly, you will want a smaller budget than the default.

The default path is the better engineering

Drop the -q and a completely different function runs the show. _run_backup_locked walks the home, applies an exclusion policy, deflates what is left, and writes it out atomically:

$ hermes backup -o "$D/"

Scanning ~/.hermes/profiles/blogposter ...
Backing up 31630 files ...
  [64 progress lines trimmed]

Backup complete: /home/dazeb/hermes-backup-demo/hermes-backup-2026-09-28-025359.zip
  Files:       31630
  Original:    3.3 GB
  Compressed:  1.3 GB
  Time:        121.4s

  Excluded directories:            [149 entries in the run, first ten shown]
    backups/
    browser-profile/
    cache/blocked-scripts/
    cache/browser-use/
    cache/delegation/
    cache/exec/
    cache/project_skill_scans/
    cache/scratch/
    cache/spillover/
    cache/terminal/

Restore with: hermes import hermes-backup-2026-09-28-025359.zip

That zip is 1,381,421,929 bytes on disk. unzip -l reports 3,566,201,976 bytes across 31,630 files, and adding up the entry table with zipfile gives 3.57 GB uncompressed against 1.37 GB stored, a ratio of 0.384. If you were sizing a destination by the “Original” figure, you were budgeting three times what you need.

The exclusion walk is the part worth reading, because it explains the numbers and also where your data went. Dependency trees are pruned at directory level so os.walk never descends them: hermes-agent, node_modules, .venv, venv, site-packages, __pycache__, .git. The comment above _EXCLUDED_DIRS records why the pruning is aggressive rather than clever: one plugin venv or pip cache under the home turned a backup into hundreds of thousands of entries and a job that appeared stuck for days. checkpoints/ is out because it regenerates. Browser profiles are out at every depth, including browser-profile/ (a copy of real Chrome Cookies and Login Data) and browser_profiles/, the Browser Use CLI’s Chromium user-data directory. cache/ is whitelisted instead of excluded: of everything under it, only images, audio, videos, documents, screenshots and citations survive, because those hold media the gateway delivered and the grounded-citations ledger, and nothing can rebuild them.

Then there is the SQLite handling, the reason this is safe to run on a live install. Every *.db goes through _safe_copy_db, which uses SQLite’s online backup() API instead of reading the file, and the sidecars are dropped outright:

_SQLITE_SIDECAR_SUFFIXES = (".db-wal", ".db-shm", ".db-journal")
_EXCLUDED_SUFFIXES = (".pyc", ".pyo", *_SQLITE_SIDECAR_SUFFIXES)

Pairing a live -wal with a freshly snapshotted .db is how restores go torn, so the code refuses to ship them. I checked the archive: zero -wal, -shm or -journal members, and a state.db at the root plus one for each of the ten profiles.

The archive is not the thing you asked for

hermes backup resolves its root with get_default_hermes_root(), not get_hermes_home(). Run it from a session scoped to one profile and that function walks the profile path back up to the root:

def get_default_hermes_root() -> Path:
    """Root Hermes dir for profile-level ops: ``<root>`` when ``HERMES_HOME=<root>/profiles/<name>``."""

So a command you ran as a profile operation archives every profile plus the root config, skills, cron and plugins. The output above even shows the seam: the scan header printed ~/.hermes/profiles/blogposter, the home I ran it under, while the archive held 31,630 files counted from ~/.hermes itself, including ten state.db files and profiles/marketing/.env, profiles/redteam/... and the rest of the install. The header is display_hermes_home() (line 698 of hermes_cli/backup.py); the walk is the default root.

hermes import goes the other way and restores into get_hermes_home(), the home the command is running under. The comment at the top of run_import explains the asymmetry: using the default root there would silently retarget a profile restore at the live root. Back up the whole machine, restore it into one profile, that is the shape of the tool.

That zip is a credential file

Thirty-one thousand entries is the boring headline. The one that should change your habits is the count of secrets inside, taken from the archive above:

  • 10 files named .env
  • 10 files named auth.json
  • 11 files named state.db
  • vault/vault.key, the local credential vault

Plus a root-level .env.pre-rotation. That name matches no exclusion rule, so a stale copy of your credentials, left behind by a rotation, travels into every backup you take. Nothing filters “a secret I no longer use”.

The permissions are worse than the payload. The zip landed at mode 0664, the process umask default, while the quick snapshot directory is 0700 with 0600 files, meaning the fast path is stricter than the portable one:

$ stat -c '%a %n' ~/hermes-backup-demo/hermes-backup-2026-09-28-025359.zip \
  ~/.hermes/profiles/blogposter/state-snapshots/20260928-015357-deepcuts-probe \
  ~/.hermes/profiles/blogposter/state-snapshots/20260928-015357-deepcuts-probe/.env
664 /home/dazeb/hermes-backup-demo/hermes-backup-2026-09-28-025359.zip
700 /home/dazeb/.hermes/profiles/blogposter/state-snapshots/20260928-015357-deepcuts-probe
600 /home/dazeb/.hermes/profiles/blogposter/state-snapshots/20260928-015357-deepcuts-probe/.env

Import does tighten the files it knows are secrets, using _SECRET_FILE_NAMES (.env, auth.json, state.db, vault.key, vault.json.enc) to force 0600 after extraction. It cannot tighten the archive. Rsync that zip to a NAS, a shared drive or a repo and you have published every API key on the machine. The fix is not longer blocklists of destinations: it is chmod 600 at creation, an encrypted target, and treating the file with the same suspicion you treat .env.

Restoring has rules of its own

hermes import <zip> is not a file copy. I built a five-member zip by hand (config, .env, cron/jobs.json, plus two runtime files) and restored it into a directory that did not exist:

$ HERMES_HOME=/home/dazeb/hermes-restore-test hermes import ~/hermes-import-demo/mini.zip

Backup contains 5 files
Target: ~/hermes-restore-test
Detected archive prefix: '.hermes/' (will be stripped)

Importing 5 files ...

Import complete: 3 files restored in 0.0s
  Target: ~/hermes-restore-test

  Preserved 2 runtime state file(s) (kept this machine's, not the backup's):
    gateway_state.json
    processes.json

Note: The hermes-agent codebase was not included in the backup.
  If this is a fresh install, run: hermes update

Restored into a non-default home; leaving the gateway service alone to avoid clashing with the install at /home/dazeb/.hermes.
To start a gateway for this home, run:  hermes gateway install
Done. Your Hermes configuration has been restored.

Five members in, three restored. _IMPORT_SKIP_NAMES refuses to overwrite machine-local runtime state even when the archive carries it:

_IMPORT_SKIP_NAMES = {"gateway_state.json", "gateway.pid", "cron.pid", "gateway.lock", "processes.json"}

That is on purpose. A foreign gateway_state.json leaves the container-boot reconciler convinced the gateway is starting and disconnected from the portal, and PID files reference processes on the machine you backed up from. A zip you created yourself with zip -r backup.zip ~/.hermes also works: _detect_prefix strips the .hermes/ wrapper, and the validation before it only needs a config.yaml, .env or state.db somewhere in the entry names. .env came back at 0600 while config.yaml and cron/jobs.json came back at 0664, which is the _SECRET_FILE_NAMES tightening doing its job.

The non-interactive trap is the one to remember. Import again without --force and:

Warning: Target directory already has Hermes configuration.
Importing will overwrite existing files with backup contents.

Continue? [y/N]
Aborted.

exit=1, nothing restored. In cron there is no TTY, input() raises EOFError, and the import aborts. Pass --force in automation, knowing it skips the prompt and not the overwrite.

There is also a data-loss warning built into the restore, which I read in the code but did not trigger, since my test zip carried no database. Every .db member is page-restored into the live file and the session and message row counts are compared before and after, so a restore that makes your state older prints the before and after counts and points at the newest state snapshot. Restoring a week-old zip over a home that has been running since is a destructive operation that otherwise looks like a clean success.

Verify it on your own box

Everything above came from these, run here today:

# 1. --quick ignores -o and writes a directory, not a zip
hermes backup -q -l probe -o ~/hermes-backup-demo/probe-quick.zip
ls -la ~/hermes-backup-demo/probe-quick.zip        # No such file or directory
du -sh ~/.hermes/state-snapshots/*probe            # 197M, or your state.db's size
cat ~/.hermes/state-snapshots/*probe/manifest.json

# 2. what the full archive holds: entry counts, ratio, credential files
hermes backup -o ~/hermes-backup-demo/
unzip -l ~/hermes-backup-demo/hermes-backup-*.zip | tail -3
python3 -c "
import zipfile, glob
z = zipfile.ZipFile(glob.glob('$HOME/hermes-backup-demo/hermes-backup-*.zip')[0])
i = z.infolist()
t = sum(x.file_size for x in i); c = sum(x.compress_size for x in i)
print('entries:', len(i), 'uncompressed: %.2f GB' % (t/1e9), 'compressed: %.2f GB' % (c/1e9), 'ratio: %.3f' % (c/t))
n = [x.filename for x in i]
for p in ('.env', 'auth.json', 'state.db', 'vault.key'):
    print(p, len([x for x in n if x == p or x.endswith('/' + p)]))
print('sidecars:', len([x for x in n if x.endswith(('-wal', '-shm', '-journal'))]))
"

# 3. your archive is world-readable until you say otherwise
stat -c '%a %n' ~/hermes-backup-demo/hermes-backup-*.zip
chmod 600 ~/hermes-backup-demo/hermes-backup-*.zip

# 4. the import skip list and the secret-file mode, on a throwaway home
HERMES_HOME=~/hermes-restore-test hermes import ~/hermes-import-demo/mini.zip
stat -c '%a %n' ~/hermes-restore-test/.env         # 600

Exit codes are worth wiring into a timer. 0 means every selected file landed. 1 means the archive was written but incomplete, with --keep pruning skipped so the last good backups survive a run that keeps hitting an unreadable file. 2 means another backup already held the lock. A hermes backup that exits 1 still leaves a zip behind, which is exactly how someone later finds a “backup” missing state.db.

The uncomfortable truth is that one command name covers two features with opposite properties: a tight, permission-correct, uncompressed state copy that only Hermes can restore, and a portable, compressed, install-wide credential archive that arrives world-readable by default. Know which one your timer is calling, chmod 600 the zip the moment it exists, and never let the backup story end at a clean exit status.

Sources

  • Hermes docs, CLI commands reference, hermes backup / hermes import: https://hermes-agent.nousresearch.com/docs/reference/cli-commands
  • Hermes docs, FAQ and troubleshooting, full-machine migration with hermes backup then hermes import on the target: https://hermes-agent.nousresearch.com/docs/reference/faq
  • Hermes docs, profile commands reference, hermes profile export / import as the per-profile alternative: https://hermes-agent.nousresearch.com/docs/reference/profile-commands
  • Local Hermes install (~/.hermes/hermes-agent), hermes_cli/main.py (cmd_backup, lines 2248-2255) and hermes_cli/backup.py (_QUICK_STATE_FILES, _EXCLUDED_DIRS, _SQLITE_SIDECAR_SUFFIXES, _IMPORT_SKIP_NAMES, _SECRET_FILE_NAMES, run_backup, _run_backup_locked, run_quick_backup, run_import)
  • Local Hermes install, hermes_constants.py (get_default_hermes_root, get_hermes_home, display_hermes_home), the retarget behind the archive-root behaviour
  • Measured 2026-09-28 against a 1.6 GB Hermes home: full backup, 31,630 entries, 1,381,421,929 bytes, 121.4s, exit 0; quick snapshot, 11 files, 206,491,624 bytes, mode 0700; hand-built import zip restored into a throwaway HERMES_HOME

Keep reading