H繁中版
文件教學與最佳實踐build a hermes plugin
<!-- Source: https://hermesbible.com/docs/guides/build-a-hermes-plugin -->

本指南帶你從零開始打造一個完整的 Hermes 外掛。完成後你將擁有一個可用的外掛,包含多個工具、生命週期鉤子、附帶的資料檔案,以及一個內建技能——支援外掛系統的所有功能。

INFO — 不確定你需要哪個指南?

Hermes 有多種不同的可插拔介面——有些使用 Python register_* API,有些是設定驅動或目錄放入式的。先用這個對照表:

如果你想加入…閱讀
自訂工具、鉤子、斜線指令、技能或 CLI 子指令本指南(通用外掛介面)
LLM / 推理後端(新的供應商)Model Provider Plugins
Gateway 頻道(Discord/Telegram/IRC/Teams 等)Adding Platform Adapters
記憶後端(Honcho/Mem0/Supermemory 等)Memory Provider Plugins
Context 壓縮引擎Context Engine Plugins
圖片生成後端Image Generation Provider Plugins
影片生成後端Video Generation Provider Plugins
TTS 後端(任何 CLI——Piper、VoxCPM、Kokoro、聲音複製等)TTS custom command providers—設定驅動,不需要 Python
STT 後端(自訂 whisper / ASR CLI)Voice Message Transcription—將 HERMES_LOCAL_STT_COMMAND 設為 shell 範本
透過 MCP 的外部工具(檔案系統、GitHub、Linear、任何 MCP 伺服器)MCP—在 config.yaml 中宣告 mcp_servers.<name>
Gateway 事件鉤子(在啟動、Session 事件、指令時觸發)Event Hooks—將 HOOK.yaml + handler.py 放入 ~/.hermes/hooks/<name>/
Shell 鉤子(在事件時執行 shell 指令)Shell Hooks—在 config.yamlhooks: 下宣告
額外的技能來源(自訂 GitHub 儲存庫、私有技能索引)Skillshermes skills tap add <repo>·Publishing a tap
一級核心推理供應商(非外掛)Adding Providers

完整的可插拔介面對照表提供所有擴充介面的整合視圖,包括設定驅動(TTS、STT、MCP、shell 鉤子)和目錄放入式(gateway 鉤子)兩種風格。

你要打造什麼

