← Home

Hermes Plugin API architecture study

How Hermes Agent enables adding platforms, tools, and providers without touching core code — and what this means for OpenHands agent-sdk extensibility.
Companion to Hermes Gateway Architecture. Studied from source, June 2026.

The Problem

An agent framework that supports 28 messaging platforms faces a constant tension: every new platform means changes to enums, routing tables, config parsers, tool schemas, CLI wizards, cron schedulers, and docs. Hermes’ built-in path touches 16+ files across the codebase for each new platform.

The plugin system eliminates this. A plugin drops into ~/.hermes/plugins/, implements register(ctx), and the framework handles the rest. Zero core code changes. The same mechanism extends to tools, LLM hooks, TTS/STT providers, image/video generators, browser backends, and context engines.

Plugin Discovery

Plugins are discovered from four sources, in order. Later sources override earlier ones on name collision:

#SourcePathLoading
1Bundled<repo>/plugins/<name>/Auto-load for backend and platform kinds
2User~/.hermes/plugins/<name>/Opt-in via plugins.enabled in config.yaml
3Project./.hermes/plugins/<name>/Opt-in via HERMES_ENABLE_PROJECT_PLUGINS env var
4Pip entry pointhermes_agent.plugins groupOpt-in via plugins.enabled

Each directory plugin needs a plugin.yaml manifest and an __init__.py with a register(ctx) function. The manifest declares metadata, kind, and environment variable requirements.

Design choice: Bundled platform plugins auto-load (every shipped adapter works out of the box). User-installed plugins require explicit opt-in — untrusted code doesn’t run unless enabled. Project plugins require an additional env var gate.

Plugin Kinds

The kind field in plugin.yaml determines loading behavior and integration style:

KindWhat it isLoading ruleExample
standaloneHooks and tools of its ownOpt-in via plugins.enableddisk-cleanup, kanban, observability
backendPluggable backend for existing core toolBundled auto-loads; user opt-inimage_gen/openai, image_gen/fal
platformGateway messaging adapterBundled auto-loads; user opt-inIRC, Discord, LINE, Teams
exclusiveCategory with one active providerSelected via <category>.provider configmemory/holographic
model-providerLLM provider profileOwn discovery via providers/model-providers/openrouter

Minimal Platform Plugin

Three files. No core code changes.

Directory layout

~/.hermes/plugins/my-platform/
  plugin.yaml       # manifest
  __init__.py        # re-exports register
  adapter.py         # adapter class + register() entry point

plugin.yaml

name: my-platform
label: My Platform
kind: platform
version: 1.0.0
description: My custom messaging platform adapter
author: Your Name
requires_env:
  - name: MY_PLATFORM_TOKEN
    description: "Bot API token"
    prompt: "Bot token"
    password: true
  - name: MY_PLATFORM_CHANNEL
    description: "Channel to join"
    prompt: "Channel"
optional_env:
  - name: MY_PLATFORM_HOME_CHANNEL
    description: "Default channel for cron delivery"

adapter.py — register()

def register(ctx):
    ctx.register_platform(
        name="my_platform",
        label="My Platform",
        adapter_factory=lambda cfg: MyPlatformAdapter(cfg),
        check_fn=check_requirements,
        validate_config=validate_config,
        env_enablement_fn=_env_enablement,
        cron_deliver_env_var="MY_PLATFORM_HOME_CHANNEL",
        standalone_sender_fn=_standalone_send,
        allowed_users_env="MY_PLATFORM_ALLOWED_USERS",
        allow_all_env="MY_PLATFORM_ALLOW_ALL_USERS",
        max_message_length=4000,
        platform_hint="You are chatting via My Platform.",
        emoji="💬",
        setup_fn=interactive_setup,
    )

adapter.py — the adapter class

class MyPlatformAdapter(BasePlatformAdapter):
    def __init__(self, config):
        super().__init__(config, Platform("my_platform"))
        # Read from config.extra or env vars

    async def connect(self) -> bool:
        # Start listeners, return True on success
        self._mark_connected()
        return True

    async def disconnect(self) -> None:
        self._mark_disconnected()

    async def send(self, chat_id, content,
                   reply_to=None, metadata=None) -> SendResult:
        # Send message via platform API
        return SendResult(success=True, message_id="...")

    async def get_chat_info(self, chat_id) -> dict:
        return {"name": chat_id, "type": "dm"}

What register_platform() Wires Up

A single ctx.register_platform() call handles approximately 20 integration points that would otherwise require editing 16+ files:

