Adding a Platform Adapter
Adding a Platform Adapter
Section titled “Adding a Platform Adapter”This guide covers adding a new messaging platform to the Hermes gateway. A platform adapter connects Hermes to an external messaging service (Telegram, Discord, WeCom, etc.) so users can interact with the agent through that service.
Architecture Overview
Section titled “Architecture Overview”User ↔ Messaging Platform ↔ Platform Adapter ↔ Gateway Runner ↔ AIAgentEvery adapter extends BasePlatformAdapter from gateway/platforms/base.py and implements:
connect()— Establish connection (WebSocket, long-poll, HTTP server, etc.) (abstract)disconnect()— Clean shutdown (abstract)send()— Send a text message to a chat (abstract)send_typing()— Show typing indicator (optional override)get_chat_info()— Return chat metadata (optional override)
Inbound messages are received by the adapter and forwarded via self.handle_message(event), which the base class routes to the gateway runner.
Plugin Path (Recommended)
Section titled “Plugin Path (Recommended)”The plugin system lets you add a platform adapter without modifying any core Hermes code. Your plugin is a directory with two files:
~/.hermes/plugins/my-platform/ PLUGIN.yaml # Plugin metadata adapter.py # Adapter class + register() entry pointPLUGIN.yaml
Section titled “PLUGIN.yaml”Plugin metadata. The requires_env and optional_env blocks auto-populate hermes config UI entries (see Surfacing Env Vars below).
name: my-platformlabel: My Platformkind: platformversion: 1.0.0description: My custom messaging platform adapterauthor: Your Namerequires_env: - MY_PLATFORM_TOKEN # bare string works - name: MY_PLATFORM_CHANNEL # or rich dict for better UX description: "Channel to join" prompt: "Channel" password: falseoptional_env: - name: MY_PLATFORM_HOME_CHANNEL description: "Default channel for cron delivery" password: falseadapter.py
Section titled “adapter.py”import osfrom gateway.platforms.base import ( BasePlatformAdapter, SendResult, MessageEvent, MessageType,)from gateway.config import Platform, PlatformConfig
class MyPlatformAdapter(BasePlatformAdapter): def __init__(self, config: PlatformConfig): super().__init__(config, Platform("my_platform")) extra = config.extra or {} self.token = os.getenv("MY_PLATFORM_TOKEN") or extra.get("token", "")
async def connect(self) -> bool: # Connect to the platform API, start listeners 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): # Send message via platform API return SendResult(success=True, message_id="...")
async def get_chat_info(self, chat_id): return {"name": chat_id, "type": "dm"}
def check_requirements() -> bool: return bool(os.getenv("MY_PLATFORM_TOKEN"))
def validate_config(config) -> bool: extra = getattr(config, "extra", {}) or {} return bool(os.getenv("MY_PLATFORM_TOKEN") or extra.get("token"))
def _env_enablement() -> dict | None: token = os.getenv("MY_PLATFORM_TOKEN", "").strip() channel = os.getenv("MY_PLATFORM_CHANNEL", "").strip() if not (token and channel): return None seed = {"token": token, "channel": channel} home = os.getenv("MY_PLATFORM_HOME_CHANNEL") if home: seed["home_channel"] = {"chat_id": home, "name": "Home"} return seed
def register(ctx): """Plugin entry point — called by the Hermes plugin system.""" ctx.register_platform( name="my_platform", label="My Platform", adapter_factory=lambda cfg: MyPlatformAdapter(cfg), check_fn=check_requirements, validate_config=validate_config, required_env=["MY_PLATFORM_TOKEN"], install_hint="pip install my-platform-sdk", # Env-driven auto-configuration — seeds PlatformConfig.extra from # env vars before adapter construction. See "Env-Driven Auto- # Configuration" section below. env_enablement_fn=_env_enablement, # Cron home-channel delivery support. Lets deliver=my_platform cron # jobs route without editing cron/scheduler.py. See "Cron Delivery" # section below. cron_deliver_env_var="MY_PLATFORM_HOME_CHANNEL", # Per-platform user authorization env vars allowed_users_env="MY_PLATFORM_ALLOWED_USERS", allow_all_env="MY_PLATFORM_ALLOW_ALL_USERS", # Message length limit for smart chunking (0 = no limit) max_message_length=4000, # LLM guidance injected into system prompt platform_hint=( "You are chatting via My Platform. " "It supports markdown formatting." ), # Display emoji="💬", )
# Optional: register platform-specific tools ctx.register_tool( name="my_platform_search", toolset="my_platform", schema={...}, handler=my_search_handler, )Configuration
Section titled “Configuration”Users configure the platform in config.yaml:
gateway: platforms: my_platform: enabled: true extra: token: "..." channel: "#general"Or via environment variables (which the adapter reads in __init__).
What the Plugin System Handles Automatically
Section titled “What the Plugin System Handles Automatically”When you call ctx.register_platform(), the following integration points are handled for you — no core code changes needed:
| Integration point | How it works |
|---|---|
| Gateway adapter creation | Registry checked before built-in if/elif chain |
| Config parsing | Platform._missing_() accepts any platform name |
| Connected platform validation | Registry validate_config() called |
| User authorization | allowed_users_env / allow_all_env checked |
| Env-only auto-enable | env_enablement_fn seeds PlatformConfig.extra + home_channel |
| Cron delivery | cron_deliver_env_var makes deliver=<name> work |
hermes config UI entries | requires_env / optional_env in plugin.yaml auto-populate |
| send_message tool | Routes through live gateway adapter |
| Webhook cross-platform delivery | Registry checked for known platforms |
/update command access | allow_update_command flag |
| Channel directory | Plugin platforms included in enumeration |
| System prompt hints | platform_hint injected into LLM context |
| Message chunking | max_message_length for smart splitting |
| PII redaction | pii_safe flag |
hermes status | Shows plugin platforms with (plugin) tag |
hermes gateway setup | Plugin platforms appear in setup menu |
hermes tools / hermes skills | Plugin platforms in per-platform config |
| Token lock (multi-profile) | Use acquire_scoped_lock() in your connect() |
| Orphaned config warning | Descriptive log when plugin is missing |
Env-Driven Auto-Configuration
Section titled “Env-Driven Auto-Configuration”Most users set up a platform by dropping env vars into ~/.hermes/.env rather than editing config.yaml. The env_enablement_fn hook lets your plugin pick those env vars up before the adapter is constructed, so hermes gateway status, get_connected_platforms(), and cron delivery see the correct state without instantiating the platform SDK.
def _env_enablement() -> dict | None: """Seed PlatformConfig.extra from env vars.
Called by the platform registry during load_gateway_config(). Return None when the platform isn't minimally configured — the caller then skips auto-enabling. Return a dict to seed extras.
The special 'home_channel' key is extracted and becomes a proper HomeChannel dataclass on the PlatformConfig; every other key is merged into PlatformConfig.extra. """ token = os.getenv("MY_PLATFORM_TOKEN", "").strip() channel = os.getenv("MY_PLATFORM_CHANNEL", "").strip() if not (token and channel): return None seed = {"token": token, "channel": channel} home = os.getenv("MY_PLATFORM_HOME_CHANNEL") if home: seed["home_channel"] = { "chat_id": home, "name": os.getenv("MY_PLATFORM_HOME_CHANNEL_NAME", "Home"), } return seed
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, # ... other fields )Cron Delivery
Section titled “Cron Delivery”To let deliver=my_platform cron jobs route to a configured home channel, set cron_deliver_env_var to the env var name that holds the default chat/room/channel ID:
ctx.register_platform( name="my_platform", ... cron_deliver_env_var="MY_PLATFORM_HOME_CHANNEL",)The scheduler reads this env var when resolving the home target for deliver=my_platform jobs, and also treats the platform as a valid cron target in _KNOWN_DELIVERY_PLATFORMS-style checks. If your env_enablement_fn seeds a home_channel dict (see above), that takes precedence — cron_deliver_env_var is the fallback for cron jobs that run before env seeding.
Surfacing Env Vars in hermes config
Section titled “Surfacing Env Vars in hermes config”hermes_cli/config.py scans plugins/platforms/*/plugin.yaml at import time and auto-populates OPTIONAL_ENV_VARS from requires_env and (optional) optional_env blocks. Use the rich-dict form to contribute proper descriptions, prompts, password flags, and URLs — the CLI setup UI picks them up for free.
name: my_platform-platformlabel: My Platformkind: platformversion: 1.0.0description: > My Platform gateway adapter for Hermes Agent.author: Your Namerequires_env: - name: MY_PLATFORM_TOKEN description: "Bot API token from the My Platform console" prompt: "My Platform bot token" url: "https://my-platform.example.com/bots" password: true - name: MY_PLATFORM_CHANNEL description: "Channel to join (e.g. #hermes)" prompt: "Channel" password: falseoptional_env: - name: MY_PLATFORM_HOME_CHANNEL description: "Default channel for cron delivery (defaults to MY_PLATFORM_CHANNEL)" prompt: "Home channel (or empty)" password: false - name: MY_PLATFORM_ALLOWED_USERS description: "Comma-separated user IDs allowed to talk to the bot" prompt: "Allowed users (comma-separated)" password: falseSupported dict keys: name (required), description, prompt, url, password (bool; auto-detected from *_TOKEN / *_SECRET / *_KEY / *_PASSWORD / *_JSON suffix when omitted), category (defaults to "messaging").
Bare-string entries (- MY_PLATFORM_TOKEN) still work — they get a generic description auto-derived from the plugin’s label. If a hardcoded entry for the same var already exists in OPTIONAL_ENV_VARS, it wins (back-compat); the plugin.yaml form acts as the fallback.
Reference Implementation
Section titled “Reference Implementation”See plugins/platforms/irc/ in the repo for a complete working example — a full async IRC adapter with zero external dependencies.
Step-by-Step Checklist (Built-in Path)
Section titled “Step-by-Step Checklist (Built-in Path)”1. Platform Enum
Section titled “1. Platform Enum”Add your platform to the Platform enum in gateway/config.py:
class Platform(str, Enum): # ... existing platforms ... NEWPLAT = "newplat"2. Adapter File
Section titled “2. Adapter File”Create gateway/platforms/newplat.py:
from gateway.config import Platform, PlatformConfigfrom gateway.platforms.base import ( BasePlatformAdapter, MessageEvent, MessageType, SendResult,)
def check_newplat_requirements() -> bool: """Return True if dependencies are available.""" return SOME_SDK_AVAILABLE
class NewPlatAdapter(BasePlatformAdapter): def __init__(self, config: PlatformConfig): super().__init__(config, Platform.NEWPLAT) # Read config from config.extra dict extra = config.extra or {} self._api_key = extra.get("api_key") or os.getenv("NEWPLAT_API_KEY", "")
async def connect(self) -> bool: # Set up connection, start polling/webhook self._mark_connected() return True
async def disconnect(self) -> None: self._running = False self._mark_disconnected()
async def send(self, chat_id, content, reply_to=None, metadata=None): # Send message via platform API return SendResult(success=True, message_id="...")
async def get_chat_info(self, chat_id): return {"name": chat_id, "type": "dm"}For inbound messages, build a MessageEvent and call self.handle_message(event):
source = self.build_source( chat_id=chat_id, chat_name=name, chat_type="dm", # or "group" user_id=user_id, user_name=user_name,)event = MessageEvent( text=content, message_type=MessageType.TEXT, source=source, message_id=msg_id,)await self.handle_message(event)3. Gateway Config (gateway/config.py)
Section titled “3. Gateway Config (gateway/config.py)”Three touchpoints:
get_connected_platforms()— Add a check for your platform’s required credentialsload_gateway_config()— Add token env map entry:Platform.NEWPLAT: "NEWPLAT_TOKEN"_apply_env_overrides()— Map allNEWPLAT_*env vars to config
4. Gateway Runner (gateway/run.py)
Section titled “4. Gateway Runner (gateway/run.py)”Five touchpoints:
_create_adapter()— Add anelif platform == Platform.NEWPLAT:branch_is_user_authorized()allowed_users map —Platform.NEWPLAT: "NEWPLAT_ALLOWED_USERS"_is_user_authorized()allow_all map —Platform.NEWPLAT: "NEWPLAT_ALLOW_ALL_USERS"- Early env check
_any_allowlisttuple — Add"NEWPLAT_ALLOWED_USERS" - Early env check
_allow_alltuple — Add"NEWPLAT_ALLOW_ALL_USERS" _UPDATE_ALLOWED_PLATFORMSfrozenset — AddPlatform.NEWPLAT
5. Cross-Platform Delivery
Section titled “5. Cross-Platform Delivery”gateway/platforms/webhook.py— Add"newplat"to the delivery type tuplecron/scheduler.py— Add to_KNOWN_DELIVERY_PLATFORMSfrozenset and_deliver_result()platform map
6. CLI Integration
Section titled “6. CLI Integration”hermes_cli/config.py— Add allNEWPLAT_*vars to_EXTRA_ENV_KEYShermes_cli/gateway.py— Add entry to_PLATFORMSlist with key, label, emoji, token_var, setup_instructions, and varshermes_cli/platforms.py— AddPlatformInfoentry with label and default_toolset (used byskills_configandtools_configTUIs)hermes_cli/setup.py— Add_setup_newplat()function (can delegate togateway.py) and add tuple to the messaging platforms listhermes_cli/status.py— Add platform detection entry:"NewPlat": ("NEWPLAT_TOKEN", "NEWPLAT_HOME_CHANNEL")hermes_cli/dump.py— Add"newplat": "NEWPLAT_TOKEN"to platform detection dict
7. Tools
Section titled “7. Tools”tools/send_message_tool.py— Add"newplat": Platform.NEWPLATto platform maptools/cronjob_tools.py— Addnewplatto the delivery target description string
8. Toolsets
Section titled “8. Toolsets”toolsets.py— Add"hermes-newplat"toolset definition with_HERMES_CORE_TOOLStoolsets.py— Add"hermes-newplat"to the"hermes-gateway"includes list
9. Optional: Platform Hints
Section titled “9. Optional: Platform Hints”agent/prompt_builder.py — If your platform has specific rendering limitations (no markdown, message length limits, etc.), add an entry to the _PLATFORM_HINTS dict. This injects platform-specific guidance into the system prompt:
_PLATFORM_HINTS = { # ... "newplat": ( "You are chatting via NewPlat. It supports markdown formatting " "but has a 4000-character message limit." ),}Not all platforms need hints — only add one if the agent’s behavior should differ.
10. Tests
Section titled “10. Tests”Create tests/gateway/test_newplat.py covering:
- Adapter construction from config
- Message event building
- Send method (mock the external API)
- Platform-specific features (encryption, routing, etc.)
11. Documentation
Section titled “11. Documentation”| File | What to add |
|---|---|
website/docs/user-guide/messaging/newplat.md | Full platform setup page |
website/docs/user-guide/messaging/index.md | Platform comparison table, architecture diagram, toolsets table, security section, next-steps link |
website/docs/reference/environment-variables.md | All NEWPLAT_* env vars |
website/docs/reference/toolsets-reference.md | hermes-newplat toolset |
website/docs/integrations/index.md | Platform link |
website/sidebars.ts | Sidebar entry for the docs page |
website/docs/developer-guide/architecture.md | Adapter count + listing |
website/docs/developer-guide/gateway-internals.md | Adapter file listing |
Parity Audit
Section titled “Parity Audit”Before marking a new platform PR as complete, run a parity audit against an established platform:
# Find every .py file mentioning the reference platformsearch_files "bluebubbles" output_mode="files_only" file_glob="*.py"
# Find every .py file mentioning the new platformsearch_files "newplat" output_mode="files_only" file_glob="*.py"
# Any file in the first set but not the second is a potential gapRepeat for .md and .ts files. Investigate each gap — is it a platform enumeration (needs updating) or a platform-specific reference (skip)?
Common Patterns
Section titled “Common Patterns”Long-Poll Adapters
Section titled “Long-Poll Adapters”If your adapter uses long-polling (like Telegram or Weixin), use a polling loop task:
async def connect(self): self._poll_task = asyncio.create_task(self._poll_loop()) self._mark_connected()
async def _poll_loop(self): while self._running: messages = await self._fetch_updates() for msg in messages: await self.handle_message(self._build_event(msg))Callback/Webhook Adapters
Section titled “Callback/Webhook Adapters”If the platform pushes messages to your endpoint (like WeCom Callback), run an HTTP server:
async def connect(self): self._app = web.Application() self._app.router.add_post("/callback", self._handle_callback) # ... start aiohttp server self._mark_connected()
async def _handle_callback(self, request): event = self._build_event(await request.text()) await self._message_queue.put(event) return web.Response(text="success") # Acknowledge immediatelyFor platforms with tight response deadlines (e.g., WeCom’s 5-second limit), always acknowledge immediately and deliver the agent’s reply proactively via API later. Agent sessions run 3–30 minutes — inline replies within a callback response window are not feasible.
Token Locks
Section titled “Token Locks”If the adapter holds a persistent connection with a unique credential, add a scoped lock to prevent two profiles from using the same credential:
from gateway.status import acquire_scoped_lock, release_scoped_lock
async def connect(self): if not acquire_scoped_lock("newplat", self._token): logger.error("Token already in use by another profile") return False # ... connect
async def disconnect(self): release_scoped_lock("newplat", self._token)Reference Implementations
Section titled “Reference Implementations”| Adapter | Pattern | Complexity | Good reference for |
|---|---|---|---|
bluebubbles.py | REST + webhook | Medium | Simple REST API integration |
weixin.py | Long-poll + CDN | High | Media handling, encryption |
wecom_callback.py | Callback/webhook | Medium | HTTP server, AES crypto, multi-app |
telegram.py | Long-poll + Bot API | High | Full-featured adapter with groups, threads |