state.db — Session State Store
state.db is the main SQLite database for Hermes Agent on the Agent Host. It is
the single source of truth for session history, message logs, and full-text
search. A separate file sessions.db exists but is a 0-byte placeholder —
all data lives in state.db.
Location
Section titled “Location”~/.hermes/state.db # Live database (WAL mode)~/.hermes/state-snapshots/ # Auto-snapshots (pre-update, etc.)Tables
Section titled “Tables”| Table | Purpose | ~Size |
|---|---|---|
| sessions | One row per conversation session. Stores session ID, source platform (slack/telegram/cli), model config, token usage, cost tracking, timestamps, parent session (for handoff chains), and title. | 300+ rows |
| messages | Individual messages per session. Stores role (user/assistant/tool/system), content, tool call metadata, timestamps, reasoning outputs, and platform message IDs. | 17 000+ rows |
| messages_fts | FTS5 virtual table over messages.content with trigram tokenizer. Powers the session_search tool for fast full-text retrieval across all sessions. |
17 000+ docs |
| schema_version | Migration version counter. Used by Hermes to apply schema upgrades on startup. | 1 row |
| state_meta | Generic key-value metadata store. | 2 rows |
| compression_locks | Advisory locks for session compression. Prevents concurrent compression of the same session. | 0 rows |
| sqlite_sequence | Auto-updated by SQLite for AUTOINCREMENT on messages.id. |
1 row |
FTS5 internal tables
Section titled “FTS5 internal tables”The trigram FTS5 index creates supporting tables automatically:
messages_fts_config, messages_fts_content, messages_fts_data,
messages_fts_docsize, messages_fts_idx, plus messages_fts_trigram_*.
These are all internal to the FTS5 virtual table and should not be queried
directly.
Key indexes
Section titled “Key indexes”CREATE INDEX idx_sessions_source ON sessions(source);CREATE INDEX idx_sessions_parent ON sessions(parent_session_id);CREATE INDEX idx_sessions_started ON sessions(started_at DESC);CREATE INDEX idx_messages_session ON messages(session_id, timestamp);CREATE INDEX idx_compression_locks_expires ON compression_locks(expires_at);Schema highlights — sessions table
Section titled “Schema highlights — sessions table”| Column | Purpose |
|---|---|
id |
Primary key, e.g. slack:C0B7C9ZFY4R:1780260332.893459 |
source |
Platform: slack, telegram, discord, cli, … |
parent_session_id |
Self-referential FK for compression handoff chains |
model, billing_provider, billing_base_url |
Model/billing config at session creation time |
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens |
Token consumption counters |
estimated_cost_usd, actual_cost_usd |
Cost tracking (estimated vs. confirmed) |
title |
Auto-generated or user-edited session title |
handoff_state, handoff_platform |
Cross-platform handoff support |
Schema highlights — messages table
Section titled “Schema highlights — messages table”| Column | Purpose |
|---|---|
id |
AUTOINCREMENT primary key |
session_id |
FK → sessions.id |
role |
user, assistant, tool, system |
content |
Message text (indexed by FTS5) |
tool_calls, tool_call_id, tool_name |
Tool invocation metadata |
reasoning, reasoning_content, reasoning_details |
Thinking/reasoning output |
platform_message_id |
Cross-platform message correlation (e.g. Slack TS) |
observed |
Flag for deduplication |
Backup coverage
Section titled “Backup coverage”The scheduled daily backup (backup.sh, see backup) deliberately excludes state.db — too large for the GitHub-hosted backup repo. To close that gap, a lightweight daily state.db snapshot now runs at 02:00 UTC (state_db_backup.sh, cron “Hermes state.db Daily Snapshot”, no_agent): WAL-safe sqlite3 .backup → gzip → ~/.hermes/state-db-snapshots/state.db-YYYYMMDD.gz, local-only (not git), 7-day rotation. Every incident can now fall back to at worst a yesterday state.
Sources of state.db recovery, with what they restore and typical freshness:
| Source | Restores | Typical freshness | Access required |
|---|---|---|---|
Daily snapshot (~/.hermes/state-db-snapshots/) |
full state.db |
yesterday at worst | local file only |
Pre-update snapshot (~/.hermes/state-snapshots/<ts>/) |
full state.db |
only at each hermes update — can be weeks/months stale |
local file only |
Manual copies (state.db.bak-*, state.db.malformed-backup-*) |
full state.db |
when someone made a copy before a repair | local file only |
hermes backup zip (manual) |
full state.db (via _safe_copy_db sqlite backup) |
only when run manually | local file only |
backup.sh daily repo (hermes-backup) |
does NOT include state.db; restores SOUL/CONTEXT/memory/config/skills/cron/scripts/plugins |
daily | git repo |
backup.sh repo secrets (.env, auth.json) |
excluded by design (Bitwarden) — a full restore needs manual secret recovery | — | Bitwarden access |
Reading the signals: a
state.db.bak-*/state.db.malformed-backup-*file never appears on its own — it is only created when someone starts repairingstate.db. Seeing one means a problem has begun: runhermes doctorand check the store.backup.shand the daily snapshot are complementary: the first protects the irreplaceable (SOUL, memory, skills, cron, config, profiles) but excludesstate.db; the second protects full session history. A complete restore needs both (plus secrets from Bitwarden).
Residual risk: the daily snapshots live on the same disk as state.db — they protect against corruption, not against losing the VPS disk. Off-host shipping of the snapshot is not sized for the GitHub repo (~56 MB/day gz) and remains a documented future option; backup.sh is the only off-host copy (excluding state.db by design).
Health monitoring: four read-only cron jobs cover different failure classes:
| Job | Frequency | Detects | Delivery |
|---|---|---|---|
state.db Corruption & Write-Failure Watchdog |
every 5 min | FTS deferrals/corruption and file is not a database write failures in the journal/log |
Slack on new deduplicated evidence |
state.db Health Watchdog |
hourly | full integrity_check + foreign_key_check on the main and profile databases; deleted state.db/WAL/SHM descriptors |
local cron output only; no routine Slack message |
state.db Snapshot Freshness Watchdog |
every 6 h | missing or older-than-48h daily snapshot | Slack on anomaly |
Hermes state.db Daily Snapshot |
daily at 02:00 UTC | validates the staged snapshot before atomic publication | Slack only when the no-agent job emits a failure |
The freshness watchdog (~/.hermes/scripts/state_db_snapshot_freshness.sh) is deliberately separate from snapshot validity: a fresh file can still be invalid, while a dead snapshot job can otherwise remain silent. The full health watchdog (~/.hermes/scripts/state_db_health_watchdog.py) is read-only and never repairs or restarts services. The scripts and current schedules are documented in the state.db recurrence postmortem. If the watchdog name differs in an existing installation, verify the live cron definition before recreating it; the script path and behavior are the source of truth.
Verifying backup includes state.db:
# List files in the latest full backupunzip -l ~/.hermes/backups/hermes-backup-*.zip | grep state.db
# Check latest quick snapshotls ~/.hermes/state-snapshots/*/state.db
# Check daily state.db snapshotls -lh ~/.hermes/state-db-snapshots/Why not sessions.db?
Section titled “Why not sessions.db?”The file ~/.hermes/sessions.db exists as a 0-byte file. It is a legacy
placeholder — all session data has been stored in state.db since the
hermes_state.py SQLite store replaced the per-session JSONL approach. The
backup system targets state.db explicitly and does not need sessions.db.