一個帶有兩個工具的計算機外掛:

  • calculate — 評估數學運算式(2**16sqrt(144)pi * 5**2
  • unit_convert — 在單位之間轉換(100 F → 37.78 C5 km → 3.11 mi

加上一個記錄每次工具呼叫的鉤子,以及一個內建技能檔案。

步驟 1:建立外掛目錄

mkdir -p ~/.hermes/plugins/calculator
cd ~/.hermes/plugins/calculator

步驟 2:撰寫 manifest

建立 plugin.yaml

name: calculator
version: 1.0.0
description: Math calculator — evaluate expressions and convert units
provides_tools:
  - calculate
  - unit_convert
provides_hooks:
  - post_tool_call

這告訴 Hermes:「我是一個叫做 calculator 的外掛,我提供工具和鉤子。」provides_toolsprovides_hooks 是外掛所註冊項目的清單。

你可以加入的選擇性欄位:

author: Your Name
requires_env:          # 以環境變數作為載入門檻;安裝時會提示
  - SOME_API_KEY       # 簡單格式——缺少時外掛停用
  - name: OTHER_KEY    # 豐富格式——安裝時顯示描述/網址
    description: "Key for the Other service"
    url: "https://other.com/keys"
    secret: true

步驟 3:撰寫工具 Schema

建立 schemas.py——這是 LLM 用來決定何時呼叫你的工具的內容:

"""Tool schemas — what the LLM sees."""

CALCULATE = {
    "name": "calculate",
    "description": (
        "Evaluate a mathematical expression and return the result. "
        "Supports arithmetic (+, -, *, /, **), functions (sqrt, sin, cos, "
        "log, abs, round, floor, ceil), and constants (pi, e). "
        "Use this for any math the user asks about."
    ),
    "parameters": {
        "type": "object",
        "properties": {
            "expression": {
                "type": "string",
                "description": "Math expression to evaluate (e.g., '2**10', 'sqrt(144)')",
            },
        },
        "required": ["expression"],
    },
}

UNIT_CONVERT = {
    "name": "unit_convert",
    "description": (
        "Convert a value between units. Supports length (m, km, mi, ft, in), "
        "weight (kg, lb, oz, g), temperature (C, F, K), data (B, KB, MB, GB, TB), "
        "and time (s, min, hr, day)."
    ),
    "parameters": {
        "type": "object",
        "properties": {
            "value": {
                "type": "number",
                "description": "The numeric value to convert",
            },
            "from_unit": {
                "type": "string",
                "description": "Source unit (e.g., 'km', 'lb', 'F', 'GB')",
            },
            "to_unit": {
                "type": "string",
                "description": "Target unit (e.g., 'mi', 'kg', 'C', 'MB')",
            },
        },
        "required": ["value", "from_unit", "to_unit"],
    },
}

為什麼 Schema 很重要:description 欄位是 LLM 決定何時使用你的工具的依據。要具體描述它的功能和使用時機。parameters 定義了 LLM 傳遞的參數。

步驟 4:撰寫工具處理器

建立 tools.py——這是 LLM 呼叫你的工具時實際執行的程式碼:

"""Tool handlers — the code that runs when the LLM calls each tool."""

import json
import math

# Safe globals for expression evaluation — no file/network access
_SAFE_MATH = {
    "abs": abs, "round": round, "min": min, "max": max,
    "pow": pow, "sqrt": math.sqrt, "sin": math.sin, "cos": math.cos,
    "tan": math.tan, "log": math.log, "log2": math.log2, "log10": math.log10,
    "floor": math.floor, "ceil": math.ceil,
    "pi": math.pi, "e": math.e,
    "factorial": math.factorial,
}

def calculate(args: dict, **kwargs) -> str:
    """Evaluate a math expression safely.

    Rules for handlers:
    1. Receive args (dict) — the parameters the LLM passed
    2. Do the work
    3. Return a JSON string — ALWAYS, even on error
    4. Accept **kwargs for forward compatibility
    """
    expression = args.get("expression", "").strip()
    if not expression:
        return json.dumps({"error": "No expression provided"})

    try:
        result = eval(expression, {"__builtins__": {}}, _SAFE_MATH)
        return json.dumps({"expression": expression, "result": result})
    except ZeroDivisionError:
        return json.dumps({"expression": expression, "error": "Division by zero"})
    except Exception as e:
        return json.dumps({"expression": expression, "error": f"Invalid: {e}"})

# Conversion tables — values are in base units
_LENGTH = {"m": 1, "km": 1000, "mi": 1609.34, "ft": 0.3048, "in": 0.0254, "cm": 0.01}
_WEIGHT = {"kg": 1, "g": 0.001, "lb": 0.453592, "oz": 0.0283495}
_DATA = {"B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3, "TB": 1024**4}
_TIME = {"s": 1, "ms": 0.001, "min": 60, "hr": 3600, "day": 86400}

def _convert_temp(value, from_u, to_u):
    # Normalize to Celsius
    c = {"F": (value - 32) * 5/9, "K": value - 273.15}.get(from_u, value)
    # Convert to target
    return {"F": c * 9/5 + 32, "K": c + 273.15}.get(to_u, c)

def unit_convert(args: dict, **kwargs) -> str:
    """Convert between units."""
    value = args.get("value")
    from_unit = args.get("from_unit", "").strip()
    to_unit = args.get("to_unit", "").strip()

    if value is None or not from_unit or not to_unit:
        return json.dumps({"error": "Need value, from_unit, and to_unit"})

    try:
        # Temperature
        if from_unit.upper() in {"C","F","K"} and to_unit.upper() in {"C","F","K"}:
            result = _convert_temp(float(value), from_unit.upper(), to_unit.upper())
            return json.dumps({"input": f"{value} {from_unit}", "result": round(result, 4),
                             "output": f"{round(result, 4)} {to_unit}"})

        # Ratio-based conversions
        for table in (_LENGTH, _WEIGHT, _DATA, _TIME):
            lc = {k.lower(): v for k, v in table.items()}
            if from_unit.lower() in lc and to_unit.lower() in lc:
                result = float(value) * lc[from_unit.lower()] / lc[to_unit.lower()]
                return json.dumps({"input": f"{value} {from_unit}",
                                 "result": round(result, 6),
                                 "output": f"{round(result, 6)} {to_unit}"})

        return json.dumps({"error": f"Cannot convert {from_unit} → {to_unit}"})
    except Exception as e:
        return json.dumps({"error": f"Conversion failed: {e}"})

處理器的關鍵規則:

  1. 簽名:def my_handler(args: dict, **kwargs) -> str
  2. **回傳值:**永遠是 JSON 字串。成功和錯誤都是。
  3. **永遠不要拋出例外:**捕捉所有例外,回傳錯誤 JSON。
  4. **接受 **kwargs:**Hermes 未來可能會傳遞額外的 context。

步驟 5:撰寫註冊程式

建立 __init__.py——這將 Schema 與處理器串接起來:

"""Calculator plugin — registration."""

import logging

from . import schemas, tools

logger = logging.getLogger(__name__)

# Track tool usage via hooks
_call_log = []

def _on_post_tool_call(tool_name, args, result, task_id, **kwargs):
    """Hook: runs after every tool call (not just ours)."""
    _call_log.append({"tool": tool_name, "session": task_id})
    if len(_call_log) > 100:
        _call_log.pop(0)
    logger.debug("Tool called: %s (session %s)", tool_name, task_id)

def register(ctx):
    """Wire schemas to handlers and register hooks."""
    ctx.register_tool(name="calculate",    toolset="calculator",
                      schema=schemas.CALCULATE,    handler=tools.calculate)
    ctx.register_tool(name="unit_convert", toolset="calculator",
                      schema=schemas.UNIT_CONVERT, handler=tools.unit_convert)

    # This hook fires for ALL tool calls, not just ours
    ctx.register_hook("post_tool_call", _on_post_tool_call)

register() 的功能:

  • 在啟動時只呼叫一次
  • ctx.register_tool() 將你的工具加入註冊表——模型會立即看到它
  • ctx.register_hook() 訂閱生命週期事件
  • ctx.register_cli_command() 註冊一個 CLI 子指令(例如 hermes my-plugin <subcommand>
  • ctx.register_command() 註冊一個對話中的斜線指令(例如 CLI / gateway 對話中的 /myplugin <args>)——見下方註冊斜線指令
  • ctx.dispatch_tool(name, arguments) — 呼叫任何其他工具(內建或來自其他外掛),自動串接父代理的 context(審批、憑證、task_id)。適用於需要呼叫 terminalread_file 或任何其他工具的斜線指令處理器,就像模型直接呼叫一樣。
  • 如果這個函式崩潰,外掛會被停用但 Hermes 繼續正常運作

dispatch_tool 範例——一個執行工具的斜線指令:

def handle_scan(ctx, raw_args: str):
    """Implement /scan by invoking the terminal tool through the registry."""
    result = ctx.dispatch_tool("terminal", {"command": f"find . -name '{raw_args}'"})
    return result  # returned to the caller's chat UI

def register(ctx):
    # Handlers receive a single raw_args string; close over ctx via a lambda.
    ctx.register_command(
        "scan",
        lambda raw: handle_scan(ctx, raw),
        description="Find files matching a glob",
    )

被派遣的工具會經過正常的審批、遮蔽和預算管線——這是一個真正的工具呼叫,不是繞過它們的捷徑。

步驟 6:測試

啟動 Hermes:

hermes

你應該會在啟動橫幅的工具列表中看到 calculator: calculate, unit_convert

試試這些提示:

2 的 16 次方是多少?
將 100 華氏度轉換為攝氏度
2 乘以 pi 的平方根是多少?
1.5 terabytes 等於多少 gigabytes?

檢查外掛狀態:

/plugins

輸出:

Plugins (1):
  ✓ calculator v1.0.0 (2 tools, 1 hooks)

除錯外掛發現

如果你的外掛沒有顯示——或者顯示了但沒有載入——設定 HERMES_PLUGINS_DEBUG=1 以在 stderr 上取得詳細的發現日誌:

HERMES_PLUGINS_DEBUG=1 hermes plugins list

你會看到每個外掛來源(內建、使用者、專案、entry-points)的:

  • 掃描了哪些目錄以及每個目錄產生了多少個 manifest
  • 每個 manifest:解析的 key、名稱、類型、來源、磁碟路徑
  • 跳過原因:disabled via confignot enabled in configexclusive pluginno plugin.yaml, depth cap reached
  • 載入時:正在匯入的外掛,加上 register(ctx) 註冊內容的一行摘要(工具、鉤子、斜線指令、CLI 指令)
  • 解析失敗時:例外的完整堆疊追蹤(YAML 掃描錯誤等)
  • register() 失敗時:指向 __init__.py 中拋出例外那一行的完整堆疊追蹤

相同的日誌會在 WARNING 層級(僅失敗時)和 DEBUG 層級(完整資訊,需設定環境變數時)寫入 ~/.hermes/logs/agent.log。所以如果你無法使用環境變數執行(例如在 gateway 內),可以改用 tail 查看日誌檔:

hermes logs --level WARNING | grep -i plugin

外掛不出現的常見原因:

  • 未在設定中啟用 — 外掛是選擇性啟用的。執行 hermes plugins enable <name>(名稱來自 plugins list 輸出,巢狀結構的外掛可能顯示為 <category>/<plugin>)。
  • 目錄結構錯誤 — 必須是 ~/.hermes/plugins/<plugin-name>/plugin.yaml(扁平式)或 ~/.hermes/plugins/<category>/<plugin-name>/plugin.yaml(最多一層分類巢狀)。更深的結構會被忽略。
  • 缺少 __init__.py — 外掛目錄需要同時有 plugin.yaml 和包含 register(ctx) 函式的 __init__.py
  • 錯誤的 kind — Gateway 適配器需要在 manifest 中設定 kind: platform。記憶供應商會被自動偵測為 kind: exclusive,透過 memory.provider 設定路由而非 plugins.enabled

你的外掛最終結構

~/.hermes/plugins/calculator/
├── plugin.yaml      # 「我是 calculator,我提供工具和鉤子」
├── __init__.py      # 串接:schemas → handlers,註冊鉤子
├── schemas.py       # LLM 讀取的內容(描述 + 參數規格)
└── tools.py         # 實際執行的程式碼(calculate、unit_convert 函式)

四個檔案,職責分明:

  • Manifest 宣告外掛是什麼
  • Schema 為 LLM 描述工具
  • 處理器 實作實際邏輯
  • 註冊 連接一切

外掛還能做什麼?

附帶資料檔案

將任何檔案放在你的外掛目錄中,在匯入時讀取:

# In tools.py or __init__.py
from pathlib import Path

_PLUGIN_DIR = Path(__file__).parent
_DATA_FILE = _PLUGIN_DIR / "data" / "languages.yaml"

with open(_DATA_FILE) as f:
    _DATA = yaml.safe_load(f)

內建技能

外掛可以附帶技能檔案,代理透過 skill_view("plugin:skill") 載入。在你的 __init__.py 中註冊:

~/.hermes/plugins/my-plugin/
├── __init__.py
├── plugin.yaml
└── skills/
    ├── my-workflow/
    │   └── SKILL.md
    └── my-checklist/
        └── SKILL.md
from pathlib import Path

def register(ctx):
    skills_dir = Path(__file__).parent / "skills"
    for child in sorted(skills_dir.iterdir()):
        skill_md = child / "SKILL.md"
        if child.is_dir() and skill_md.exists():
            ctx.register_skill(child.name, skill_md)

代理現在可以用命名空間名稱載入你的技能:

skill_view("my-plugin:my-workflow")   # → 外掛的版本
skill_view("my-workflow")              # → 內建版本(不受影響)

關鍵特性:

  • 外掛技能是唯讀的——它們不會進入 ~/.hermes/skills/,無法透過 skill_manage 編輯。
  • 外掛技能不會列在系統提示的 <available_skills> 索引中——它們是選擇性顯式載入的。
  • 裸技能名稱不受影響——命名空間防止了與內建技能的衝突。
  • 當代理載入外掛技能時,會在前面加上一個套件上下文橫幅,列出來自同一外掛的兄弟技能。

TIP — 舊版模式

舊的 shutil.copy2 模式(將技能複製到 ~/.hermes/skills/)仍然有效,但有與內建技能名稱衝突的風險。新外掛請優先使用 ctx.register_skill()

以環境變數作為門檻

如果你的外掛需要 API 金鑰:

# plugin.yaml — 簡單格式(向後相容)
requires_env:
  - WEATHER_API_KEY

如果 WEATHER_API_KEY 未設定,外掛會被停用並顯示清楚的訊息。不會崩潰,不會在代理中產生錯誤——只會顯示「Plugin weather disabled (missing: WEATHER_API_KEY)」。

當使用者執行 hermes plugins install 時,會互動式提示輸入任何缺少的 requires_env 變數。值會自動儲存到 .env

為了更好的安裝體驗,使用帶有描述和註冊網址的豐富格式:

# plugin.yaml — 豐富格式
requires_env:
  - name: WEATHER_API_KEY
    description: "API key for OpenWeather"
    url: "https://openweathermap.org/api"
    secret: true
欄位必要說明
name環境變數名稱
description安裝提示時顯示給使用者
url取得憑證的地方
secret如果為 true,輸入會被隱藏(如密碼欄位)

兩種格式可以在同一個清單中混用。已設定的變數會被靜默跳過。

延遲安裝選擇性 Python 依賴

如果你的外掛包裝了一個不是每個使用者都會安裝的 SDK(供應商 SDK、大型 ML 函式庫、平台特定套件),不要在模組頂部 import 它。在工具處理器內部使用 tools.lazy_deps.ensure(...) 輔助函式——Hermes 會在首次使用時安裝該套件,由使用者的 security.allow_lazy_installs 設定門檻控制。

# tools.py
from tools.lazy_deps import ensure, FeatureUnavailable

def my_tool_handler(args, **kwargs):
    try:
        ensure("my-plugin.my-backend")   # key must be in LAZY_DEPS
    except FeatureUnavailable as exc:
        return {"error": str(exc)}

    import my_backend_sdk   # safe now
    ...

tools/lazy_deps.py 中安全模型的兩條規則:

規則原因
你的功能 key 必須出現在 tree 內的 LAZY_DEPS 允許清單中防止惡意設定誘導 Hermes 安裝任意套件——只有 Hermes 本身附帶的規格才有資格
規格僅限 PyPI 名稱不支援 --index-urlgit+https:// 或 file: 路徑。在允許清單條目內使用 PEP 440("my-sdk>=1.2,<2")固定版本

對於透過 pip 分發的第三方外掛,將選擇性依賴宣告為你自己 pyproject.toml 中的 [project.optional-dependencies] extras,並告知使用者執行 pip install your-plugin[backend]——該路徑不經過 lazy_deps。延遲安裝機制最適用於內建外掛,因為在每次安裝時附帶硬依賴會膨脹 Hermes 的基礎佔用空間。

security.allow_lazy_installs: false 在全域設定時,ensure() 會立即拋出 FeatureUnavailable 並附上修正提示——你的外掛應該捕捉它並優雅降級(回傳錯誤結果,而不是崩潰工具迴圈)。

執行緒安全的延遲單例

外掛通常會快取一個高成本物件——SDK 客戶端、HTTP session、連線池——在首次使用的模組層級變數中:

_client = None

def get_client():
    global _client
    if _client is not None:
        return _client
    _client = ExpensiveClient(...)   # ← TOCTOU 競爭條件
    return _client

這是一個隱藏的陷阱。Hermes 在一個程序中運行多個執行緒(委派的工具呼叫、背景工作者、自我改善分叉),所以兩個執行緒可能在 _client 設定之前都呼叫 get_client()兩者都通過 is not None 檢查,兩者都執行高成本的建立,第二次寫入覆蓋第一次——洩漏失敗者開啟的任何資源(連線、檔案控制代碼、背景執行緒)。

不要手動實作鎖。使用 plugins/plugin_utils.py 中的輔助函式:

from plugins.plugin_utils import lazy_singleton, SingletonSlot

# Zero-arg accessor → decorate it:
@lazy_singleton
def get_client():
    return ExpensiveClient(load_config())   # runs exactly once

client = get_client()    # safe across threads
get_client.reset()       # drop the instance (tests / teardown)

# Accessor that takes a build argument → use a slot:
_slot: SingletonSlot = SingletonSlot()

def get_client(config=None):
    return _slot.get(lambda: ExpensiveClient(resolve(config)))

def reset_client():
    _slot.reset()

兩者都使用雙重檢查鎖定序列化並行的首次呼叫,並最多執行一次工廠函式。如果工廠拋出例外,不會快取任何內容,下次呼叫會重試。Honcho 記憶外掛(plugins/memory/honcho/client.py)是參考消費者。

經驗法則:每當你寫 global _something 後面跟著 is None 檢查和建立時,改用這些輔助函式。

條件式工具可用性

對於依賴選擇性函式庫的工具:

ctx.register_tool(
    name="my_tool",
    schema={...},
    handler=my_handler,
    check_fn=lambda: _has_optional_lib(),  # False = tool hidden from model
)

覆蓋內建工具

要用自己的實作替換內建工具(例如將預設的瀏覽器工具替換為 headed-Chrome CDP 後端,或用自訂的企業索引替換 web_search),傳入 override=True

def register(ctx):
    ctx.register_tool(
        name="browser_navigate",             # same name as the built-in
        toolset="plugin_my_browser",         # your own toolset namespace
        schema={...},
        handler=my_custom_navigate,
        override=True,                       # explicit opt-in
    )

沒有 override=True 的情況下,註冊表會拒絕任何會遮蔽來自不同工具集的現有工具的註冊——這防止了意外覆蓋。覆蓋會在 INFO 層級記錄,可在 ~/.hermes/logs/agent.log 中稽核。外掛在內建工具之後載入,所以註冊順序是正確的:你的處理器替換了內建的處理器。

註冊多個鉤子

def register(ctx):
    ctx.register_hook("pre_tool_call", before_any_tool)
    ctx.register_hook("post_tool_call", after_any_tool)
    ctx.register_hook("pre_llm_call", inject_memory)
    ctx.register_hook("on_session_start", on_new_session)
    ctx.register_hook("on_session_end", on_session_end)

鉤子參考

每個鉤子都在 Event Hooks 參考 中有完整文件——回調簽名、參數表、精確的觸發時機和範例。以下是摘要:

鉤子觸發時機回調簽名回傳值
pre_tool_call任何工具執行前tool_name: str, args: dict, task_id: str忽略
post_tool_call任何工具回傳後tool_name: str, args: dict, result: str, task_id: str, duration_ms: int忽略
pre_llm_call每回合一次,在工具呼叫迴圈之前session_id: str, user_message: str, conversation_history: list, is_first_turn: bool, model: str, platform: strcontext 注入
post_llm_call每回合一次,在工具呼叫迴圈之後(僅成功回合)session_id: str, user_message: str, assistant_response: str, conversation_history: list, model: str, platform: str忽略
on_session_start新 Session 建立(僅首次回合)session_id: str, model: str, platform: str忽略
on_session_end每次 run_conversation 呼叫結束 + CLI 退出session_id: str, completed: bool, interrupted: bool, model: str, platform: str忽略
on_session_finalizeCLI/gateway 拆除活躍 Sessionsession_id: str | None, platform: str忽略
on_session_resetGateway 切換新的 Session key(/new/resetsession_id: str, platform: str忽略

大多數鉤子是即發即忘的觀察者——它們的回傳值被忽略。例外是 pre_llm_call,它可以注入 context 到對話中。

所有回調都應接受 **kwargs 以確保向前相容。如果鉤子回調崩潰,會被記錄並跳過。其他鉤子和代理繼續正常運作。

pre_llm_call context 注入

這是唯一回傳值重要的鉤子。當 pre_llm_call 回調回傳一個帶有 "context" key 的 dict(或一個純字串)時,Hermes 會將該文字注入到目前回合的使用者訊息中。這是記憶外掛、RAG 整合、護欄和任何需要為模型提供額外 context 的外掛的機制。

回傳格式

# Dict with context key
return {"context": "Recalled memories:\n- User prefers dark mode\n- Last project: hermes-agent"}

# Plain string (equivalent to the dict form above)
return "Recalled memories:\n- User prefers dark mode"

# Return None or don't return → no injection (observer-only)
return None

任何非 None、非空且帶有 "context" key 的回傳(或純非空字串)都會被收集並附加到目前回合的使用者訊息。

注入運作方式

注入的 context 附加到使用者訊息,而非系統提示。這是刻意的設計選擇:

  • Prompt 快取保護 — 系統提示在各回合之間保持一致。Anthropic 和 OpenRouter 會快取系統提示前綴,所以保持穩定可在多回合對話中節省 75% 以上的輸入 token。如果外掛修改了系統提示,每個回合都會是快取未命中。
  • 暫時性 — 注入只在 API 呼叫時發生。對話歷史中的原始使用者訊息永遠不會被修改,也不會持久化到 Session 資料庫。
  • 系統提示是 Hermes 的領域 — 它包含模型特定的指引、工具強制規則、人格指令和快取的技能內容。外掛在使用者輸入旁邊貢獻 context,而不是修改代理的核心指令。

範例:記憶回憶外掛

"""Memory plugin — recalls relevant context from a vector store."""

import httpx

MEMORY_API = "https://your-memory-api.example.com"

def recall_context(session_id, user_message, is_first_turn, **kwargs):
    """Called before each LLM turn. Returns recalled memories."""
    try:
        resp = httpx.post(f"{MEMORY_API}/recall", json={
            "session_id": session_id,
            "query": user_message,
        }, timeout=3)
        memories = resp.json().get("results", [])
        if not memories:
            return None  # nothing to inject

        text = "Recalled context from previous sessions:\n"
        text += "\n".join(f"- {m['text']}" for m in memories)
        return {"context": text}
    except Exception:
        return None  # fail silently, don't break the agent

def register(ctx):
    ctx.register_hook("pre_llm_call", recall_context)

範例:護欄外掛

"""Guardrails plugin — enforces content policies."""

POLICY = """You MUST follow these content policies for this session:
- Never generate code that accesses the filesystem outside the working directory
- Always warn before executing destructive operations
- Refuse requests involving personal data extraction"""

def inject_guardrails(**kwargs):
    """Injects policy text into every turn."""
    return {"context": POLICY}

def register(ctx):
    ctx.register_hook("pre_llm_call", inject_guardrails)

範例:純觀察者鉤子(不注入)

"""Analytics plugin — tracks turn metadata without injecting context."""

import logging
logger = logging.getLogger(__name__)

def log_turn(session_id, user_message, model, is_first_turn, **kwargs):
    """Fires before each LLM call. Returns None — no context injected."""
    logger.info("Turn: session=%s model=%s first=%s msg_len=%d",
                session_id, model, is_first_turn, len(user_message or ""))
    # No return → no injection

def register(ctx):
    ctx.register_hook("pre_llm_call", log_turn)

多個外掛回傳 context

當多個外掛從 pre_llm_call 回傳 context 時,它們的輸出會以雙換行符連接並一起附加到使用者訊息。順序依外掛發現順序(按外掛目錄名稱字母順序)。

註冊 CLI 指令

外掛可以加入自己的 hermes <plugin> 子指令樹:

def _my_command(args):
    """Handler for hermes my-plugin <subcommand>."""
    sub = getattr(args, "my_command", None)
    if sub == "status":
        print("All good!")
    elif sub == "config":
        print("Current config: ...")
    else:
        print("Usage: hermes my-plugin <status|config>")

def _setup_argparse(subparser):
    """Build the argparse tree for hermes my-plugin."""
    subs = subparser.add_subparsers(dest="my_command")
    subs.add_parser("status", help="Show plugin status")
    subs.add_parser("config", help="Show plugin config")
    subparser.set_defaults(func=_my_command)

def register(ctx):
    ctx.register_tool(...)
    ctx.register_cli_command(
        name="my-plugin",
        help="Manage my plugin",
        setup_fn=_setup_argparse,
        handler_fn=_my_command,
    )

註冊後,使用者可以執行 hermes my-plugin statushermes my-plugin config 等。

記憶供應商外掛使用基於慣例的方法:在你的外掛的 cli.py 檔案中加入 register_cli(subparser) 函式。記憶外掛發現系統會自動找到它——不需要 ctx.register_cli_command() 呼叫。詳見 Memory Provider Plugin 指南

**啟用供應商門檻:**記憶外掛 CLI 指令只在它們的供應商是設定中的啟用 memory.provider 時才會出現。如果使用者尚未設定你的供應商,你的 CLI 指令不會弄亂幫助輸出。

註冊斜線指令

外掛可以註冊對話中的斜線指令——使用者在對話中輸入的指令(如 /lcm status/ping)。這在 CLI 和 gateway(Telegram、Discord 等)中都有效。

def _handle_status(raw_args: str) -> str:
    """Handler for /mystatus — called with everything after the command name."""
    if raw_args.strip() == "help":
        return "Usage: /mystatus [help|check]"
    return "Plugin status: all systems nominal"

def register(ctx):
    ctx.register_command(
        "mystatus",
        handler=_handle_status,
        description="Show plugin status",
    )

註冊後,使用者可以在任何 Session 中輸入 /mystatus。指令會出現在自動完成、/help 輸出和 Telegram 機器人選單中。

簽名:ctx.register_command(name: str, handler: Callable, description: str = "", args_hint: str = "")

參數型別說明
namestr不含前導斜線的指令名稱(例如 "lcm""mystatus"
handlerCallable[[str], str | None]呼叫時傳入原始參數字串。也可以是 async
descriptionstr顯示在 /help、自動完成和 Telegram 機器人選單中

register_cli_command() 的關鍵差異:

register_command()register_cli_command()
呼叫方式Session 中的 /name終端機中的 hermes name
運作環境CLI Session、Telegram、Discord 等僅限終端機
處理器接收原始 args 字串argparse Namespace
使用情境診斷、狀態、快速操作複雜的子指令樹、設定精靈

**衝突保護:**如果外掛嘗試註冊一個與內建指令(helpmodelnew 等)衝突的名稱,註冊會被靜默拒絕並記錄警告。內建指令永遠優先。

**非同步處理器:**Gateway 派遣會自動偵測並等待非同步處理器,所以你可以使用同步或非同步函式:

async def _handle_check(raw_args: str) -> str:
    result = await some_async_operation()
    return f"Check result: {result}"

def register(ctx):
    ctx.register_command("check", handler=_handle_check, description="Run async check")

從斜線指令派遣工具

需要編排工具(透過 delegate_task 產生子代理、呼叫 file_edit 等)的斜線指令處理器應使用 ctx.dispatch_tool() 而非直接存取框架內部。父代理 context(工作區提示、旋轉器、模型繼承)會自動串接。

def register(ctx):
    def _handle_deliver(raw_args: str):
        result = ctx.dispatch_tool(
            "delegate_task",
            {
                "goal": raw_args,
                "toolsets": ["terminal", "file", "web"],
            },
        )
        return result

    ctx.register_command(
        "deliver",
        handler=_handle_deliver,
        description="Delegate a goal to a subagent",
    )

簽名:ctx.dispatch_tool(name: str, args: dict, *, parent_agent=None) -> str

參數型別說明
namestr在工具註冊表中註冊的工具名稱(例如 "delegate_task""file_edit"
argsdict工具參數,與模型傳送的格式相同
parent_agentAgent | None選擇性覆蓋。省略時,從目前的 CLI 代理解析(或在 gateway 模式下優雅降級)

執行時行為:

  • CLI 模式:parent_agent 從活躍的 CLI 代理解析,所以工作區提示、旋轉器和模型選擇會如預期繼承。
  • **Gateway 模式:**沒有 CLI 代理,所以工具會優雅降級——工作區從設定的終端機工作目錄讀取,不顯示旋轉器。
  • **明確覆蓋:**如果呼叫者明確傳入 parent_agent=,會被尊重而不覆蓋。

這是從外掛指令派遣工具的公開、穩定介面。外掛不應存取 ctx._cli_ref.agent 或類似的私有狀態。

處理 Slack Block Kit 按鈕點擊

發送帶有互動元素(按鈕、溢出選單、日期選擇器等)的 Block Kit 訊息的外掛可以直接向 Slack 適配器註冊點擊處理器——不需要對 slack_bolt.AsyncApp 進行猴子補丁。

def register(ctx):
    async def _on_approve(ack, body, action):
        # ack within 3 seconds — slack_bolt requirement.
        await ack()
        # body["channel"]["id"], body["user"]["id"], body["message"]["ts"]
        # action["action_id"], action["value"]
        sweep_id = (action.get("value") or "").split("|", 1)[-1]
        # ...do the deterministic work, then post a follow-up.

    ctx.register_slack_action_handler("inbox_sweep_approve", _on_approve)

簽名:ctx.register_slack_action_handler(action_id, callback) -> None

參數型別說明
action_idstr | re.Pattern | dict任何 slack_bolt.App.action() 接受的內容:字面 action_id、匹配多個 id 的已編譯正則表達式,或約束 dict 如 {"action_id": "...", "block_id": "..."}
callback非同步可呼叫依照 slack_bolt 慣例接收 (ack, body, action)

執行時行為:

  • 處理器在外掛載入時排入佇列,並在 Slack 平台連線時串接到適配器的 slack_bolt.AsyncApp
  • 每個回調都以防禦方式包裝:如果你的處理器拋出例外,Gateway 會記錄錯誤並盡力 ack 點擊以免 Slack 重試。
  • 標準 slack_bolt 規則適用——在 3 秒內 await ack(),然後執行較長的工作。
  • 對於多工作區部署,處理器會對來自任何已連線工作區的點擊觸發;如果需要限定範圍,使用 body["team"]["id"]

這是外掛參與 Slack 互動的公開方式。舊版外掛可能使用了補丁 SlackAdapter.connect;請優先使用此 API。

TIP

本指南涵蓋通用外掛(工具、鉤子、斜線指令、CLI 指令)。以下章節概述每種專用外掛類型的撰寫模式;每個都連結到其完整指南以取得欄位參考和範例。

專用外掛類型

Hermes 有五種超出通用介面的專用外掛類型。每個都作為目錄位於 plugins/<category>/<name>/(內建)或 ~/.hermes/plugins/<category>/<name>/(使用者)。合約依類別不同——選擇你需要的類型,然後閱讀其完整指南。

Model Provider 外掛——加入 LLM 後端

將 profile 放入 plugins/model-providers/<name>/

# plugins/model-providers/acme/__init__.py
from providers import register_provider
from providers.base import ProviderProfile

register_provider(ProviderProfile(
    name="acme",
    aliases=("acme-inference",),
    display_name="Acme Inference",
    env_vars=("ACME_API_KEY", "ACME_BASE_URL"),
    base_url="https://api.acme.example.com/v1",
    auth_type="api_key",
    default_aux_model="acme-small-fast",
    fallback_models=("acme-large-v3", "acme-medium-v3"),
))
# plugins/model-providers/acme/plugin.yaml
name: acme-provider
kind: model-provider
version: 1.0.0
description: Acme Inference — OpenAI-compatible direct API

在任何東西呼叫 get_provider_profile()list_providers() 時延遲發現——auth.pyconfig.pydoctor.pymodels.pyruntime_provider.py 和 chat_completions 傳輸會自動串接。使用者外掛按名稱覆蓋內建外掛。

完整指南:Model Provider Plugins—欄位參考、可覆蓋的鉤子(prepare_messagesbuild_extra_bodybuild_api_kwargs_extrasfetch_models)、api_mode 選擇、認證類型、測試。

Platform 外掛——加入 Gateway 頻道

將適配器放入 plugins/platforms/<name>/

# plugins/platforms/myplatform/adapter.py
from gateway.platforms.base import BasePlatformAdapter

class MyPlatformAdapter(BasePlatformAdapter):
    async def connect(self): ...
    async def send(self, chat_id, text): ...
    async def disconnect(self): ...

def check_requirements():
    import os
    return bool(os.environ.get("MYPLATFORM_TOKEN"))

def _env_enablement():
    import os
    tok = os.getenv("MYPLATFORM_TOKEN", "").strip()
    if not tok:
        return None
    return {"token": tok}

def register(ctx):
    ctx.register_platform(
        name="myplatform",
        label="MyPlatform",
        adapter_factory=lambda cfg: MyPlatformAdapter(cfg),
        check_fn=check_requirements,
        required_env=["MYPLATFORM_TOKEN"],
        # Auto-populate PlatformConfig.extra from env so env-only setups
        # show up in `hermes gateway status` without SDK instantiation.
        env_enablement_fn=_env_enablement,
        # Opt in to cron delivery: `deliver=myplatform` routes to this var.
        cron_deliver_env_var="MYPLATFORM_HOME_CHANNEL",
        emoji="💬",
        platform_hint="You are chatting via MyPlatform. Keep responses concise.",
    )
# plugins/platforms/myplatform/plugin.yaml
name: myplatform-platform
label: MyPlatform
kind: platform
version: 1.0.0
description: MyPlatform gateway adapter
requires_env:
  - name: MYPLATFORM_TOKEN
    description: "Bot token from the MyPlatform console"
    password: true
optional_env:
  - name: MYPLATFORM_HOME_CHANNEL
    description: "Default channel for cron delivery"
    password: false

完整指南:Adding Platform Adapters—完整的 BasePlatformAdapter 合約、訊息路由、認證門檻、設定精靈整合。參見 plugins/platforms/irc/ 取得一個僅使用標準函式庫的運作範例。

Memory Provider 外掛——加入跨 Session 的知識後端

MemoryProvider 的實作放入 plugins/memory/<name>/

# plugins/memory/my-memory/__init__.py
from agent.memory_provider import MemoryProvider

class MyMemoryProvider(MemoryProvider):
    @property
    def name(self) -> str:
        return "my-memory"

    def is_available(self) -> bool:
        import os
        return bool(os.environ.get("MY_MEMORY_API_KEY"))

    def initialize(self, session_id: str, **kwargs) -> None:
        self._session_id = session_id

    def sync_turn(self, user_content, assistant_content, *,
                  session_id="", messages=None) -> None:
        ...

    def prefetch(self, query, *, session_id="") -> str:
        ...

    def get_tool_schemas(self) -> list[dict]:
        return []   # required @abstractmethod — see full guide

def register(ctx):
    ctx.register_memory_provider(MyMemoryProvider())

記憶供應商是單選的——一次只能有一個啟用,透過 config.yaml 中的 memory.provider 選擇。

完整指南:Memory Provider Plugins—完整的 MemoryProvider ABC、執行緒合約、Profile 隔離、透過 cli.py 的 CLI 指令註冊。

Context Engine 外掛——替換 context 壓縮器

# plugins/context_engine/my-engine/__init__.py
from agent.context_engine import ContextEngine

class MyContextEngine(ContextEngine):
    @property
    def name(self) -> str:
        return "my-engine"

    def update_from_response(self, usage) -> None: ...
    def should_compress(self, prompt_tokens: int = None) -> bool: ...
    def compress(self, messages, current_tokens=None, focus_topic=None) -> list: ...

def register(ctx):
    ctx.register_context_engine(MyContextEngine())

Context 引擎是單選的——透過 config.yaml 中的 context.engine 選擇。

完整指南:Context Engine Plugins

圖片生成後端

將供應商放入 plugins/image_gen/<name>/

# plugins/image_gen/my-imggen/__init__.py
from agent.image_gen_provider import ImageGenProvider

class MyImageGenProvider(ImageGenProvider):
    @property
    def name(self) -> str:
        return "my-imggen"

    def is_available(self) -> bool: ...
    def generate(self, prompt: str, aspect_ratio="landscape", **kwargs) -> dict:
        # returns success_response(...) / error_response(...)
        ...

def register(ctx):
    ctx.register_image_gen_provider(MyImageGenProvider())
# plugins/image_gen/my-imggen/plugin.yaml
name: my-imggen
kind: backend
version: 1.0.0
description: Custom image generation backend

完整指南:Image Generation Provider Plugins—完整的 ImageGenProvider ABC、list_models() / get_setup_schema() 元資料、success_response() / error_response() 輔助函式、base64 vs URL 輸出、使用者覆蓋、pip 分發。

參考範例:plugins/image_gen/openai/(透過 OpenAI SDK 的 DALL-E / GPT-Image)、plugins/image_gen/openai-codex/plugins/image_gen/xai/(Grok 圖片生成)。

非 Python 擴充介面

Hermes 也接受完全不是 Python 外掛的擴充。這些在可插拔介面對照表中列出;以下章節簡述每種撰寫風格。

MCP 伺服器——註冊外部工具

Model Context Protocol (MCP) 伺服器可以將自己的工具註冊到 Hermes 中,不需要任何 Python 外掛。在 ~/.hermes/config.yaml 中宣告:

mcp_servers:
  filesystem:
    command: "npx"
    args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
    timeout: 120

  linear:
    url: "https://mcp.linear.app/sse"
    auth:
      type: "oauth"

Hermes 在啟動時連線到每個伺服器,列出其工具,並與內建工具一起註冊。LLM 看到它們就像任何其他工具一樣。完整指南:MCP

Gateway 事件鉤子——在生命週期事件時觸發

將 manifest + 處理器放入 ~/.hermes/hooks/<name>/

# ~/.hermes/hooks/long-task-alert/HOOK.yaml
name: long-task-alert
description: Send a push notification when a long task finishes
events:
  - agent:end
# ~/.hermes/hooks/long-task-alert/handler.py
async def handle(event_type: str, context: dict) -> None:
    if context.get("duration_seconds", 0) > 120:
        # send notification …
        pass

事件包括 gateway:startupsession:startsession:endsession:resetagent:startagent:stepagent:end 和萬用字元 command:*。鉤子中的錯誤會被捕獲並記錄——永遠不會阻塞主管線。

完整指南:Gateway Event Hooks

Shell 鉤子——在工具呼叫時執行 shell 指令

如果你只想在工具觸發時執行一個腳本(通知、稽核日誌、桌面提醒、自動格式化),在 config.yaml 中使用 shell 鉤子——不需要 Python:

hooks:
  - event: post_tool_call
    command: "notify-send 'Tool ran: {tool_name}'"
    when:
      tools: [terminal, patch, write_file]

支援與 Python 外掛鉤子相同的所有事件(pre_tool_callpost_tool_callpre_llm_callpost_llm_callon_session_starton_session_endpre_gateway_dispatch),加上用於 pre_tool_call 封鎖決策的結構化 JSON 輸出。

完整指南:Shell Hooks

技能來源——加入自訂技能註冊表

如果你維護一個 GitHub 技能儲存庫(或想從內建來源之外的社群索引拉取),將它加入為一個 tap

hermes skills tap add myorg/skills-repo
hermes skills search my-workflow --source myorg/skills-repo
hermes skills install myorg/skills-repo/my-workflow

發布你自己的 tap 只需要一個帶有 skills/<skill-name>/SKILL.md 目錄的 GitHub 儲存庫——不需要伺服器或註冊表帳號。

完整指南:Skills Hub·Publishing a custom tap(儲存庫結構、最小範例、非預設路徑、信任等級)。

透過指令範本的 TTS / STT

任何讀寫音訊或文字的 CLI 都可以透過 config.yaml 接入——不需要 Python 程式碼:

tts:
  provider: voxcpm
  providers:
    voxcpm:
      type: command
      command: "voxcpm --ref ~/voice.wav --text-file {input_path} --out {output_path}"
      output_format: mp3
      voice_compatible: true

對於 STT,將 HERMES_LOCAL_STT_COMMAND 指向一個 shell 範本。支援的佔位符:{input_path}{output_path}{format}{voice}{model}{speed}(TTS);{input_path}{output_dir}{language}{model}(STT)。任何與路徑互動的 CLI 都會自動成為一個外掛。

完整指南:TTS custom command providers·STT

透過 pip 分發

要公開分享外掛,在你的 Python 套件中加入一個 entry point:

# pyproject.toml
[project.entry-points."hermes_agent.plugins"]
my-plugin = "my_plugin_package"
pip install hermes-plugin-calculator
# 外掛在下次 hermes 啟動時自動發現

為 NixOS 分發

NixOS 使用者可以透過宣告式方式安裝你的外掛,前提是你提供帶有 entry points 的 pyproject.toml

Entry-point 外掛(推薦用於分發):

# User's configuration.nix
services.hermes-agent.extraPythonPackages = [
  (pkgs.python312Packages.buildPythonPackage {
    pname = "my-plugin";
    version = "1.0.0";
    src = pkgs.fetchFromGitHub {
      owner = "you";
      repo = "hermes-my-plugin";
      rev = "v1.0.0";
      hash = "sha256-...";  # nix-prefetch-url --unpack
    };
    format = "pyproject";
    build-system = [ pkgs.python312Packages.setuptools ];
  })
];

目錄外掛(不需要 pyproject.toml):

services.hermes-agent.extraPlugins = [
  (pkgs.fetchFromGitHub {
    owner = "you";
    repo = "hermes-my-plugin";
    rev = "v1.0.0";
    hash = "sha256-...";
  })
];

完整文件請見 Nix 設定指南,包括 overlay 用法和衝突檢查。

常見錯誤

處理器沒有回傳 JSON 字串:

# 錯誤——回傳 dict
def handler(args, **kwargs):
    return {"result": 42}

# 正確——回傳 JSON 字串
def handler(args, **kwargs):
    return json.dumps({"result": 42})

處理器簽名缺少 **kwargs

# 錯誤——如果 Hermes 傳入額外 context 會出問題
def handler(args):
    ...

# 正確
def handler(args, **kwargs):
    ...

處理器拋出例外:

# 錯誤——例外傳播,工具呼叫失敗
def handler(args, **kwargs):
    result = 1 / int(args["value"])  # ZeroDivisionError!
    return json.dumps({"result": result})

# 正確——捕捉並回傳錯誤 JSON
def handler(args, **kwargs):
    try:
        result = 1 / int(args.get("value", 0))
        return json.dumps({"result": result})
    except Exception as e:
        return json.dumps({"error": str(e)})

Schema 描述太模糊:

# 不好——模型不知道何時使用
"description": "Does stuff"

# 好——模型確切知道何時和如何使用
"description": "Evaluate a mathematical expression. Use for arithmetic, trig, logarithms. Supports: +, -, *, /, **, sqrt, sin, cos, log, pi, e."


使用 Cron 自動化一切