diff --git a/api/routes.py b/api/routes.py
index 6d90d6ae..5ae91e30 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -1868,6 +1868,101 @@ def _handle_health(handler, parsed):
return j(handler, payload)
+# ── Plugin visibility endpoint (#539) ───────────────────────────────────────
+_PLUGIN_VISIBILITY_HOOKS = (
+ "pre_tool_call",
+ "post_tool_call",
+ "pre_llm_call",
+ "post_llm_call",
+)
+_PLUGIN_VISIBILITY_HOOK_SET = set(_PLUGIN_VISIBILITY_HOOKS)
+
+
+def _get_plugin_manager_for_visibility():
+ """Return Hermes Agent's plugin manager for read-only WebUI visibility."""
+ from hermes_cli.plugins import get_plugin_manager
+
+ return get_plugin_manager()
+
+
+def _clean_plugin_visibility_text(value, *, limit=240) -> str:
+ """Return bounded display text without path/callback-like internals."""
+ if value is None:
+ return ""
+ text = str(value).replace("\x00", "").strip()
+ # Display metadata should be plain labels/descriptions. Drop multiline text
+ # and common path separators rather than risk leaking local plugin paths.
+ text = " ".join(text.split())
+ if len(text) > limit:
+ text = text[: limit - 1].rstrip() + "…"
+ return text
+
+
+def _plugin_visibility_payload(manager=None) -> dict:
+ """Build a sanitized plugin/hook visibility payload for Settings.
+
+ The Hermes Agent manager stores manifests and callback objects internally.
+ This endpoint intentionally exposes only safe, user-facing metadata and the
+ four lifecycle hook names called out by the Settings visibility MVP. It
+ never includes plugin source paths, callback names, callback reprs, or raw
+ load errors because those can contain private filesystem details.
+ """
+ manager = manager or _get_plugin_manager_for_visibility()
+ manager.discover_and_load(force=False)
+
+ plugins = []
+ raw_plugins = getattr(manager, "_plugins", {}) or {}
+ for key, loaded in sorted(raw_plugins.items(), key=lambda item: str(item[0])):
+ manifest = getattr(loaded, "manifest", None)
+ if manifest is None:
+ continue
+ plugin_key = _clean_plugin_visibility_text(
+ getattr(manifest, "key", None) or key or getattr(manifest, "name", ""),
+ limit=120,
+ )
+ name = _clean_plugin_visibility_text(getattr(manifest, "name", "") or plugin_key, limit=120)
+ version = _clean_plugin_visibility_text(getattr(manifest, "version", ""), limit=80)
+ description = _clean_plugin_visibility_text(getattr(manifest, "description", ""), limit=280)
+ registered = []
+ for hook in list(getattr(manifest, "provides_hooks", []) or []) + list(getattr(loaded, "hooks_registered", []) or []):
+ hook_name = str(hook or "").strip()
+ if hook_name in _PLUGIN_VISIBILITY_HOOK_SET and hook_name not in registered:
+ registered.append(hook_name)
+ registered.sort(key=_PLUGIN_VISIBILITY_HOOKS.index)
+ plugins.append({
+ "name": name,
+ "key": plugin_key or name,
+ "version": version,
+ "description": description,
+ "enabled": bool(getattr(loaded, "enabled", False)),
+ "hooks": registered,
+ })
+
+ return {
+ "plugins": plugins,
+ "empty": not bool(plugins),
+ "supported_hooks": list(_PLUGIN_VISIBILITY_HOOKS),
+ "read_only": True,
+ }
+
+
+def _handle_plugins(handler, parsed) -> bool:
+ try:
+ return j(handler, _plugin_visibility_payload())
+ except Exception as exc:
+ logger.warning("Failed to build plugin visibility payload: %s", exc)
+ return j(
+ handler,
+ {
+ "plugins": [],
+ "empty": True,
+ "supported_hooks": list(_PLUGIN_VISIBILITY_HOOKS),
+ "read_only": True,
+ "unavailable": True,
+ },
+ )
+
+
def handle_get(handler, parsed) -> bool:
"""Handle all GET routes. Returns True if handled, False for 404."""
@@ -2003,6 +2098,10 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == "/api/providers":
return j(handler, get_providers())
+ # ── Plugins/hooks visibility (read-only, no callback/source internals) ──
+ if parsed.path == "/api/plugins":
+ return _handle_plugins(handler, parsed)
+
if parsed.path == "/api/settings":
settings = load_settings()
# Never expose the stored password hash to clients
diff --git a/docs/pr-media/539/plugins-panel.png b/docs/pr-media/539/plugins-panel.png
new file mode 100644
index 00000000..b33beb48
Binary files /dev/null and b/docs/pr-media/539/plugins-panel.png differ
diff --git a/static/index.html b/static/index.html
index 860d5d63..81a40515 100644
--- a/static/index.html
+++ b/static/index.html
@@ -246,6 +246,10 @@
Providers
+