build-a-hermes-plugin.md
1 --- 2 sidebar_position: 9 3 sidebar_label: "Build a Plugin" 4 title: "Build a Hermes Plugin" 5 description: "Step-by-step guide to building a complete Hermes plugin with tools, hooks, data files, and skills" 6 --- 7 8 # Build a Hermes Plugin 9 10 This guide walks through building a complete Hermes plugin from scratch. By the end you'll have a working plugin with multiple tools, lifecycle hooks, shipped data files, and a bundled skill — everything the plugin system supports. 11 12 ## What you're building 13 14 A **calculator** plugin with two tools: 15 - `calculate` — evaluate math expressions (`2**16`, `sqrt(144)`, `pi * 5**2`) 16 - `unit_convert` — convert between units (`100 F → 37.78 C`, `5 km → 3.11 mi`) 17 18 Plus a hook that logs every tool call, and a bundled skill file. 19 20 ## Step 1: Create the plugin directory 21 22 ```bash 23 mkdir -p ~/.hermes/plugins/calculator 24 cd ~/.hermes/plugins/calculator 25 ``` 26 27 ## Step 2: Write the manifest 28 29 Create `plugin.yaml`: 30 31 ```yaml 32 name: calculator 33 version: 1.0.0 34 description: Math calculator — evaluate expressions and convert units 35 provides_tools: 36 - calculate 37 - unit_convert 38 provides_hooks: 39 - post_tool_call 40 ``` 41 42 This tells Hermes: "I'm a plugin called calculator, I provide tools and hooks." The `provides_tools` and `provides_hooks` fields are lists of what the plugin registers. 43 44 Optional fields you could add: 45 ```yaml 46 author: Your Name 47 requires_env: # gate loading on env vars; prompted during install 48 - SOME_API_KEY # simple format — plugin disabled if missing 49 - name: OTHER_KEY # rich format — shows description/url during install 50 description: "Key for the Other service" 51 url: "https://other.com/keys" 52 secret: true 53 ``` 54 55 ## Step 3: Write the tool schemas 56 57 Create `schemas.py` — this is what the LLM reads to decide when to call your tools: 58 59 ```python 60 """Tool schemas — what the LLM sees.""" 61 62 CALCULATE = { 63 "name": "calculate", 64 "description": ( 65 "Evaluate a mathematical expression and return the result. " 66 "Supports arithmetic (+, -, *, /, **), functions (sqrt, sin, cos, " 67 "log, abs, round, floor, ceil), and constants (pi, e). " 68 "Use this for any math the user asks about." 69 ), 70 "parameters": { 71 "type": "object", 72 "properties": { 73 "expression": { 74 "type": "string", 75 "description": "Math expression to evaluate (e.g., '2**10', 'sqrt(144)')", 76 }, 77 }, 78 "required": ["expression"], 79 }, 80 } 81 82 UNIT_CONVERT = { 83 "name": "unit_convert", 84 "description": ( 85 "Convert a value between units. Supports length (m, km, mi, ft, in), " 86 "weight (kg, lb, oz, g), temperature (C, F, K), data (B, KB, MB, GB, TB), " 87 "and time (s, min, hr, day)." 88 ), 89 "parameters": { 90 "type": "object", 91 "properties": { 92 "value": { 93 "type": "number", 94 "description": "The numeric value to convert", 95 }, 96 "from_unit": { 97 "type": "string", 98 "description": "Source unit (e.g., 'km', 'lb', 'F', 'GB')", 99 }, 100 "to_unit": { 101 "type": "string", 102 "description": "Target unit (e.g., 'mi', 'kg', 'C', 'MB')", 103 }, 104 }, 105 "required": ["value", "from_unit", "to_unit"], 106 }, 107 } 108 ``` 109 110 **Why schemas matter:** The `description` field is how the LLM decides when to use your tool. Be specific about what it does and when to use it. The `parameters` define what arguments the LLM passes. 111 112 ## Step 4: Write the tool handlers 113 114 Create `tools.py` — this is the code that actually executes when the LLM calls your tools: 115 116 ```python 117 """Tool handlers — the code that runs when the LLM calls each tool.""" 118 119 import json 120 import math 121 122 # Safe globals for expression evaluation — no file/network access 123 _SAFE_MATH = { 124 "abs": abs, "round": round, "min": min, "max": max, 125 "pow": pow, "sqrt": math.sqrt, "sin": math.sin, "cos": math.cos, 126 "tan": math.tan, "log": math.log, "log2": math.log2, "log10": math.log10, 127 "floor": math.floor, "ceil": math.ceil, 128 "pi": math.pi, "e": math.e, 129 "factorial": math.factorial, 130 } 131 132 133 def calculate(args: dict, **kwargs) -> str: 134 """Evaluate a math expression safely. 135 136 Rules for handlers: 137 1. Receive args (dict) — the parameters the LLM passed 138 2. Do the work 139 3. Return a JSON string — ALWAYS, even on error 140 4. Accept **kwargs for forward compatibility 141 """ 142 expression = args.get("expression", "").strip() 143 if not expression: 144 return json.dumps({"error": "No expression provided"}) 145 146 try: 147 result = eval(expression, {"__builtins__": {}}, _SAFE_MATH) 148 return json.dumps({"expression": expression, "result": result}) 149 except ZeroDivisionError: 150 return json.dumps({"expression": expression, "error": "Division by zero"}) 151 except Exception as e: 152 return json.dumps({"expression": expression, "error": f"Invalid: {e}"}) 153 154 155 # Conversion tables — values are in base units 156 _LENGTH = {"m": 1, "km": 1000, "mi": 1609.34, "ft": 0.3048, "in": 0.0254, "cm": 0.01} 157 _WEIGHT = {"kg": 1, "g": 0.001, "lb": 0.453592, "oz": 0.0283495} 158 _DATA = {"B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3, "TB": 1024**4} 159 _TIME = {"s": 1, "ms": 0.001, "min": 60, "hr": 3600, "day": 86400} 160 161 162 def _convert_temp(value, from_u, to_u): 163 # Normalize to Celsius 164 c = {"F": (value - 32) * 5/9, "K": value - 273.15}.get(from_u, value) 165 # Convert to target 166 return {"F": c * 9/5 + 32, "K": c + 273.15}.get(to_u, c) 167 168 169 def unit_convert(args: dict, **kwargs) -> str: 170 """Convert between units.""" 171 value = args.get("value") 172 from_unit = args.get("from_unit", "").strip() 173 to_unit = args.get("to_unit", "").strip() 174 175 if value is None or not from_unit or not to_unit: 176 return json.dumps({"error": "Need value, from_unit, and to_unit"}) 177 178 try: 179 # Temperature 180 if from_unit.upper() in {"C","F","K"} and to_unit.upper() in {"C","F","K"}: 181 result = _convert_temp(float(value), from_unit.upper(), to_unit.upper()) 182 return json.dumps({"input": f"{value} {from_unit}", "result": round(result, 4), 183 "output": f"{round(result, 4)} {to_unit}"}) 184 185 # Ratio-based conversions 186 for table in (_LENGTH, _WEIGHT, _DATA, _TIME): 187 lc = {k.lower(): v for k, v in table.items()} 188 if from_unit.lower() in lc and to_unit.lower() in lc: 189 result = float(value) * lc[from_unit.lower()] / lc[to_unit.lower()] 190 return json.dumps({"input": f"{value} {from_unit}", 191 "result": round(result, 6), 192 "output": f"{round(result, 6)} {to_unit}"}) 193 194 return json.dumps({"error": f"Cannot convert {from_unit} → {to_unit}"}) 195 except Exception as e: 196 return json.dumps({"error": f"Conversion failed: {e}"}) 197 ``` 198 199 **Key rules for handlers:** 200 1. **Signature:** `def my_handler(args: dict, **kwargs) -> str` 201 2. **Return:** Always a JSON string. Success and errors alike. 202 3. **Never raise:** Catch all exceptions, return error JSON instead. 203 4. **Accept `**kwargs`:** Hermes may pass additional context in the future. 204 205 ## Step 5: Write the registration 206 207 Create `__init__.py` — this wires schemas to handlers: 208 209 ```python 210 """Calculator plugin — registration.""" 211 212 import logging 213 214 from . import schemas, tools 215 216 logger = logging.getLogger(__name__) 217 218 # Track tool usage via hooks 219 _call_log = [] 220 221 def _on_post_tool_call(tool_name, args, result, task_id, **kwargs): 222 """Hook: runs after every tool call (not just ours).""" 223 _call_log.append({"tool": tool_name, "session": task_id}) 224 if len(_call_log) > 100: 225 _call_log.pop(0) 226 logger.debug("Tool called: %s (session %s)", tool_name, task_id) 227 228 229 def register(ctx): 230 """Wire schemas to handlers and register hooks.""" 231 ctx.register_tool(name="calculate", toolset="calculator", 232 schema=schemas.CALCULATE, handler=tools.calculate) 233 ctx.register_tool(name="unit_convert", toolset="calculator", 234 schema=schemas.UNIT_CONVERT, handler=tools.unit_convert) 235 236 # This hook fires for ALL tool calls, not just ours 237 ctx.register_hook("post_tool_call", _on_post_tool_call) 238 ``` 239 240 **What `register()` does:** 241 - Called exactly once at startup 242 - `ctx.register_tool()` puts your tool in the registry — the model sees it immediately 243 - `ctx.register_hook()` subscribes to lifecycle events 244 - `ctx.register_cli_command()` registers a CLI subcommand (e.g. `hermes my-plugin <subcommand>`) 245 - `ctx.register_command()` registers an in-session slash command (e.g. `/myplugin <args>` inside CLI / gateway chat) — see [Register slash commands](#register-slash-commands) below 246 - `ctx.dispatch_tool(name, arguments)` — call any other tool (built-in or from another plugin) with the parent agent's context (approvals, credentials, task_id) wired up automatically. Useful from slash-command handlers that need to invoke `terminal`, `read_file`, or any other tool as if the model had called it directly. 247 - If this function crashes, the plugin is disabled but Hermes continues fine 248 249 **`dispatch_tool` example — a slash command that runs a tool:** 250 251 ```python 252 def handle_scan(ctx, argstr): 253 """Implement /scan by invoking the terminal tool through the registry.""" 254 result = ctx.dispatch_tool("terminal", {"command": f"find . -name '{argstr}'"}) 255 return result # returned to the caller's chat UI 256 257 def register(ctx): 258 ctx.register_command("scan", handle_scan, help="Find files matching a glob") 259 ``` 260 261 The dispatched tool goes through the normal approval, redaction, and budget pipelines — it's a real tool invocation, not a shortcut around them. 262 263 ## Step 6: Test it 264 265 Start Hermes: 266 267 ```bash 268 hermes 269 ``` 270 271 You should see `calculator: calculate, unit_convert` in the banner's tool list. 272 273 Try these prompts: 274 ``` 275 What's 2 to the power of 16? 276 Convert 100 fahrenheit to celsius 277 What's the square root of 2 times pi? 278 How many gigabytes is 1.5 terabytes? 279 ``` 280 281 Check plugin status: 282 ``` 283 /plugins 284 ``` 285 286 Output: 287 ``` 288 Plugins (1): 289 ✓ calculator v1.0.0 (2 tools, 1 hooks) 290 ``` 291 292 ## Your plugin's final structure 293 294 ``` 295 ~/.hermes/plugins/calculator/ 296 ├── plugin.yaml # "I'm calculator, I provide tools and hooks" 297 ├── __init__.py # Wiring: schemas → handlers, register hooks 298 ├── schemas.py # What the LLM reads (descriptions + parameter specs) 299 └── tools.py # What runs (calculate, unit_convert functions) 300 ``` 301 302 Four files, clear separation: 303 - **Manifest** declares what the plugin is 304 - **Schemas** describe tools for the LLM 305 - **Handlers** implement the actual logic 306 - **Registration** connects everything 307 308 ## What else can plugins do? 309 310 ### Ship data files 311 312 Put any files in your plugin directory and read them at import time: 313 314 ```python 315 # In tools.py or __init__.py 316 from pathlib import Path 317 318 _PLUGIN_DIR = Path(__file__).parent 319 _DATA_FILE = _PLUGIN_DIR / "data" / "languages.yaml" 320 321 with open(_DATA_FILE) as f: 322 _DATA = yaml.safe_load(f) 323 ``` 324 325 ### Bundle skills 326 327 Plugins can ship skill files that the agent loads via `skill_view("plugin:skill")`. Register them in your `__init__.py`: 328 329 ``` 330 ~/.hermes/plugins/my-plugin/ 331 ├── __init__.py 332 ├── plugin.yaml 333 └── skills/ 334 ├── my-workflow/ 335 │ └── SKILL.md 336 └── my-checklist/ 337 └── SKILL.md 338 ``` 339 340 ```python 341 from pathlib import Path 342 343 def register(ctx): 344 skills_dir = Path(__file__).parent / "skills" 345 for child in sorted(skills_dir.iterdir()): 346 skill_md = child / "SKILL.md" 347 if child.is_dir() and skill_md.exists(): 348 ctx.register_skill(child.name, skill_md) 349 ``` 350 351 The agent can now load your skills with their namespaced name: 352 353 ```python 354 skill_view("my-plugin:my-workflow") # → plugin's version 355 skill_view("my-workflow") # → built-in version (unchanged) 356 ``` 357 358 **Key properties:** 359 - Plugin skills are **read-only** — they don't enter `~/.hermes/skills/` and can't be edited via `skill_manage`. 360 - Plugin skills are **not** listed in the system prompt's `<available_skills>` index — they're opt-in explicit loads. 361 - Bare skill names are unaffected — the namespace prevents collisions with built-in skills. 362 - When the agent loads a plugin skill, a bundle context banner is prepended listing sibling skills from the same plugin. 363 364 :::tip Legacy pattern 365 The old `shutil.copy2` pattern (copying a skill into `~/.hermes/skills/`) still works but creates name collision risk with built-in skills. Prefer `ctx.register_skill()` for new plugins. 366 ::: 367 368 ### Gate on environment variables 369 370 If your plugin needs an API key: 371 372 ```yaml 373 # plugin.yaml — simple format (backwards-compatible) 374 requires_env: 375 - WEATHER_API_KEY 376 ``` 377 378 If `WEATHER_API_KEY` isn't set, the plugin is disabled with a clear message. No crash, no error in the agent — just "Plugin weather disabled (missing: WEATHER_API_KEY)". 379 380 When users run `hermes plugins install`, they're **prompted interactively** for any missing `requires_env` variables. Values are saved to `.env` automatically. 381 382 For a better install experience, use the rich format with descriptions and signup URLs: 383 384 ```yaml 385 # plugin.yaml — rich format 386 requires_env: 387 - name: WEATHER_API_KEY 388 description: "API key for OpenWeather" 389 url: "https://openweathermap.org/api" 390 secret: true 391 ``` 392 393 | Field | Required | Description | 394 |-------|----------|-------------| 395 | `name` | Yes | Environment variable name | 396 | `description` | No | Shown to user during install prompt | 397 | `url` | No | Where to get the credential | 398 | `secret` | No | If `true`, input is hidden (like a password field) | 399 400 Both formats can be mixed in the same list. Already-set variables are skipped silently. 401 402 ### Conditional tool availability 403 404 For tools that depend on optional libraries: 405 406 ```python 407 ctx.register_tool( 408 name="my_tool", 409 schema={...}, 410 handler=my_handler, 411 check_fn=lambda: _has_optional_lib(), # False = tool hidden from model 412 ) 413 ``` 414 415 ### Register multiple hooks 416 417 ```python 418 def register(ctx): 419 ctx.register_hook("pre_tool_call", before_any_tool) 420 ctx.register_hook("post_tool_call", after_any_tool) 421 ctx.register_hook("pre_llm_call", inject_memory) 422 ctx.register_hook("on_session_start", on_new_session) 423 ctx.register_hook("on_session_end", on_session_end) 424 ``` 425 426 ### Hook reference 427 428 Each hook is documented in full on the **[Event Hooks reference](/docs/user-guide/features/hooks#plugin-hooks)** — callback signatures, parameter tables, exactly when each fires, and examples. Here's the summary: 429 430 | Hook | Fires when | Callback signature | Returns | 431 |------|-----------|-------------------|---------| 432 | [`pre_tool_call`](/docs/user-guide/features/hooks#pre_tool_call) | Before any tool executes | `tool_name: str, args: dict, task_id: str` | ignored | 433 | [`post_tool_call`](/docs/user-guide/features/hooks#post_tool_call) | After any tool returns | `tool_name: str, args: dict, result: str, task_id: str, duration_ms: int` | ignored | 434 | [`pre_llm_call`](/docs/user-guide/features/hooks#pre_llm_call) | Once per turn, before the tool-calling loop | `session_id: str, user_message: str, conversation_history: list, is_first_turn: bool, model: str, platform: str` | [context injection](#pre_llm_call-context-injection) | 435 | [`post_llm_call`](/docs/user-guide/features/hooks#post_llm_call) | Once per turn, after the tool-calling loop (successful turns only) | `session_id: str, user_message: str, assistant_response: str, conversation_history: list, model: str, platform: str` | ignored | 436 | [`on_session_start`](/docs/user-guide/features/hooks#on_session_start) | New session created (first turn only) | `session_id: str, model: str, platform: str` | ignored | 437 | [`on_session_end`](/docs/user-guide/features/hooks#on_session_end) | End of every `run_conversation` call + CLI exit | `session_id: str, completed: bool, interrupted: bool, model: str, platform: str` | ignored | 438 | [`on_session_finalize`](/docs/user-guide/features/hooks#on_session_finalize) | CLI/gateway tears down an active session | `session_id: str \| None, platform: str` | ignored | 439 | [`on_session_reset`](/docs/user-guide/features/hooks#on_session_reset) | Gateway swaps in a new session key (`/new`, `/reset`) | `session_id: str, platform: str` | ignored | 440 441 Most hooks are fire-and-forget observers — their return values are ignored. The exception is `pre_llm_call`, which can inject context into the conversation. 442 443 All callbacks should accept `**kwargs` for forward compatibility. If a hook callback crashes, it's logged and skipped. Other hooks and the agent continue normally. 444 445 ### `pre_llm_call` context injection 446 447 This is the only hook whose return value matters. When a `pre_llm_call` callback returns a dict with a `"context"` key (or a plain string), Hermes injects that text into the **current turn's user message**. This is the mechanism for memory plugins, RAG integrations, guardrails, and any plugin that needs to provide the model with additional context. 448 449 #### Return format 450 451 ```python 452 # Dict with context key 453 return {"context": "Recalled memories:\n- User prefers dark mode\n- Last project: hermes-agent"} 454 455 # Plain string (equivalent to the dict form above) 456 return "Recalled memories:\n- User prefers dark mode" 457 458 # Return None or don't return → no injection (observer-only) 459 return None 460 ``` 461 462 Any non-None, non-empty return with a `"context"` key (or a plain non-empty string) is collected and appended to the user message for the current turn. 463 464 #### How injection works 465 466 Injected context is appended to the **user message**, not the system prompt. This is a deliberate design choice: 467 468 - **Prompt cache preservation** — the system prompt stays identical across turns. Anthropic and OpenRouter cache the system prompt prefix, so keeping it stable saves 75%+ on input tokens in multi-turn conversations. If plugins modified the system prompt, every turn would be a cache miss. 469 - **Ephemeral** — the injection happens at API call time only. The original user message in the conversation history is never mutated, and nothing is persisted to the session database. 470 - **The system prompt is Hermes's territory** — it contains model-specific guidance, tool enforcement rules, personality instructions, and cached skill content. Plugins contribute context alongside the user's input, not by altering the agent's core instructions. 471 472 #### Example: Memory recall plugin 473 474 ```python 475 """Memory plugin — recalls relevant context from a vector store.""" 476 477 import httpx 478 479 MEMORY_API = "https://your-memory-api.example.com" 480 481 def recall_context(session_id, user_message, is_first_turn, **kwargs): 482 """Called before each LLM turn. Returns recalled memories.""" 483 try: 484 resp = httpx.post(f"{MEMORY_API}/recall", json={ 485 "session_id": session_id, 486 "query": user_message, 487 }, timeout=3) 488 memories = resp.json().get("results", []) 489 if not memories: 490 return None # nothing to inject 491 492 text = "Recalled context from previous sessions:\n" 493 text += "\n".join(f"- {m['text']}" for m in memories) 494 return {"context": text} 495 except Exception: 496 return None # fail silently, don't break the agent 497 498 def register(ctx): 499 ctx.register_hook("pre_llm_call", recall_context) 500 ``` 501 502 #### Example: Guardrails plugin 503 504 ```python 505 """Guardrails plugin — enforces content policies.""" 506 507 POLICY = """You MUST follow these content policies for this session: 508 - Never generate code that accesses the filesystem outside the working directory 509 - Always warn before executing destructive operations 510 - Refuse requests involving personal data extraction""" 511 512 def inject_guardrails(**kwargs): 513 """Injects policy text into every turn.""" 514 return {"context": POLICY} 515 516 def register(ctx): 517 ctx.register_hook("pre_llm_call", inject_guardrails) 518 ``` 519 520 #### Example: Observer-only hook (no injection) 521 522 ```python 523 """Analytics plugin — tracks turn metadata without injecting context.""" 524 525 import logging 526 logger = logging.getLogger(__name__) 527 528 def log_turn(session_id, user_message, model, is_first_turn, **kwargs): 529 """Fires before each LLM call. Returns None — no context injected.""" 530 logger.info("Turn: session=%s model=%s first=%s msg_len=%d", 531 session_id, model, is_first_turn, len(user_message or "")) 532 # No return → no injection 533 534 def register(ctx): 535 ctx.register_hook("pre_llm_call", log_turn) 536 ``` 537 538 #### Multiple plugins returning context 539 540 When multiple plugins return context from `pre_llm_call`, their outputs are joined with double newlines and appended to the user message together. The order follows plugin discovery order (alphabetical by plugin directory name). 541 542 ### Register CLI commands 543 544 Plugins can add their own `hermes <plugin>` subcommand tree: 545 546 ```python 547 def _my_command(args): 548 """Handler for hermes my-plugin <subcommand>.""" 549 sub = getattr(args, "my_command", None) 550 if sub == "status": 551 print("All good!") 552 elif sub == "config": 553 print("Current config: ...") 554 else: 555 print("Usage: hermes my-plugin <status|config>") 556 557 def _setup_argparse(subparser): 558 """Build the argparse tree for hermes my-plugin.""" 559 subs = subparser.add_subparsers(dest="my_command") 560 subs.add_parser("status", help="Show plugin status") 561 subs.add_parser("config", help="Show plugin config") 562 subparser.set_defaults(func=_my_command) 563 564 def register(ctx): 565 ctx.register_tool(...) 566 ctx.register_cli_command( 567 name="my-plugin", 568 help="Manage my plugin", 569 setup_fn=_setup_argparse, 570 handler_fn=_my_command, 571 ) 572 ``` 573 574 After registration, users can run `hermes my-plugin status`, `hermes my-plugin config`, etc. 575 576 **Memory provider plugins** use a convention-based approach instead: add a `register_cli(subparser)` function to your plugin's `cli.py` file. The memory plugin discovery system finds it automatically — no `ctx.register_cli_command()` call needed. See the [Memory Provider Plugin guide](/docs/developer-guide/memory-provider-plugin#adding-cli-commands) for details. 577 578 **Active-provider gating:** Memory plugin CLI commands only appear when their provider is the active `memory.provider` in config. If a user hasn't set up your provider, your CLI commands won't clutter the help output. 579 580 ### Register slash commands 581 582 Plugins can register in-session slash commands — commands users type during a conversation (like `/lcm status` or `/ping`). These work in both CLI and gateway (Telegram, Discord, etc.). 583 584 ```python 585 def _handle_status(raw_args: str) -> str: 586 """Handler for /mystatus — called with everything after the command name.""" 587 if raw_args.strip() == "help": 588 return "Usage: /mystatus [help|check]" 589 return "Plugin status: all systems nominal" 590 591 def register(ctx): 592 ctx.register_command( 593 "mystatus", 594 handler=_handle_status, 595 description="Show plugin status", 596 ) 597 ``` 598 599 After registration, users can type `/mystatus` in any session. The command appears in autocomplete, `/help` output, and the Telegram bot menu. 600 601 **Signature:** `ctx.register_command(name: str, handler: Callable, description: str = "")` 602 603 | Parameter | Type | Description | 604 |-----------|------|-------------| 605 | `name` | `str` | Command name without the leading slash (e.g. `"lcm"`, `"mystatus"`) | 606 | `handler` | `Callable[[str], str \| None]` | Called with the raw argument string. May also be `async`. | 607 | `description` | `str` | Shown in `/help`, autocomplete, and Telegram bot menu | 608 609 **Key differences from `register_cli_command()`:** 610 611 | | `register_command()` | `register_cli_command()` | 612 |---|---|---| 613 | Invoked as | `/name` in a session | `hermes name` in a terminal | 614 | Where it works | CLI sessions, Telegram, Discord, etc. | Terminal only | 615 | Handler receives | Raw args string | argparse `Namespace` | 616 | Use case | Diagnostics, status, quick actions | Complex subcommand trees, setup wizards | 617 618 **Conflict protection:** If a plugin tries to register a name that conflicts with a built-in command (`help`, `model`, `new`, etc.), the registration is silently rejected with a log warning. Built-in commands always take precedence. 619 620 **Async handlers:** The gateway dispatch automatically detects and awaits async handlers, so you can use either sync or async functions: 621 622 ```python 623 async def _handle_check(raw_args: str) -> str: 624 result = await some_async_operation() 625 return f"Check result: {result}" 626 627 def register(ctx): 628 ctx.register_command("check", handler=_handle_check, description="Run async check") 629 ``` 630 631 :::tip 632 This guide covers **general plugins** (tools, hooks, slash commands, CLI commands). For specialized plugin types, see: 633 - [Memory Provider Plugins](/docs/developer-guide/memory-provider-plugin) — cross-session knowledge backends 634 - [Context Engine Plugins](/docs/developer-guide/context-engine-plugin) — alternative context management strategies 635 ::: 636 637 ### Distribute via pip 638 639 For sharing plugins publicly, add an entry point to your Python package: 640 641 ```toml 642 # pyproject.toml 643 [project.entry-points."hermes_agent.plugins"] 644 my-plugin = "my_plugin_package" 645 ``` 646 647 ```bash 648 pip install hermes-plugin-calculator 649 # Plugin auto-discovered on next hermes startup 650 ``` 651 652 ### Distribute for NixOS 653 654 NixOS users can install your plugin declaratively if you provide a `pyproject.toml` with entry points: 655 656 **Entry-point plugins** (recommended for distribution): 657 ```nix 658 # User's configuration.nix 659 services.hermes-agent.extraPythonPackages = [ 660 (pkgs.python312Packages.buildPythonPackage { 661 pname = "my-plugin"; 662 version = "1.0.0"; 663 src = pkgs.fetchFromGitHub { 664 owner = "you"; 665 repo = "hermes-my-plugin"; 666 rev = "v1.0.0"; 667 hash = "sha256-..."; # nix-prefetch-url --unpack 668 }; 669 format = "pyproject"; 670 build-system = [ pkgs.python312Packages.setuptools ]; 671 }) 672 ]; 673 ``` 674 675 **Directory plugins** (no `pyproject.toml` needed): 676 ```nix 677 services.hermes-agent.extraPlugins = [ 678 (pkgs.fetchFromGitHub { 679 owner = "you"; 680 repo = "hermes-my-plugin"; 681 rev = "v1.0.0"; 682 hash = "sha256-..."; 683 }) 684 ]; 685 ``` 686 687 See the [Nix Setup guide](/docs/getting-started/nix-setup#plugins) for complete documentation including overlay usage and collision checking. 688 689 ## Common mistakes 690 691 **Handler doesn't return JSON string:** 692 ```python 693 # Wrong — returns a dict 694 def handler(args, **kwargs): 695 return {"result": 42} 696 697 # Right — returns a JSON string 698 def handler(args, **kwargs): 699 return json.dumps({"result": 42}) 700 ``` 701 702 **Missing `**kwargs` in handler signature:** 703 ```python 704 # Wrong — will break if Hermes passes extra context 705 def handler(args): 706 ... 707 708 # Right 709 def handler(args, **kwargs): 710 ... 711 ``` 712 713 **Handler raises exceptions:** 714 ```python 715 # Wrong — exception propagates, tool call fails 716 def handler(args, **kwargs): 717 result = 1 / int(args["value"]) # ZeroDivisionError! 718 return json.dumps({"result": result}) 719 720 # Right — catch and return error JSON 721 def handler(args, **kwargs): 722 try: 723 result = 1 / int(args.get("value", 0)) 724 return json.dumps({"result": result}) 725 except Exception as e: 726 return json.dumps({"error": str(e)}) 727 ``` 728 729 **Schema description too vague:** 730 ```python 731 # Bad — model doesn't know when to use it 732 "description": "Does stuff" 733 734 # Good — model knows exactly when and how 735 "description": "Evaluate a mathematical expression. Use for arithmetic, trig, logarithms. Supports: +, -, *, /, **, sqrt, sin, cos, log, pi, e." 736 ```