hermes-agent/website/docs/developer-guide/plugin-architecture.md
ethernet 0fce82164a Pluginify provider/platform/terminal backends
Move provider adapters (anthropic, bedrock, azure), platform adapters
(telegram, slack, discord, feishu, dingtalk, matrix), and terminal backends
(modal, daytona) out of core into plugins/ workspace members. Core references
them via the plugin registries (get_provider_namespace / get_provider_service /
get_tool_provider / get_credential_pool_hook) instead of direct imports.

- Provider/platform/terminal adapters relocated under plugins/; pyproject
  extras reference workspace members; nix variants aggregate per-platform extras.
- Anthropic credential discovery + OAuth-masquerade guard live in the plugin's
  credential_pool_hook; browser-open guarded by _can_open_graphical_browser.
- Vercel AI Gateway + Vercel Sandbox removed (upstream deletion); get_bedrock_model_ids
  removed (replaced by bedrock_model_ids_or_none + discover_bedrock_models).
- Terminal backends resolve ModalEnvironment / DaytonaEnvironment lazily from
  the plugin registry.
- uv.lock regenerated against the pluginified workspace.

Plugin test suites updated for the relocation: imports point at
hermes_agent_<plat>.adapter, caplog logger-name filters and monkeypatch targets
use the new module paths, and credential/rollback tests patch
registries.get_provider_service rather than the removed agent.*_adapter modules.

Verified: zero dead imports of relocated modules in core (import smoke test +
rename-map grep); nix develop succeeds; targeted plugin suites green
(bedrock, anthropic-auxiliary, matrix, dingtalk, feishu, credential_pool,
switch_model_rollback). Remaining full-suite failures are pre-existing on the
pre-merge tree (telegram setUpModule __code__) or environmental (voice/media/
PTY/network-dependent), not introduced here.
2026-05-29 09:28:00 -04:00

14 KiB

sidebar_position title description
2 Plugin Architecture How the plugin system works — workspace layout, capability registries, dependency isolation, and the hermetic core boundary

Plugin Architecture

Since v0.14, Hermes Agent is built on a plugin-first architecture: every optional capability — model providers, platform adapters, TTS/STT, terminal backends, image generation — lives in its own installable Python package under plugins/. The core codebase (agent/, hermes_cli/, gateway/, tools/) never imports from a plugin package directly. Instead, plugins register their capabilities into typed registries during register(), and the core queries those registries at runtime.

This page covers the structural design. For the user-facing guide to creating plugins, see Build a Hermes Plugin. For enabling/disabling plugins, see Plugins.

Why everything is a plugin

Before v0.14, optional capabilities were wired into core with tools/lazy_deps.py — a runtime pip install helper called ensure(). On NixOS (and any sealed-venv environment), ensure() can't work because the venv is immutable at build time. The old design also meant:

  • Single source of truth was split — deps were declared in pyproject.toml extras AND in LAZY_DEPS dicts inside plugin code.
  • Core was coupled to pluginsfrom hermes_agent_bedrock import has_aws_credentials in hermes_cli/auth_commands.py meant adding a new provider required editing core files.
  • Testing was fragileensure() mocking was complex and tests regularly passed locally but failed in CI (or vice versa) because of venv state leaks.

The plugin-first architecture fixes all three:

Problem Fix
ensure() doesn't work on NixOS Dependencies are installed by the package manager. No runtime pip install.
Dual source of truth for deps Each plugin's pyproject.toml is the only place its deps are declared.
Core imports plugins directly Core queries typed registries. Plugins register themselves.
Flaky ensure() tests Gone. If a plugin isn't installed, ImportError — same as any Python package.

Workspace layout

All plugin packages live under plugins/ as members of a uv workspace. Each plugin is a standard Python package with its own pyproject.toml:

plugins/
├── model-providers/
│   ├── anthropic/
│   │   ├── pyproject.toml          # package: hermes-agent-anthropic
│   │   ├── plugin.yaml             # directory-scanner manifest (dev mode)
│   │   └── hermes_agent_anthropic/
│   │       ├── __init__.py          # register(), public re-exports
│   │       ├── adapter.py           # Anthropic-specific client building
│   │       └── ...
│   ├── bedrock/
│   │   ├── pyproject.toml          # package: hermes-agent-bedrock
│   │   └── hermes_agent_bedrock/
│   │       └── ...
│   └── azure-foundry/
│       ├── pyproject.toml          # package: hermes-agent-azure
│       └── hermes_agent_azure/
│           └── ...
├── platforms/
│   ├── telegram/
│   │   ├── pyproject.toml          # package: hermes-agent-telegram
│   │   └── hermes_agent_telegram/
│   │       └── ...
│   ├── slack/
│   ├── discord/
│   ├── feishu/
│   ├── dingtalk/
│   └── matrix/
├── tts/
│   ├── pyproject.toml              # package: hermes-agent-tts
│   └── hermes_agent_tts/
├── stt/
│   ├── pyproject.toml              # package: hermes-agent-stt
│   └── hermes_agent_stt/
├── image_gen/
│   └── fal_pkg/
│       ├── pyproject.toml          # package: hermes-agent-fal
│       └── hermes_agent_fal/
├── terminals/
│   ├── daytona/
│   ├── modal/
│   └── vercel/
└── ...

