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.
Plugins are discovered from four sources, in order. Later sources override earlier ones on name collision:
| # | Source | Path | Loading |
|---|---|---|---|
| 1 | Bundled | <repo>/plugins/<name>/ | Auto-load for backend and platform kinds |
| 2 | User | ~/.hermes/plugins/<name>/ | Opt-in via plugins.enabled in config.yaml |
| 3 | Project | ./.hermes/plugins/<name>/ | Opt-in via HERMES_ENABLE_PROJECT_PLUGINS env var |
| 4 | Pip entry point | hermes_agent.plugins group | Opt-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.
The kind field in plugin.yaml determines loading behavior and integration style:
| Kind | What it is | Loading rule | Example |
|---|---|---|---|
standalone | Hooks and tools of its own | Opt-in via plugins.enabled | disk-cleanup, kanban, observability |
backend | Pluggable backend for existing core tool | Bundled auto-loads; user opt-in | image_gen/openai, image_gen/fal |
platform | Gateway messaging adapter | Bundled auto-loads; user opt-in | IRC, Discord, LINE, Teams |
exclusive | Category with one active provider | Selected via <category>.provider config | memory/holographic |
model-provider | LLM provider profile | Own discovery via providers/ | model-providers/openrouter |
Three files. No core code changes.
~/.hermes/plugins/my-platform/ plugin.yaml # manifest __init__.py # re-exports register adapter.py # adapter class + register() entry point
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"
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,
)
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"}
A single ctx.register_platform() call handles approximately 20 integration points that would otherwise require editing 16+ files:
| Integration Point | Mechanism |
|---|---|
| Gateway adapter creation | Registry checked before built-in if/elif chain |
| Platform enum acceptance | Platform._missing_() creates dynamic members for registered plugins |
| Config validation | validate_config() called at startup |
| Connected-platform detection | is_connected() or fallback to validate_config |
| User authorization | allowed_users_env / allow_all_env checked by core auth |
| Env-only auto-enable | env_enablement_fn seeds PlatformConfig.extra from env vars |
| YAML config bridge | apply_yaml_config_fn translates config.yaml keys |
| Cron delivery routing | cron_deliver_env_var makes deliver=<name> work |
| Out-of-process sending | standalone_sender_fn for cron when gateway is separate |
| Setup wizard UI | setup_fn + requires_env / optional_env in manifest |
| send_message tool | Routes through live adapter automatically |
| System prompt hints | platform_hint injected into LLM context |
| Message chunking | max_message_length for smart splitting |
| PII redaction | pii_safe flag |
| Status display | Shows plugin platforms with (plugin) tag |
| Channel directory | Plugin platforms included in enumeration |
/update command | allow_update_command flag |
| Token lock (multi-profile) | acquire_scoped_lock() in connect() |
| Orphaned config warning | Descriptive log when config references a missing plugin |
Every field on the PlatformEntry dataclass, grouped by purpose:
| Field | Type | What |
|---|---|---|
name | str | Identifier in config.yaml (e.g. "irc") |
label | str | Human-readable label (e.g. "IRC") |
adapter_factory | (PlatformConfig) → Adapter | Factory callable — using a factory instead of a bare class enables custom init, try/except wrapping, extra kwargs |
check_fn | () → bool | Returns True when platform dependencies are available |
| Field | Type | When called |
|---|---|---|
validate_config | (PlatformConfig) → bool | At adapter creation — reject before connect() |
is_connected | (PlatformConfig) → bool | By 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 | () → None | Interactive setup wizard — prompts user, saves env vars |
| Field | Type | Purpose |
|---|---|---|
cron_deliver_env_var | str | Env var name for home channel — makes deliver=<name> cron jobs work |
standalone_sender_fn | async (...) → dict | Out-of-process message delivery for cron jobs running separately from gateway |
| Field | Default | Purpose |
|---|---|---|
allowed_users_env | "" | Env var for comma-separated allowed user IDs |
allow_all_env | "" | Env var to allow all users (dev mode) |
max_message_length | 0 | Message limit for smart chunking (0 = no limit) |
platform_hint | "" | Injected into system prompt (e.g. “no markdown on IRC”) |
pii_safe | False | Redact PII in session descriptions |
emoji | "🔌" | CLI/gateway display |
allow_update_command | True | Whether /update is allowed from this platform |
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:
| Method | Purpose |
|---|---|
connect() → bool | Establish connection (WebSocket, long-poll, HTTP server). Return True on success. |
disconnect() | Clean shutdown. |
send(chat_id, content, reply_to?, metadata?) → SendResult | Send a message. May receive markdown; adapter formats for its platform. |
get_chat_info(chat_id) → dict | Return {name, type} for a chat. Type is "dm", "group", or "channel". |
Key override points (optional, with sensible defaults):
_keep_typing(chat_id) — typing indicator heartbeat. Override to layer platform-specific mid-flight UX (e.g. LINE sends a “still thinking” button at 45s before the reply token expires).format_message(content) — platform-specific formatting (Telegram MarkdownV2, Discord markdown, IRC plain text).edit_message(chat_id, msg_id, content) — update a sent message (streaming edits). Default: no-op.create_handoff_thread(parent_id, name) — create an isolated thread for session handoffs. Default: None.REQUIRES_EDIT_FINALIZE — class attribute. Set True for platforms that need an explicit finalize call to close a message lifecycle (e.g. DingTalk AI Cards).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.
PluginContext is the facade handed to every plugin’s register(ctx). Platform registration is one of 14 registration methods:
| Method | What 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:
ctx.llm — host-owned LLM facade for chat/structured completions using the user’s model config. No separate API key needed.ctx.inject_message(content, role) — push a message into the active conversation from external sources.ctx.dispatch_tool(name, args) — call any registered tool with proper agent context wiring.Plugins register callbacks for named lifecycle points. The framework calls invoke_hook(name, **kwargs) and collects return values.
| Hook | When it fires | Can modify? |
|---|---|---|
pre_tool_call | Before a tool executes | Yes — can block |
post_tool_call | After a tool returns | Observer |
transform_terminal_output | Terminal output post-processing | Yes — return replacement |
transform_tool_result | Tool result post-processing | Yes — return replacement |
transform_llm_output | LLM response before delivery | Yes — first non-None wins |
pre_llm_call / post_llm_call | Around LLM API calls | Observer |
pre_api_request / post_api_request | Around HTTP API requests | Observer |
api_request_error | HTTP API request failure | Observer |
on_session_start / end / finalize / reset | Session lifecycle events | Observer |
subagent_start / stop | Sub-agent lifecycle | Observer |
pre_gateway_dispatch | Incoming message before auth/dispatch | Yes — skip, rewrite, or allow |
pre_approval_request / post_approval_response | Tool approval lifecycle | Observer 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.
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
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.
The simplest bundled plugin. No external packages. Uses asyncio for the IRC protocol. Key features:
register() with all hooks: env_enablement_fn, cron_deliver_env_var, standalone_sender_fn, setup_fn_env_enablement() reads IRC_SERVER, IRC_CHANNEL, IRC_PORT, etc. and seeds PlatformConfig.extrastandalone_sender_fn opens an ephemeral IRC connection, sends the message, and disconnects — for cron delivery when gateway is a separate processsetup_fn prompts for server, channel, nick, runs a connectivity testLINE’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():
RequestCache state machine to serve the cached response via a fresh reply token (free)interrupt_session_activity override to handle /stop command orphansUses 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.
Uses microsoft-teams-apps SDK. Adaptive Cards for rich UI, proactive messaging via App.send(), tenant-scoped auth.
Adding a platform the built-in way requires touching these files:
| # | File / Area | What changes |
|---|---|---|
| 1 | gateway/platforms/<name>.py | New adapter file |
| 2 | gateway/config.py | Platform enum member + config parsing |
| 3 | gateway/run.py | if/elif in _create_adapter() |
| 4 | gateway/run.py | Authorized user map integration |
| 5 | toolsets.py | Platform in toolset matrix |
| 6 | tools/send_message_tool.py | platform_map + routing function |
| 7 | tools/cronjob_tools.py | deliver parameter description |
| 8 | cron/scheduler.py | Platform in deliver target map |
| 9 | gateway/channel_directory.py | Session-based discovery list |
| 10 | hermes_cli/status.py | Status display entry |
| 11 | hermes_cli/gateway.py | Setup wizard platform list |
| 12 | hermes_cli/config.py | Env var definitions |
| 13 | agent/redact.py | PII redaction patterns |
| 14–16 | README, 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.
_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.
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.
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.
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.
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.
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.
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.
| Aspect | Hermes | SmolPaws | OpenHands agent-sdk |
|---|---|---|---|
| Plugin system | Full framework: manifest, discovery, PluginContext, registry, hooks | None — each ingress is custom code | None — monolithic |
| Platform adapter base | BasePlatformAdapter (4 abstract methods, ~4700 lines of shared behavior) | No shared base — each ingress reimplements connection, auth, delivery | No concept of platforms/channels |
| Adding a platform | Plugin: 3 files, 0 core changes | New app directory + custom code + manual wiring | N/A |
| Config extensibility | env_enablement_fn, apply_yaml_config_fn | Hard-coded per ingress | Hard-coded |
| Lifecycle hooks | 15+ named hooks with whitelist validation | None | None |
| Tool registration | Plugin tools in shared registry, per-platform toolset toggle | System prompt + built-in tools only | Fixed tool set |
| Provider backends | Pluggable: TTS, STT, image gen, video gen, browser, web search, context engine | Fixed (macOS say, local whisper) | Fixed |
| Host LLM access | ctx.llm — plugins use host’s model config | N/A | N/A |
If we want OpenHands agent-sdk (or SmolPaws’ agent-sdk fork) to support external platforms without core changes, here’s what Hermes teaches us:
connect(), disconnect(), send(), onMessage(). The base class handles message routing to the agent server — adapters only implement platform I/O.register() and factory-based instantiation. Check the registry before any hard-coded channel list.~/.openhands/plugins/) for packages that export a register(ctx) function. npm/pip entry points can come later.plugin.yaml or package.json fields) for metadata, required env vars, and kind.env_enablement_fn is the single most impactful hook in Hermes’ plugin system._missing_(), but a registry-first lookup pattern achieves the same result.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.