Skip to content

hermes

Hermes Agent (catalog agent hermes) is Nous Research's open-source Python agent, with a classic CLI, a TUI (terminal interface), an Electron desktop app, the Webapp (the desktop workspace served to a browser by hermes webapp), a messaging gateway that drives the agent from chat platforms, cron jobs and delegated subagents; every front end stores every conversation in one SQLite store per profile home, state.db, in OpenAI chat-completions message shape. It is not a fork of another agent, but it imports Claude Code and Codex CLI sessions into its own store (Imported sessions), and Agent Zero's Orchestrator plugin can start it (Started by another agent). This document rests on the main branch at commit 6bb377a of 2026-10-30, after release v2026.9.24. Parsed: the hermes parser reads state.db, its quick snapshots, the divert JSONL sessions, pastes/pending.json, the prompt history, the cron run archive, response_store.db, the trajectory files, the standing approvals in config.yaml and the pairing stores, and the desktop app's connections.json.

Source and evidence

NousResearch/hermes-agent, MIT (LICENSE), open source, Python with an Electron desktop app, commit 6bb377a51d5c8e279a7a5e34d717d653d421d064 (2026-10-30). The repository carries no version in pyproject.toml (0.0.0); the release the commit follows is the latest tag, v2026.9.24, which hermes_cli/__init__.py names as __release_date__ (hermes_cli/__init__.py:5). Every claim in the Collection and Parsing parts is from source at that commit unless it cites another; the vendor's documentation pages in website/docs/ are cited at the same commit for product behaviour, and the claims about older builds in Format versions cite 8b66a51 (2026-10-04) or 86fa9e6 (2026-10-19). The prompt history format rests on prompt-toolkit/python-prompt-toolkit at d8adbe9, the desktop updater cache on electron-userland/electron-builder at 39db76f and the Orchestrator plugin on agent0ai/agent-zero at ff2a5b9. Read only: nothing was installed or run.

Observed

Source reviews

  • 2026-10-30: NousResearch/hermes-agent at 6bb377a, every citation re-read there. Added the Webapp and its credential directory, the opt-in vault key in the OS keychain, the config backend seam and the managed /etc/hermes scope, the gui.log, the browser uploads, the emergency, malformed and retired-WAL copies of state.db, the session_meta role, the local policy record that holds a classic worktree session's tree, the aging of sweep deletion markers and the key tables; recorded the CLI importer's change to plain cli sessions and the move of the Mem0 and Langfuse plugins to the plugin catalog.
  • 2026-10-21: NousResearch/hermes-agent at 86fa9e6, with prompt-toolkit/python-prompt-toolkit at d8adbe9 (3.0.52, the version Hermes pins) for the history file format: the Parser plan's proposals are now what the parser does; added the shapes of the cron run documents, the response store's history, the Bot Mode room events and the desktop connection registry, the snapshot count after hermes update, the gateway tokens in the desktop connection files, and the source-host review.
  • 2026-10-19: HEAD 86fa9e6ca1de7ee71132d2be0103f3e37d1fa872, against the 8b66a51036c1e20920a17cdd049fdf55c968d683 of 2026-10-04. Added the permission outcomes and standing settings, the model-switch markers, the sources of past use beyond state.db (Other stores), the trace upload and the desktop backends, the scratch directories and worktrees, the deletion tombstones and the prune default, and the schema 32 drift (Format versions); every catalog path was unchanged.

Collection

Per-user storage

Home resolution: context override, then HERMES_HOME, then the platform default (hermes_constants.py:194-201): ~/.hermes on Linux and macOS, %LOCALAPPDATA%\hermes (fallback ~/AppData/Local/hermes) on Windows, each with an optional HERMES_DATA_DIR_SUFFIX appended (hermes_constants.py:57-64). The suffix is appended with no separator: a channel build's -channel-build-canary gives ~/.hermes-channel-build-canary, and an explicit HERMES_HOME is never suffixed (environment-variables.md:886), so a suffixed home is .hermes<suffix> or AppData/Local/hermes<suffix>. The desktop app resolves the same default and on Windows keeps using a legacy ~/.hermes when %LOCALAPPDATA%\hermes does not exist and no suffix is set (apps/desktop/electron/data-paths.mjs:26-33, 57-64), so both forms occur on Windows. No XDG paths.

A system-wide managed scope, /etc/hermes/config.yaml, is an overlay applied on top of each profile's own config.yaml (hermes_cli/managed_scope.py:31, hermes_cli/config_backend.py:17-18); it is outside every home. With HERMES_CONFIG_BACKEND=remote a profile's settings come from a remote configuration service instead of its config.yaml (hermes_cli/config_backend.py:1-14, plugins/config_backends/remote/__init__.py:1-2).

Scratch directories

cache/scratch/<entry> is the TMPDIR of every child process, reaped when idle (hermes_constants.py:978, hermes_constants_scratch.py:1-6); it is under the catalog's .hermes entry and collected. sandboxes/ (or TERMINAL_SANDBOX_DIR) holds the container backends' persistent bind mounts, sandboxes/docker/<task> for Docker (tools/environments/base.py:187-191, tools/environments/docker.py:856); it is excluded (Exclusions).

The git worktrees the agent creates are inside the user's repository, not the home: <repo>/.worktrees/<name> on branch hermes/<name> for hermes -w, .worktrees/subagent-<id> on hermes-subagent/<name> for delegated subagents, and .worktrees/<child id> for kanban children (hermes_cli/worktree_ops.py:396-402, tools/subagent_worktree.py:81-96, hermes_cli/kanban_db_graph.py:181). A --tui -w session's sessions.cwd lies inside its tree (hermes_cli/worktree_ops.py:863-885); a classic -w session's row holds the repository root, and its tree is the cwd of the session's local policy record (hermes_cli/main_resume_args.py:28-38; Local policy records). A retained TUI worktree is locked with the reason hermes session=<id> db=<path> (hermes_cli/worktree_ops.py:769-779). The startup sweep archives an idle dirty or unpushed tree to the git ref refs/hermes/archive/<tree> and removes the directory (hermes_cli/worktree_ops.py:1111-1122). The project catalog collects the repository's .hermes/ files, not .worktrees/.

Profiles