Integration PointMechanism
Gateway adapter creationRegistry checked before built-in if/elif chain
Platform enum acceptancePlatform._missing_() creates dynamic members for registered plugins
Config validationvalidate_config() called at startup
Connected-platform detectionis_connected() or fallback to validate_config
User authorizationallowed_users_env / allow_all_env checked by core auth
Env-only auto-enableenv_enablement_fn seeds PlatformConfig.extra from env vars
YAML config bridgeapply_yaml_config_fn translates config.yaml keys
Cron delivery routingcron_deliver_env_var makes deliver=<name> work
Out-of-process sendingstandalone_sender_fn for cron when gateway is separate
Setup wizard UIsetup_fn + requires_env / optional_env in manifest
send_message toolRoutes through live adapter automatically
System prompt hintsplatform_hint injected into LLM context
Message chunkingmax_message_length for smart splitting
PII redactionpii_safe flag
Status displayShows plugin platforms with (plugin) tag
Channel directoryPlugin platforms included in enumeration
/update commandallow_update_command flag
Token lock (multi-profile)acquire_scoped_lock() in connect()
Orphaned config warningDescriptive log when config references a missing plugin

PlatformEntry: Full Surface

Every field on the PlatformEntry dataclass, grouped by purpose:

Core (required)

FieldTypeWhat
namestrIdentifier in config.yaml (e.g. "irc")
labelstrHuman-readable label (e.g. "IRC")
adapter_factory(PlatformConfig) → AdapterFactory callable — using a factory instead of a bare class enables custom init, try/except wrapping, extra kwargs
check_fn() → boolReturns True when platform dependencies are available

Configuration hooks

FieldTypeWhen called
validate_config(PlatformConfig) → boolAt adapter creation — reject before connect()
is_connected(PlatformConfig) → boolBy gateway status and setup UI — fallback: validate_config
env_enablement_fn() → dict?During config load, before adapter construction — seeds PlatformConfig.extra from env vars
apply_yaml_config_fn(yaml, pcfg) → dict?During config load, after generic shared-key loop — bridges platform-specific YAML keys to env/extras
setup_fn() → NoneInteractive setup wizard — prompts user, saves env vars

Delivery & scheduling

FieldTypePurpose
cron_deliver_env_varstrEnv var name for home channel — makes deliver=<name> cron jobs work
standalone_sender_fnasync (...) → dictOut-of-process message delivery for cron jobs running separately from gateway

Auth, UX, display

FieldDefaultPurpose
allowed_users_env""Env var for comma-separated allowed user IDs
allow_all_env""Env var to allow all users (dev mode)
max_message_length0Message limit for smart chunking (0 = no limit)
platform_hint""Injected into system prompt (e.g. “no markdown on IRC”)
pii_safeFalseRedact PII in session descriptions
emoji"🔌"CLI/gateway display
allow_update_commandTrueWhether /update is allowed from this platform

BasePlatformAdapter Contract

classDiagram
    class BasePlatformAdapter {
        <<abstract>>
        +connect() bool
        +disconnect() void
        +send(chat_id, content, reply_to?, metadata?) SendResult
        +get_chat_info(chat_id) dict
        +handle_message(event) void
        +format_message(content) str
        +truncate_message(content, max_length) list
        +send_typing(chat_id) void
        +stop_typing(chat_id) void
        +edit_message(chat_id, msg_id, content) SendResult
        +create_handoff_thread(parent_id, name) str?
        #_keep_typing(chat_id) void
        #_process_message_background(event, key) void
        #_mark_connected() void
        #_mark_disconnected() void
    }
    class IRCAdapter {
        +connect() bool
        +disconnect() void
        +send(...) SendResult
        +get_chat_info(...) dict
    }
    class LINEAdapter {
        +connect() bool
        +send(...) SendResult
        #_keep_typing(...) void
        +interrupt_session_activity(...) void
    }
    BasePlatformAdapter <|-- IRCAdapter
    BasePlatformAdapter <|-- LINEAdapter
    

Four abstract methods that every adapter must implement:

MethodPurpose
connect() → boolEstablish connection (WebSocket, long-poll, HTTP server). Return True on success.
disconnect()Clean shutdown.
send(chat_id, content, reply_to?, metadata?) → SendResultSend a message. May receive markdown; adapter formats for its platform.
get_chat_info(chat_id) → dictReturn {name, type} for a chat. Type is "dm", "group", or "channel".

Key override points (optional, with sensible defaults):

Inbound message flow: the adapter receives a platform event, constructs a MessageEvent, and calls self.handle_message(event). The base class routes it to the gateway runner — the adapter never touches the agent directly.

Beyond Platforms: The Full PluginContext

PluginContext is the facade handed to every plugin’s register(ctx). Platform registration is one of 14 registration methods:

MethodWhat it registers
register_platform()Gateway messaging adapter (see above)
register_tool()Agent tool — appears in the tool registry alongside built-ins
register_hook()Lifecycle hook callback (15+ hook points)
register_command()In-session slash command (/lcm, /stats)
register_cli_command()CLI subcommand (hermes honcho ...)
register_context_engine()Replace built-in ContextCompressor
register_image_gen_provider()Image generation backend
register_video_gen_provider()Video generation backend
register_tts_provider()Text-to-speech backend
register_transcription_provider()Speech-to-text backend
register_browser_provider()Browser automation backend
register_web_search_provider()Web search backend
register_auxiliary_task()Plugin-owned LLM auxiliary task (routing config)
register_skill()Plugin-provided skill file

Plus utility methods on ctx:

Lifecycle Hooks

Plugins register callbacks for named lifecycle points. The framework calls invoke_hook(name, **kwargs) and collects return values.

HookWhen it firesCan modify?
pre_tool_callBefore a tool executesYes — can block
post_tool_callAfter a tool returnsObserver
transform_terminal_outputTerminal output post-processingYes — return replacement
transform_tool_resultTool result post-processingYes — return replacement
transform_llm_outputLLM response before deliveryYes — first non-None wins
pre_llm_call / post_llm_callAround LLM API callsObserver
pre_api_request / post_api_requestAround HTTP API requestsObserver
api_request_errorHTTP API request failureObserver
on_session_start / end / finalize / resetSession lifecycle eventsObserver
subagent_start / stopSub-agent lifecycleObserver
pre_gateway_dispatchIncoming message before auth/dispatchYes — skip, rewrite, or allow
pre_approval_request / post_approval_responseTool approval lifecycleObserver only

Hook semantics: hooks are a fixed whitelist (VALID_HOOKS). Registering an unknown hook name raises an error. This prevents typo-based silent failures while keeping the surface finite and documented.

How It All Flows

sequenceDiagram
    participant S as Startup
    participant PM as PluginManager
    participant Ctx as PluginContext
    participant PR as PlatformRegistry
    participant GW as GatewayRunner

    S->>PM: discover_and_load()
    PM->>PM: scan bundled/ user/ project/ pip
    PM->>PM: parse plugin.yaml manifests
    loop each enabled plugin
        PM->>PM: import __init__.py
        PM->>Ctx: create PluginContext(manifest)
        PM->>PM: call register(ctx)
        Ctx->>PR: register_platform(PlatformEntry)
        Ctx->>PM: register_tool / register_hook / ...
    end
    Note over PM: Discovery complete

    S->>GW: start gateway
    GW->>GW: load_gateway_config()
    GW->>PR: env_enablement_fn → seed PlatformConfig
    GW->>PR: apply_yaml_config_fn → bridge YAML
    loop each configured platform
        GW->>PR: create_adapter(name, config)
        PR->>PR: check_fn() → validate_config()
        PR->>GW: return adapter instance
        GW->>GW: adapter.connect()
    end
    

Platform Enum: Dynamic Members

A clever trick: the Platform enum uses _missing_() to accept unknown platform names for registered plugins:

class Platform(Enum):
    TELEGRAM = "telegram"
    DISCORD = "discord"
    # ... 20 built-in members

    @classmethod
    def _missing_(cls, value):
        # Check bundled plugin dirs
        if value in cls._scan_bundled_plugin_platforms():
            pseudo = object.__new__(cls)
            pseudo._value_ = value
            pseudo._name_ = value.upper()
            cls._value2member_map_[value] = pseudo
            return pseudo

        # Check runtime registry
        if platform_registry.is_registered(value):
            # same pattern
            ...

        return None  # reject unknown strings

This means Platform("irc") works without modifying the enum file, and Platform("irc") is Platform("irc") holds (identity-stable via _value2member_map_ caching). Arbitrary strings are rejected — only known plugin platforms create pseudo-members.

Real Plugin Examples

IRC — zero-dep stdlib adapter

The simplest bundled plugin. No external packages. Uses asyncio for the IRC protocol. Key features:

LINE — time-window constraint pattern

LINE’s reply token expires after ~60 seconds. If the LLM is slow, the free reply token is wasted and the fallback is a metered Push API call. The adapter solves this by overriding _keep_typing():

Discord — full-featured external dependency

Uses discord.py. Thread management (auto-archive, handoff threads), slash command registration with Discord’s native picker, voice channel support, file attachments with type detection, rate limiting.

Teams — enterprise webhook adapter

Uses microsoft-teams-apps SDK. Adaptive Cards for rich UI, proactive messaging via App.send(), tenant-scoped auth.

Built-in vs Plugin Path: The Cost

Adding a platform the built-in way requires touching these files:

#File / AreaWhat changes
1gateway/platforms/<name>.pyNew adapter file
2gateway/config.pyPlatform enum member + config parsing
3gateway/run.pyif/elif in _create_adapter()
4gateway/run.pyAuthorized user map integration
5toolsets.pyPlatform in toolset matrix
6tools/send_message_tool.pyplatform_map + routing function
7tools/cronjob_tools.pydeliver parameter description
8cron/scheduler.pyPlatform in deliver target map
9gateway/channel_directory.pySession-based discovery list
10hermes_cli/status.pyStatus display entry
11hermes_cli/gateway.pySetup wizard platform list
12hermes_cli/config.pyEnv var definitions
13agent/redact.pyPII redaction patterns
14–16README, AGENTS.md, docs/Documentation (3+ files)

The plugin path replaces all of this with one register() call. The framework consults the registry at each integration point and discovers plugin platforms dynamically.

Design Patterns Worth Stealing

1. Registry-first dispatch

_create_adapter() checks the plugin registry first, then falls through to the built-in if/elif chain. This means plugins can override built-in platforms (last writer wins) and the core code doesn’t grow per platform.

2. Factory over class

adapter_factory is a callable, not a class reference. This lets plugins do lambda cfg: IRCAdapter(cfg) with custom error handling, conditional kwargs, or environment-dependent construction — without expanding the registry API.

3. Declarative config hooks

env_enablement_fn and apply_yaml_config_fn let a plugin own its configuration schema without touching the core config parser. The gateway calls these hooks during load_gateway_config() and merges the results into PlatformConfig.extra. The plugin controls what env vars mean and how YAML keys map to runtime config.

4. Fixed hook whitelist

Unlike open event systems where any string is a valid event name, Hermes uses a fixed VALID_HOOKS set. Registering an unknown hook name raises an error. This catches typos and makes the hook surface discoverable and documentable.

5. Standalone sender for out-of-process delivery

When cron runs separately from the gateway, there’s no live adapter to send through. standalone_sender_fn is a self-contained async function that opens an ephemeral connection, sends, and disconnects. This pattern decouples the send capability from the persistent adapter lifecycle.

6. Dynamic enum extension

Platform._missing_() creates pseudo-members for registered plugins. Identity-stable via caching. Rejects arbitrary strings. This means config, routing, and switch/match code work with plugin platforms without enum modification.

7. Provider registries with precedence

TTS, STT, image gen, browser, web search — each has a dedicated registry. Built-in providers always win over plugins. Config-level command-providers win over plugin providers. This gives clear, predictable override semantics.

Comparison: Hermes vs SmolPaws vs Agent SDK

AspectHermesSmolPawsOpenHands agent-sdk
Plugin systemFull framework: manifest, discovery, PluginContext, registry, hooksNone — each ingress is custom codeNone — monolithic
Platform adapter baseBasePlatformAdapter (4 abstract methods, ~4700 lines of shared behavior)No shared base — each ingress reimplements connection, auth, deliveryNo concept of platforms/channels
Adding a platformPlugin: 3 files, 0 core changesNew app directory + custom code + manual wiringN/A
Config extensibilityenv_enablement_fn, apply_yaml_config_fnHard-coded per ingressHard-coded
Lifecycle hooks15+ named hooks with whitelist validationNoneNone
Tool registrationPlugin tools in shared registry, per-platform toolset toggleSystem prompt + built-in tools onlyFixed tool set
Provider backendsPluggable: TTS, STT, image gen, video gen, browser, web search, context engineFixed (macOS say, local whisper)Fixed
Host LLM accessctx.llm — plugins use host’s model configN/AN/A

What Agent SDK Would Need

If we want OpenHands agent-sdk (or SmolPaws’ agent-sdk fork) to support external platforms without core changes, here’s what Hermes teaches us:

Minimum viable plugin system

  1. Channel adapter base class with connect(), disconnect(), send(), onMessage(). The base class handles message routing to the agent server — adapters only implement platform I/O.
  2. Channel registry with register() and factory-based instantiation. Check the registry before any hard-coded channel list.
  3. Plugin discovery — at minimum, scan a directory (~/.openhands/plugins/) for packages that export a register(ctx) function. npm/pip entry points can come later.
  4. Manifest file (plugin.yaml or package.json fields) for metadata, required env vars, and kind.

Stretch goals (worth doing)

What to skip

Key Insight

The gap between “built-in platform” and “plugin platform” in Hermes is 16 files vs 3 files. The entire plugin system exists to collapse that gap. The core abstraction is simple: a registry that the framework checks before its hard-coded paths, plus a context object that lets plugins register capabilities at startup. Everything else — hooks, config bridges, provider registries — is refinement on top of that pattern.

For agent-sdk, the equivalent gap is between “I can add a channel by modifying the SDK source” and “I can add a channel by dropping a package into a directory.” Closing that gap requires the same two things: a registry and a context.

Hermes Gateway →  ·  SmolPaws Slack →  ·  SmolPaws Discord →  ·  ← Home