The root pyproject.toml declares the workspace:

[tool.uv.workspace]
members = [
    "plugins/model-providers/anthropic",
    "plugins/model-providers/bedrock",
    "plugins/model-providers/azure-foundry",
    "plugins/platforms/telegram",
    "plugins/platforms/slack",
    # ... all 21 workspace members
]

And each plugin depends on the main hermes-agent package for shared utilities:

# plugins/platforms/telegram/pyproject.toml
[project]
name = "hermes-agent-telegram"
dependencies = [
    "hermes-agent",
    "python-telegram-bot>=22.0",
]

[tool.uv.sources]
hermes-agent = { workspace = true }

Single source of truth for dependencies

A plugin's pyproject.toml is the only place its runtime dependencies are declared. The root pyproject.toml maps extras to workspace members:

[project.optional-dependencies]
telegram = ["hermes-agent-telegram"]
slack = ["hermes-agent-slack"]
anthropic = ["hermes-agent-anthropic"]
all = [
    "hermes-agent-telegram",
    "hermes-agent-slack",
    "hermes-agent-anthropic",
    # ... all plugins
]

When you uv sync --extra telegram, uv resolves the workspace member hermes-agent-telegram and installs it (with its own deps) into the venv.

There is no LAZY_DEPS dict, no ensure(), no duplicate pin lists. The pyproject.toml is the truth; uv.lock is the resolution.

The hermetic core boundary

The core codebase (agent/, hermes_cli/, gateway/, tools/) must never import from a hermes_agent_* plugin package. This is enforced by convention and should be checked in CI.

How core accesses plugin capabilities

Instead of direct imports, the core queries typed registries in agent/plugin_registries.py:

# ❌ OLD — core directly imports plugin
from hermes_agent_bedrock import has_aws_credentials

# ✅ NEW — core queries the registry
from agent.plugin_registries import registries

bedrock_auth = registries.get_auth_provider("bedrock")
if bedrock_auth and bedrock_auth.provider.has_credentials():
    ...

Registry types

Registry What it stores Populated by Queried by
auth_providers Auth check/resolve functions Model-provider plugins hermes_cli/auth.py, auth_commands.py, doctor.py
transport_builders Client builders + message converters Model-provider plugins agent/transports/, auxiliary_client.py
platform_adapters Adapter classes + check_requirements() Platform plugins gateway/run.py, tools/send_message_tool.py
tool_providers Tool functions + constants TTS, STT, FAL, terminal plugins tools/voice_mode.py, image_generation_tool.py, terminal_tool.py
model_metadata Context lengths, model IDs, betas Model-provider plugins agent/model_metadata.py, hermes_cli/models.py
credential_pools Credential read/write/refresh Model-provider plugins agent/credential_pool.py

Each registry entry is a dataclass or protocol instance with well-typed fields. The PluginRegistries singleton lives at agent.plugin_registries.registries.

Plugin registration

Each plugin's register(ctx) function populates the registries:

# plugins/model-providers/bedrock/hermes_agent_bedrock/__init__.py
def register(ctx):
    from agent.plugin_registries import AuthProviderEntry, ModelMetadataEntry

    ctx.register_auth_provider(
        name="bedrock",
        provider=BedrockAuthProvider(),
        cli_group="AWS / Bedrock",
    )
    ctx.register_model_metadata(ModelMetadataEntry(
        name="bedrock",
        list_models=bedrock_model_ids_or_none,
        get_context_length=get_bedrock_context_length,
    ))

The PluginContext (hermes_cli/plugins.py) delegates each register_*() call to the matching method on the global PluginRegistries singleton. This keeps the existing PluginManager lifecycle intact — plugins are still discovered and loaded the same way, they just register into more registries.

Existing specialized registries

Some plugin categories already had registries before the refactor. These continue to work alongside the new generic registries:

Registry Module Used by
platform_registry gateway/platform_registry.py ctx.register_platform()
tts_registry agent/tts_registry.py ctx.register_tts_provider()
transcription_registry agent/transcription_registry.py ctx.register_transcription_provider()
image_gen_provider agent/image_gen_provider.py ctx.register_image_gen_provider()
video_gen_provider agent/video_gen_provider.py ctx.register_video_gen_provider()
context_engine agent/context_engine.py ctx.register_context_engine()
memory_manager agent/memory_manager.py MemoryProvider subclasses

The new plugin_registries module covers the capabilities that didn't have a registry before: auth, transport building, model metadata, credential pooling, and tool-provider registration.

Plugin discovery

Plugins are discovered through three mechanisms (same as before the refactor, but now with workspace awareness):

  1. Directory scanner — scans plugins/ (bundled), ~/.hermes/plugins/ (user), .hermes/plugins/ (project) for directories with plugin.yaml. This is the primary path for dev-mode and for user-installed plugins.

  2. Entry points — packages that declare [project.entry-points."hermes_agent.plugins"] in their pyproject.toml. This is the primary path for pip install-ed plugins and for NixOS installs where the venv already contains the installed packages.

  3. uv workspace members — the 21 builtin plugins are workspace members, so uv sync --extra <name> installs them into the venv. At runtime, the entry-point scanner finds them because each plugin declares the hermes_agent.plugins entry point in its pyproject.toml.

On NixOS, loadWorkspace discovers all workspace members from uv.lock automatically, and mkVirtualEnv { hermes-agent = ["all"] } installs all plugin packages as transitive deps.

Building and publishing

Dev / source installs

uv sync --all-extras    # install all plugins + their deps
uv sync --extra telegram  # install just the telegram plugin

Wheel publishing (custom build backend)

The root pyproject.toml uses a custom PEP 517 build backend (_build_backend.py) that wraps setuptools.build_meta. At wheel build time it:

  1. Reads each plugin's pyproject.toml from the workspace.
  2. Inlines the plugin's runtime dependencies into the corresponding [project.optional-dependencies] extra.
  3. Delegates to setuptools to build the wheel.

This means the published wheel has telegram = ["python-telegram-bot>=22.0", ...] instead of telegram = ["hermes-agent-telegram"] — because the individual plugin packages aren't on PyPI.

Source installs and NixOS use workspace resolution directly and never hit the build-backend rewrite path.

NixOS

services.hermes-agent = {
  enable = true;
  # All plugins are included by default via "all" extra.
  # Select specific plugins with:
  extraDependencyGroups = [ "telegram" "anthropic" ];
};

loadWorkspace discovers all workspace members from uv.lock. No structural changes to the Nix files are needed — the existing mkVirtualEnv + extraDependencyGroups mechanism already handles it.

Tests

Plugin test files live in the plugin's own tests/ directory:

plugins/platforms/telegram/
├── tests/
│   ├── conftest.py
│   ├── test_telegram_format.py
│   └── ...
└── hermes_agent_telegram/
    └── ...

The test runner (scripts/run_tests_parallel.py) discovers tests under both tests/ (core) and plugins/ (plugins). The root conftest.py provides shared fixtures for both.

Running a plugin's tests requires the plugin to be installed:

uv sync --extra telegram
scripts/run_tests.sh plugins/platforms/telegram/tests/

If the plugin isn't installed, its tests fail with ModuleNotFoundError — which is correct. You can't run telegram tests without the telegram package.

Migration checklist (for adding a new plugin)

When a new optional capability is added to Hermes:

  1. Create a plugin package under plugins/<category>/<name>/ with:

    • pyproject.toml (name, version, deps, entry point declaration)
    • plugin.yaml (for directory-scanner discovery in dev)
    • hermes_agent_<name>/__init__.py with register(ctx)
    • hermes_agent_<name>/tests/ for plugin-specific tests
  2. Add to workspace — add the directory to [tool.uv.workspace].members and [tool.uv.sources] in the root pyproject.toml.

  3. Add an extra — add name = ["hermes-agent-<name>"] to [project.optional-dependencies] and include it in all.

  4. Register capabilities — in register(ctx), call the appropriate ctx.register_*() methods to populate the typed registries.

  5. No core edits — the core code should not need to change. If it does, that's a sign the registry surface is incomplete and needs a new register_*() method on PluginContext.

  6. Run uv lock — resolve the new workspace member.

  7. Add NixOS support — if the plugin has native deps, add an override in nix/python.nix. Otherwise loadWorkspace handles it automatically.

The rule

If it can be a plugin, it must be a plugin.

Adding optional capabilities to core files is a code review rejection. If the plugin surface doesn't support what you need, extend the surface (new registry type, new hook, new ctx method) — don't inline the capability.