Named profiles are complete homes under <root>/profiles/<name>/ (hermes_cli/profiles.py:288-298); every path in this section can also appear one level down under profiles/*/.

Docker and Podman volumes

The compose file bind-mounts the host ~/.hermes to /opt/data, the container's HERMES_HOME (docker-compose.yml:36-37, Dockerfile:491), so a compose deployment leaves its state in the host home. The image declares /opt/data a volume (Dockerfile:540), and the Docker guide suggests a named volume hermes-data for it (docker.md:252); the catalog's volume line hermes|*hermes* matches it. Such a volume's _data directory is the Hermes home itself: its files are state.db, sessions/<id>.jsonl, state-snapshots/<id>/state.db, pastes/pending.json and profiles/<name>/... with no .hermes/ prefix. The image's runtime user hermes has /opt/data as its login home (Dockerfile:178), so a credential a tool writes to its own home inside the container (.ssh/<file>, .config/gh/<file>) lands in _data too. The home-relative exclusions and secret globs are anchored to .hermes*/, so each has a volume-relative twin matched against _data. The excluded subtrees (Exclusions) in a volume are hermes-agent/[!.]*, hermes-agent/.[!e]*, models, runtimes, node, installs, tools, sandboxes, checkpoints/store/objects, checkpoints/store/indexes, checkpoints/legacy-*, backups, browser-profile, browser-profiles, browser_profiles, chrome-debug, bot-desktop/browser-profile, plugins/*/node_modules and plugins/*/.venv. The credential files (Credentials) in a volume are flagged by .env, .env.bak*, .op.env, npmrc, auth.json, auth.json.*, auth/*, .anthropic_oauth.json, .copilot_jwt.json, google_*.json, google_chat_user_tokens/*, slack_tokens.json, honcho.json, mem0.json, webhook_subscriptions.json, teams_pipeline_store.json, mcp-tokens/*, vault/*, browser_auth/*, bot-desktop/*, webapp/*, pairing/*, platforms/pairing/*, feishu_comment_pairing.json, whatsapp/session/*, platforms/whatsapp/session/*, matrix/store/*, platforms/matrix/store/*, cache/bws_cache*.json, workspace/meetings/node_token.json, weixin/accounts/*, runtime/photon-sidecar.json, proxy/*, home/*, config.yaml, config.yaml.*, .ssh/*, .aws/credentials, .aws/config, .gnupg/*, .kube/*, .docker/*, .azure/*, .config/gh/*, .config/gcloud/* and .envrc; the AWS twin names its credential files instead, .aws/credentials, .aws/config and the AWS CLI's assumed-role credential cache .aws/cli/cache/*.json (Using an IAM role in the AWS CLI), since a bare .aws/* would also flag Kiro's .aws/amazonq/ transcripts in a home; the SSO token cache .aws/sso/cache/*.json (Configuring IAM Identity Center authentication with the AWS CLI) is flagged by Kiro's line of the same form, and a file such as .aws/amazonq/<file> stays unflagged. The credential copies inside a snapshot are flagged by state-snapshots/*/.env, state-snapshots/*/auth.json, state-snapshots/*/config.yaml, state-snapshots/*/feishu_comment_pairing.json, state-snapshots/*/pairing/* and state-snapshots/*/platforms/pairing/*. A named profile in a volume, profiles/<name>/, is a whole Hermes home with its own quick snapshots (hermes_cli/backup.py:1127-1129), so each credential glob above has a profile form, whose * also crosses into the profile's state-snapshots/<id>/: profiles/*/.env, profiles/*/.env.bak*, profiles/*/.op.env, profiles/*/npmrc, profiles/*/auth.json, profiles/*/auth.json.*, profiles/*/auth/*, profiles/*/.anthropic_oauth.json, profiles/*/.copilot_jwt.json, profiles/*/google_*.json, profiles/*/google_chat_user_tokens/*, profiles/*/slack_tokens.json, profiles/*/honcho.json, profiles/*/mem0.json, profiles/*/webhook_subscriptions.json, profiles/*/teams_pipeline_store.json, profiles/*/mcp-tokens/*, profiles/*/vault/*, profiles/*/browser_auth/*, profiles/*/pairing/*, profiles/*/platforms/pairing/*, profiles/*/whatsapp/session/*, profiles/*/platforms/whatsapp/session/*, profiles/*/matrix/store/*, profiles/*/platforms/matrix/store/*, profiles/*/cache/bws_cache*.json, profiles/*/weixin/accounts/*, profiles/*/runtime/photon-sidecar.json, profiles/*/proxy/*, profiles/*/home/*, profiles/*/config.yaml, profiles/*/config.yaml.*, profiles/*/bot-desktop/*, profiles/*/webapp/*, profiles/*/feishu_comment_pairing.json, profiles/*/workspace/meetings/node_token.json, profiles/*/.ssh/*, profiles/*/.aws/credentials, profiles/*/.aws/config, profiles/*/.aws/sso/cache/*.json, profiles/*/.aws/cli/cache/*.json, profiles/*/.gnupg/*, profiles/*/.kube/*, profiles/*/.docker/*, profiles/*/.azure/*, profiles/*/.config/gh/*, profiles/*/.config/gcloud/* and profiles/*/.envrc. OpenHands' volume-relative profiles/*.json also flags a named profile's .json files, such as profiles/<name>/pastes/pending.json, since its * crosses /, so --no-secrets skips them; state.db and the sessions/<id>.jsonl transcripts are unaffected.

Contents of the home

Relative to the home:

Started by another agent

At agent0ai/agent-zero ff2a5b9, Agent Zero's Orchestrator plugin, which tells that agent how to drive other coding CLIs on the host or in its container, defaults Hermes to yolo: true and runs hermes chat --quiet --source tool -q <task> --yolo, which bypasses every dangerous-command approval prompt for the run (hermes_cli/_parser.py:307-308; plugins/_orchestrator/default_config.yaml:7-37, plugins/_orchestrator/skills/orchestrator/references/hermes.md:16). Such a session is an ordinary session in this home's state.db, started by another agent with approvals bypassed; the plugin's permission settings are in Permission rows in agent-zero.md.

Desktop app

Electron productName is Hermes (apps/desktop/package.json:3), so its userData is the Electron default Hermes directory under the per-user application data folder: Library/Application Support/Hermes on macOS, AppData/Roaming/Hermes on Windows and .config/Hermes on Linux. It is overridable by HERMES_DESKTOP_USER_DATA_DIR and otherwise suffixed with HERMES_DATA_DIR_SUFFIX like the home (.config/Hermes<suffix>, Library/Application Support/Hermes<suffix> and AppData/Roaming/Hermes<suffix>, data-paths.mjs:35-39). The app depends on electron-updater (apps/desktop/package.json:126), whose download cache is named after the package name hermes plus -updater (package.json:2; appInfo.ts:159-161) and placed in %LOCALAPPDATA% on Windows (AppAdapter.ts:60-72): AppData/Local/hermes-updater, beside the home, holding downloaded installers.

The userData directory holds active-profile.json, connection.json, connections.json, desktop-installation.json, updates.json, window-state.json, backend-ownership.json and managed-ssh-update-recovery.json (apps/desktop/electron/main.ts:1248, 1320-1330); conversations stay in state.db. connections.json is the registry of the backends the app talks to (Desktop connection registry); both connection files are credentials (Credentials).

Credentials

Hermes keeps its own list of credential stores, PROFILE_CREDENTIAL_PATHS (hermes_cli/profiles.py:156-189), relative to each profile home. Each path below sits under every Hermes home form of Per-user storage (.hermes/, .hermes<suffix>/, AppData/Local/hermes/, AppData/Local/hermes<suffix>/ and profiles/<name>/ inside any of them), as .hermes/auth.json does on Linux and macOS and the same name under AppData/Local/hermes/ does on Windows:

Path Holds
auth.json OAuth provider credentials and the credential pool
.env API keys, platform bot tokens
config.yaml MCP server env values and headers (below)
config.yaml.bak-<stamp>, config.yaml.bak.<n>, config.yaml.corrupt.<time>, config.yaml.bak-pre-migrate-<time> (the files config.yaml.*) the config writers' copies of config.yaml; the glob also takes any other config.yaml.* a user names, which Hermes' own list leaves out
auth/google_oauth.json, google_token.json, google_client_secret.json, google_oauth_pending.json Google OAuth
google_chat_user_tokens/, google_chat_user_client_secret.json, google_chat_user_oauth_pending.json, google_chat_user_token.json (the files google_chat_user_*.json), google_chat_user_oauth_pending/ Google Chat user OAuth
.op.env 1Password service-account token
npmrc npm registry auth
.anthropic_oauth.json Anthropic OAuth tokens
slack_tokens.json Slack tokens
honcho.json, mem0.json memory provider keys
webhook_subscriptions.json, teams_pipeline_store.json webhook shared secrets
mcp-tokens/ MCP OAuth tokens
vault/ vault.key beside vault.json.enc, or the vault.keyref pointer
browser-profile, browser_auth, bot-desktop/<file>, browser-profiles, browser_profiles, chrome-debug browser cookies and logins
webapp/session-token, webapp/native-sign-in-tokens.json, webapp/sign-in-registration.json the Webapp browser credential and sign-in tokens
pairing/, platforms/pairing/, feishu_comment_pairing.json gateway pairing stores
whatsapp/session/<file>, platforms/whatsapp/session/<file>, matrix/store/<file>, platforms/matrix/store/<file> messaging sessions
cache/bws_cache.json, cache/bws_cache.enc.json (both cache/bws_cache*.json) Bitwarden cache
workspace/meetings/node_token.json Google Meet node secret
weixin/accounts WeChat bot tokens
.copilot_jwt.json exchanged Copilot API token
runtime/photon-sidecar.json Photon sidecar token
proxy/ca.key beside proxy/ca.crt, and the proxy tokens iron-proxy CA key and tokens
home/<file> the subprocess HOME in containers: gh, git, ssh and npm credentials
backups, state-snapshots copies of the stores above
.ssh/<file>, .aws/<file>, .gnupg/<file>, .kube/<file>, .docker/<file>, .azure/<file>, .config/gh/<file>, .config/gcloud/<file>, .envrc OS credential directories inside a profile home

The subprocess HOME is home/ only in containers (hermes_constants.py:765-788), and the proxy's CA files are proxy/ca.crt and proxy/ca.key (agent/proxy_sources/iron_proxy.py:149-151, 262); the Webapp's three files are named at gateway/webapp_listener.py:32, hermes_cli/webapp_sign_in.py:49 and hermes_cli/webapp_access.py:32; the Bot Mode desktop keeps bot-desktop/lease.json and bot-desktop/sandbox.json beside its browser profile (tools/bot_desktop/lease.py:79, tools/bot_desktop/sandbox_host.py:67); the OS credential directories come from agent/file_safety.py:210 (hermes_cli/profiles.py:153-154). Copies named auth.json.* and .env.bak*, like the config.yaml copies in the table, are also credential stores (hermes_cli/profiles.py:191-198). The Google Chat pending grants are google_chat_user_oauth_pending.json or google_chat_user_oauth_pending/<email>.json (plugins/platforms/google_chat/oauth.py:10).

