Hermes plugin development
Hermes loads agent plugins as DIRECTORY plugins, not loose .py files. Most "my hook
never fires" reports trace to a packaging-shape mismatch. Everything below was verified
against hermes_cli/plugins.py in the Hermes source checkout (~/.hermes/hermes-agent/),
2026-08-06.
The ABI (verified)
- Discovery sources (plugins.py:1350-1393): bundled
<repo>/plugins/<name>/; user
~/.hermes/plugins/<name>/; project ./.hermes/plugins/<name>/ (opt-in via
HERMES_ENABLE_PROJECT_PLUGINS=1); pip packages exposing the hermes_agent.plugins
entry-point group. Later sources override earlier on name collision.
- A directory plugin MUST contain
plugin.yaml AND __init__.py with a
register(ctx) function. _scan_directory skips anything that is not a subdirectory
containing plugin.yaml/plugin.yml (if not child.is_dir(): continue) — flat .py
files in ~/.hermes/plugins/ are INVISIBLE to the loader. Category layout
(<root>/<category>/<name>/plugin.yaml) is supported, depth capped at two segments.
- plugin.yaml shape (example: bundled
plugins/disk-cleanup/plugin.yaml):
name, version, description, author, hooks: [<VALID_HOOKS names>].
kind defaults to "standalone"; unknown kinds warn and fall back.
- register(ctx): callbacks registered with
ctx.register_hook("<hook_name>", cb).
Callbacks must tolerate **kwargs (forward compatibility). Declare hook names in the
manifest's hooks: list. __init__.py is imported as hermes_plugins.<slug> with
submodule_search_locations=[plugin_dir] — so from .sibling import register works
and siblings inside the dir are importable (keep lazy-imported helpers in the dir).
- VALID_HOOKS (plugins.py:135-216):
pre_tool_call, post_tool_call,
transform_terminal_output, transform_tool_result, transform_llm_output,
pre_llm_call, post_llm_call, pre_verify, pre_api_request, post_api_request,
api_request_error, on_session_start, on_session_end, on_session_finalize,
on_session_reset, on_skill_lifecycle, subagent_start, subagent_stop,
pre_gateway_dispatch, pre_approval_request, post_approval_response, plus the
kanban_* task-lifecycle hooks.
- User plugins are OPT-IN: "None = opt-in default (nothing enabled)" — a manifest not
listed in
plugins.enabled (config) is recorded but NOT loaded. Enable via
hermes plugins enable <name> or hermes config set plugins.enabled [...]. Bundled
backend/platform plugins auto-load; standalone, user-installed and entry-point
plugins all require plugins.enabled.
invoke_hook wraps every callback in its own try/except — a raising callback logs a
warning and the core loop continues. Callbacks should still self-guard.
pre_llm_call context injection: callbacks may return {"context": "..."} (or a
plain string) — it is injected into the USER message, never the system prompt
(preserves the prompt-cache prefix). Injected context is ephemeral, never persisted.
post_tool_call has NO return channel into the model context — the stash/pop
pattern (disk stash keyed by project path, popped by pre_llm_call) is the way to
inject once.
- Debugging:
HERMES_PLUGINS_DEBUG=1 prints verbose discovery logs (scanned dirs,
parsed manifests, skip reasons, what register() registered) to stderr AND
~/.hermes/logs/agent.log.
Payload keys per hook (the trap)
Callbacks receive **kwargs; no payload carries cwd:
post_tool_call (PLUGIN emitter, model_tools._emit_post_tool_call_hook):
function_name, function_args, result, session_id, task_id, tool_call_id,
turn_id, api_request_id, duration_ms, status, error_type, error_message,
middleware_trace. Note: the shell-hook spelling (tool_name, args, cwd — see
agent/shell_hooks.py / hermes hooks test) is a DIFFERENT surface; plugins get
function_name/function_args.
pre_llm_call: session_id, user_message, conversation_history, is_first_turn,
model, platform.
on_session_start: session_id.
pre_tool_call: tool_name, args, session_id, task_id, tool_call_id.
Adapters must normalize both spellings (tool_name|function_name, args|function_args)
and derive the project dir via os.getcwd() at callback time — matching what the
pre_llm_call pop side resolves (_project_cwd(os.getcwd())). Keep stash keys and pop
keys symmetric or the round-trip silently misses (test with monkeypatch.chdir).
Memory provider plugins (specialized type)
Memory providers are a SPECIALIZED plugin type, not plain hook plugins: single-select,
routed through memory.provider in config.yaml (NOT plugins.enabled), auto-detected as
kind: exclusive. The interface is the MemoryProvider ABC in
agent/memory_provider.py — 4 abstract members (name, is_available(),
initialize(session_id, **kwargs), get_tool_schemas()) plus ~14 optional hooks
(prefetch, sync_turn, on_session_end, on_session_switch, on_memory_write,
get_config_schema, save_config, backup_paths, ...). Registration:
register(ctx) → ctx.register_memory_provider(provider). Discovery scans bundled
plugins/memory/<name>/ and $HERMES_HOME/plugins/<name>/ (text heuristic:
register_memory_provider or MemoryProvider in __init__.py; bundled wins on
collision; a bare MemoryProvider subclass also loads). Tool schemas are OpenAI
function-calling format; handle_tool_call returns a JSON string. The provider runs
IN-PROCESS — no MCP/IPC anywhere in the call path, so a server-backed memory (e.g. an
MCP memory server) plugs in as a thin Python shim. Full ABC surface, per-turn call
points, threading contract, config/CLI surfaces, and a live loader probe:
Read references/memory-provider-interface.md when implementing a MemoryProvider.
Runtime call points (MemoryManager)
System prompt assembly → system_prompt_block(); pre-turn → prefetch_all (gated by
is_trivial_prompt — greetings skip recall; skill scaffolding stripped; external
prefetch bounded 8s); post-turn → sync_all + queue_prefetch_all on a
background executor (5s drain); tool injection at agent init (gated by the memory
toolset, name-collision skip); handle_tool_call dispatch; session-boundary hooks.
Threading & lifecycle contract
sync_turn MUST be non-blocking: daemon thread, join the previous sync thread (≤5s)
before starting the next.
prefetch must be fast — background the real recall and return cached results if needed.
- Writes only for primary agents: when
agent_context is not primary (cron/subagent),
skip sync.
- Profile isolation: storage paths from the
hermes_home kwarg, never hardcoded ~/.hermes.
- Re-init must close the previous client (double initialize would leak the child).
Config surface
get_config_schema() field dicts drive hermes memory setup: secret: True + env_var
→ .env; non-secrets → save_config(values, hermes_home). Providers can read their own
block via cfg_get(load_config_readonly(), "plugins", "<name>"). Optional cli.py with
register_cli(subparser) registers hermes <provider> subcommands, gated on being the
active provider.
MCP-bridge pattern (server-backed providers)
A remote memory server plugs in as a thin in-process Python shim implementing
MemoryProvider. Use the official mcp SDK — it ships in the hermes venv
(~/.hermes/hermes-agent/venv/, with pytest 9.x — that venv is the plugin test runtime).
- Import
mcp LAZILY inside connect() so unit tests with a fake client never need it.
- Sync-over-asyncio: persistent loop thread +
asyncio.run_coroutine_threadsafe; for stdio
the child must NOT be re-spawned per call (session is persistent).
stdio_client yields a 2-tuple (read, write); streamable_http_client yields a
3-tuple (read, write, get_session_id) — verify against the installed SDK, they differ.
StdioServerParameters(command, args) — pass CLI flags through args (e.g. --data-root)
for test isolation.
- On connect timeout, CANCEL the pending
_open task before closing the loop, or a
half-spawned child leaks.
- Tool results:
CallToolResult.content[0].text is a JSON string — parse, don't re-wrap.
Memory-provider testing patterns
- Unit: duck-typed fake client (connect/search/write/stats/share/close), injected via a
client_factory ctor param. Provider must behave client-less: prefetch → "",
handle_tool_call → {"error": ...} JSON.
- Spec-load the plugin module in pytest:
importlib.util.spec_from_file_location +
sys.modules registration; conftest adds the plugin dir to sys.path for the
absolute-import fallback inside the plugin.
- Integration (slow marker +
--run-slow): spawn the REAL server with a temp data root via
spawn args; fail (not skip) on spawn failure when --run-slow was explicitly requested;
binary-missing is a skip.
- Pin result shapes against the server's real records — read the server source, don't guess.
Memory-provider pitfalls
- Test isolation is only as real as the spawn args. If the server CLI resolves its data
root ONLY from a flag (e.g.
--data-root), an env var that works for in-process test
hosts is IGNORED by the spawned binary → integration tests silently write into the REAL
bank. Pass the flag through spawn args AND verify isolation by counting your test
project's rows in the real store before and after the run.
is_available() must not construct the client — pin with a test whose factory raises.
- Do not expose server-injected params (projectId) in model-facing tool schemas — the
provider injects them at dispatch.
- The loader heuristic scans only the first 8192 bytes of
__init__.py.
Empirical verification — a plugin file present ≠ a plugin loaded
Never infer execution from file contents (a register() body reads as if it runs) or
from a manifest that declares the hook. Check in order:
__pycache__: a loaded plugin module leaves a pycache next to it. Absent pycache
in ~/.hermes/plugins/ = never imported.
- Logs:
grep <plugin-name> ~/.hermes/logs/agent.log* for execution lines (logger
output, "Plugin discovery complete: N found, M enabled") — not lint mentions.
- Live probe: trigger the hook's event and observe its side effect (e.g. run a
tool with a hook's env on, then confirm the expected side-effect file lands).
- Live gate: hooks load at SESSION START — enabling mid-session changes nothing in
the current session. Run a fresh
hermes chat -q "<prompt that exercises the hook>"
and check the side effect (log line, file, message). A one-shot session is the only
honest end-to-end probe.
- For script-hook payload shapes,
hermes hooks test/doctor show _DEFAULT_PAYLOADS;
for the plugin emitter, read model_tools._emit_post_tool_call_hook.
Gotchas
- Flat-file deployment = dead plugin. Loose
.py files copied into
~/.hermes/plugins/ (plus a .ai-badger/manifest.json record, say) produce ZERO
manifests → zero plugins loaded → no hook ever fires, while the module's
register(ctx) + ctx.register_hook(...) calls are written correctly against the
ABI; only the packaging shape is wrong. Forensic signals: the flat modules never
produce a __pycache__ entry and never appear in ~/.hermes/logs/agent.log. Fix
direction: real directory plugin, plugins.enabled registration, and a live-session
verification gate.
- Sibling modules must ship INSIDE the plugin directory: lazy sibling imports resolve
Path(__file__).parent — a module that loads a sibling from its own dir breaks if
siblings land elsewhere.
- Staleness/refusal guards run at register() time — a
COPY_SKEW_REFUSAL-style gate
never runs if the plugin never loads; it cannot be the only protection. If the module
has a "copies are stale, refuse to register" check, the installer record must sit where
the checker reads it — INSIDE the plugin dir, not beside it, or the protection silently
no-ops.
- Graceful degradation —
register() returning early (stale copies, missing
framework root) is fine: the plugin loads, hooks absent, session unaffected. A dead
recorded frameworkRoot degrades to no-version-context, never a broken session.
- Legacy cleanup — when moving from a flat layout to the directory shape, delete the
old flat files and the old manifest dir (only framework-owned names) so the loader
scans a clean user scope.
- Prior research can misread file contents as runtime: a written record that treated
an installed
register() body as proof of registration is exactly the failure mode
the empirical checks above exist for. Any "the plugin registers X" claim needs the
empirical checks.
Verification checklist
References
references/memory-provider-interface.md — full MemoryProvider ABC surface, per-turn call points, threading contract, config/CLI surfaces; read when implementing a MemoryProvider.
references/provider-implementation.md — worked provider implementation (MemoryProvider shim over a server transport); read when writing a provider implementation.
1---2name: hermes-plugin-development3description: Use when writing or debugging Hermes Agent Python plugins — including the memory-provider specialized plugin type: directory-plugin packaging (plugin.yaml + __init__.py with register(ctx) — flat .py files are INVISIBLE), VALID_HOOKS list, per-hook payload keys (no cwd; tool_name vs function_name), plugins.enabled opt-in, pre_llm_call context injection, stash/pop for post_tool_call, HERMES_PLUGINS_DEBUG=1, and the MemoryProvider ABC with its threading & lifecycle contract.4license: MIT5---67# Hermes plugin development89Hermes loads agent plugins as DIRECTORY plugins, not loose `.py` files. Most "my hook10never fires" reports trace to a packaging-shape mismatch. Everything below was verified11against `hermes_cli/plugins.py` in the Hermes source checkout (`~/.hermes/hermes-agent/`),122026-08-06.1314## The ABI (verified)1516- **Discovery sources** (plugins.py:1350-1393): bundled `<repo>/plugins/<name>/`; user17 `~/.hermes/plugins/<name>/`; project `./.hermes/plugins/<name>/` (opt-in via18 `HERMES_ENABLE_PROJECT_PLUGINS=1`); pip packages exposing the `hermes_agent.plugins`19 entry-point group. Later sources override earlier on name collision.20- **A directory plugin MUST contain `plugin.yaml` AND `__init__.py` with a21 `register(ctx)` function.** `_scan_directory` skips anything that is not a subdirectory22 containing `plugin.yaml`/`plugin.yml` (`if not child.is_dir(): continue`) — flat `.py`23 files in `~/.hermes/plugins/` are INVISIBLE to the loader. Category layout24 (`<root>/<category>/<name>/plugin.yaml`) is supported, depth capped at two segments.25- **plugin.yaml shape** (example: bundled `plugins/disk-cleanup/plugin.yaml`):26 `name`, `version`, `description`, `author`, `hooks: [<VALID_HOOKS names>]`.27 `kind` defaults to `"standalone"`; unknown kinds warn and fall back.28- **register(ctx)**: callbacks registered with `ctx.register_hook("<hook_name>", cb)`.29 Callbacks must tolerate `**kwargs` (forward compatibility). Declare hook names in the30 manifest's `hooks:` list. `__init__.py` is imported as `hermes_plugins.<slug>` with31 `submodule_search_locations=[plugin_dir]` — so `from .sibling import register` works32 and siblings inside the dir are importable (keep lazy-imported helpers in the dir).33- **VALID_HOOKS** (plugins.py:135-216): `pre_tool_call`, `post_tool_call`,34 `transform_terminal_output`, `transform_tool_result`, `transform_llm_output`,35 `pre_llm_call`, `post_llm_call`, `pre_verify`, `pre_api_request`, `post_api_request`,36 `api_request_error`, `on_session_start`, `on_session_end`, `on_session_finalize`,37 `on_session_reset`, `on_skill_lifecycle`, `subagent_start`, `subagent_stop`,38 `pre_gateway_dispatch`, `pre_approval_request`, `post_approval_response`, plus the39 `kanban_*` task-lifecycle hooks.40- **User plugins are OPT-IN**: "None = opt-in default (nothing enabled)" — a manifest not41 listed in `plugins.enabled` (config) is recorded but NOT loaded. Enable via42 `hermes plugins enable <name>` or `hermes config set plugins.enabled [...]`. Bundled43 `backend`/`platform` plugins auto-load; standalone, user-installed and entry-point44 plugins all require `plugins.enabled`.45- **`invoke_hook` wraps every callback in its own try/except** — a raising callback logs a46 warning and the core loop continues. Callbacks should still self-guard.47- **`pre_llm_call` context injection**: callbacks may return `{"context": "..."}` (or a48 plain string) — it is injected into the USER message, never the system prompt49 (preserves the prompt-cache prefix). Injected context is ephemeral, never persisted.50- **`post_tool_call` has NO return channel into the model context** — the stash/pop51 pattern (disk stash keyed by project path, popped by `pre_llm_call`) is the way to52 inject once.53- **Debugging**: `HERMES_PLUGINS_DEBUG=1` prints verbose discovery logs (scanned dirs,54 parsed manifests, skip reasons, what `register()` registered) to stderr AND55 `~/.hermes/logs/agent.log`.5657## Payload keys per hook (the trap)5859Callbacks receive `**kwargs`; **no payload carries `cwd`**:6061- `post_tool_call` (PLUGIN emitter, `model_tools._emit_post_tool_call_hook`):62 `function_name`, `function_args`, `result`, `session_id`, `task_id`, `tool_call_id`,63 `turn_id`, `api_request_id`, `duration_ms`, `status`, `error_type`, `error_message`,64 `middleware_trace`. Note: the shell-hook spelling (`tool_name`, `args`, `cwd` — see65 `agent/shell_hooks.py` / `hermes hooks test`) is a DIFFERENT surface; plugins get66 `function_name`/`function_args`.67- `pre_llm_call`: `session_id`, `user_message`, `conversation_history`, `is_first_turn`,68 `model`, `platform`.69- `on_session_start`: `session_id`.70- `pre_tool_call`: `tool_name`, `args`, `session_id`, `task_id`, `tool_call_id`.7172Adapters must normalize both spellings (`tool_name|function_name`, `args|function_args`)73and derive the project dir via `os.getcwd()` at callback time — matching what the74`pre_llm_call` pop side resolves (`_project_cwd(os.getcwd())`). Keep stash keys and pop75keys symmetric or the round-trip silently misses (test with `monkeypatch.chdir`).7677## Memory provider plugins (specialized type)7879Memory providers are a SPECIALIZED plugin type, not plain hook plugins: single-select,80routed through `memory.provider` in config.yaml (NOT `plugins.enabled`), auto-detected as81`kind: exclusive`. The interface is the `MemoryProvider` ABC in82`agent/memory_provider.py` — 4 abstract members (`name`, `is_available()`,83`initialize(session_id, **kwargs)`, `get_tool_schemas()`) plus ~14 optional hooks84(`prefetch`, `sync_turn`, `on_session_end`, `on_session_switch`, `on_memory_write`,85`get_config_schema`, `save_config`, `backup_paths`, ...). Registration:86`register(ctx)` → `ctx.register_memory_provider(provider)`. Discovery scans bundled87`plugins/memory/<name>/` and `$HERMES_HOME/plugins/<name>/` (text heuristic:88`register_memory_provider` or `MemoryProvider` in `__init__.py`; bundled wins on89collision; a bare MemoryProvider subclass also loads). Tool schemas are OpenAI90function-calling format; `handle_tool_call` returns a JSON string. The provider runs91IN-PROCESS — no MCP/IPC anywhere in the call path, so a server-backed memory (e.g. an92MCP memory server) plugs in as a thin Python shim. Full ABC surface, per-turn call93points, threading contract, config/CLI surfaces, and a live loader probe:94Read `references/memory-provider-interface.md` when implementing a MemoryProvider.9596### Runtime call points (MemoryManager)9798System prompt assembly → `system_prompt_block()`; pre-turn → `prefetch_all` (gated by99`is_trivial_prompt` — greetings skip recall; skill scaffolding stripped; external100prefetch bounded ~8s); post-turn → `sync_all` + `queue_prefetch_all` on a101background executor (~5s drain); tool injection at agent init (gated by the `memory`102toolset, name-collision skip); `handle_tool_call` dispatch; session-boundary hooks.103104### Threading & lifecycle contract105106- `sync_turn` MUST be non-blocking: daemon thread, join the previous sync thread (≤5s)107 before starting the next.108- `prefetch` must be fast — background the real recall and return cached results if needed.109- Writes only for primary agents: when `agent_context` is not primary (cron/subagent),110 skip sync.111- Profile isolation: storage paths from the `hermes_home` kwarg, never hardcoded `~/.hermes`.112- Re-init must close the previous client (double initialize would leak the child).113114### Config surface115116`get_config_schema()` field dicts drive `hermes memory setup`: `secret: True` + `env_var`117→ .env; non-secrets → `save_config(values, hermes_home)`. Providers can read their own118block via `cfg_get(load_config_readonly(), "plugins", "<name>")`. Optional `cli.py` with119`register_cli(subparser)` registers `hermes <provider>` subcommands, gated on being the120active provider.121122### MCP-bridge pattern (server-backed providers)123124A remote memory server plugs in as a thin in-process Python shim implementing125MemoryProvider. Use the official `mcp` SDK — it ships in the hermes venv126(`~/.hermes/hermes-agent/venv/`, with pytest 9.x — that venv is the plugin test runtime).127- Import `mcp` LAZILY inside `connect()` so unit tests with a fake client never need it.128- Sync-over-asyncio: persistent loop thread + `asyncio.run_coroutine_threadsafe`; for stdio129 the child must NOT be re-spawned per call (session is persistent).130- `stdio_client` yields a **2-tuple** (read, write); `streamable_http_client` yields a131 **3-tuple** (read, write, get_session_id) — verify against the installed SDK, they differ.132- `StdioServerParameters(command, args)` — pass CLI flags through `args` (e.g. `--data-root`)133 for test isolation.134- On connect timeout, CANCEL the pending `_open` task before closing the loop, or a135 half-spawned child leaks.136- Tool results: `CallToolResult.content[0].text` is a JSON string — parse, don't re-wrap.137138### Memory-provider testing patterns139140- Unit: duck-typed fake client (connect/search/write/stats/share/close), injected via a141 `client_factory` ctor param. Provider must behave client-less: prefetch → "",142 handle_tool_call → `{"error": ...}` JSON.143- Spec-load the plugin module in pytest: `importlib.util.spec_from_file_location` +144 sys.modules registration; conftest adds the plugin dir to sys.path for the145 absolute-import fallback inside the plugin.146- Integration (slow marker + `--run-slow`): spawn the REAL server with a temp data root via147 spawn args; **fail (not skip) on spawn failure** when --run-slow was explicitly requested;148 binary-missing is a skip.149- Pin result shapes against the server's real records — read the server source, don't guess.150151### Memory-provider pitfalls152153- **Test isolation is only as real as the spawn args.** If the server CLI resolves its data154 root ONLY from a flag (e.g. `--data-root`), an env var that works for in-process test155 hosts is IGNORED by the spawned binary → integration tests silently write into the REAL156 bank. Pass the flag through spawn args AND verify isolation by counting your test157 project's rows in the real store before and after the run.158- `is_available()` must not construct the client — pin with a test whose factory raises.159- Do not expose server-injected params (projectId) in model-facing tool schemas — the160 provider injects them at dispatch.161- The loader heuristic scans only the first 8192 bytes of `__init__.py`.162163## Empirical verification — a plugin file present ≠ a plugin loaded164165Never infer execution from file contents (a `register()` body reads as if it runs) or166from a manifest that declares the hook. Check in order:1671681. **`__pycache__`**: a loaded plugin module leaves a pycache next to it. Absent pycache169 in `~/.hermes/plugins/` = never imported.1702. **Logs**: `grep <plugin-name> ~/.hermes/logs/agent.log*` for execution lines (logger171 output, "Plugin discovery complete: N found, M enabled") — not lint mentions.1723. **Live probe**: trigger the hook's event and observe its side effect (e.g. run a173 tool with a hook's env on, then confirm the expected side-effect file lands).1744. **Live gate**: hooks load at SESSION START — enabling mid-session changes nothing in175 the current session. Run a fresh `hermes chat -q "<prompt that exercises the hook>"`176 and check the side effect (log line, file, message). A one-shot session is the only177 honest end-to-end probe.1785. For script-hook payload shapes, `hermes hooks test`/`doctor` show `_DEFAULT_PAYLOADS`;179 for the plugin emitter, read `model_tools._emit_post_tool_call_hook`.180181## Gotchas182- **Flat-file deployment = dead plugin.** Loose `.py` files copied into183 `~/.hermes/plugins/` (plus a `.ai-badger/manifest.json` record, say) produce ZERO184 manifests → zero plugins loaded → no hook ever fires, while the module's185 `register(ctx)` + `ctx.register_hook(...)` calls are written correctly against the186 ABI; only the packaging shape is wrong. Forensic signals: the flat modules never187 produce a `__pycache__` entry and never appear in `~/.hermes/logs/agent.log`. Fix188 direction: real directory plugin, `plugins.enabled` registration, and a live-session189 verification gate.190- **Sibling modules must ship INSIDE the plugin directory**: lazy sibling imports resolve191 `Path(__file__).parent` — a module that loads a sibling from its own dir breaks if192 siblings land elsewhere.193- **Staleness/refusal guards run at register() time** — a `COPY_SKEW_REFUSAL`-style gate194 never runs if the plugin never loads; it cannot be the only protection. If the module195 has a "copies are stale, refuse to register" check, the installer record must sit where196 the checker reads it — INSIDE the plugin dir, not beside it, or the protection silently197 no-ops.198- **Graceful degradation** — `register()` returning early (stale copies, missing199 framework root) is fine: the plugin loads, hooks absent, session unaffected. A dead200 recorded `frameworkRoot` degrades to no-version-context, never a broken session.201- **Legacy cleanup** — when moving from a flat layout to the directory shape, delete the202 old flat files and the old manifest dir (only framework-owned names) so the loader203 scans a clean user scope.204- **Prior research can misread file contents as runtime**: a written record that treated205 an installed `register()` body as proof of registration is exactly the failure mode206 the empirical checks above exist for. Any "the plugin registers X" claim needs the207 empirical checks.208209## Verification checklist210211- [ ] Plugin lives at `~/.hermes/plugins/<name>/` with `plugin.yaml` + `__init__.py`212- [ ] `hermes plugins list` shows it enabled (or `plugins.enabled` contains its key)213- [ ] pycache exists / agent.log shows registration after a fresh session214- [ ] Live side-effect probe confirms the hook fires end-to-end215216## References217218- `references/memory-provider-interface.md` — full MemoryProvider ABC surface, per-turn call points, threading contract, config/CLI surfaces; read when implementing a MemoryProvider.219- `references/provider-implementation.md` — worked provider implementation (MemoryProvider shim over a server transport); read when writing a provider implementation.