/ website / docs / guides / build-a-hermes-plugin.md
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  ```