config.yaml is a credential file: the documentation puts non-secret settings in config.yaml and secrets in .env (configuration.md:62-63), but an mcp_servers entry takes env values and headers such as Authorization: "Bearer ..." (mcp.md:318-340). The installer checkout's own hermes-agent/.env is loaded as a fallback (cli.py:339-340, hermes_cli/env_loader.py:652-659), so it holds keys inside an otherwise excluded tree. The pairing stores are credentials, since a <platform>-pending.json file holds live pairing codes; they are also standing permission state (Permission outcomes and settings).

hermes sessions export --format trace --upload takes a Hugging Face token from HF_TOKEN or three alternate variable names in the environment (agent/trace_upload.py:36, agent/trace_upload.py:170-172), which the setup message tells the user to put in .env. A GitHub token, when the user gives one, is GITHUB_TOKEN or GH_TOKEN in .env (or GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY_PATH and GITHUB_APP_INSTALLATION_ID for a GitHub App), used by the Skills Hub, plugin installs, the update check and the webhook platform's github_comment delivery, with the gh CLI's own login (gh auth token) as the fallback (tools/skills_hub_github.py:73-75, tools/skills_hub_github.py:135-149, hermes_cli/git_credentials.py:11-17, hermes_cli/github_api.py:9-14, gateway/platforms/webhook.py:73-74).

The OS keychain holds a Hermes secret in one opt-in case: with vault.key_storage: keychain the vault key is the keychain item of service Hermes Agent vault key (macOS Keychain, Windows Credential Manager, Secret Service or KWallet), and vault/ keeps only the pointer vault.keyref (agent/vault_keychain.py:1-22). Secret managers (1Password, command sources) are pluggable.

The desktop app's connection.json and connections.json hold each remote gateway's token (and extra gateway headers) as an envelope that Electron's safeStorage encrypts with the operating system's key store, or as {"encoding": "plain", "value": <token>} when the app's secret storage policy is off (apps/desktop/electron/main.ts:1320-1325, apps/desktop/electron/main.ts:8075-8080, apps/desktop/electron/main.ts:8596-8603). On macOS they are Library/Application Support/Hermes/connection.json and Library/Application Support/Hermes/connections.json, on Linux .config/Hermes/connection.json and .config/Hermes/connections.json, and on Windows the same two names under AppData/Roaming/Hermes/, each also under Hermes<suffix>. The connections.json.corrupt-<time> copy the app keeps of an unreadable registry is a credential file too (apps/desktop/electron/main.ts:8582).

state.db mixes credentials typed into commands with the transcript; the fields are named in Secrets.

Exclusions

Hermes' own backup exclusions are the best guide (hermes_cli/backup.py:77-97, 99, 104): hermes-agent (code checkout), models, runtimes, node (GGUF models, llama.cpp, Node, "tens to hundreds of GB", hermes_constants.py:288-292), installs and tools (hermes_cli/home_data_layout.py:5-6), checkpoints (shadow git), live Chromium profiles, node_modules, .venv, site-packages, sandboxes (tools/environments/base.py:191).

The catalog excludes, under every Hermes home: models, runtimes, node, installs, tools and sandboxes; backups; the live browser profiles browser-profile, browser-profiles, browser_profiles, chrome-debug and the Bot Mode desktop's bot-desktop/browser-profile (tools/bot_desktop/browser.py:28-31); a plugin's dependency trees plugins/<name>/node_modules and plugins/<name>/.venv (hermes_cli/plugins_cmd_catalog.py:476); and the shadow git store's checkpoints/store/objects, checkpoints/store/indexes and the archived pre-v2 repositories checkpoints/legacy-<timestamp> (tools/checkpoint_manager.py:13-25), which keeps checkpoints/store/projects/<hash16>.json for discovery. The desktop app's Cache, Code Cache and GPUCache are excluded in its Hermes user-data directory and the suffixed ones, and the updater cache AppData/Local/hermes-updater (Desktop app), which the suffixed-home line AppData/Local/hermes?* reaches, is excluded.

state-snapshots/ and backups/ hold older copies of state.db, so they may be the only record of a deleted or pruned session (What survives a delete). The catalog collects state-snapshots/, whose count is bounded (Transcript stores) and whose databases the parser reads, and excludes the zip archives under backups/; use --full when deletion is suspected and a zip backup may hold more. A snapshot also holds copies of .env, auth.json, config.yaml, feishu_comment_pairing.json and the pairing stores, which the home-relative secret globs (their * crosses /) and the volume-relative globs of Docker and Podman volumes flag. cache/ is not excluded: cache/terminal holds tool output. sandboxes/ stays excluded: a container backend's persistent workspace can hold whole repositories, and the commands that ran there are in state.db.

The installer checkout is excluded entry by entry rather than as one directory (hermes-agent/[!.]* and hermes-agent/.[!e]*), because an exclusion wins over a secret glob: pruning the whole tree would lose hermes-agent/.env, which is collected and flagged. The exclusion and secret globs are anchored to .hermes*/ and AppData/Local/hermes*/, so they cover profiles and suffixed homes without matching unrelated paths.

Project-local files

Where the project path is recorded

Parsing

Transcript stores

Globs relative to the user home:

state.db runs in WAL mode by default with a DELETE fallback on filesystems that refuse it (hermes_state_wal.py:254-262); read state.db-wal with the main file. FTS5 tables messages_fts and messages_fts_trigram (hermes_state_common.py:953, 1073) are indexes; skip them. No compression or encryption.

