Hermes Agent Wiki 非公式・日本語wiki

Hermes プラグインを作る

目次

このガイドでは、Hermes プラグインを一から作り上げる手順を通して解説します。最後まで進めると、複数のツール、ライフサイクルフック、同梱データファイル、バンドルされたスキルを備えた、実際に動くプラグインが手元に残ります — プラグインシステムが対応するすべての要素が含まれています。

ポータブル Agent Plugins v1 パッケージ

Hermes は、Agent Plugins v1.0.0 形式を対象としたディレクトリパッケージのインストールと読み込みにも対応しています。これは Hermes が既に持っているポータブルなコンポーネント向けの互換アダプタであり、ネイティブな plugin.yaml と register(ctx) によるプラグインを置き換えるものではありません。

my-portable-plugin/
├── plugin.json
├── skills/
│   └── summarize/
│       ├── SKILL.md
│       └── references/
└── mcp.json

通常のワークフローでポータブルパッケージをインストールし、有効化します。

hermes plugins install owner/repository --no-enable
hermes plugins list
hermes plugins enable <plugin-name>

ポータブルパッケージはインストール後、明示的に有効化しない限り無効のままです。有効化されたパッケージは、skills/*/SKILL.md ディレクトリと、ルートの mcp.json による stdio MCP サーバーを即座に提供できます。スキルは読み取り専用で名前空間化され、skills_list と skill_view を通じて読み込まれます。MCP コマンドは、シェルを経由せず、単一の実行可能トークンと別個の引数リストとして渡されます。完全修飾されたスキル名を確認するには skills_list を使ってください。ポータブルスキルの名前空間は agent-plugin-<slug>-<hash> という決定的な形式を持ち、検出されたプラグインキーから導出されるため、サニタイズ後の名前が衝突することはありません。ポータブルパッケージの MCP サーバーは、mcp.json で付けられた名前をそのまま使います。ユーザー自身の mcp_servers ブロックと同じ規則です。これにより、モデルから見える mcp__<server>__<tool> という名前でも、プロバイダーの64文字の上限の中にツールの動詞が収まります。サーバー名の重複は読み込み時の衝突として扱われます。config.yaml のサーバーはパッケージより優先され、先に読み込まれたパッケージが後のものより優先されます。負けた側は、両方の名前を挙げた警告とともにスキップされます。

Hermes は plugin.json、Agent Skills のフロントマター、固定されたコンポーネントの配置、mcp.json、解決済みパス、シンボリックリンクの内包関係をローカルで検証します。パッケージの読み込み中に JSON スキーマを取得することはありません。不正なスキルや MCP エントリは、有効な兄弟コンポーネントがまだ読み込める場合、その境界だけでスキップされます。PLUGIN_ROOT は解決済みのパッケージルートを指します。PLUGIN_DATA は Hermes が管理する、プロファイルスコープの書き込み可能なディレクトリを指します。 ポータブル MCP の env に宣言された値は可視のパッケージデータであり、シークレット保管の仕組みではありません。mcp.json に認証情報を置かないでください。

現在サポートされているポータブルのサブセットは、stdio と Streamable HTTP の MCP エントリです。ポータブルな streamable-http エントリは、Hermes の既存のネイティブなリモート MCP クライアント(URL ベースの mcp_servers 設定を支えるのと同じランタイム)を通じてルーティングされ、v1 の境界規則が適用されます: URL はユーザー情報やフラグメントを含まない絶対 http(s) でなければならず、プレーンな HTTP は localhost / ループバックホストに対してのみ許可され、設定されたヘッダーはクロスオリジンのリダイレクトを越えて転送されることはありません。旧来の sse エントリは報告されてスキップされます。Agent Plugins v1 は、trust、permissions、provenance、sandbox のいずれも定義していません。パッケージを有効化すると、その instructions とローカル実行ファイルには、他のインストール済み Hermes プラグインと同じフルトラストの扱いが与えられます。

パッケージは、自分の MCP サーバーのうち1つに確認の関門を設けるよう Hermes に求めることができます。利用者が config.yaml に trust: untrusted と書いたときと同じ働きです。 お金を使う、取引をする、メッセージを送る、アカウントを変更する、といったツールを持つサーバーに使ってください。 スキルの指示だけに頼るのではなく、書き込みのできる呼び出しを1回ずつ利用者が承認する形になります。

{
  "extensions": {
    "com.nousresearch.hermes": {
      "servers": {
        "trade": { "trust": "untrusted" }
      }
    }
  }
}

サーバー名は mcp.json のエントリと一致している必要があります。untrusted にすると、そのサーバーへのツール呼び出しのうち readOnlyHint: true の注記がないものはすべて、先に利用者に確認を求めます。 答えられる人がいない場面(cron や無人での実行)では、安全側に倒して失敗します。 指定できる値はほかに既定の full だけなので、パッケージは権限を狭めることはできても、広げることはできません。 同じ名前のサーバーが config.yaml にあると、trust を含めてパッケージ側のエントリを置き換えます。この拡張は、ほかのハーネスでは無視されます。 trust は、同じサーバーエントリの中で app、requires、liveness と並べて書けます(アプリケーションの宣言 を参照)。

長時間動くサーバーは、Hermes 専用のツール呼び出しの timeout(秒)を plugin.json で宣言することもできます。ほかのサーバーの既定値は変わりません。

{
  "extensions": {
    "com.nousresearch.hermes": {
      "servers": { "analysis": { "timeout": 1000 } }
    }
  }
}

値は正の有限の数で、プラットフォームのスレッド待機の上限以内でなければなりません。この値はサーバーごとのネイティブな timeout に対応し、省略すると従来どおり timeouts.mcp.tool_call が使われます。同じ名前のネイティブな mcp_servers エントリがある場合は、タイムアウトも含めてパッケージ側のエントリ全体を置き換えます。この設定で延びるのは待ち時間の上限だけで、キャンセルや権限の方針は変わりません。サーバー内部で分析の締め切りがあるなら別に設定し、その結果がクライアントへ届くまでの時間も残しておいてください。この拡張は、ほかのハーネスでは無視されます。

その Hermes が知らないサーバーエントリのキー(新しいリリースで追加されたもの)は警告を出して無視され、hermes plugins validate がそのキーを一覧に出しますが、パッケージ自体はインストールされます。この決まりより前のリリースでは、代わりにパッケージが拒否されます。また、キーを無視したリリースは意図したより安全でない動きをすることがあるため、新しいキーを宣言するときは、カタログの requires_hermes で対応する最低バージョンもあわせて指定してください。

サーバーごとに明示した MCP 呼び出しの時間枠は、Hermes の外側のツール実行器にも伝わります。時間枠が、同時に実行するバッチの共通の締め切りを超える呼び出しは、直接呼ばれてもツール検索経由で呼ばれても、順番待ちの区切りとして1つずつ実行されます。その呼び出しは自分専用の時間枠を持ち、同じモデル応答に含まれるほかのツールの時間は延ばしません。もっと短い既存の MCP 呼び出しは、通常どおり並列に受け付けられます。中断と承認の扱いは変わりません。

rendered specification は現在 v1.0.0 を Working Draft としていますが、versioned specification repository では Published として記録されています。Hermes は、どちらの可変なステータスラベルでもなく、正規の v1.0.0 スキーマ識別子と規範テキストに動作を紐づけています。これは Agent Plugins への完全準拠を主張するものではなく、明示的にサポートされるサブセットです。

ネイティブプラグインの互換性契約

ネイティブな plugin.yaml と register(ctx) によるプラグインは、単一のグローバルなプラグイン API 番号ではなく、振る舞いによって保護されています。Hermes は PLUGIN_API_VERSION を公開せず、マニフェスト全体で api: の一致を要求することもなく、無関係な値に API バージョンを付与することもありません。文書化された振る舞いを使うプラグインは、通常の Hermes アップグレードの後も動作し続けるはずです。

互換性の規則は次のとおりです。

  • 加算的に進化する。 文書化された PluginContext のメソッドは削除・リネームされません。新しいパラメータは省略可能で、デフォルト値を持ち、キーワード専用であるべきです。既存の戻り値フィールドは削除・暗黙の型変更をされません。
  • フックのペイロードはキーワードペイロードである。 新しいフックデータは、既存フィールドの意味や位置を変えることなく、キーワードフィールドとして追加されます。Hermes はコールバックのシグネチャを検査します: 従来のコールバックは自身が宣言したフィールドだけを受け取り、**kwargs を持つコールバックは現在のペイロード全体を受け取ります。新しいプラグインは **kwargs を受け入れるべきです。そうすれば、シグネチャを変えずに追加データを取り込めます。
  • マニフェストは追加に対して開いている。 plugin.yaml の未知のフィールドは無視されます。したがって古い Hermes のリリースでも、プラグインのコード自体がサポート済みのランタイム動作を使っていれば、新しいリリースで導入されたメタデータを含むマニフェストのプラグインを読み込めます。
  • プロバイダーインターフェースはデフォルト値によって成長する。 新しいプロバイダーのメソッドにはデフォルト実装があります。新しいコールバックコンテキストは省略可能で、シグネチャ検査によってプロバイダーがそれを受け取れると分かった場合にのみ転送されます。抽象メソッドや無条件で転送される引数を追加するには、フラグデー的な一括切り替えではなく移行期間が必要です。
  • 境界を越える契約にはバージョンを付ける。 ワイヤーペイロードや永続化形式を定義する機能(observer のペイロードや secret-source の状態など)は、独自のスキーマバージョンを持てます。そのローカルなスキーマ内ではフィールドを加算的に保ってください。永続化されたプラグインの状態や設定は読み取り可能なままにするか、明示的な移行を提供してください。古い形式で書かれた再開セッションは、依然としてリプレイできなければなりません。無関係なコールバックやコンテキストの値にバージョンリテラルを追加しないでください。

この契約が対象にするのは、文書化された部分だけです。実行時にコアの関数、メソッド、モジュールの属性、内部のテーブルを置き換えたり包んだりすること(AIAgent.<method> への代入、Hermes のモジュールへの setattr、sys.modules やコアの dict への書き込み)は、サポートされた拡張の方法ではありません。内部の構成が変わるたびに壊れますし、同じ箇所に手を入れるほかのプラグインとも衝突します。プラグインのカタログは、受け入れの時点でこれを拒否します(hermes plugins validate の no core override の検査)。必要な公開フックが無い場合は、それを説明した issue を立ててください。

廃止方針

文書化されたネイティブプラグインの振る舞いを廃止できるのは、次のすべてを満たす場合だけです。

  1. プラグインガイドとリリースノートに、置き換え先と移行手順が示されていること。
  2. プロセスごとに最大 1 回だけ警告が発せられ、置き換え先と最短の削除リリースが名指しされること。
  3. 古い振る舞いが、少なくとも後続の 2 回のマイナーリリースの間サポートされること。
  4. その期間中、旧経路と新経路の両方に対して振る舞いベースの互換性カバレッジがあること。

期間終了後の削除には、永続化データや再開可能なセッションに必要な移行がすべて含まれていなければなりません。実務上は、削除よりも加算的なエイリアスやアダプタが好まれます。

Hermes は、隔離された HERMES_HOME から検出される固定済みの外部プラグインフィクスチャで、この契約を強制します。これらのテストは PluginManager を通じてプラグインを読み込み・呼び出し、内部のシンボル一覧やソースコードの形ではなく、実際の登録とコールバックの結果を検証します。

2026年9月のモジュール分割: 旧インポートパスは削除済み

Hermes の内部は、2026年9月(PR #102117)に <stem>_<topic> という兄弟モジュールへ分割されました。**Internal

パスは 2026-09-14 まで一時的な互換レイヤーで解決されていましたが、そのレイヤーは削除されました。そのため、旧パスを import し続けているプラグインは ImportError で読み込みに失敗します(理由は hermes plugins list に表示されます)。

こうしたプラグインを直すには、その名前をいま定義しているモジュールから import してください。内部に頼らず、ctx と文書化された ABC を使うほうがさらに確実です。旧パスから新パスへの完全な対応表は、互換レイヤーを含んでいた最後のコミットにある COMPAT_MANIFEST.md です。

作るもの

2つのツールを持つ calculator プラグインです。

  • calculate — 数式を評価する(2**16、sqrt(144)、pi * 5**2)
  • unit_convert — 単位を変換する(100 F → 37.78 C、5 km → 3.11 mi)

さらに、すべてのツール呼び出しを記録するフックと、バンドルされたスキルファイルも作ります。

ステップ1: プラグインディレクトリを作る

ディレクトリを作成し、ステップ2に進みます。

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

Plugin Doctor で検証する

hermes plugins doctor [path-or-id] は、Hermes 自身が使っているのと同じディレクトリ検出、マニフェストパーサー、名前空間付き import、register(ctx)、フックレジストリ、ツールレジストリを実行します。不正なフック名、**kwargs を受け取らないコールバック、登録の失敗、宣言済みとの登録済みツール/フックのずれを報告します。desktop/plugin.js を持つパッケージでは、Desktop アプリがその古い複製を動かしているときにも警告します(統合パッケージを開発するを参照)。エラー時に終了コードを非ゼロにするには --ci を渡します。

hermes plugins doctor . --ci

Doctor は一時的な HERMES_HOME を使い、チェック後にプラグインの登録状態を復元し、登録実行中の Python ソケット接続を直接ブロックして偶発的なネットワークアクセスを検出します。これはサンドボックスではありません — プラグインのコードは現在のユーザーの権限でプロセス内で実行され、サブプロセスを起動することもできるため、import してよいと信頼できるコードにだけ Doctor を実行してください。

ステップ2: マニフェストを書く

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_tools と provides_hooks は、プラグインが登録するものの一覧です。

register() が登録するツールは、すべて provides_tools に並べてください。このフィールドは、利用者が入れたプラグインのツールを読み込むかどうかを決めません。プラグインを有効にすれば、宣言の有無にかかわらず register() が登録したものはすべて使えます。このフィールドが実際に効くのは次の場面です。

  • hermes plugins validate: 登録されたツールが一覧と一致しないと「declared tools」の検査が失敗し、カタログへの受け入れが止まります。
  • カタログの表示: カタログやダッシュボード / Desktop の Plugins ページに出る「N tools」の表示と、ツール名での検索に使われます。
  • ダッシュボードの認証の案内: 「needs auth」の表示と hermes auth <name> コマンドの案内には、宣言したツールの利用可否チェックだけが使われます。
  • 同梱の kind: platform プラグインのみ: アダプターの読み込みを後回しにしたまま、CLI/TUI のセッションで tools.py を読み込むかどうかを、このフィールドが切り替えます。外向きのクライアントツールを参照してください。

追加できる任意のフィールドの例です。

author: Your Name
requires_env:          # gate loading on env vars; prompted during install
  - SOME_API_KEY       # simple format — plugin disabled if missing
  - name: OTHER_KEY    # rich format — shows description/url during install
    description: "Key for the Other service"
    url: "https://other.com/keys"
    secret: true
capabilities:          # privileged host surfaces you request (consent flow)
  - tools.override     # replace built-in tools (needs user consent)
  - llm.model_override # choose the model for host-owned LLM calls

capabilities を宣言する

プラグインが権限を要する host のサーフェス — 組み込みツールの上書き、ctx.llm 呼び出しに使うモデルの選択など — を必要とする場合は、capabilities: に宣言します。インストール/有効化のタイミングでユーザーにその一覧が示され、一度だけ同意します。後のバージョンで capability が追加された場合、更新フローはその追加分についてだけ再度確認します。宣言されていない、または同意されていない capability は単純にオフになります(フェイルクローズ)。そのため、使う前に確認し、なければ穏やかに縮退動作してください。

def register(ctx):
    if ctx.has_capability("tools.override"):
        ctx.register_tool(..., override=True)
    else:
        ctx.register_tool(...)   # register under a non-conflicting name

既知の capability id は tools.override、llm.provider_override、llm.model_override、llm.agent_id_override、llm.profile_override、llm.task_override です(正本のレジストリは hermes_cli/plugin_capabilities.py)。未知の id は無視されます。旧来の capability ごとの設定キー(plugins.entries.<id>.allow_tool_override など)も依然として動作しますが非推奨です — ユーザーが単一の、監査可能な同意画面を得られるように、代わりに capability を宣言してください。capability は同意と監査であり、サンドボックスではありません。それらは host の API サーフェスをゲートするだけです。

pip で配布されるプラグインには、インストール後の plugin.yaml ディレクトリがありません。そのため、代わりに配布メタデータで capability を宣言します。companion となる hermes_agent.plugin_capabilities エントリーポイントグループを使います。各宣言は <plugin-id>.<capability-id> という名前で、hermes_agent.plugins エントリーポイントと同じオブジェクトを指します。

[project.entry-points."hermes_agent.plugins"]
calculator = "my_pkg:register"

[project.entry-points."hermes_agent.plugin_capabilities"]
"calculator.tools.override" = "my_pkg:register"

Hermes はこれらをインストール済みメタデータから読み取り、あなたのコードを import しません。そのため、hermes plugins capabilities と同意フローは、pip インストールでも正確な状態を保ちます。

ユーザーの Codex サインインを使う(コアが行うリクエスト)

ユーザーの ChatGPT / Codex サブスクリプションが必要なプラグイン(OpenAI Realtime のクライアントシークレットの発行、Codex のバックエンドのエンドポイントの呼び出しなど)は、 ~/.codex/auth.json や Hermes の auth.json を読んだり、更新したり、書き換えたりしてはいけません。Codex のリフレッシュ トークンは1回しか使えません。プラグインが自分で更新すると、Hermes のロック付きの更新と競合し、 負けた側のローテーションの連鎖が取り消されて、ユーザーはサインアウトされてしまいます。 代わりに、リクエストを Hermes に任せてください。Hermes は自分の更新の経路で認証情報を解決し、 Authorization を自分で付けて、レスポンスだけを返します。プラグインのコードがトークンを目にすることはありません。

plugin.yaml でサインインを宣言します。

name: live-voice
requires_auth:
  - openai-codex

そのうえで公開のヘルパーを呼びます。普通の関数なので、register(ctx) のコードだけでなく、ダッシュボードのプラグイン (dashboard/plugin_api.py)や同梱したモジュールからも import できます。

from hermes_cli.plugin_provider_requests import ProviderNotSignedIn, credentialed_provider_request

def mint_client_secret(session: dict) -> dict:
    try:
        resp = credentialed_provider_request(
            "openai-codex", "POST", "https://api.openai.com/v1/realtime/client_secrets",
            json={"session": session}, timeout=15)
    except ProviderNotSignedIn as exc:
        return {"ok": False, "error": str(exc)}  # tells the user how to sign in
    if resp.status != 200:
        return {"ok": False, "error": f"client secret failed ({resp.status}): {resp.text[:300]}"}
    return {"ok": True, **resp.json()}

これは、~/.codex/auth.json を読み、トークンの期限が近いと auth.openai.com/oauth/token に POST して、結果を書き戻していた手作りのヘルパーを置き換えるものです。そのコードは削除してください。プラグイン側に更新の手順はもう残っていません。

credentialed_provider_request(provider, method, url, *, json=None, headers=None, timeout=30.0) は ProviderResponse(status、headers、body、.text、.json())を返します。 決まりは次のとおりです。

  • 宣言したプラグインだけ。 呼び出し元は、コールスタック上にファイルがあるプラグインです。

Hermes はそれをプラグインのディレクトリの下から見つけ、その plugin.yaml を読みます。requires_auth にプロバイダーを載せていないプラグインや、 インストール済みのどのプラグインにも属さないコードは PermissionError になります。ヘルパーは 自分の関数の中から呼んでください。ヘルパーをそのまま run_in_executor に渡すと、 スタック上にプラグインのフレームが残らないため、呼び出しは拒否されます。

  • プロバイダーのオリジンだけ。 openai-codex の場合、トークンが送られるのは

https://chatgpt.com と https://api.openai.com だけです。それ以外の URL では、リクエストを送る前に PermissionError になります。Codex の認証情報が独自のゲートウェイへ向かうプロファイルも拒否されます。 そのキーはゲートウェイのものだからです。

  • リダイレクトしない。 3xx のレスポンスはそのまま返り、追いかけることはありません。そのため

ヘッダーが第三のホストに届くことはありません。

  • プロファイル単位。 使われる認証情報は、アクティブなプロファイルの Codex サインインです。

チャットが使うものと同じです(hermes auth add openai-codex、またはデスクトップアプリの Codex サインイン)。サインインがない場合は ProviderNotSignedIn になり、そのメッセージに サインインのコマンドが示されます。

  • 渡した headers も送られます。ただし、Hermes が付ける

Authorization(またはアカウント)のヘッダーを置き換えるものは除きます。

requires_auth は hermes plugins show <name> とインストール時に表示されます。 カタログの再ピン留めでこれが追加される場合は、更新の前にユーザーに確認します。capabilities と同じく、 これは同意と見える化のためのものです。サンドボックスではありません。プロセス内のプラグインのコードは、 ユーザーが読めるファイルなら何でも読めてしまいます。そうするプラグインはレビューで却下されます。

使用量のパネル: 使用量のエンドポイントを自分で呼ばないでください。 agent.account_usage.fetch_account_usage("openai-codex", read_only=True) を使います。これは AccountUsageSnapshot(プランと、used_percent と reset_at を持つセッション/週単位の枠)を返し、 認証情報を更新したりローテーションしたりすることはありません。 サインアウトはプラグインではなく Hermes の役割です。認証情報のファイルを削除するのではなく、 ユーザーには hermes auth logout openai-codex を案内してください。

マニフェスト v2 一覧

plugin.yaml は加算的な v2 スキーマ(#64165)にも対応しています。すべてのフィールドは省略可能です。manifest_version を持たないマニフェストは v1 マニフェストであり、今後も永続的に完全サポートされます。未知のフィールドは読み込みを壊しません — 警告付きで無視されます(前方互換性)。また、このバージョンの Hermes が理解できない新しい manifest_version でも、警告付きで読み込まれます。

フィールド 型 意味
manifest_version int マニフェストのファイル形式バージョン。省略時は 1。現在の最大値: 2。api_version とは独立。
api_version int プラグインが対象とするランタイムのプラグイン API 世代(ctx サーフェス / フックのシグネチャ)。manifest_version とは意図的に別軸で、api_version: 1 のプラグインが v2 マニフェストを使うこともできる。
requires_plugins list プラグイン間の依存関係: version_range: ">=1.0,<2" を任意で指定した - id: other-plugin。あくまで助言: 依存先がなくても明確な警告が出るだけでプラグインは読み込まれる — 実行時に ctx.has_plugin("other-plugin") で確認する。読み込みの順序はこの依存関係を尊重する: A が B を要求する場合、B の register() は A より先に実行される(トポロジカルソート、アルファベット順のタイブレーク。循環は警告しアルファベット順にフォールバックする)。
python_dependencies list of str 宣言した Python の要件(例: "requests>=2.0,<3")。インストール時には同意を求める。有効化すると、既存の core・追加パッケージ・有効なプラグインの和集合とあわせて、候補を PM に受け入れる。準備が成功すると、環境と設定がトランザクションとして反映される。失敗したときは、以前の選択と有効なプラグインの集合がそのまま残る。同意しなかった場合、インストールしたプラグインは無効のままになる。上限を固定すること。
python_runtime str external — プラグインが自身のインタプリタ/venv を管理する(サイドカーパターン)。Hermes は何もインストールせず、既存の pyproject.toml にも触れない。
config_schema mapping plugins.entries.<id>.settings 以下のキーを記述する JSON-schema 風の説明: api_url: {type: str, default: "", description: "...", required: false}。読み込み時に検証され、不一致はキー名と期待する型を示す実用的な警告としてログに出る — 読み込みエラーにはならない。型は str、int、float、bool、list、dict(と JSON-schema の別名)、secret。Desktop の Plugins タブの設定フォームも駆動する — Desktop の設定フォーム を参照。
license str SPDX 形式のライセンス id(例: MIT)。
homepage str プロジェクトの URL。
tags list of str 自由形式の発見用タグ(例: [gateway, telegram])。
provides_locales list 言語パックの宣言です。id(- pl)か {id, endonym, rtl} の対応を並べると、ローダーがその `locales/<id>[.tui\ .desktop].yaml` を自動で登録します。言語パックを同梱する を参照してください。
requires_auth list of str プラグインが Hermes を通して使う、サインイン先のプロバイダー(現在は openai-codex)。credentialed_provider_request に必要です。インストール時と hermes plugins show に表示されます。ユーザーの Codex サインインを使う を参照してください。
# plugin.yaml — manifest v2 example
name: my-plugin
version: 1.2.0
manifest_version: 2
api_version: 1
license: MIT
homepage: https://github.com/owner/my-plugin
tags: [gateway, demo]
requires_plugins:
  - id: other-plugin
    version_range: ">=1.0,<2"
python_dependencies:
  - "somepkg>=1.0,<2"     # consent before PM admission
config_schema:
  api_url: {type: str, default: "", description: "Service endpoint"}

Python の依存関係

ディレクトリ型プラグインは、自身の PyPI パッケージを持ち込めます。マニフェスト内(前述の python_dependencies)で宣言するか、より好ましい方法として、plugin.yaml の横に置く pyproject.toml で宣言します。

[project]
name = "my-plugin"
version = "1.0.0"
requires-python = ">=3.11"
dependencies = [
    "somepkg>=1.0,<2",
    "other[extra]>=3.11",
]

両方が存在する場合は pyproject.toml が優先されます。Hermes がこれらに対して行うことは次のとおりです。

  • インストール / 有効化 — PM は、core・選択した追加パッケージ・有効なプラグインの和集合を、

依存関係のホームを共有するすべてのプロファイルにわたって解決します。独自の HERMES_HOME のルートも含みます。 新しいプラグインは無効の状態でダウンロードされ、有効にする前に Python の依存関係への同意が必要です。 pyproject.toml は、python_dependencies や旧来の pip_dependencies より優先されます。

  • 不可分な反映 — PM は、有効化や、有効なプラグインの差し替えを反映する前に、新しい環境の世代を

準備します。解決・ダウンロード・ビルドのどれかに失敗しても、以前の環境とプラグインの選択は そのまま残ります。既存のプラグインが犠牲になることはありません。

  • 更新しても和集合は保たれる — hermes update は、新しい世代を準備するときに有効なプラグインも

含めます。更新後に pip で入れ直す処理はありません。hermes plugins update は、有効なプラグインの 差し替えを準備してから、そのコードと依存関係の世代をまとめて入れ替えます。

  • 要件の衛生管理 — 形式の崩れた PEP 508 の要件は拒否されます。環境マーカーはそのまま残り、

対象のインタプリタが評価します。hermes-agent 自身への依存は、チェックアウトが Hermes を提供するので 省かれます。URL を直接指定した要件は管理対象外です。それらには、プラグイン自身が持つ外部の ランタイムを使ってください。

  • --no-deps を付けると、依存関係への同意なしで新しいプラグインをダウンロードし、--enable を

付けていても無効のままにします。有効なプラグインを差し替えるときに、PM の受け入れを回避することはできません。

  • --yes-deps を付けると依存関係の質問に前もって答えたことになり、画面のない環境でのインストール(CI、SSH

での自動化、コンテナのエントリポイント)でも拒否されずに、宣言された依存関係を用意します。 --no-deps とは同時に使えません。

  • python_runtime: external は、sidecar の依存関係を共有の和集合から外します。

Hermes はその Python ランタイムをインストールせず、その宣言も変更しません。

  • 読み込むものが何もないのはエラー — hermes plugins validate は、__init__.py、

desktop/plugin.js、plugin.json のいずれも横にない plugin.yaml を拒否します。pip レイアウトの パッケージには、ディレクトリ型プラグインのラッパーが必要です。

  • security.allow_lazy_installs: false は、必要になった時点での取得を止めます。依存関係への明示的な

同意と明示的な有効化があれば、PM の準備は許可されます。検出だけでインストールが起きることはありません。

HERMES_HOME/plugins/ は hermes update と Desktop の更新を生き延びます — アップデータが再構築するのは venv とチェックアウトだけで、home ディレクトリには決して触れません。

依存関係のセキュリティ方針

Hermes は自分自身の依存関係について、ロックするときに公開からの経過日数を検査しています。チェックアウトの [tool.uv] exclude-newer = "3 days" によって、公開から3日たっていないリリースは uv.lock に入りません。乗っ取られたアップロードがあっても、利用者に届く前に上流で見つける時間を取るためです。利用者はこの確認済みのロックからインストールし、利用者の端末で日付を確かめ直すことはありません。この確認は、プラグインの依存関係には効きません。Hermes がプラグインを自分の環境へ解決するとき、Hermes が版を厳密に固定しているパッケージはその版のまま残り、Hermes が使うそれ以外のパッケージは Hermes が宣言した範囲の中で動くことがあり、残りはすべてプラグインの要件どおりに決まります。そのため、プラグインは昨日公開されたリリースを下限に指定して、今日インストールさせることができます。それで何が入ってくるかに責任を持つのは、Hermes ではなくプラグインの作者です。Hermes が厳密に固定しているパッケージについて別の版を要求するプラグインは、Hermes の固定が更新されるまでインストールに失敗します。

自分で方針を決めて、それを守ってください。強くおすすめするのは次のとおりです。

  • すべての依存関係に上限を付ける — 安定版のパッケージなら >=floor,<next_major、1.0 未満のものなら >=0.29,<0.32 のように書きます。>=X.Y だけでは、今後のリリースを確認しないまま全部受け入れることになります。
  • 下限は、API の互換性がある最も古い版にする。その週に出たばかりの版にはしません。出たばかりの wheel を下限にすると、公開されたその日からインストールする全員がその版を強いられます。>=old,!=broken,<next と書けば、広い範囲を保ったまま、問題のあるリリースだけを避けられます。
  • 公開からの経過日数の検査を自分でも行う。下限を新しいリリースに上げるまで数日待ち、自分の CI では uv --exclude-newer "3 days"(または UV_EXCLUDE_NEWER)で解決します。こうすると、テストしたロックと利用者が受け取るロックが同じになります。
  • ロックを固定し、更新は確認してから入れる。依存関係の更新はコードの変更として扱ってください。上流の差分を読んでから、固定し直します。

プラグインカタログの審査では、固定された SHA 時点の依存関係の一覧(plugin.yaml または pyproject.toml)を読み、上限のない下限指定や上限の付け忘れを指摘します。下限が単に新しいというだけでは、掲載は保留されません。

ステップ3: ツールスキーマを書く

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"],
    },
}

スキーマが重要な理由: description フィールドは、LLM がいつそのツールを使うかを判断する手がかりです。何をするツールで、いつ使うべきかを具体的に書いてください。parameters は LLM が渡す引数を定義します。

ステップ4: ツールハンドラーを書く

tools.py を作成します — これは LLM がツールを呼び出したときに実際に実行されるコードです。

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

# 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. 決して raise しない: すべての例外を捕捉し、代わりにエラー JSON を返します。
  4. **kwargs を受け入れる: Hermes はコンテキストのキーワード(task_id、session_id、user_task、

parent_agent、...)を注入し、あなたのシグネチャが名前を挙げているものだけを転送するので、def handler(args) でも 動作します。**kwargs は、加算的に増えていく完全なコンテキストに参加するための方法です。

ステップ5: 登録を書く

__init__.py を作成します — これがスキーマとハンドラーを結びつけます。

"""Calculator plugin — registration."""

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() が行うこと:

  • 起動時に必ず1回だけ呼ばれます
  • ctx.register_tool() はあなたのツールをレジストリに置きます — モデルはすぐにそれを認識します
  • ctx.register_hook() はライフサイクルイベントを購読します
  • ctx.register_cli_command() は CLI サブコマンド(例: hermes my-plugin <subcommand>)を登録します
  • ctx.register_command() はセッション内のスラッシュコマンド(例: CLI / ゲートウェイのチャット内で /myplugin <args>)を登録します — 下の スラッシュコマンドを登録する を参照してください
  • ctx.dispatch_tool(name, arguments) — 承認、認証情報、task_id といった親エージェントのコンテキストを自動的に配線した状態で、他の任意のツール(組み込みでも別プラグインのものでも)を呼び出します。モデルが直接呼んだかのように terminal、read_file、その他のツールを呼び出す必要があるスラッシュコマンドハンドラーから使うと便利です。
  • ctx.get_config() / ctx.set_config() はこのプラグインの設定名前空間だけにアクセスします。ctx.state は、アクティブなプロファイル下でプラグインが所有するランタイムデータを保存します。
  • この関数がクラッシュした場合、そのプラグインは無効化されますが 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",
    )

ディスパッチされたツールは、通常の承認・redaction・budget のパイプラインを通ります — それらを回避する近道ではなく、実際のツール呼び出しです。

設定とランタイム状態を保存する

ユーザーに見える振る舞いには、プラグイン相対の設定キーを使ってください。Hermes はそれらを plugins.entries.<plugin-id>.settings の下で解決し、グローバル、他プラグイン、トラバーサルの各種パスは拒否します。

def register(ctx):
    endpoint = ctx.get_config("endpoint", default="https://example.invalid")
    retries = ctx.get_config("retry.attempts", default=3)

    ctx.set_config("endpoint", endpoint)
    ctx.set_config("retry.attempts", retries)

config.yaml にランタイムの記録を置くのではなく、プラグインが所有するカーソル、キャッシュ、重複排除データには ctx.state を使ってください。

def register(ctx):
    cursor = ctx.state.get("cursor", default={"page": 0})
    ctx.state.set("cursor", {"page": cursor["page"] + 1})

state はプロファイルスコープで、アトミックに置き換えられ、複数の書き手に対して安全で、プラグインごとに 10 MiB に制限されます。ポータブルパッケージは、PLUGIN_DATA として同じディレクトリを共有します。ネイティブプラグインは、衝突耐性のある Windows 安全な名前空間を受け取ります。既存の state が壊れている場合は、それが報告され、保持されます。

設定と state は所有者が異なります: 設定は config.yaml にあるユーザーに見える振る舞いであり、state は <HERMES_HOME>/plugin-data/ にあるプラグイン所有のランタイムデータです。どちらの API も、他プラグインの名前空間を公開しません。

Desktop の設定フォーム

マニフェストの config_schema に宣言した各キーは、Desktop アプリの Settings → Plugins にあるプラグイン専用のページで、1行ずつ表示されます(このページは Capabilities → Plugins でプラグインの行にある歯車アイコンから開きます)。Desktop 側のコードは不要です — バックエンドの plugins.manage list がスキーマと各キーの現在値を返し、保存すると ctx.set_config() と同じ書き込み経路を通るため、plugins.entries.<id>.settings.<key> があなたのプラグインが読み返す値になります。フォームは type によって表駆動されます。

マニフェストの type フィールド 追加のキー
str(既定) テキスト入力 choices: [a, b](または enum:)を指定するとドロップダウンになる
int、float 数値入力
bool スイッチ
list、dict JSON エディタ
secret マスクされた入力 env: MY_PLUGIN_TOKEN — 値が格納される .env 変数名(既定は <PLUGIN_ID>_<KEY> の大文字スネークケース)

すべてのエントリは label(または title。どちらも無い場合はキー名が文頭だけ大文字の形で表示され、maps_api_key なら "Maps API key" になります)、description(ラベルの下に出るヘルプテキスト)、default、required(その行に Required の印を付けます)も受け付けます。

config_schema:
  api_url: {type: str, default: "https://api.example.com", label: "API URL", description: "Service endpoint"}
  retries: {type: int, default: 3}
  mode: {type: str, choices: [fast, careful], default: fast}
  api_key: {type: secret, env: MY_PLUGIN_API_KEY, description: "Personal access token"}

シークレットは config.yaml に一切触れません。 secret フィールドは .env の変数名と、値が設定済みかどうかだけを保持します。Desktop はプロバイダーの API キーと同じ認証情報の経路(PUT /api/env)で値を保存し、あなたのプラグインは requires_env のエントリとまったく同じように、agent.secret_scope の get_secret("MY_PLUGIN_API_KEY") で読み取ります。.env や auth.json、ほかのツールの認証情報のファイルを自分で読まないでください(カタログのルール 11)。plugins.manage settings アクションは、シークレットキーの変更や、型または choices がスキーマと矛盾する値を拒否します。

ステップ6: テストする

Hermes を起動します。

hermes

バナーのツール一覧に calculator: calculate, unit_convert が表示されるはずです。

次のプロンプトを試してください。

What's 2 to the power of 16?
Convert 100 fahrenheit to celsius
What's the square root of 2 times pi?
How many gigabytes is 1.5 terabytes?

プラグインの状態を確認します。

/plugins

出力:

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

プラグインの検出をデバッグする

プラグインが表示されない、または表示されても読み込まれない場合は、HERMES_PLUGINS_DEBUG=1 を設定すると、詳細な検出ログが stderr に出力されます。

HERMES_PLUGINS_DEBUG=1 hermes plugins list

すべてのプラグインソース(bundled、user、project、entry-points)について、次が表示されます。

  • スキャンされたディレクトリと、それぞれが生み出したマニフェストの数
  • マニフェストごとに: 解決済みのキー、名前、種類、ソース、ディスク上のパス
  • スキップされた理由: disabled via config、not enabled in config、exclusive plugin、no plugin.yaml, depth cap reached
  • 読み込み時: import されているプラグインと、register(ctx) が登録したもの(ツール、フック、スラッシュコマンド、CLI コマンド)の1行サマリー
  • 解析失敗時: 例外の完全なトレースバック(YAML スキャナーのエラーなど)
  • register() の失敗時: __init__.py のどの行が raise したかを示す完全なトレースバック

同じログは、環境変数が設定されている場合、常に ~/.hermes/logs/agent.log に WARNING レベル(失敗のみ)と DEBUG レベル(すべて)で書き込まれます。そのため、環境変数を付けて実行できない場合(例えばゲートウェイの内部から)は、代わりにログファイルを tail してください。

hermes logs --level WARNING | grep -i plugin

プラグインが表示されないよくある理由:

  • config で有効化されていない — プラグインはオプトインです。hermes plugins enable <name>(この name は plugins list の出力に出てくるもので、ネストされたレイアウトでは <category>/<plugin> の形になり得ます)を実行してください。
  • ディレクトリのレイアウトが間違っている: ネイティブパッケージは ~/.hermes/plugins/<plugin-name>/plugin.yaml(フラット)か、1段のカテゴリ階層を使います。ポータブルパッケージは同じ場所でルートの plugin.json を使います。それより深い階層は無視されます。
  • __init__.py がない: ネイティブパッケージには plugin.yaml と、register(ctx) 関数を持つ __init__.py の両方が必要です。ポータブルパッケージは Python を import せず、__init__.py も不要です。
  • kind が間違っている — ゲートウェイアダプタはマニフェストに kind: platform が必要です。メモリプロバイダーは kind: exclusive として自動検出され、plugins.enabled の代わりに memory.provider 設定を通じてルーティングされます。

プラグインの最終的な構造

~/.hermes/plugins/calculator/
├── plugin.yaml      # "I'm calculator, I provide tools and hooks"
├── __init__.py      # Wiring: schemas → handlers, register hooks
├── schemas.py       # What the LLM reads (descriptions + parameter specs)
└── tools.py         # What runs (calculate, unit_convert functions)

4つのファイル、明確な分離です。

  • マニフェスト はプラグインが何であるかを宣言します
  • スキーマ は LLM 向けにツールを説明します
  • ハンドラー は実際のロジックを実装します
  • 登録 はすべてを結びつけます

プラグインで他に何ができるか?

データファイルを同梱する

任意のファイルをプラグインディレクトリに置き、import 時にそれを読みます。

# In tools.py or __init__.py
from pathlib import Path
from ruamel.yaml import YAML

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

with open(_DATA_FILE) as f:
    _DATA = YAML(typ="safe").load(f)

それは*同梱する*ファイル向けです。*書き込む* state はまた別で、次の節を参照してください。

永続的な state を保存する

ランタイムの state をプラグインディレクトリに書き込んではいけません。それはインストールツリーであり、hermes plugins update / remove は git-pull するか削除します — ユーザーのデータはそれと一緒に失われます。正しい保存先は、両方を生き延び、アクティブなプロファイルに追従する、プラグインごとのデータルートです。

from plugins.plugin_storage import plugin_data_dir, plugin_db

# <hermes home>/plugin-data/<name>/ — created on first use
state_file = plugin_data_dir("my-plugin") / "state.json"

# Or a SQLite database at <data dir>/data.db (WAL mode, thread-friendly)
conn = plugin_db("my-plugin")
conn.execute("CREATE TABLE IF NOT EXISTS runs (id TEXT PRIMARY KEY)")

プラグインごとに1つのディレクトリを使うことで、どのプラグインのデータも1つの予測可能な場所で調べられます。シークレットはここには属しません — 認証情報の読み取りは、他のどこでも同じように、標準の .env / secret-scope の経路を通ります。

スキルを同梱する

プラグインは、エージェントが 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")   # → plugin's version
skill_view("my-workflow")              # → built-in version (unchanged)

重要な性質:

  • プラグインのスキルは読み取り専用です — ~/.hermes/skills/ には入らず、skill_manage で編集できません。
  • プラグインのスキルはシステムプロンプトの <available_skills> インデックスに掲載されません — それらはオプトインで明示的に読み込むものです。
  • 裸のスキル名は影響を受けません — 名前空間が、組み込みスキルとの衝突を防ぎます。
  • エージェントがプラグインのスキルを読み込むと、同じプラグインの兄弟スキルを列挙する bundle context のバナーが先頭に付きます。

言語パックを同梱する

プラグインは、UI の言語を追加したり、既存の言語の言い回しを差し替えたりできます。しかも全部の画面に一度に効きます。 対象は Python 側(agent.i18n.t(): 承認プロンプト、ゲートウェイの返信、ツールの動詞、ヒント)、hermes --tui の 画面、Desktop アプリです。provides_locales を宣言して YAML を同梱するだけで、Python は書かなくてかまいません。

~/.hermes/plugins/hermes-lang-pl/
├── plugin.yaml
└── locales/
    ├── pl.yaml            # core (Python) strings — same key tree as the bundled locales/en.yaml
    ├── pl.tui.yaml        # optional: TUI strings (keys in locales/_keys.tui.json)
    └── pl.desktop.yaml    # optional: Desktop strings (keys in locales/_keys.desktop.json)
name: hermes-lang-pl
version: 1.0.0
description: Polish language pack
provides_locales:
  - id: pl              # lowercase BCP-47-style id: pl, pt-br, zh-hant
    endonym: Polski     # what language switchers show
    rtl: false

provides_locales が宣言されていると、ローダーは register() の前に ctx.register_locale_dir(<plugin>/locales) を 呼びます(__init__.py の無いマニフェストだけのパックは、マニフェストだけの Desktop プラグインと同じように読み込まれます)。 カタログは重ね合わせ式で、部分的でかまいません。優先順は、パック → ユーザーの上書き(<HERMES_HOME>/locales/)→ 同梱 → 英語 → キーそのもの、です。パックには変えたいキーだけを入れればよく、同じキーは最後に読み込まれたパックが勝ちます。 core の値は英語と同じ名前付きの {placeholders} を使います。TUI/Desktop の項目のうち英語の値が関数になっているものは、 位置指定の {0}、{1} プレースホルダーを使った文字列として書きます。

コードを持つプラグインは、プログラムから登録することもできます。ハンドルは PluginRegistration なので、プラグインを アンロードすればその層も外れます。登録によって display.language が変わることはありません。

def register(ctx):
    here = Path(__file__).parent
    ctx.register_locale("pl", here / "locales" / "pl.yaml", endonym="Polski")        # YAML path
    ctx.register_locale("pl", {"approval": {"denied": "      ✗ Odrzucono"}})          # mapping, nested or flat
    ctx.register_locale("pl", here / "locales" / "pl.tui.yaml", surface="tui")       # core | tui | desktop
    ctx.register_locale_dir(here / "locales")                                         # every <lang>[.surface].yaml

hermes plugins validate は、宣言された各 id について、解析できてテキストだけで書かれた locales/<id>.yaml があるかを確かめます (テキスト以外の末端値はエラーです)。その画面の英語カタログに無いキーがあれば、キー名を挙げて警告します。 描画側は i18n.languages / i18n.catalog の RPC でパックの層を取得します。 利用者向けの説明: 言語パック。

環境変数でゲートする

プラグインに API キーが必要な場合:

# plugin.yaml — simple format (backwards-compatible)
requires_env:
  - WEATHER_API_KEY

WEATHER_API_KEY が設定されていない場合、プラグインは明確なメッセージ付きで無効化されます。クラッシュもエージェント側のエラーも起きません — ただ「Plugin weather disabled (missing: WEATHER_API_KEY)」と表示されるだけです。

ユーザーが hermes plugins install を実行すると、不足している requires_env の変数について対話的に入力を求められます。値は自動的に .env に保存されます。

より良いインストール体験のために、説明とサインアップ URL を持つ rich format を使います。

# plugin.yaml — rich format
requires_env:
  - name: WEATHER_API_KEY
    description: "API key for OpenWeather"
    url: "https://openweathermap.org/api"
    secret: true
フィールド 必須 説明
name はい 環境変数名
description いいえ インストールプロンプトでユーザーに表示される
url いいえ 認証情報をどこで取得できるか
secret いいえ true の場合、入力はパスワード欄のように隠される

両方の形式を同じリストに混在させることができます。すでに設定済みの変数は黙ってスキップされます。

任意の Python 依存関係を遅延インストールする

Hermes のプロジェクトの追加パッケージに含まれる SDK なら、それが必要になる処理の場所で pm.ensure_import を使います。使えるかどうかを読み取りだけで確かめるには pm.available を使います。 頻繁に呼ばれる check_fn の中で依存関係をインストールしないでください。

次の例では、既存の追加パッケージ bedrock を要求しています。

from pm import InstallError, ensure_import

def my_tool_handler(args, **kwargs):
    try:
        ensure_import("bedrock")
    except InstallError as exc:
        return {"error": str(exc)}

    import boto3
    # Use the SDK here.

引数は pyproject.toml の追加パッケージの名前です。任意のパッケージ指定や、プラグイン名で 修飾したキーではありません。以前の LAZY_DEPS の登録簿と FeatureUnavailable 例外は、もうありません。

新しい環境が選ばれた場合、このヘルパーは再起動が必要だと知らせることがあります。 そのときは、動いているプロセスの中で2つ目の環境から import しようとせず、そのエラーを返してください。 すでに使える依存関係は、security.allow_lazy_installs が false でも インストールの必要はありません。

ディレクトリ型プラグイン自身の Python の依存関係は、その pyproject.toml の [project] の下に dependencies として宣言します。自分で書いたプロジェクトファイルがない場合、PM は plugin.yaml または plugin.yml にある、旧来の pip_dependencies と python_dependencies のリストを合わせて使います。 PM が以前に生成したプロジェクトファイルが、これらのリストより優先されることはありません。 同意、ワークスペースへの参加、最新かどうかの確認は、同じ宣言を使います。 PM は、プラグインを有効にする前に、その依存関係を core の要件とあわせて準備します。 生成されるワークスペースが、プラグインのディレクトリや同梱のロックファイルを書き換えることはありません。 依存関係が競合すると受け入れは拒否され、以前の選択がそのまま残ります。 PM がほかのプラグインを自動で無効にすることはありません。

pip で手動インストールした依存関係は、PM の永続的な宣言にはなりません。 あとで環境が入れ替わったときに、残るとは限りません。Python のランタイムが PM の外にある ラッパー型のプラグインは、 メモリプロバイダーの存続の約束を頼りにできます。 ランタイムの配置と遅延インストールの方針は、パッケージ管理を 参照してください。

スレッドセーフな遅延シングルトン

プラグインは、SDK クライアント、HTTP セッション、コネクションプールといった高価なオブジェクトを、初回使用時に構築するモジュールレベルの変数にキャッシュすることがよくあります。

_client = None

def get_client():
    global _client
    if _client is not None:
        return _client
    _client = ExpensiveClient(...)   # ← TOCTOU race
    return _client

これは危険な罠です。Hermes は1つのプロセス内で複数のスレッドを実行します(委任されたツール呼び出し、バックグラウンドワーカー、self-improvement のフォーク)。そのため、2つのスレッドが _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()

どちらも、二重チェックロックで最初の同時呼び出しを直列化し、ファクトリを最大1回だけ実行します。ファクトリが raise した場合、何もキャッシュされず、次の呼び出しで再試行されます。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
)

組み込みツールを上書きする

組み込みツールを自分の実装で置き換えるには(例えば既定のブラウザツールをヘッド付き 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 がなければ、レジストリは別の toolset に属する既存ツールを覆い隠すような登録を拒否します — これが偶発的な上書きを防ぎます。組み込みツールを上書きするには、さらに、オペレーターが config.yaml の plugins.entries.<plugin_id>.allow_tool_override: true で opt-in する必要があります。 そのゲートがなければ、register_tool(override=True) は PluginToolOverrideError を raise します。上書きはログに記録されるため、 ~/.hermes/logs/agent.log で監査できます。プラグインは組み込みツールより後に読み込まれるため、登録の順序は 正しく機能します。あなたのハンドラーが組み込みのものを置き換えます。

core と一緒に出荷されていないプラグインには、追加のオペレーター許可も必要です。 Hermes core と一緒に出荷されていない プラグイン(user、project、pip ソース)が既存の組み込みツールに対して override=True を使う場合、さらに config.yaml でプラグインごとの opt-in が必要です。

plugins:
  entries:
    my-plugin:                    # the plugin's registry key from `hermes plugins list`
      allow_tool_override: true

この許可がなければ、ctx.register_tool(..., override=True) は PluginToolOverrideError を raise します。 register() の例外はローダーによって捕捉されるため、そのプラグインは無効化され、Hermes はそのまま続行します。このゲートが 存在するのは、有効化されたプラグインが shell_exec や write_file のような権限の強い組み込みツールを無音で 置き換えてしまうと、モデルがそこを経由するものすべてを傍受できてしまうからです。同梱プラグインはこの対象外です — そこでの上書きは メンテナーの判断です。config を読み込めない場合、このゲートはフェイルクローズします。

この鍵を手で編集することは通常ありません。hermes plugins enable <name> は、プラグインのマニフェストが capabilities: の下でそれを宣言している場合にだけ、その capability を許可するかどうかを尋ねます(同意画面、既定は「しない」)。 capability を何も宣言していないプラグインは、許可を求めるプロンプトなしに有効化されます。--allow-tool-override / --no-allow-tool-override フラグは、どちらの場合でも、スクリプト化されたインストールのため、あるいはまだマニフェストの ブロックを採用していないプラグインを事前承認するために、その許可を明示的に設定・取り消しします。 同じ許可は deregister() もゲートします。それがなければ、プラグインは自分が所有していないツールを削除できません (さもなければ上書きチェックを回避する方法になってしまいます)。

複数のフックを登録する

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 reference に、コールバックのシグネチャ、パラメータの表、正確な発火タイミング、例まで含めて完全に文書化されています。ここではその要約を示します。

フック 発火するタイミング コールバックのシグネチャ 戻り値
pre_tool_call 任意のツールが実行される前 tool_name: str, args: dict, task_id: str 任意の指示: {"action": "block", "message": ...} はその呼び出しを拒否する。{"action": "approve", "message": ...} は人間の承認ゲートへエスカレーションする
post_tool_call 任意のツールが戻った後 tool_name: str, args: dict, result: str, task_id: str, duration_ms: int 無視される
pre_llm_call 各ターンにつき1回、ツール呼び出しループの前 session_id: str, user_message: str, conversation_history: list, is_first_turn: bool, model: str, platform: str コンテキストの注入
post_llm_call 各ターンにつき1回、ツール呼び出しループの後(成功したターンのみ) session_id: str, user_message: str, assistant_response: str, conversation_history: list, model: str, platform: str 無視される
pre_api_request プロバイダーへの生の各 API リクエストの前(モデルがツールを呼ぶ場合、1ターンに複数回) session_id: str, model: str, provider: str, base_url: str, api_mode: str, api_call_count: int, message_count: int, tool_count: int, approx_input_tokens: int, max_tokens: int, request: dict 無視される
post_api_request プロバイダーへの各 API リクエストが返った後 pre_api_request のフィールドに加えて `api_duration: float, finish_reason: str, response_model: str \ None, usage: dict, response: dict, assistant_content_chars: int, assistant_tool_call_count: int` 無視される
api_request_error プロバイダーの API 呼び出しが raise したとき 相関フィールドに加えて `status_code: int \ None, retry_count: int \ None, max_retries: int \ None, retryable: bool \ None, reason: str \ None, error: dict, request: dict` 無視される
pre_auxiliary_call 補助的な LLM 呼び出し(タイトル付け、圧縮、MoA、vision、承認など)のプロバイダー試行ごと、その前。pre_api_request ではない aux_task: str に加えて pre_api_request のフィールド(session_id/task_id/turn_id は親ターンのものか空、api_request_id: str, retry_count: int, streaming: bool, request: dict) 無視される
post_auxiliary_call その試行が戻るか raise した後 pre_auxiliary_call のフィールドに加えて `api_duration: float, finish_reason, response_model, usage: dict \ None, response: dict \ None, error: str \ None, error_type: str \ None` 無視される
on_session_start 新しいセッションが作成された(最初のターンのみ) 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_finalize CLI / ゲートウェイがアクティブなセッションを分解する `session_id: str \ None, platform: str` 無視される
on_session_reset ゲートウェイが新しいセッションキーに入れ替える(/new、/reset) session_id: str, platform: str 無視される
gateway_platform_event 認可済みのプラットフォーム固有イベントが、ゲートウェイの境界で正規化される(現時点では Telegram のリアクション) platform: str, event_type: str, payload: dict 無視される
kanban_task_claimed kanban タスクがクレームされる(ディスパッチャプロセス、ワーカーが起動する前) `task_id: str, board: str \ None, assignee: str \ None, run_id: int \ None, profile_name: str` 無視される
kanban_task_completed kanban タスクが完了する(ワーカープロセス) `task_id, board, assignee, run_id, profile_name, summary: str \ None` 無視される
kanban_task_blocked kanban タスクがブロックされる(ワーカープロセス) `task_id, board, assignee, run_id, profile_name, reason: str \ None` 無視される

ほとんどのフックは fire-and-forget な observer で、戻り値は無視されます。例外は、会話にコンテキストを注入できる pre_llm_call と、block / approve の指示を返せる pre_tool_call です。

すべてのコールバックは、前方互換性のために **kwargs を受け入れるべきです。フックのコールバックがクラッシュした場合、それはログに記録されてスキップされます。他のフックとエージェントは通常どおり続行します。

kanban のライフサイクルフックは、board の DB 変更がコミットされた後に発火するため、コールバックは常に永続化された状態を見ることになり、SQLite の書き込みロックを保持することは決してありません。kanban のワーカーは別個の hermes -p <profile> chat -q サブプロセスとして動作するため、kanban_task_claimed はディスパッチャプロセスで発火し、kanban_task_completed / kanban_task_blocked はワーカープロセスで発火します — すべての遷移を中央で観測するにはディスパッチャでフックし、タスクごとのセッション内コンテキストが必要ならワーカーでフックしてください。

API リクエストフックは、生のプロバイダーリクエストに対する observer で、ターンごとの pre_llm_call / post_llm_call ペアより一段下の階層です。ツールを呼ぶ1つのターンは複数の API リクエストを発生させ、これらのフックはそのそれぞれの前後で発火します。これらは(トレーシング、コスト計算、レイテンシダッシュボードなどの)可観測性プラグインのために存在します。request と response の kwargs は、サニタイズ済みでサイズ上限のある、プロバイダーのペイロードの JSON ビュー(機密キーは redact され、長い文字列は truncate され、SDK のオブジェクトは正規化される)で、usage はプレーンなトークンサマリーの dict です。すべてのペイロードは相関フィールド turn_id、api_request_id、task_id、session_id、api_call_count を持つため、プラグインはリクエスト、ツール呼び出し、ターンをつなぎ合わせられます。api_request_error はプロバイダーの呼び出しが raise したときに発火し、status_code、retry_count / max_retries、retryable、reason、そして type と message を持つ error dict を追加します。

pre_llm_call のコンテキスト注入

これは戻り値が意味を持つ唯一のフックです。pre_llm_call のコールバックが "context" キーを持つ dict(または単純な文字列)を返すと、Hermes はそのテキストを現在のターンのユーザーメッセージに注入します。これは、メモリプラグイン、RAG インテグレーション、ガードレール、そしてモデルに追加のコンテキストを提供する必要のあるあらゆるプラグインのための仕組みです。

戻り値の形式

# 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

"context" キーを持つ非 None・非空の戻り値(あるいは非空の単純な文字列)は、すべて集約され、現在のターンのユーザーメッセージに追記されます。

大きすぎるコンテキストの溢れ

フックごとのコンテキストは、既定で 10,000 文字に制限されています。それを超える分は $HERMES_HOME/hook_outputs/<session_id>/<uuid>.txt に書き込まれ、先頭/末尾のプレビューと保存先パスに置き換えられます。モデルは本当に全文が必要なら read_file や terminal で読めます。これにより、暴走したプラグインが以降すべてのターンのプロンプトを肥大化させ、prompt cache のプレフィックスを壊してしまうのを防ぎます。config.yaml で調整できます。

hooks:
  output_spill:
    enabled: true          # default: true
    max_chars: 10000       # default; set higher to opt out of spilling
    preview_head: 500      # chars shown at the top of the preview
    preview_tail: 500      # chars shown at the bottom of the preview
    # directory: null      # default: $HERMES_HOME/hook_outputs

注入の仕組み

注入されたコンテキストは、システムプロンプトではなくユーザーメッセージに追記されます。これは意図的な設計判断です。

  • prompt cache の保持 — システムプロンプトはターンをまたいで同一のままです。Anthropic と OpenRouter はシステムプロンプトのプレフィックスをキャッシュするため、それを安定させることで、複数ターンの会話における入力トークンを 75% 以上節約できます。プラグインがシステムプロンプトを書き換えていたら、ターンごとにキャッシュミスになってしまいます。
  • 一時的 — この注入は API 呼び出し時にだけ発生します。会話履歴内の元のユーザーメッセージは決して変更されず、セッションデータベースには何も永続化されません。
  • システムプロンプトは Hermes の領分 — そこには、モデル固有のガイダンス、ツールの強制規則、パーソナリティの指示、キャッシュされたスキルの内容が含まれます。プラグインは、エージェントの中核的な instructions を書き換えるのではなく、ユーザーの入力と並んでコンテキストを提供します。

例: メモリ回想プラグイン

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

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)

例: observer 専用フック(注入なし)

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

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)

複数のプラグインがコンテキストを返す場合

複数のプラグインが pre_llm_call からコンテキストを返す場合、それらの出力は二重の改行で結合され、まとめてユーザーメッセージに追記されます。順序はプラグインの検出順(プラグインディレクトリ名のアルファベット順)に従います。

ミドルウェア: 何が起こるかを変える

フックは(上で説明した数少ない steering 用の形を除けば)エージェントループを観測するだけです。ミドルウェアは何が起こるかを変えます: リクエストミドルウェアは、下流の何かがそれを見る前に、有効なペイロードを書き換えます。実行ミドルウェアは実際の呼び出しをラップします。同じ register(ctx) のエントリーポイントから登録します。

def cap_find_output(tool_name, args, **kwargs):
    """Rewrite terminal find commands to cap their output."""
    command = args.get("command", "")
    if tool_name == "terminal" and command.startswith("find "):
        return {
            "args": {**args, "command": command + " | head -100"},
            "source": "my-plugin",
            "reason": "cap find output",
        }
    return None  # leave the call unchanged

def register(ctx):
    ctx.register_middleware("tool_request", cap_find_output)

種類の正本の一覧は、hermes_cli/middleware.py の VALID_MIDDLEWARE です。

種類 受け取るもの 戻り値の契約
tool_request tool_name、args、original_args、コンテキストの kwargs フック、ガードレール、承認、実行がそれを見る前に、有効なツール引数を置き換えるには {"args": {...}} を返す。呼び出しをそのままにするには None を返す。
llm_request request、original_request、コンテキストの kwargs Hermes がそれを送る前に、有効なプロバイダー向け kwargs を置き換えるには {"request": {...}} を返す。
tool_execution ペイロードと next_call ツールの実行をラップする。下流のチェーンを実行するには next_call(payload) をちょうど1回呼ぶ(呼ばなければ短絡できる)。結果を返す。
llm_execution ペイロードと next_call 同じ形で、プロバイダー呼び出しをラップする。

実務上重要な規則:

  • リクエストミドルウェアのチェーン: 各コールバックは、それより前のコールバックによって書き換えられたペイロードを見ます。一方、original_args / original_request は常にミドルウェア適用前のコピーを保持します。ペイロードはコールバック間でコピーされるため、自由に変更できます。
  • 返す dict には source、reason、name の文字列を含められます。それらはミドルウェアのトレースに残り、下流の observer フックは middleware_trace の kwarg としてそれを受け取ります。
  • 実行ミドルウェアの next_call は1回限りです。2回呼ぶと raise します。プロバイダーやツールを再実行してしまうからです。
  • raise したミドルウェアのコールバックはログに記録されてスキップされ、チェーンは続行します。next_call の後で raise した下流の失敗は、そのまま伝播します。ミドルウェアが基盤のランタイム経路を壊すことは決してありません。
  • ミドルウェアのペイロードは、observer のテレメトリフィールドと並んで middleware_schema_version(hermes.middleware.v1)を持ちます。
  • 未知の種類は失敗せず警告付きで登録されます。そのため、新しい Hermes 向けに書かれたプラグインでも、古い Hermes でそのまま読み込まれます。

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 status、hermes my-plugin config などを実行できるようになります。

メモリプロバイダープラグインは、代わりに規約ベースのアプローチを使います。プラグインの cli.py ファイルに register_cli(subparser) 関数を追加してください。メモリプラグインの検出システムがそれを自動的に見つけます — ctx.register_cli_command() の呼び出しは不要です。詳細は Memory Provider Plugin guide を参照してください。

アクティブなプロバイダーによるゲート: メモリプラグインの CLI コマンドは、そのプロバイダーが memory.provider としてアクティブな場合にのみ表示されます。ユーザーがまだあなたのプロバイダーを設定していない場合、あなたの CLI コマンドがヘルプ出力を煩雑にすることはありません。

スラッシュコマンドを登録する

プラグインは、ユーザーが会話中に入力するセッション内スラッシュコマンド(/lcm status や /ping のようなもの)を登録できます。これは CLI とゲートウェイ(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",
    )

登録すると、ユーザーはどのセッションでも /mystatus と入力できます。このコマンドは自動補完、/help の出力、Telegram のボットメニューに表示されます。

シグネチャ: ctx.register_command(name: str, handler: Callable, description: str = "", args_hint: str = "")

パラメータ 型 説明
name str 先頭のスラッシュを除いたコマンド名(例: "lcm"、"mystatus")
handler `Callable[[str], str \ None]` 生の引数文字列で呼ばれる。async でもよい。
description str /help、自動補完、Telegram のボットメニューに表示される

register_cli_command() との主な違い:

register_command() register_cli_command()
呼び出し方 セッション内で /name ターミナルで hermes name
動作する場所 CLI セッション、Telegram、Discord など ターミナルのみ
ハンドラーが受け取るもの 生の引数文字列 argparse の Namespace
用途 診断、状態確認、簡単な操作 複雑なサブコマンドツリー、セットアップウィザード

衝突の保護: プラグインが組み込みコマンド(help、model、new など)と衝突する名前を登録しようとすると、登録は警告ログとともに黙って拒否されます。組み込みコマンドは常に優先されます。

非同期ハンドラー: ゲートウェイのディスパッチは非同期ハンドラーを自動的に検出して await します。そのため、同期・非同期どちらの関数も使えます。

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")

ハンドラーが動く場所: スラッシュコマンドのハンドラーは、そのセッションを持つゲートウェイのプロセスの中で動きます。利用者が従来の hermes chat CLI でコマンドを打った場合も同じです。ハンドラーは利用者の端末やディレクトリで動いているわけではないので、次の点に注意してください。

  • 表示したい文字列は戻り値として返してください。print() はゲートウェイの標準出力に書き込まれるだけで、利用者の目には届きません。
  • セッションの作業ディレクトリは os.getcwd() ではなく agent.runtime_cwd.resolve_agent_cwd() から取ってください。前者はゲートウェイ自身のディレクトリを返します。相対パスはこのディレクトリを基準に解決します。
  • セッションの識別情報は os.environ ではなく、gateway.session_context.get_session_env("HERMES_SESSION_PLATFORM") やほかの HERMES_SESSION_* の名前で読んでください。
from agent.runtime_cwd import resolve_agent_cwd

def _handle_todo(raw_args: str) -> str:
    todo = resolve_agent_cwd() / "TODO.md"  # the session's folder, not the gateway's
    return todo.read_text() if todo.exists() else "No TODO.md here."

スラッシュコマンドからツールをディスパッチする

ツールを組織立てて呼び出す必要があるスラッシュコマンドハンドラー(delegate_task でサブエージェントを起動する、file_edit を呼ぶなど)は、フレームワークの内部に手を伸ばすのではなく ctx.dispatch_tool() を使うべきです。親エージェントのコンテキスト(ワークスペースのヒント、スピナー、モデルの継承)は自動的に配線されます。

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

パラメータ 型 説明
name str ツールレジストリに登録されているツール名(例: "delegate_task"、"file_edit")
args dict モデルが送るのと同じ形のツール引数
parent_agent `Agent \ None` 任意の上書き。省略した場合、現在の CLI エージェントから解決される(またはゲートウェイモードでは穏やかに縮退動作する)

ランタイムの振る舞い:

  • CLI モード: parent_agent はアクティブな CLI エージェントから解決されるため、ワークスペースのヒント、スピナー、モデルの選択は期待どおりに継承されます。
  • ゲートウェイモード: CLI エージェントが存在しないため、ツールは穏やかに縮退動作します — ワークスペースは設定済みのターミナル作業ディレクトリから読まれ、スピナーは表示されません。
  • 明示的な上書き: 呼び出し側が明示的に parent_agent= を渡した場合、それが尊重され上書きされません。

これは、プラグインコマンドからツールをディスパッチするための、公開された安定インターフェースです。プラグインは ctx._cli_ref.agent のような private な状態に手を伸ばすべきではありません。

フックの内側から動く(プロファイル + ツール)

ctx._cli_ref は対話的な CLI セッションでのみ設定されます。ゲートウェイ、非対話的な hermes chat -q の実行、そしてkanban が起動したワーカーセッションでは None です — そのため、_cli_ref を経由するプラグインのロジックは、まさにそれらの文脈で無音に何もしなくなります。フックが実際に必要とするものは、2つの安定した、セッションに依存しない API でカバーされます。

  • ctx.profile_name — アクティブなプロファイル名(例: "default"、あるいは kanban ワーカーでの assignee のプロファイル)。HERMES_HOME から導出されるため、_cli_ref への依存なしにどこでも動作します。
  • ctx.dispatch_tool(name, args) — 登録済みの任意のツール(組み込みでもプラグインでも)を呼び出します。kanban_* ツール、delegate_task、terminal、read_file なども含みます。どのプロセスでフックが発火していても、フックのコールバックから機能します。

これらを組み合わせると、kanban のライフサイクルフックは、フレームワークの内部に触れずに、遷移を観測して board に対して動くことができます。

def register(ctx):
    def on_blocked(*, task_id, reason=None, **kw):
        # Runs in the worker process; ctx._cli_ref is None here.
        ctx.dispatch_tool("kanban_comment", {
            "task_id": task_id,
            "comment": f"[{ctx.profile_name}] auto-noted block: {reason}",
        })
    ctx.register_hook("kanban_task_blocked", on_blocked)

完全な hermes <subcommand>(例: hermes kanban show)を実行するには、ctx.dispatch_tool("terminal", {"command": "hermes kanban show ..."}) のように terminal ツールでシェルアウトしてください — ヘッドレスなワーカーセッションにはプロセス内のスラッシュコマンドブリッジが存在せず、フックから Hermes を動かすためのサポートされた方法はツールです。

エージェントのブラウザに生の CDP コマンドを送る

ブラウザツールより下の層でエージェントのブラウザを操作する必要があるプラグインは、 SUPERVISOR_REGISTRY.capture(task_id) で、そのタスクの稼働中のスーパーバイザー接続に固定された CDP ハンドルを取得し、call(method, params=None, *, session_id=None, timeout=10.0) で コマンドを送れます。その接続が切れると、ハンドルは CapturedCDPInvalid を送出し、 新しい接続へ付け替えられることはありません。CDPSupervisor の非公開の属性を読み書きする代わりに、 これを使ってください(非公開属性の読み書きはカタログで拒否されます)。これは信頼されたプロセス内の 通信手段にすぎず、送信元・同意・所有者の確認は何もしてくれません。契約の全体は Browser CDP Supervisor を参照してください。

いま実行中の cron の回を知る

ctx.current_cron_execution() は、現在のコードが実行されている予約実行の回を返します。cron の外では None を返します。実行中に発火するどのフック(pre_tool_call、post_tool_call、pre_llm_call など)からも、ツールのハンドラからも使えます。値は変更できない CronExecution です。

フィールド 意味
job_id、job_name cron ジョブ。
execution_id 実行台帳(hermes cron runs)におけるこの回の行。
source どの経路でこの回が起動されたか: "builtin"(組み込みのスケジューラ)、"direct"(スケジューラの外から起動された回。例: hermes cron run)、または外部スケジューラの名前。
scheduled_instant この回が起動するスケジュール上の時点。手動実行などスケジュール外の回では None になるので、予約実行と手動実行を見分けるにはこのフィールドを確認します。
started_at この回が始まった時刻。
profile ジョブを所有するプロファイル。

スケジューラがこの値を設定するのは、その回が実行権の取得に勝ったあとだけで、回が終わると消去します。値は回ごとに分かれているため、同時に起動した2つのジョブやプロファイルが互いの値を見ることはありません。モデルがこの値を偽造することもできません。フックの引数は Hermes が渡すもので、ツールのサブプロセス(terminal、execute_code)は別のインタプリタで動くからです。delegate_task で起動したサブエージェントは予約実行の回そのものではないため、None を受け取ります。

def register(ctx):
    def guard(*, tool_name, args, **kw):
        run = ctx.current_cron_execution()
        if tool_name == "deploy" and (run is None or run.scheduled_instant is None):
            return {"action": "block", "message": "deploy only runs from its scheduled cron job"}
    ctx.register_hook("pre_tool_call", guard)

自動化のブループリントを追加する

ctx.register_automation_blueprint(key, *, title, description, schedule_template, prompt_template, category="general", slots=(), deliver_default="origin", skills=(), tags=()) を呼ぶと、空欄を埋めるだけで使える自動化を Automation Blueprints catalog に追加できます。追加したものは、/blueprint(CLI、TUI、メッセンジャー)、ダッシュボードの Blueprints タブ、Desktop の Cron ページで組み込みのものと並んで表示され、プラグインの名前がラベルとして付きます。引数は組み込みの AutomationBlueprint(cron/blueprint_catalog.py)のフィールドと同じで、各スロットは BlueprintSlot のフィールドを持つ dict です。

def register(ctx):
    ctx.register_automation_blueprint(
        "standup",
        title="Team standup digest",
        description="Every weekday, summarize what changed in a repo since yesterday.",
        category="work",
        schedule_template="{minute} {hour} * * 1-5",
        prompt_template="Summarize yesterday's commits and open PRs for {repo} as a short standup digest.",
        slots=[
            {"name": "repo", "type": "text", "label": "Which repo?", "default": "acme/app"},
            {"name": "time", "type": "time", "label": "What time?", "default": "09:15"},
            {"name": "deliver", "type": "enum", "label": "Where to deliver?", "default": "origin",
             "options": ["origin", "local"], "strict": False},
        ],
        tags=["work", "daily"],
    )

ユーザーは /blueprint teamtools:standup(または単に /blueprint standup)を実行するか、Desktop やダッシュボードのフォームで選ぶと、これを使えます。

  • キーには名前空間が付きます。 カタログ上のキーは <plugin>:<key>(teamtools:standup)になるので、プラグインが組み込みのブループリントやほかのプラグインのブループリントを置き換えることはできません。キーはそのまま渡してください。: を含むキーは拒否されます。
  • スロットとテンプレート。 スロットの type は time(HH:MM)、enum、weekdays、text のいずれかです。prompt_template では、どのスロットも {name} の形で使えます。schedule_template では、スロット名に加えて {minute}/{hour}(time という名前のスロットから取られる)と {dow}(recurrence/day のスロットから取られ、なければ *)が使えます。deliver という名前のスロットには、Desktop とダッシュボードのフォームで、そのプロファイルの実際の配信先が選択肢として入ります。
  • 登録時に検証されます。 未知のプレースホルダー、不正なスロットの型、重複したキー、解釈できないスケジュールがあると、警告をログに出してそのブループリントだけを飛ばします。プラグインの残りは通常どおり読み込まれます。すべてのスロットに既定値があるときは、Hermes が読み込み時に一度ブループリントを埋めてみるので、壊れたテンプレートはユーザーが初めて *Schedule it* を押したときではなく、その時点で失敗します。
  • プロファイルごとです。 プラグインはプロファイルごとに読み込まれるので、ブループリントが一覧に出るのは、そのプラグインを有効にしたプロファイルだけです。
  • ジョブはプラグインより長く残ります。 ブループリントで予約すると、プロンプトとスケジュールが埋められた普通の cron ジョブができます。プラグインを無効にしたりアンインストールしたりすると、ブループリントはカタログから消えますが、作られたジョブは動き続けます。ジョブの skills がプラグイン同梱のスキルを指している場合、プラグインが無効の間、そのジョブはそのスキルを飛ばします(飛ばしたことはログに残ります)。

マニフェストでの機能宣言は要りません。スロットはただの dict なので、プラグインのホスト分離のもとでも動きます。

Slack の Block Kit ボタンクリックを処理する

インタラクティブな要素(ボタン、オーバーフローメニュー、日付ピッカーなど)を持つ Block Kit のメッセージを投稿するプラグインは、slack_bolt.AsyncApp をモンキーパッチすることなく、クリックハンドラーを Slack アダプタに直接登録できます。

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_id `str \ re.Pattern \ dict` slack_bolt.App.action() が受け付けるもの全て: リテラルの action_id、複数の id にマッチするコンパイル済み正規表現、または {"action_id": "...", "block_id": "..."} のような制約 dict
callback 非同期の callable slack_bolt の規約に従い (ack, body, action) を受け取る

ランタイムの振る舞い:

  • ハンドラーはプラグイン読み込み時にキューに入り、Slack プラットフォームが接続したタイミングでアダプタの slack_bolt.AsyncApp に配線されます。
  • 各コールバックは防御的にラップされます。あなたのハンドラーが raise した場合、ゲートウェイはエラーをログに記録し、Slack が再試行しないようベストエフォートで ack します。
  • 標準の slack_bolt の規則が適用されます — 3秒以内に await ack() してから、時間のかかる処理を行ってください。
  • マルチワークスペースのデプロイでは、接続されたどのワークスペースからのクリックでもハンドラーは発火します。挙動をスコープする必要があれば body["team"]["id"] を使ってください。

これは、プラグインが Slack のインタラクティビティに参加するための公開された方法です。古いプラグインは SlackAdapter.connect をパッチすることがありますが、代わりにこの API を使ってください。slack_bolt の全面(events、shortcuts、commands — Block Kit のアクションだけではない)を使うには、下の汎用の register_platform_handler("slack", ...) を使います。

ネイティブプラットフォームハンドラーを登録する(任意のプラットフォーム)

core アダプタがルーティングしないプラットフォームイベント — 追加の update タイプ、ネイティブなボタンのコールバック、リアクション / メンバーイベント、webhook のルートなど — を受け取る必要のあるプラグインは、そのプラットフォームのアダプタが接続時に呼び出すハンドラーファクトリを登録できます。これはすべてのゲートウェイプラットフォームで機能します。

def register(ctx):
    def _wire(native, adapter):
        # native: the platform's client/app object (see table below)
        # adapter: the platform adapter instance (treat as read-only)
        # Import platform SDKs HERE so register() works without them.
        ...

    ctx.register_platform_handler("discord", _wire)

シグネチャ: ctx.register_platform_handler(platform, factory) -> None

パラメータ 型 説明
platform str ゲートウェイプラットフォーム名、小文字("telegram"、"discord"、"slack"、"matrix" など)
factory callable 接続時に (native, adapter) を受け取る

プラットフォームごとの native の実体:

プラットフォーム native オブジェクト 典型的なフック先
telegram PTB の Application add_handler — 任意の update タイプ、パターンでスコープされたコールバック
discord discord.ext.commands.Bot add_listener — リアクション、メンバーイベント、スレッド、ボイス
slack slack_bolt.AsyncApp app.event() / app.action() / app.command()
matrix Matrix クライアント イベントコールバック
teams Teams の App on_message / on_card_action デコレータ
dingtalk DingTalkStreamClient 他のストリームトピック向けの register_callback_handler
feishu lark_oapi クライアント API 呼び出し、イベントルーティング
line、api_server、msgraph_webhook aiohttp の web.Application router.add_get/post — カスタムルート(ルーターが固まる前に配線される)
その他すべて(whatsapp、signal、irc、email、sms、ntfy、wecom、weixin、bluebubbles、yuanbao など) None 接続時のフック。adapter ハンドルを通じて動作する

ランタイムの振る舞い:

  • ファクトリはプラグイン読み込み時にキューへ入り、そのプラットフォームが接続したときに呼ばれます — ディスパッチの順序が重要なプラットフォーム(Telegram、Slack、Teams、aiohttp のルーター)では、core のハンドラーより前に実行されるため、スコープされたプラグインのハンドラーが優先され、他のすべてはそのまま素通しされます。
  • 追加するハンドラーは必ず first-match のディスパッチテーブルにスコープしてください。 Telegram では CallbackQueryHandler(..., pattern=r"^myplugin:") のように使ってください — スコープされていないハンドラーは、core のボタンフロー(実行の承認、モデルピッカー、clarify プロンプト)を飲み込んでしまいます。
  • 各ファクトリは分離されています。raise した場合、エラーはログに記録され、プラットフォームはそのまま接続を続けます。
  • プラットフォームの SDK は、モジュールレベルではなくファクトリの本体内で import してください — SDK が入っていなくても register() が動作する必要があります。
  • 1つのプラグインが複数のプラットフォーム向けにファクトリを登録できます。それぞれ、対応するプラットフォームが接続したときにだけ発火します。

Telegram の別名: ctx.register_telegram_handler(factory) は ctx.register_platform_handler("telegram", factory) の後方互換のための別名です。

例 — Telegram、パターンでスコープされたインラインボタン:

def register(ctx):
    def _wire(application, adapter):
        from telegram.ext import CallbackQueryHandler

        async def _on_button(update, context):
            query = update.callback_query
            await query.answer()
            # ...handle "myplugin:*" callbacks

        application.add_handler(
            CallbackQueryHandler(_on_button, pattern=r"^myplugin:")
        )

    ctx.register_platform_handler("telegram", _wire)

例 — Discord、リアクションイベント:

def register(ctx):
    def _wire(bot, adapter):
        async def on_raw_reaction_add(payload):
            ...  # e.g. reaction-based voting / moderation

        bot.add_listener(on_raw_reaction_add, "on_raw_reaction_add")

    ctx.register_platform_handler("discord", _wire)

実行中のプラグイン読み込み:すぐ有効になるものと次のセッションからのもの

プラグインは、ゲートウェイ(または TUI/Desktop のサーバー)がすでに動いている最中にも読み込まれることがあります。hermes plugins install/enable、Desktop やダッシュボードからのインストール、カタログの再ピン留め、ツールがきっかけの強制的な 再検出がそれにあたります。これらの経路はどれも実際の強制再スキャン(discover_plugins(force=True))を行い、 その中から PluginManager.on_plugin_loaded(callback) が、新しく読み込まれたプラグインごとに1つの要約を渡して呼ばれます (hermes_cli/plugins_activation.py)。

{"name": "late-mcp", "key": "late-mcp",
 "activated_now": {"gateway_commands": ["late"], "callbacks": ["telegram"]},
 "deferred": {"tools": ["late_tool"], "prompt": ["late.section"], "mcp_servers": ["worker"]}}
  • すぐ有効になるもの — ゲートウェイのスラッシュコマンド、ゲートウェイの変換フックとその他のフック、プラットフォームの

コールバックです。ゲートウェイのランナーは起動時に購読し、動いているすべてのアダプターの冪等な rewire_plugin_handlers() を呼びます。そのため、後から読み込まれたプラグインが登録した register_platform_handler のファクトリ(や Slack のアクションハンドラー)も、 再起動なしで配線されます。再配線は、ネイティブのクライアントごとに (plugin, factory qualname) で重複を除きます。Telegram では、後から来たハンドラーがコアの受け皿である filters.COMMAND / CallbackQueryHandler より前へ引き上げられます(PTB はグループごとに最初に一致したものへ振り分けるため)。接続時に登録された場合とまったく同じ位置に並びます。

  • 次のセッションからのもの — tools と prompt の節は次のセッションから効きます(実行中のセッションの

プロンプトとツールスキーマはキャッシュを保つため固定です。/skills install と同じ規則です)。mcp_servers(プラグインの mcp.json のサーバー。mcp.json での名前のまま)は、mcp.reload か次のセッションで接続されます。

  • 配線を外す仕組みはありません。実行中にプラグインを無効にしても、すでに配線されたハンドラーはゲートウェイを

再起動するまで残り、各画面もそのように表示します。

インストールの各入口は、まさにこの内訳を報告します。hermes plugins install/enable は、動いている ゲートウェイに合図を送ったあと(コントロールソケットの reload-plugins 動詞)にこれを表示し、plugins.manage install/toggle/update は activation と gateway_reloaded を返します(restart_required が true になるのは、どのゲートウェイも応答しなかったときだけです)。

専門化されたプラグインの種類

Hermes には、一般的なプラグインの範囲を超えた、5つの専門化されたプラグインの種類があります。それぞれ plugins/<category>/<name>/(同梱)または ~/.hermes/plugins/<category>/<name>/(ユーザー)の下にディレクトリとして出荷されます。契約はカテゴリごとに異なります — 必要なものを選び、その完全なガイドを読んでください。

モデルプロバイダープラグイン — LLM バックエンドを追加する

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.py、config.py、doctor.py、models.py、runtime_provider.py、そして chat_completions のトランスポートが自動的にそれへ配線されます。ユーザープラグインは、同名の同梱プラグインを上書きします。

完全なガイド: Model Provider Plugins — フィールド一覧、上書き可能なフック(prepare_messages、build_extra_body、build_api_kwargs_extras、fetch_models)、api_mode の選択、認証タイプ、テスト方法。

プラットフォームプラグイン — ゲートウェイチャネルを追加する

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():
    from agent.secret_scope import get_secret
    return bool(get_secret("MYPLATFORM_TOKEN"))

def _env_enablement():
    from agent.secret_scope import get_secret
    tok = (get_secret("MYPLATFORM_TOKEN") or "").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 の完全な契約、メッセージのルーティング、認証のゲート、セットアップウィザードとの統合。実際に動く stdlib のみの例は plugins/platforms/irc/ を見てください。

メモリプロバイダープラグイン — セッションをまたぐ知識バックエンドを追加する

plugins/memory/<name>/ に MemoryProvider の実装を置きます。

# 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:
        from agent.secret_scope import get_secret
        return bool(get_secret("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 で選ばれた、一度に1つだけが有効になります。

プロバイダーが一般的なプラグインとしても読み込まれる場合、そのライフサイクルフックは一般的な検出の側が所有します。メモリローダーは、その同じプラグインのソースが一般的な検出を通じて正常に読み込まれるまでの、フォールバックとしてのみフックを供給します。プロバイダーが繰り返し読み込まれると、フォールバックのフックグループは置き換えられますが、そのグループ内の異なるコールバックは保持されます。これは、異なるプラグインのソース由来のフックを重複排除するものではなく、プロバイダーの有効化を変えるものでもありません。

完全なガイド: Memory Provider Plugins — MemoryProvider ABC の全体、スレッドの契約、プロファイルの分離、cli.py による CLI コマンドの登録。

コンテキストエンジンプラグイン — コンテキスト圧縮器を置き換える

# 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,
                 force=False, memory_context="") -> list: ...

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

コンテキストエンジンは単一選択です — 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 と URL 出力、ユーザーによる上書き、pip での配布。

参照例: plugins/image_gen/openai/(OpenAI SDK 経由の DALL-E / GPT-Image)、plugins/image_gen/openai-codex/、plugins/image_gen/xai/(Grok の画像生成)。

Computer-use のバックエンドプラグイン

computer_use ツールは、ComputerUseBackend ABC(tools/computer_use/backend.py)を通して、ただ1つのドライバーとやり取りします。 どれを使うかは config.yaml の computer_use.backend で選びます。既定は組み込みの cua-driver で、これも plugins/computer_use/cua/ に 普通のプロバイダーとして同梱されています。別のドライバーは、~/.hermes/plugins/<name>/ に置いたプロバイダープラグインで、 その register(ctx) が ctx.register_computer_use_provider() を呼びます。ディレクトリ名が、computer_use.backend に書く値になります。 Computer-use のプロバイダーは、記憶プロバイダーやコンテキストエンジンと同じく、1つだけを選ぶ方式です。

# ~/.hermes/plugins/my-driver/__init__.py
from tools.computer_use.backend import (
    ActionResult, CaptureResult, ComputerUseBackend, ComputerUseProvider,
)

class MyBackend(ComputerUseBackend):
    def start(self): ...                    # open the driver session
    def stop(self): ...                     # tear it down (also called at exit)
    def is_available(self): return True
    def capture(self, mode="som", app=None, pid=None, window_id=None):
        return CaptureResult(mode=mode, width=1920, height=1080, png_b64=...)
    def click(self, **kw): return ActionResult(ok=True, action="click")
    def drag(self, **kw): return ActionResult(ok=True, action="drag")
    def scroll(self, **kw): return ActionResult(ok=True, action="scroll")
    def type_text(self, text, **kw): return ActionResult(ok=True, action="type")
    def key(self, keys, **kw): return ActionResult(ok=True, action="key")
    def list_apps(self): return []
    def focus_app(self, app, raise_window=False): return ActionResult(ok=True, action="focus_app")
    def set_value(self, value, element=None):
        # A driver that can't do an action says so per call; the tool schema never changes.
        return ActionResult(ok=False, action="set_value", code="unsupported_action",
                            message="my-driver has no accessibility value setter")

class MyDriverProvider(ComputerUseProvider):
    name = "my-driver"
    display_name = "My driver"

    def create_backend(self, *, permission_mode):
        return MyBackend()                  # permission_mode: standard | bounded | unrestricted

    def is_available(self):                 # gates the tool; cheap, no network
        return True

    def doctor(self):                       # optional: `hermes computer-use doctor`
        print("my-driver: ok")
        return 0

def register(ctx):
    ctx.register_computer_use_provider(MyDriverProvider())
# ~/.hermes/plugins/my-driver/plugin.yaml
name: my-driver
version: 1.0.0
description: Alternative computer-use driver   # shown in the picker

hermes tools → Computer Use で選ぶか(インストール済みのプロバイダーは、CLI でも Desktop のツールセットパネルでも cua-driver の下に並びます)、 設定に直接書きます。plugins.enabled への登録は要りません。

# config.yaml
computer_use:
  backend: my-driver     # default: cua

ルール:

  • 有効なプロバイダーは一度に1つだけです。 import されてインスタンスが作られるのは、選ばれたものだけです。選ばれていない

インストール済みのプロバイダーはディスクに置かれたままで、選択肢として(plugin.yaml から)一覧に出るだけです。 プロバイダーが自分で自分を有効にすることはありません。

  • 選択はプロファイルごとで、セッションがドライバーを起動するときに読まれます。 設定した名前が見つからないと、

その名前とインストール済みのプロバイダーを示すエラーで呼び出しが失敗します。Hermes がほかのドライバーに切り替えて動くことはありません。

  • モデルから見えるスキーマはどのプロバイダーでも同じなので、ドライバーを差し替えてもプロンプトキャッシュはそのまま使えます。

できない操作は、操作ごとに ActionResult(ok=False, code="unsupported_action", ...) で知らせてください。

  • 承認のゲート、Bot Desktop のリース、スクリーンショットの重複除去、要素数の上限、画像認識への振り分けは、すべてバックエンドより上の

ツール側で動きます。バックエンドが受け持つのは画面の操作だけです。

  • hermes computer-use status/doctor/permissions が cua-driver の確認をするのは、cua が選ばれているときだけです。

ほかのプロバイダーでは、その is_available() の結果を表示し、doctor() があればそれを実行します。

  • プロバイダーは稼働中のドライバーのセッションを返すため、プロセス内でしか読み込めません。

plugins.isolation: host のもとでは、ユーザーのプロバイダーは拒否され、読み込めなかったことが呼び出しの結果として返ります。

Python でない拡張ポイント

Hermes は、Python プラグインではない拡張も受け入れます。これらは Pluggable interfaces table に示されています。以下の節では、それぞれの執筆スタイルを簡単に概観します。

MCP サーバー — 外部ツールを登録する

Model Context Protocol(MCP)サーバーは、Python のプラグインを一切必要とせずに、自身のツールを Hermes に登録します。~/.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。

ゲートウェイイベントフック — ライフサイクルイベントで発火する

マニフェストとハンドラーを ~/.hermes/hooks/<name>/ に置きます。プラグインとは異なり plugins.enabled の手順はありません — ゲートウェイは起動時にすべての有効なフックディレクトリを import するため、ファイルを置くこと自体がオプトインになります(trust model)。

# ~/.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:startup、session:start、session:end、session:reset、agent:start、agent:step、agent:end、そしてワイルドカードの command:* があります。フック内のエラーは捕捉されてログに記録されます — メインのパイプラインを止めることは決してありません。

完全なガイド: Gateway Event Hooks。

シェルフック — ツール呼び出し時にシェルコマンドを実行する

ツールが発火したときにスクリプトを実行するだけでよい場合(通知、監査ログ、デスクトップアラート、自動フォーマッタなど)は、config.yaml のシェルフックを使ってください — Python は不要です。

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

Python プラグインのフックと同じイベント(pre_tool_call、post_tool_call、pre_llm_call、post_llm_call、on_session_start、on_session_end、pre_gateway_dispatch)に加えて、pre_tool_call の block 判定のための構造化された 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(リポジトリのレイアウト、最小の例、既定でないパス、trust level)。

コマンドテンプレート経由の 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 に argv トークン化されたテンプレートを指定します。これは暗黙のシェル解釈なしに実行されるため、信頼できるローカルコマンドがシェル構文を必要とする場合は sh -c、cmd /c、あるいは PowerShell を明示的に使ってラップしてください。対応するプレースホルダーは {input_path}、{output_path}、{format}、{voice}、{model}、{speed}(TTS)、{input_path}、{output_dir}、{language}、{model}(STT)です。パスを扱う CLI であれば、自動的にプラグインとして扱えます。

完全なガイド: TTS custom command providers ・ STT。

pip で配布する

公開して配布するには、Python パッケージにエントリーポイントを追加します。

# pyproject.toml
[project.entry-points."hermes_agent.plugins"]
my-plugin = "my_plugin_package"

インストールの所有者が用意した環境(たとえば Nix の derivation)に配布物が入っている場合、 エントリーポイントによる検出は引き続き使えます。 これは検出の仕組みであって、PM が選んだ世代へパッケージを差し込んでよいという許可ではありません。 管理されたインストールでは、pyproject.toml か、マニフェストの Python の要件を持つディレクトリ型 プラグインとして配布し、PM がトランザクションとして受け入れられるように hermes plugins install / enable を 使ってください。新しい環境が選ばれたら Hermes を再起動します。 hermes pm install が受け付けるのは管理対象のツール名で、任意の PyPI パッケージではありません。

NixOS 向けに配布する

NixOS ユーザーは、エントリーポイントを持つ pyproject.toml を提供すれば、あなたのプラグインを宣言的にインストールできます。

エントリーポイント型プラグイン(配布に推奨):

# User's configuration.nix
services.hermes-agent.extraPythonPackages = [
  (config.services.hermes-agent.package.python.pkgs.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 = [ config.services.hermes-agent.package.python.pkgs.setuptools ];
  })
];

ディレクトリ型プラグイン(pyproject.toml は不要):

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

overlay の使い方や衝突チェックを含む完全な文書は Nix Setup guide を参照してください。

よくある間違い

ハンドラーが JSON 文字列を返していない:

# Wrong — returns a dict
def handler(args, **kwargs):
    return {"result": 42}

# Right — returns a JSON string
def handler(args, **kwargs):
    return json.dumps({"result": 42})

ハンドラーのシグネチャに **kwargs がない:

# Works — the dispatcher only forwards the context keywords a signature names
def handler(args):
    ...

# Better — receives every injected context field (task_id, session_id, parent_agent, ...)
def handler(args, **kwargs):
    ...

ハンドラーが例外を raise する:

# Wrong — exception propagates, tool call fails
def handler(args, **kwargs):
    result = 1 / int(args["value"])  # ZeroDivisionError!
    return json.dumps({"result": result})

# Right — catch and return error 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)})

スキーマの description が曖昧すぎる:

# Bad — model doesn't know when to use it
"description": "Does stuff"

# Good — model knows exactly when and how
"description": "Evaluate a mathematical expression. Use for arithmetic, trig, logarithms. Supports: +, -, *, /, **, sqrt, sin, cos, log, pi, e."