Session ids are <YYYYmmdd_HHMMSS>_<hex> from local time (hermes_state_ids.py:56-60). A session created through the unified runtime (Hermes' single owner of session creation for the local front ends and cron) is instead local- followed by the SHA-256 of its profile, principal and request ids, and a cron run's request id is cron:<job>:<execution>; earlier runs were cron_<job>_<YYYYmmdd_HHMMSS> (hermes_state_keys.py:11-17, hermes_state_keys.py:71-74). The parser treats the id as an opaque string, so both forms work.

What survives a delete

hermes sessions delete and the terminal session browser remove the session, its delegate children, their messages rows and the files under sessions/ named after the id (<id>.json, <id>.jsonl, session_<id>.json, request_dump_<id>_*.json); the dashboard, desktop and Webapp deletes pass include_compression_chain and remove the whole compression chain with it (hermes_state_sessions.py:1629-1649, hermes_state_sessions.py:1702-1730, hermes_cli/sessions_cmd.py:672, hermes_cli/web_routers/sessions.py:488). hermes sessions prune, and the sweep when sessions.auto_prune is turned on, remove ended sessions inactive for retention_days (90) the same way and may VACUUM the file; automatic pruning is off by default, and the user is asked once when the disk holding state.db runs low (hermes_cli/config_defaults.py:2258-2268, hermes_state_maintenance.py:478-502), so old sessions stay in state.db unless the user opted in or ran hermes sessions prune; older copies live in state-snapshots/*/state.db. Archiving (hermes sessions archive, the sidebar, the idle sweep sessions.auto_archive, off by default) only sets archived = 1 (and auto_archived for the sweep) and keeps the messages (hermes_state_sessions.py:953-958, hermes_state_sessions.py:1969-1981).

Every delete path, the prune sweep included, first writes deletion fences (tombstones) into state.db state_meta that outlive the rows (hermes_state_mutation_retirement.py:148-163, hermes_state_mutation_retirement.py:307-317), removes the session's gateway_routing entry, and turns each request the session had admitted into a terminal admission tombstone with the payload emptied (hermes_state_mutation_retirement.py:100-140); the shapes are under Deletion fences. A user delete's marker is permanent; a sweep's marker and the admission tombstones are reclaimed 7 days after retired_at (hermes_state_mutation_retirement.py:400-417). The marker is the only record of when a session was deleted and of its id; it holds no text. A deleted session's conversation_generations row stays (Conversation generations).

No delete touches .hermes_history, the cron/output/ run files, response_store.db, the state-snapshots/ copies of state.db (the last 20 by default, each a whole older database), the other copies of State database copies, the trajectory files in the working directory, the conversation_generations row that names the session's platform chat, or a memory provider's copy; sessions Hermes imported from Claude Code or Codex CLI stay in state.db after their original files are gone (Other stores).

A snapshot is the whole database at the snapshot time, so it outlives a session pruned after 90 idle days, a session deleted with hermes sessions delete, a VACUUM, and a state.db replaced, rebuilt or quarantined; it does not see anything written after it. Its rows join the live database's exactly: the same sessions.id, the same cwd and git_repo_root, the same messages.timestamp (the backup API copies the pages, not a re-encoding). Two things make a snapshot row differ from its live twin: a message rewound or compacted after the snapshot (live active = 0, so the parser's [rewound] prefix), and a session whose end_reason, title or later model was set after it (the session start row's text). Those rows are kept from both.

A divert holds messages the replaced database never received, so most of its lines have no twin in the live database; a line does have one when the same messages also reached a database the parser reads (a snapshot, or a new state.db that a later flush wrote them to), and then it carries the same session id and, when the line has one, the same timestamp. Whether Hermes replays a divert into a new database was not determined; the session-row heal of agent/session_persistence.py:428-449 replays the in-memory transcript only when a session row was deleted, a different failure.

The analyzer reads the snapshots, the sessions/<id>.jsonl diverts, .hermes_history, cron/output/, response_store.db and the trajectory files as secondary sources (see Secondary sources), the imported sessions as Hermes sessions, and the tombstones and the conversation_generations rows as system rows (Past-use rows and secondary sources, Deletion rows). It does not read the copies of response_store.db and the other stores inside a snapshot, the copies of State database copies, or a memory provider's copy.

Record schema

Schema in hermes_state_common.py:451-562; schema_version.version is 32 (hermes_state_common.py:289). Type names below are the JSON types of the stored values: SQLite TEXT is string, REAL and INTEGER are number, and a nullable column adds null.

Sessions table

sessions (hermes_state_common.py:461-529); token and cost counters, compression bookkeeping and the handoff columns are omitted.

Key Type Values Since
id string
runtime_revision number schema 32
runtime_generation number schema 32
source string
created_source string or null
user_id string or null
session_key string or null
chat_id string or null
chat_type string or null
thread_id string or null
display_name string or null
origin_json string or null
model string or null
model_config string or null
system_prompt_hash string or null
parent_session_id string or null
started_at number
ended_at number or null
end_reason string or null
message_count number
cwd string or null
git_branch string or null
git_repo_root string or null
title string or null
last_activity_at number or null
profile_name string or null
archived number 0, 1
auto_archived number 0, 1
pinned number 0, 1
hidden number 0, 1
tool_names string or null
transcript_revision number schema 32

source values: the automation sources (hermes_state.py:477-479), local for the unified runtime, the importer's tool names (Imported sessions) and the messaging platforms (Messaging sessions). started_at and ended_at are epoch seconds. cwd and git_branch are set at creation and updated by update_session_cwd (hermes_state_sessions.py:373, 567-575). system_prompt_hash joins system_prompts.hash. end_reason is an open set: besides compression and branched (Joins and timestamps) the writers set reset reasons such as session_reset and session_switch, close reasons such as agent_close, ws_orphan_reap and startup_orphan_reap (hermes_state_common.py:179-201), and front-end reasons such as cli_close, tui_close and user_exit. chat_type takes the platform adapters' values (dm, group, channel, thread among them). archived, auto_archived, pinned and hidden are flags, 0 or 1 (hermes_state_portability.py:75, 722).

Messages table

messages (hermes_state_common.py:531-562), one row per OpenAI message:

Key Type Values Since
id number
session_id string
role string
content string or null
tool_call_id string or null
tool_calls string or null
tool_name string or null
effect_disposition string or null
timestamp number
token_count number or null
finish_reason string or null
reasoning string or null
reasoning_content string or null
reasoning_details string or null
codex_reasoning_items string or null
codex_message_items string or null
platform_message_id string or null
active number 0, 1
compacted number 0, 1
api_content string or null
display_kind string or null
display_metadata string or null
message_uid string or null
tool_call_uid string or null
tool_call_uids string or null

Tool call entries

Each entry of a tool_calls list, as the agent builds it, with type taken from the model provider's tool call and arguments the model's JSON text (agent/chat_completion_helpers.py:1609-1611):

Key Type Values Since
id string
call_id string
response_item_id string or null
type string
function object
name string
arguments string

name and arguments are the keys of function.

Content parts

A part list holds OpenAI chat-completions content parts: text parts and image parts, whose type is image_url (with an image_url object holding the url), image or input_image (agent/session_persistence.py:46, agent/image_routing.py:562):

Key Type Values Since
type string text, image_url, image, input_image
text string
image_url object

The agent's own turn writer stores a part list's text projection, each image as [screenshot] (agent/session_persistence.py:122-133); a gateway line of a divert keeps the message as the gateway queued it, parts included.

Divert lines

A line of sessions/<id>.jsonl (Transcript stores) is the message dict JSON-encoded as a whole, so tool_calls is the list itself rather than a JSON string, and an agent's line carries its booleans _compressed_summary and observed (agent/session_persistence.py:242-256, hermes_state.py:434-448). The keys the parser reads:

Key Type Values Since
role string
content string, array, object or null
tool_calls array or null
tool_call_id string or null
tool_name string or null
timestamp number or null
reasoning string or null
reasoning_content string or null
display_kind string or null

Model usage

Model per message is not stored; use sessions.model, or session_model_usage.model (hermes_state_common.py:564-584) when a session switched models. One row per (session_id, model, billing_provider, billing_base_url, billing_mode, task), upserted with the token delta of each API call that reports usage (hermes_state_usage.py:362-390):

Key Type Values Since
session_id string
model string
billing_provider string
billing_base_url string
billing_mode string
task string
api_call_count number
first_seen number or null
last_seen number or null

task is empty for the main agent loop and names the work for auxiliary calls (vision, compression, title_generation, background_review, moa_reference, moa_aggregator and plugin_side_question among them); first_seen and last_seen are time.time() epoch seconds. sessions.model is overwritten by a mid-session /model switch (hermes_state_sessions.py:717-744), so it holds the latest model, not the first.

Model-switch markers

Model switches made in the TUI, the desktop app or another front end of the TUI gateway are also durable message rows: a role = 'user' row with display_kind = 'model_switch' and the content [System: The active model for this chat has changed to <model> via provider <provider>. From this point forward, ...], timestamped at the switch (tui_gateway/server.py:1152, tui_gateway/server.py:1169-1209). Each switch first deactivates the earlier marker rows of the session (active = 0), which are kept, never deleted (hermes_state_messages.py:1086-1092), so every switch survives as one row with the model, the provider and the time; the classic CLI's /model writes no row and only updates sessions.model. Other display_kind values, hidden, steer, internal_notification, async_delegation_complete, process_complete and failed_turn among them, mark rows that are not typed input either (hermes_state_detached_results.py:29, agent/prompt_builder.py:583, agent/turn_failure_copy.py:74, gateway/response_filters.py:33, tools/process_registry_notifications.py:350).

Permission outcomes and settings

Permission outcomes are in the result row of the shell tool, registered as terminal (tools/terminal_tool.py:1727), whose content is a JSON object (tools/terminal_tool.py:1044-1050, tools/terminal_tool.py:1101-1126, tools/terminal_tool_result.py:269, tools/terminal_tool_background.py:188-189):

Key Type Values Since
output string
exit_code number
error string or null
status string
user_summary string
approval string
approval_pending boolean true
command string
description string
pattern_key string
smart_denied boolean
allow_permanent boolean

A command that needed approval and got it carries "approval": "Command required approval (<description>) and was approved by the user." or "Command was flagged (<description>) and auto-approved by smart approval." (smart approval is Hermes' guardian model that judges a flagged command). A refused one is {"output": "", "exit_code": -1, "error": <message>, "status": "blocked"} with user_summary when a person was asked, and one waiting for a gateway /approve has "status": "pending_approval" with the command, its description, pattern_key, smart_denied and allow_permanent. The refusal message names the outcome: BLOCKED by smart approval, a user denial with the user's /deny reason, a timeout ("Silence is not consent"), or a withdrawn prompt; the outcome enum is denied, timeout, notify_failed, cancelled, blocked (tools/approval.py:538, tools/approval.py:800, tools/approval.py:894-900). The outcome key itself is not written to the result.

An "always" answer appends the command's pattern to command_allowlist in the profile's config.yaml (tools/approval.py:411-424, tools/approval.py:480-505), and a session's /yolo bypass (all approvals off for that session) is kept as yolo_mode: true in sessions.model_config for the CLI, TUI and desktop and in the gateway's routing entry for messaging sessions (tools/approval_yolo.py:1-8, agent/agent_init.py:1117-1127). The other standing settings are in config.yaml: approvals.mode (manual, smart or off, default smart), approvals.cron_mode, single_query_mode and unattended_mode (deny or approve, default deny), approvals.deny (globs that block even under /yolo) and command_allowlist (hermes_cli/config_defaults.py:1652-1688, tools/approval_context.py:263-280); the environment variable HERMES_YOLO_MODE turns every check off for the process (tools/approval.py:46). The messaging gateway's pairing store, the standing list of platform users allowed to drive the agent, is pairing/<platform>-approved.json beside <platform>-pending.json (gateway/pairing.py:1-7, gateway/pairing.py:353-356, gateway/pairing.py:432-438), a JSON object keyed by user id:

Key Type Values Since
user_name string
approved_at number

approved_at is epoch seconds.

Messaging sessions

A messaging session's row names the platform and the people: sessions.source is the platform (telegram, discord, whatsapp, whatsapp_cloud, slack, signal, mattermost, matrix, email, sms, dingtalk, feishu, wecom, wecom_callback, weixin, bluebubbles, qqbot, yuanbao, webhook, msgraph_webhook, relay (the experimental connector relay), or a plugin platform such as irc, line, teams or google_chat; api_server and local are not messaging platforms) (gateway/config.py:217-262), and the row carries user_id, chat_id, chat_type, thread_id, display_name (the chat name) and origin_json, the serialized source (gateway/session.py:69-81, gateway/session.py:133-136, gateway/session_recovery.py:338-347, hermes_state_gateway.py:236-292, run_agent.py:57-75):

Key Type Values Since
platform string
chat_id string
chat_name string or null
chat_type string
user_id string or null
user_name string or null
thread_id string or null
chat_topic string or null

A message that came through the experimental connector relay (a generic adapter fed by a separate connector service) records the underlying platform, not relay (gateway/session.py:100-103).

Deletion fences

state_meta is a key and JSON value table, columns key and value (hermes_state_common.py:640-643). A delete writes the key gateway.retired_session.v1.<session id> (hermes_state_mutation_retirement.py:9, hermes_state_mutation_retirement.py:286-300), and a multiplexed gateway writes gateway.retired_route.v1. fences for other profiles (hermes_state_mutation_retirement.py:14). The value:

Key Type Values Since
retired_at number
own boolean true
expires boolean true

retired_at is epoch seconds; "expires": true is present when a sweep rather than a delete removed the session. Each admitted request becomes a gateway.terminal_admission.v1.<admission id> row with the principal, request id and digests and the payload emptied (hermes_state_mutation_retirement.py:100-140).

Local policy records

A session created through the unified runtime has a creation receipt in state_meta under gateway.local_policy.v1:<session id>, whose policy.cwd is the directory its tools run in (hermes_state_local.py:9, hermes_state_local.py:14-31, hermes_state_local.py:390-401):

Key Type Values Since
profile_id string
principal_id string
request_id string
session_id string
route string
policy object

Joins and timestamps

  • Message to session: messages.session_id = sessions.id; project path is sessions.cwd (or git_repo_root), branch sessions.git_branch.
  • Tool use to result: messages.tool_calls[].id (or call_id) equals a later role='tool' row's tool_call_id. Hermes pairs on call_id then id, splitting at | (agent/message_sanitization.py:539-547).
  • Parent to child: sessions.parent_session_id. A parent whose end_reason='compression' continues in its child; 'branched' marks a /branch fork (hermes_state_common.py:170-175); source='subagent' rows are delegated tasks.
  • System prompt: sessions.system_prompt_hash = system_prompts.hash.
  • Times: messages.timestamp, sessions.started_at and ended_at are REAL epoch seconds (Record schema); how snapshot and divert rows join the live rows is under What survives a delete.

Other stores

The stores below sit beside state.db. Each says whether the parser reads it.

Prompt history

.hermes/.hermes_history: the prompt history of the classic CLI and of the TUI, shared by both (hermes_cli/cli_init_mixin.py:415, hermes_cli/cli_tui_mixin.py:2204, ui-tui/src/lib/history.ts:6-7). prompt_toolkit's FileHistory format: per entry a blank line, a # <timestamp> line and the entry's lines each prefixed +; the TUI writes the timestamp as UTC YYYY-MM-DD HH:MM:SS.mmm and keeps the last 1000 entries in memory only (ui-tui/src/lib/history.ts:71-78), while prompt_toolkit 3.0.52, the version Hermes pins, writes datetime.now(), host-local time with microseconds and no zone (src/prompt_toolkit/history.py:297-306), so the number of fraction digits tells the two writers apart. Every typed line, slash commands included (/model, /yolo, /snapshot), with no session id or project. Nothing that deletes a session touches it, so it survives a delete, a prune, a rewind and a removed project. Joins on text and time only. Secondary; read by the parser.

Cron run archive

cron/output/<job id>/<YYYY-mm-dd_HH-MM-SS>.md: one Markdown file per cron run, local time in the name, with a # Cron Job: <id> header and ## Prompt and ## Response sections, pruned per job to a configured count (cron/jobs.py:119, cron/jobs.py:3353-3364, cron/scheduler_prompt.py:72-73). The name's time and the document's **Run Time:** are hermes_time.now(), Hermes' configured zone or the host's, written without an offset (cron/jobs.py:3353-3361, hermes_time.py:147-150). A completed run's document is the header (# Cron Job: <name>, **Job ID:**, **Run Time:**, **Schedule:**), then **Prompt Characters:** <n> and ## Prompt with the prompt, a blank line, **Response Characters:** <m> and ## Response with the response and a newline; the two lengths frame the sections, so a heading quoted inside the prompt is no boundary (cron/scheduler.py:2219-2227, cron/scheduler.py:2622-2628, cron/scheduler_prompt.py:70-80). Older writers left out the frames, and Hermes' own reader then takes the last ## Response heading as the boundary (cron/scheduler_prompt.py:90-104); a failed, blocked or script-gated run is a short document with no prompt section (cron/scheduler.py:2248, cron/scheduler.py:2284-2289). Survives the prune or delete of the run's source='cron' session. Joins on the job id and time, not on a session id. Secondary; read by the parser.

API server response store

.hermes/response_store.db, one per profile home the API server serves: the API server's store for the OpenAI Responses API (previous_response_id chaining) (gateway/platforms/api_server_response_store.py:61, gateway/platforms/api_server.py:1689). Table responses (response_id, data, accessed_at) and table conversations (name, response_id); it keeps the 100 most recently used responses (gateway/platforms/api_server_response_store.py:17, gateway/platforms/api_server_response_store.py:34-37, gateway/platforms/api_server_response_store.py:72-75). data is a JSON object, the full cumulative history at that response (gateway/platforms/api_server_openai_routes.py:392-396):

Key Type Values Since
response object
conversation_history array
instructions string or null
session_id string or null

response is the Responses API envelope (id, object, status, created_at in epoch seconds, model, output, usage), and conversation_history is a list of OpenAI chat-completions message dicts, the shape of a messages row: the earlier history, the turn's user message and the agent's messages, or the assistant's final text when the agent returned none (gateway/platforms/api_server_openai_routes.py:361-389, gateway/platforms/api_server_openai_routes.py:1532-1560); the messages carry no time of their own. No session delete path names it (a grep for response_store and ResponseStore in hermes_state*.py and hermes_cli/sessions*.py finds only the WAL helper), so it outlives a deleted api_server session. Joins on session_id. Secondary; read by the parser.

Trajectory files

trajectory_samples.jsonl and failed_trajectories.jsonl in the working directory (project files, collected by the project catalog): one line per run in the ShareGPT format (a common layout for conversation training data), no session id (agent/trajectory.py:37-41):

Key Type Values Since
conversations array
timestamp string
model string
completed boolean

timestamp is local ISO 8601 without a zone. Each conversations entry is {from, value}: system (the tool-calling system prompt), human (the user's message), gpt (a reply, or a tool-call turn in <tool_call> markup) and tool (the call results in <tool_response> blocks) (agent/agent_runtime_helpers.py:170-196). Written only with --save-trajectories; survives everything. Joins on text within a project. Secondary; read by the parser.

Imported sessions

hermes_cli/foreign_sessions.py reads Claude Code ~/.claude/projects/*/*.jsonl (or under CLAUDE_CONFIG_DIR) and Codex CLI ~/.codex/sessions/**/rollout-*.jsonl (or under CODEX_HOME) and copies their user and assistant turns into state.db as a new session, with tool calls reduced to [ran tool: <name>] text (hermes_cli/foreign_sessions.py:27-28, hermes_cli/foreign_sessions.py:174-179, 77, 170). Two paths write it:

The turns carry no time, so their timestamp is the import's (hermes_state_messages.py:665-671). The parser reads these rows as Hermes sessions (they are in state.db), and its start row names the source tool, the original path and the foreign session id when origin_json has them (Past-use rows and secondary sources); such a session is a copy of another agent's transcript that outlives the original file.

Bot Mode group chats

Bot Mode is the desktop app's roster of profiles as named bots that share group chats. Tables hosted_rooms and hosted_room_events are an append-only room log in the gateway's root state.db (gateway/hosted_rooms.py:1-5, gateway/hosted_rooms.py:88-117):

Key Type Values Since
room_id string
seq number
event_id string
kind string
actor_json string
payload_json string
created_at number

The event kinds are fixed per actor: message.user for the user, message.member for a member bot, member.unavailable, room.activity, room.stop_requested and the turn.* kinds for the gateway, and authority.*, room.created, room.disbanded, room.members_changed and room.renamed for the system; actor_json is {kind, id} with optional display_name, profile and connection_id (gateway/hosted_rooms.py:48-60). A message.user payload is {thread_id, text, attachments?} and a message.member payload carries the member's turn coordinates and text (gateway/hosted_room_discussion.py:211-223, gateway/hosted_room_discussion.py:400-421). Whether a room message also becomes a messages row was not determined. Read by the parser as system rows.

Conversation generations

conversation_generations in state.db: one row per messaging routing peer, deliberately never deleted, so it names every platform chat (session_key is agent:<profile>:<platform>:...) that ever had a conversation after its sessions are gone (hermes_state_common.py:658-685):

Key Type Values Since
source string
session_key string
generation number

No time column. A record of relay use, not of turns, with no text to join on; read by the parser as system rows.

Desktop connection registry

The desktop app's connections.json (version 2): exactly one local entry is always present, and an entry that failed to load is kept under quarantined as {reason, entry} (apps/desktop/electron/connection-registry.ts:19-20, apps/desktop/electron/connection-registry.ts:44-106). The registry:

Key Type Values Since
version number 2
primary string
launchMode string last-used, primary
lastUsed string
connections array
quarantined array

Each connection:

Key Type Values Since
id string
kind string local, remote, ssh, cloud
label string
url string
authMode string token, oauth
token object
headers object
org string
name string
host string
user string
port number
keyPath string
remoteHermesPath string
remoteProfile string

remote is a hermes serve gateway and cloud is Hermes Cloud; url, authMode, token and headers belong to remote and cloud, org and name to cloud, and the rest to ssh. It records which other Hermes hosts the app drove, not their sessions. Read by the parser.

Debug-share paste list

.hermes/pastes/pending.json: the paste.rs links (paste.rs is a public, anonymous paste service) that hermes debug share, the gateway's /debug and the dashboard's debug-share endpoint uploaded and that are still due for deletion (hermes_cli/debug.py:42-43). A JSON list of entries, merged by URL keeping the later time (hermes_cli/debug.py:180-195, hermes_cli/debug.py:32):

Key Type Values Since
url string
expire_at number

expire_at is epoch seconds, the upload time plus 21600 seconds (6 hours). An entry is dropped once its paste is deleted, or 24 hours after expire_at when the delete keeps failing (hermes_cli/debug.py:73-103); the gateway sweeps it hourly and every hermes debug call sweeps it first (gateway/run.py:4341-4345, hermes_cli/debug.py:669-671). The file is missing or [] when nothing was shared in the last 6 to 30 hours. Links to dpaste.com (the fallback paste service) are never written, since dpaste.com has no anonymous delete (hermes_cli/debug.py:182-184). Read by the parser (Sync, relay and origin rows).

Snapshot manifest

Each state-snapshots/<id>/manifest.json (hermes_cli/backup.py:1430-1439):

Key Type Values Since
id string
timestamp string
label string or null
file_count number
total_size number
files object
failed_dbs array
oversized_skipped array

Not read by the parser, which takes the snapshot time from <id>.

State database copies

Three other writers leave older copies of state.db beside it in the home:

A database that is not SQLite at all is moved aside as state.db.zeroed-<time>-<pid>.bak or state.db.notadb-<time>-<pid>.bak (hermes_state_dbfile.py:695-718). Not read by the parser.

RetainDB queue

.hermes/retaindb_queue.db: the write-behind queue of the RetainDB memory provider (a paid cloud memory service), present only when memory.provider is retaindb (plugins/memory/retaindb/__init__.py:344-346). Table pending; a row is deleted as soon as its upload succeeds, so the table holds only turns not yet uploaded (plugins/memory/retaindb/__init__.py:194-198, plugins/memory/retaindb/__init__.py:226-241):

Key Type Values Since
id number
user_id string
session_id string
messages_json string
created_at string
last_error string or null

messages_json holds the turn's user and assistant text; created_at is ISO 8601 UTC. Not read by the parser.

Format versions

schema_version.version was 31 at 8b66a51 (hermes_state_common.py:283) and is 32 at 86fa9e6 and 6bb377a (hermes_state_common.py:289); the tables did not change between the last two. Migrations add columns in place (hermes_state_schema.py:1061-1102), so a parser must select columns that exist (PRAGMA table_info); the parser reads select *, so new columns do not affect it, and SCHEMA_VERSION is the constant that names the version. Gateway metadata was in sessions/sessions.json before v18 (hermes_state_schema.py:1339-1342). The legacy session_<id>.json layout was not examined.

Schema 32 added to sessions the columns runtime_revision, runtime_generation and transcript_revision (missing at hermes_state_common.py:382-447), and four tables of the unified runtime: runtime_epoch, session_admissions (each queued, steered or redirected request against a session, with payload_json and status), worker_executions and worker_receipts (hermes_state_common.py:586-638); messages and session_model_usage are unchanged. The local- session ids, the deletion fences and the prune default changed between 8b66a51 and 86fa9e6: sessions.auto_prune was true with retention_days 90 at hermes_cli/config_defaults.py:2276-2285 and is false now (What survives a delete).

Changed between 86fa9e6 and 6bb377a:

Every other file, table, column and constant the parser depends on is unchanged from 86fa9e6: state.db, the \x00json: prefix, the divert sessions/<id>.jsonl (hermes_state.py:435-442), state-snapshots and pastes/pending.json with its 21600-second lifetime (hermes_cli/debug.py:43).

Secrets

Tool-call arguments are stored unredacted on purpose, so state.db holds credentials typed into commands (agent/chat_completion_helpers.py:1607-1616); tool results, messages.api_content and system_prompts.prompt (which embeds memory and context files) carry secrets too. request_dump_* files are redacted before writing (agent_runtime_helpers.py:1590-1594). sessions.origin_json holds messaging user and chat identifiers. The same holds for the copies of state.db in state-snapshots/ and State database copies.

Parser plan

Inputs, under any Hermes home (a home's .hermes, .hermes<suffix>, AppData/Local/hermes, AppData/Local/hermes<suffix>, any of them followed by profiles/<name>, or the root of a Docker or Podman named volume's _data, which holds the Hermes home itself, see Docker and Podman volumes), each marked for the timeline's deduplication (see Secondary sources in docs/output.md):

Source Kind Why
state.db (via sqlite_util, so the WAL is read) primary the store of record for every front end
state-snapshots/<id>/state.db secondary an older copy of state.db; read with the same reader after one system row state snapshot <id>: older copy of state.db, timestamped from the id's UTC time when the id starts with one, else empty; rows pair with the live rows exactly, and the copies several snapshots keep of a pruned session collapse to the first
sessions/<id>.jsonl secondary a divert of messages state.db did not take; session_id from the file name, no project path (the match is by session id), each line's own timestamp or none
.hermes_history secondary the prompt history (Prompt history); one user row per entry at its time, a system row slash command: <entry> for an entry starting /, no session or project
cron/output/<job>/<time>.md secondary a cron run's prompt and response (Cron run archive); a system row cron run: job <job> (from cron/output, file name time, host-local), then the prompt as user and the response as assistant, or cron run output: <document> for a document with no prompt section, all at the file name's time, no session
response_store.db secondary the API server's history per response (API server response store); per response by created_at a system row api server response <id> status=<status> model=<model> conversation=<name> (from response_store.db responses, created_at), then the conversation_history messages no earlier response of the session held at the same position, read with the state.db message reader, with data.session_id and no time
trajectory_samples.jsonl, failed_trajectories.jsonl (project files) secondary ShareGPT runs (Trajectory files); per line a system row trajectory: model=<model> completed=<completed> (from <file>, timestamp host-local) at its timestamp, then human as user, gpt as assistant, tool as tool_result, system as trajectory system prompt: <value>, with the project directory and no time or session
config.yaml settings command_allowlist (Permission rows)
pairing/<platform>-approved.json, platforms/pairing/<platform>-approved.json settings the gateway's approved users (Permission rows)
connections.json in the desktop app's userData directory settings the remote backends (Sync, relay and origin rows)
sessions/<id>.json, session_<id>.json not read no writer, shape unknown
backups/*.zip, sessions/saved/*.json, the stores other than state.db inside a snapshot, connection.json not read archives, exports and copies

Not read: state.db.pre-update-emergency-*.bak, state.db.malformed-backup-*, state.db.retired-wal-*, state-snapshots/<id>/manifest.json, retaindb_queue.db, gateway_routing, session_admissions.

The snapshots, the diverts, the response store and the trajectory files keep the default 2-second tolerance: their times are copies of the live ones, not later stamps, or absent. .hermes_history and the cron run files keep host-local times without a zone (the TUI's history entries excepted), written as if UTC, so their window is 14 hours plus 2 seconds, which spans every zone offset. Rows: one per message; an assistant row with N tool_calls yields one assistant row (when content is non-empty), one thinking row (when reasoning/reasoning_content is set), and N tool_use rows. Order by timestamp, id. Include active=0 rows: they are evidence of rewound or compacted turns.

Column Source
timestamp_utc messages.timestamp (epoch s)
session_id messages.session_id
project_path sessions.cwd, else git_repo_root
git_branch sessions.git_branch
turn_type role: user → user, assistant → assistant, tool → tool_result, system → system; tool_calls[] → tool_use; reasoning → thinking
model sessions.model
tool_name tool_calls[].function.name, or messages.tool_name
tool_use_id tool_calls[].id / messages.tool_call_id
text content (strip \x00json:, join text parts, an image part as [image]); for tool_use the first of the command, path, file_path, url and query keys of arguments, else the arguments
source_line messages.id

- stands for an absent value in every row text below. wants() accepts the files of a Hermes home at the root of a volume's _data as it accepts the home forms, and those rows carry the manifest's docker user (Docker and Podman volumes).

Permission rows

The records are under Permission outcomes and settings.

  • After a terminal tool_result whose JSON content has approval, permission decision: approved (<approval note>); with status blocked, permission decision: blocked <error>, followed by  user_summary=<user_summary> when a person was asked; with status pending_approval, permission decision: pending pattern=<pattern_key> smart_denied=<true|false>; each with the call's tool_name, tool_use_id and the result's time. A result that is not a JSON object gives no row.
  • One permission setting: yolo_mode=<true|false> (from state.db sessions.model_config, started_at) row per session whose model_config has the key, at started_at.
  • From config.yaml, one permission setting: command_allowlist=<pattern> (from config.yaml, file mtime) row per entry of the top-level block list, at the file's modification time. Only that shape is read, the block style of Hermes' round-trip YAML writer (- <scalar> items, plain, single- or double-quoted) (hermes_cli/config.py:1954, hermes_cli/config_backend.py:465-468, hermes_yaml.py:89-98); any other construct under the key is one whole-file row parser: config.yaml command_allowlist is not a flat list (<line>: <problem>) after the entries before it. The nested approvals settings (mode, cron_mode, single_query_mode, unattended_mode, deny) need a YAML reader and are not read, and a profile served by the remote config backend has no file to read.
  • From pairing/<platform>-approved.json or platforms/pairing/<platform>-approved.json (the newer location, gateway/pairing.py:58-59), one row per user at approved_at, permission setting: pairing.<platform>.<user_id> user_name=<user_name> (from pairing/<platform>-approved.json, approved_at), or (from platforms/pairing/... for the newer file. The pending files hold live pairing codes and are not read. Both files are flagged secret, so a --no-secrets collection gives none of these rows.

Model rows

system rows after each session's start row: the session's session_model_usage rows with task empty, ordered by first_seen, give model: <model> for the first and model: <old> -> <new> for each later one, at first_seen, with  provider=<billing_provider> when it is set; auxiliary task rows are skipped. The table aggregates per model, so a switch back to a model already used is not visible. A session with no such rows gets one model: <sessions.model> row at started_at. The model column of the other rows stays sessions.model (Model usage).

Model-switch markers, system, in message order: each messages row with display_kind = 'model_switch' is one row model: <old> -> <model> provider=<provider> at the row's timestamp, with model set to the new model and source_line to messages.id, parsed from the marker text (Model-switch markers; the provider part only when one was set). The marker names only the new model: <old> is the session's previous marker's model, else the last main-loop session_model_usage model whose first_seen is before the marker; with neither, or when it is the new model, the row is model: <model> with the provider suffix. A later switch sets the marker's active to 0, which is no rewind, so the row has no [rewound] prefix, and a marker is never a user row. A marker whose text has another form is a system row model_switch: <text>. These rows sit beside the session_model_usage rows, which show the first API call on each model rather than the switch.

Proposed, not implemented: every other non-empty display_kind on a user row becomes a system row prefixed with the kind, so injected notices are not counted as typed prompts, and a session_meta row is skipped like Hermes' own readers skip it.

Past-use rows and secondary sources

  • The snapshots, the diverts, .hermes_history, the cron run files, response_store.db and the trajectory files are secondary sources, with the rows the source table gives. A history entry pairs with the session's user row on text (the TUI's within 2 seconds, the classic CLI's within the host's zone offset); a slash command, which no store repeats, is a system row. A cron run's prompt and response pair with the source='cron' session's turns when the text matches; a response-store message and a trajectory turn carry no time and pair on text within the session or the project.
  • Imported sessions are read as Hermes sessions (What survives a delete). The start row of a session whose origin_json has imported_from ends  | imported from <tool> <path> session <foreign_session_id>, since its turns carry the import's time (Imported sessions); a CLI import has no origin_json and gets no such suffix.
  • conversation_generations: one system row per row, relay peer: <source> <session_key> generation <n> (from state.db conversation_generations), with no time and no session, which names chats whose sessions are gone.
  • hosted_room_events: one system row per event at its created_at, bot mode room <name> (<room_id>) seq <seq> <kind> actor <actor kind>:<actor id> (<display_name>): <text> for a message.user or message.member event and the payload as compact JSON for the other kinds (Bot Mode group chats); never a user row, since whether a room message is also a messages row was not determined.

Sync, relay and origin rows

Sync: Hermes records no sync state for a session. Nothing uploads a session, a transcript or a title to the Nous account: the Nous Portal (Nous Research's account and billing site, portal.nousresearch.com) is used for sign-in, inference and the tool gateway (hermes_cli/portal_cli.py:10-20), and a grep of the tree for session upload, sync, share and cloud-session names found nothing beyond the trace upload and the paths listed next. hermes sessions delete removes the rows and the session's files and calls nothing remote (hermes_state_sessions.py:1702-1746).

One command uploads a session: hermes sessions export --format trace --upload [--public] converts a session to Claude Code JSONL and uploads it to the user's Hugging Face dataset <user>/hermes-traces as sessions/<session id>.jsonl, private unless --public, and writes nothing locally on success (agent/trace_upload.py:1-5, agent/trace_upload.py:209-221, hermes_cli/sessions_cmd.py:497-516); the only local traces are the command in the shell's own history and HF_TOKEN in .env, so no session synced: row is possible.

Content also leaves the host on these opt-in paths, none of which marks a session:

The parser yields no session synced: row and leaves records_sync off. It reads pastes/pending.json and yields one system row per entry, debug report uploaded: <url> (from pastes/pending.json, expire_at minus 6 hours), timestamped expire_at minus 21600 seconds with an empty session_id, which shows that logs left the host in the last day and marks no session; bad entries are under Bad-line rows.

Relay: a gateway session's messages also live on the messaging platform, which is the channel the user typed in, not a sync; sessions.chat_id, origin_json and messages.platform_message_id name them there (Messaging sessions). A session whose source is a messaging platform (every gateway platform but local and api_server), or that has a chat_id and another source than those two (a plugin platform), gets session relayed: <source> chat <chat_id> type <chat_type> thread <thread_id> user <user_id> name <user_name> display <display_name> (from state.db sessions, started_at) after its model rows, user_name from origin_json and display_name, else origin_json chat_name; the parser sets records_relay.

Origin: the desktop app's connections.json registry lists local, remote (a hermes serve the user runs), ssh and cloud (Hermes Cloud, Nous Research's hosted Hermes) connections; a session on a remote, SSH or cloud backend lives in that host's state.db, and whether the desktop keeps any local copy was not determined (apps/desktop/electron/main.ts:1320-1325, apps/desktop/electron/connection-registry.ts:1-10, apps/desktop/electron/connection-registry.ts:44-47). The parser reads connections.json (Desktop connection registry) and yields one system row per connection that is not local, remote backend: <kind> <label> <url, or user@host:port for ssh> (from connections.json, file mtime) followed by  org=<org>,  name=<name> and  profile=<remoteProfile> when recorded, and remote backend quarantined by the app: <reason> (from connections.json, file mtime) per quarantined entry, with no session (session origin: needs one) and never printing token or headers. It sets no records_origin. The single-connection connection.json is not read.

Scratch directory rows

The scratch directories are cache/scratch/ and sandboxes/ under the Hermes home and the git worktrees under <repo>/.worktrees/ (Scratch directories).

cache/scratch and sandboxes are under the catalog's Hermes home, so the analyzer's catalog rule classes any path under it as a scratch directory. A .worktrees/<name> path is not under a home entry: a session whose cwd is <repo>/.worktrees/<name> and whose git_branch starts with hermes/ or hermes-subagent/ gets scratch directory: <cwd> (git worktree the agent created in <repo>, branch <git_branch>) after its model rows, at started_at. A classic -w session, whose row holds the repository root, and a kanban child's worktree, whose branch the source does not fix, get none.

Deletion rows

One session deleted: <id> (from state.db state_meta gateway.retired_session.v1, retired_at) row per marker, at retired_at, with  sweep after the id when it has expires, and with the marker's id as session_id; the start row of an archived session ends  | archived=1. The markers are described under Deletion fences. The gateway.retired_route.v1. fences are not read.

Bad-line rows

A state.db, a snapshot or a response_store.db that is not an SQLite file is one row parser: not a SQLite database, one SQLite cannot read is parser: SQLite error: <message>, and a response_store.db with no responses table is parser: no responses table; a state.db with no messages table gives no rows.

sessions/<id>.jsonl goes through iter_jsonl and error_rows. In pastes/pending.json a file that does not parse or is not a JSON list is one parser: row, and an entry that is not an object with a url is its own parser: unparseable record <N> row carrying the entry.

In state.db a sessions.origin_json or model_config, a state_meta fence value, or a hosted_room_events actor_json or payload_json that does not decode to a JSON object is a row parser: unparseable record <rowid> (<table>.<column>: <reason>): <value as stored>, with the session's id where there is one, after the file's other rows. In response_store.db a data value is reported the same way (responses.data), and a history entry that is not an object as (responses.data conversation_history <index>: not an object). .hermes_history goes through the Aider parser's read_input_history, whose bad line is parser: unparseable line (not a FileHistory line): <line>; a trajectory file's line that does not decode, is not an object or has no conversations list is parser: unparseable line (<reason>): <line>. A pairing file or connections.json that does not parse is one whole-file row and an entry that is not an object is its own unparseable record <N> (not an object) row; config.yaml is under Permission rows.

Malformed tool_calls: a value that is not valid JSON, is not a list (a lone object is read as a one-call list) or holds an entry that is not an object gives one row through error_rows, after the message's other rows, parser: unparseable record <messages.id> (tool_calls is not valid JSON: <reason>), (tool_calls is not a list) or (tool_calls entry is not an object), followed by :  and the value as stored, with the message's session_id and project path and an empty timestamp_utc; the entries that are objects still give tool_use rows. A divert line uses the same rule, with <N> the line number and the value written as compact JSON when it is not a string.

Fixtures

A state.db reduced to the columns the parser reads, as the schema at 6bb377a writes them (Sessions table, Messages table); the assistant row's tool_calls follows the writer of agent/chat_completion_helpers.py:1609-1611 and the tool result the terminal envelope of tools/terminal_tool.py:1044-1050:

CREATE TABLE schema_version (version INTEGER NOT NULL);
INSERT INTO schema_version VALUES (32);
CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT NOT NULL, model TEXT,
  parent_session_id TEXT, started_at REAL NOT NULL, ended_at REAL, end_reason TEXT,
  cwd TEXT, git_branch TEXT, git_repo_root TEXT, title TEXT, archived INTEGER NOT NULL DEFAULT 0);
CREATE TABLE messages (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL,
  role TEXT NOT NULL, content TEXT, tool_call_id TEXT, tool_calls TEXT, tool_name TEXT,
  timestamp REAL NOT NULL, finish_reason TEXT, reasoning TEXT, reasoning_content TEXT,
  active INTEGER NOT NULL DEFAULT 1, compacted INTEGER NOT NULL DEFAULT 0,
  display_kind TEXT);
INSERT INTO sessions VALUES ('20261029_093015_5c7e19a2','cli','openai/gpt-5.1',NULL,
  1793266215.0,NULL,NULL,'/home/u/work/site','fix-nav','/home/u/work/site','Count lines',0);
INSERT INTO messages (session_id,role,content,tool_call_id,tool_calls,tool_name,timestamp,
  finish_reason,reasoning,display_kind) VALUES
 ('20261029_093015_5c7e19a2','user','how many lines in index.html?',NULL,NULL,NULL,
  1793266216.4,NULL,NULL,NULL),
 ('20261029_093015_5c7e19a2','assistant','',NULL,
  '[{"id":"call_9f","call_id":"call_9f","response_item_id":null,"type":"function","function":{"name":"terminal","arguments":"{\"command\":\"wc -l index.html\"}"}}]',
  NULL,1793266217.9,'tool_calls','Count with wc.',NULL),
 ('20261029_093015_5c7e19a2','tool','{"output": "212 index.html", "exit_code": 0, "error": null}',
  'call_9f',NULL,'terminal',1793266218.6,NULL,NULL,NULL),
 ('20261029_093015_5c7e19a2','user','[System: The active model for this chat has changed to anthropic/claude-opus-4.1 via provider openrouter. From this point forward, ...]',
  NULL,NULL,NULL,1793266230.0,NULL,NULL,'model_switch'),
 ('20261029_093015_5c7e19a2','assistant','index.html has 212 lines.',NULL,NULL,NULL,
  1793266231.2,'stop',NULL,NULL);

A messaging session with a refused command, a deletion marker and a routing peer, from the writers of Messaging sessions, Permission outcomes and settings, Deletion fences and Conversation generations:

INSERT INTO sessions (id,source,user_id,chat_id,chat_type,display_name,origin_json,model_config,
  started_at,archived) VALUES ('20261028_201500_b41c0de7','discord','88213','55102','channel',
  'build-alerts','{"platform":"discord","chat_id":"55102","chat_name":"build-alerts",
  "chat_type":"channel","user_id":"88213","user_name":"ops_kim"}','{"yolo_mode": false}',
  1793218500.0,0);
INSERT INTO messages (session_id,role,content,tool_call_id,tool_name,timestamp) VALUES
 ('20261028_201500_b41c0de7','tool','{"output": "", "exit_code": -1, "error": "Command denied:
  recursive delete. Use the approval prompt to allow it, or rephrase the command.", "status":
  "blocked", "user_summary": "rm -rf build/"}','call_c2','terminal',1793218533.5);
INSERT INTO state_meta VALUES ('gateway.retired_session.v1.20261020_081200_77aa01fe',
  '{"retired_at": 1793229100.25, "expires": true, "own": true}');
INSERT INTO conversation_generations VALUES ('discord','agent:main:discord:channel:55102',3);

.hermes_history, one TUI entry (UTC, milliseconds) and one classic CLI entry (host-local, microseconds), from the writers of Prompt history:

# 2026-10-29 09:30:16.400
+how many lines in index.html?

# 2026-10-29 10:02:44.918203
+/snapshot before-refactor

pairing/discord-approved.json, from gateway/pairing.py:432-438:

{"88213": {"user_name": "ops_kim", "approved_at": 1793210400.0}}

pastes/pending.json, from hermes_cli/debug.py:180-195:

[{"url": "https://paste.rs/Xq3vT", "expire_at": 1793289600.5}]

Source host rows

The agent records no source-host integration: at 6bb377a a grep for github, gitlab, bitbucket, oauth, installation, repositor, pull request and clone found no connected account, no cached repository list and no repository-bound remote conversation. A GitHub token, when present, is a variable in .env or the gh CLI's own login (Credentials), and the webhook platform's GitHub and GitLab routes live in config.yaml platforms.webhook.extra.routes (gateway/platforms/webhook.py:1-10); a webhook-driven session is a relayed session with source webhook.

Confidence

  • High: home resolution, profile layout, state.db and every file named in Per-user storage and Credentials (source constants).
  • High: the backup exclusion list as a guide to size.
  • High: table and column names, timestamp unit, content prefix, tool-call shape, joins, WAL.
  • Medium: the desktop userData path, which follows the Electron default from productName rather than an explicit setPath for stable builds (apps/desktop/electron/product-identity.ts:26-47).
  • Medium: the full set of source values beyond those listed in the Sessions table and Messaging sessions.
  • Medium: that a terminal result's JSON reaches messages.content unchanged; a result Hermes spilled or truncated before storing would give no permission decision row.
  • For the round: the sizes of state-snapshots, cache/scratch and the state database copies on a real install; whether a classic -w session's sessions.cwd is the repository root and its tree only in the local policy; what a session_meta row stores in content; whether a CLI import of a Claude Code or Codex CLI session is marked anywhere but its title.
  • For the round: the Webapp's files beyond webapp/ and uploads/, and whether a session driven from the Webapp differs from a desktop one in state.db.
  • Not determined: the format of auth.json beyond "OAuth provider credentials" (configuration.md:25).
  • Not determined: Termux installs, which come from APT (scripts/install.sh:439) and were not checked.
  • Not determined: the legacy session_<id>.json schema, which nothing writes (Transcript stores).
  • Not determined: whether a Bot Mode room message is also a messages row, whether a cron run's stored prompt equals the session's first user message, the branch of a kanban child's worktree, and what a profile served by the remote config backend keeps on disk.