SSD Nodes Learn Hosting plans →
ガイド Matt Connor著者 Matt Connor

DeepSeek Harness用dshプラグインの作り方

空のフォルダーからdshプラグインを作成します。package.jsonの重要な項目、マウントするpatchファイル、実際に動くtool、必要な2つのhookを確認できます。

dsh プラグインとは何か

dsh プラグインは、apply 関数をエクスポートし、DeepSeek Harness に読み込ませるための小さな YAML ファイルを 1 つ含む npm パッケージです。最初に別のプラグイン SDK を学ぶ必要はありません。dsh は Cordis アプリケーションです。「すべてがプラグインである」という説明は文字どおりで、ツールレジストリ、エージェントループ、セッションストア、Web サーバーはすべて同じプラグインツリーに属する要素です。あなたのパッケージはそのツリーに加わります。

Cordis は汎用の構成フレームワークです。独立して開発され、Koishi chatbot framework の基盤として長年使われています。プラグインの読み込みとアンロードを処理し、プラグイン間の依存関係を解決します。エージェントについての知識はありません。エージェントに関する機能はすべて、その上に重ねられた harness パッケージから提供されます。そのため、以下のプラグイン構造は非常に小さく見えます。多くの機能は継承されます。

プラグインには 2 つの部分があります。ホスト側は Node で実行され、ツールとイベントリスナーを登録し、独自のサービスも提供できます。ブラウザー側は Web UI 内で実行され、インターフェーススロットを登録します。最初のプラグインはほぼ常にホスト側だけで構成します。必要になるまで、ブラウザー側は任意の機能として扱ってください。

このガイドは、2026 年 8 月 19 日時点の npm latest タグである @deepseek-ai/dsh version 0.1.0-rc.7 を前提に書かれています。dsh は developer preview であり、独自の README にも互換性を壊す変更があると記載されています。以下のキー名はすべて、その日時点の upstream documentation と repository から確認したものです。2026 年 10 月 12 日に再確認した時点では、npm latest タグは 0.2.0-rc.2 でした。以下のセクションでは、このリリースで動作が異なる箇所を示します。preview API では release candidate の間にフィールド名が変更されるため、キーを利用する前にもう一度確認してください。harness がまだ実行されていない場合は、まず VPS 上での DeepSeek Harness と dsh API key と model configuration の手順でセットアップしてから、ここに戻ってください。

パッケージ化する前に、まず一時ファイルを1つ読み込む

最初にパッケージ化すると、理解するまでに時間がかかります。最初にファイルを1つ読み込み、ランタイムがコードを呼び出すことを確認してから、パッケージ化します。

harness のチェックアウト外にフォルダーを作成し、その中にファイルを1つ置きます。

import type { Context } from '@deepseek-ai/cordis'

export const name = 'hello-plugin'

export function apply(ctx: Context) {
  console.log('[hello-plugin] plugin loaded')
}

export const name は、診断時にプラグインを識別するためのメタデータです。apply は契約全体です。Cordis はこれを1回呼び出し、プラグイン用にスコープされたコンテキストを渡します。そのコンテキストに登録した内容は、プラグインが破棄されるときに自動的に解除されます。

その隣に cordis.yml を記述します。

- insert:
    - id: hello
      name: '/absolute/path/to/scratch-plugin/hello.ts'

次に、そのファイルを上に重ねたプロファイルを起動します。

dsh web --patch ./scratch-plugin/cordis.yml

dsh が PATH にない場合は、npx @deepseek-ai/dsh web --patch ./scratch-plugin/cordis.yml でも同じことができます。この npx 経由では、このガイドが説明するバージョンではなく、キャッシュされた古いリリース候補が渡されることがあります。そのため、harness が文書化されたフラグを明確に拒否する場合は、自分のファイルを疑う前に、dsh のインストールとバージョンエラーの対処法を確認してください。dsh を起動したターミナルに [hello-plugin] plugin loaded と表示されるはずです。何も表示されない場合、その行は解決されていません。

name フィールドには、npm パッケージ名またはファイルシステムパスを指定します。上流のドキュメントでは、パスは絶対パスでなければならないと説明されています。一時プラグインが出力を生成しない場合、最初に確認するのは相対パスの ./hello.ts です。次に確認するのはファイル拡張子です。文書化されたループは、harness リポジトリのクローンから pnpm dsh web --patch ... として実行されます。この場合、TypeScript のエントリは tsx 経由で読み込まれます。dsh を npm から入手した場合は、行の参照先を通常の JavaScript にするか、先にファイルをビルドしてください。

--patch はランチャーのフラグです。そのオーバーレイは、すべてのバンドルと独自のプロファイルパッチの後に適用されます。そのため、一時オーバーレイは常に優先されます。反復して調整する間は、これが望ましい動作です。

役に立つ最小のツールを作成する

ログ行が出れば、プラグインが読み込まれたことは確認できます。ツールが動作すれば、プラグインがエージェントの一部になったことを確認できます。

import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'

export const name = 'greet-tool'
export const inject = ['tools']

export function apply(ctx: Context) {
  ctx.tools.register(defineTool({
    name: 'greet',
    description: 'Greet someone by name.',
    parameters: {
      name: { type: 'string', required: true, description: 'The name to greet' },
    },
    output: {
      schema: { type: 'string' },
      render: (_args, value) => [{ type: 'text', text: value }],
    },
    async execute(args) {
      return `Hello, ${args.name}!`
    },
  }))
}

export const inject = ['tools'] は省略されやすい行です。Cordis の設定内のエントリは同時に起動するため、ファイル内の行位置から読み込み順序は決まりません。順序は宣言した依存関係によって決まります。inject によって、Cordis は ctx.tools が存在するまで待機してから apply を呼び出します。これがないと、登録先となるレジストリがまだ存在しない時点でコードが実行される可能性があります。

オブジェクトの残りの部分は、モデルに見える契約を定義します。parameters は引数のスキーマで、execute にはそのスキーマに基づいて解析済みの引数が渡されます。output.schema は execute が返す値を説明し、render はその値をモデルが読み取るコンテンツブロックに変換します。この2つを分離することで、インターフェースには一方を表示しながら、モデルにはもう一方を読み取らせることができます。

プロファイルを起動し、アシスタントに名前で誰かへ挨拶するよう依頼します。応答は execute を通じて返されます。ctx による登録は可逆的であるため、プラグインを破棄するとツールの登録も自動的に解除されます。ソケットやファイルハンドルなど、Cordis が管理できないリソースについては、ctx.effect() を呼び出して disposer を渡します。

最初のプラグインが実際に触れる2つの拡張ポイント

継ぎ目の一覧は長くなります。最初のプラグインでは、そのほとんどを2つの拡張ポイントでカバーできます。

会話イベントは、永続化されてログに記録されるストリームです。0.1.0-rc.7 では、名前は session/event、turn/start、turn/end、step/start、step/end、user/message、assistant/message、assistant/chunk、tool/call、tool/result でした。0.2.0-rc.2 には assistant/chunk がありません。ライブストリーミングは agent/assistant-stream に移されました。このフレームはプロセス内に留まり、ログには記録されません。また、新しい永続イベント assistant/attempt により、失敗、再試行、キャンセルされた試行を保持できます。通常のリスナーをアタッチします。

ctx.on('tool/call', (payload) => {
  console.log('[my-plugin] tool/call', JSON.stringify(payload))
})

ペイロードを1回出力して確認します。このガイド自体も含め、どのガイドからもペイロードのフィールド名をコピーしないでください。プレビュー API では、ペイロードの構造が最も頻繁に変更されるためです。

2つ目の拡張ポイントはウォーターフォールです。agent/pre-step、agent/request、agent/request-error、llm/stream、および tools/* イベントはウォーターフォールです。ウォーターフォールリスナーのシグネチャは異なります。next コールバックを受け取り、そのコールバックが呼び出された場合にだけチェーンが続行します。

ctx.on('agent/request', async (payload, next) => {
  const startedAt = Date.now()
  const downstream = await next()
  console.log('[my-plugin] model request took', Date.now() - startedAt, 'ms')
  return downstream
})

await next() を忘れると、フックを追加したことになりません。モデル呼び出しを何もしない処理に置き換えたことになり、エージェントはそこで停止します。これは、ゲートウェイプラグインが意図的にリクエストを拒否する場合に短絡する設計だからです。この違いが、最初のプラグインで多くの混乱を招きます。周囲の処理を書く前に、next() の呼び出しを書いてください。

agent/request はモデル呼び出し自体をラップします。そのペイロードには、呼び出しを実行するエージェント、現在開いているターン番号、リクエストが属するステップ、そのターンの abort signal が含まれます。そのため、リクエストロガーやレートリミッターに適した継ぎ目になります。tools/* のウォーターフォールは、1つ下の層でも同じ構造です。tools/pre-execute はディスパッチ前に許可、拒否、承認要求を行います。tools/execute はディスパッチをラップします。tools/post-execute は正規化された結果を置き換えることも、ブロックすることもできます。tools/result は確定した結果を監視するだけです。

他のユーザーがインストールできるバンドルとしてパッケージ化する

バンドルは、package.jsonでパッチファイルを指すdsh.bundleフィールドを宣言した npm パッケージです。この宣言が、作業用ファイルとインストール可能なパッケージの唯一の違いです。

{
  "name": "dsh-plugin-hello",
  "version": "0.1.0",
  "type": "module",
  "main": "lib/index.js",
  "files": ["lib", "cordis.patch.yml", "README.md", "LICENSE"],
  "engines": { "node": "^22.19 || >=24", "dsh": ">=0.1.0-rc.6" },
  "dsh": { "bundle": { "patch": "./cordis.patch.yml" } },
  "keywords": ["dsh-plugin", "deepseek-harness"],
  "scripts": { "build": "tsdown", "prepare": "pnpm run build" },
  "exports": {
    ".": { "types": "./lib/index.d.ts", "default": "./lib/index.js" },
    "./cordis.patch.yml": "./cordis.patch.yml",
    "./package.json": "./package.json"
  }
}

その横にあるcordis.patch.ymlは短い内容です。

- insert:
    - id: dsh-plugin-hello
      name: dsh-plugin-hello

nameの行はパッケージ名なので、これら2つの文字列は一致させる必要があります。idの行は、後のレイヤーでユーザーが設定を上書きするときの対象になります。安定した名前を選び、別のプラグインに再利用しないでください。

filesにはcordis.patch.ymlを記載する必要があります。省略すると、公開された tarball に、パッケージに含まれていないファイルを指すdsh.bundle.patchが含まれます。そのため、パッケージはインストールされてもツリーに何も追加しません。

プラグインフォルダーを含むディレクトリから、プロファイルにインストールします。

dsh plugin --profile demo add ./dsh-plugin-hello
dsh --profile demo --dump-config
dsh --profile demo

dsh plugin --profile <name>は残りの引数を、そのプロファイルディレクトリ内の pnpm に渡します。そのため、addとremoveは pnpm と同じように動作します。アンインストールするにはdsh plugin --profile demo remove dsh-plugin-helloを使用します。付属のweb、headless、sdk、sdk-minimal、acpのプロファイルは、初回使用時にテンプレートから自動作成されます。0.2.0-rc.2 では、それ以外の名前は2通りの方法で作成されます。dsh pluginを使用すると、ベースバンドルだけから作成を開始します。dsh --profile <name> --from-default-profile <template>を使用すると、付属テンプレートのバンドル一覧をコピーします。まだ存在しないカスタムプロファイルを起動すると失敗し、dsh pluginを実行するよう案内されます。

合成ツリーに行が表示されない理由

合成は、空のエントリ一覧から始まり、固定された順序でレイヤーを積み重ねます。まず、プロファイルの dsh.profile.bundles に記載された各バンドルを、記載順に適用します。次に、プロファイル自身の cordis.patch.yml を適用します。その後に $DSH_HOME/cordis.patch.yml を適用し、最後にコマンドラインから指定した --patch オーバーレイを適用します。後のレイヤーにある行は、id が同じ先行レイヤーの行を置き換えます。

プロファイルは $DSH_HOME/profiles/<name> 以下に配置されます。プロファイルディレクトリには、順序付きの bundles リストを含む dsh.profile マニフェストを格納した package.json と、ユーザー独自のパッチファイルがあります。バンドル名は、まず dsh のインストール先から解決され、次にプロファイルの node_modules から解決されます。pnpm がツリー外のプラグインを配置するのはこの場所です。

dsh --profile demo --dump-config は、何も起動せずに完全に合成されたツリーを出力します。この出力が、デバッグ時の切り分けの基準になります。行の id が存在しない場合、問題は合成にあります。解決できない名前か、パッチファイルがパッケージ化されていないことが原因です。行が存在するのに何も起きない場合、問題はコードにあります。最初にこの点を確認すれば、推測による調査の大半を省けます。

実際に読み込みエラーが現れる場所

apply 内で発生したエラーは明確に現れます。プロセスはその例外によって終了し、自分のコード行を指すスタックトレースが表示されます。

解決に失敗しても、エラーは目立ちません。loader は解決できないモジュールをクラッシュさせず、Cordis logger を通じて報告します。上流のチュートリアルでは、起動時にこれらのメッセージが失われたように見える場合があると説明しています。console exporter が接続される前に出力されるためです。そのため、パスの誤りは、読み込まれたものの何もしない plugin とまったく同じように見えます。コードを読む前に、上記の --dump-config check を実行する価値があるのはこのためです。

開発中は、apply の最初の文として console.log を記述しておきます。これがない場合、問題がどちら側にあるかを判断できます。後で削除してもコストはかかりません。サーバーでは、試行錯誤の間は service manager の下ではなく harness を foreground で実行してください。そうすれば、loader の出力が確認しに行く必要のある journal ではなく、端末に表示されます。

再起動に依存せず反復する

0.1.0-rc.7 では、ホスト側の対応は単純でした。再起動します。このリリースの Web アプリケーションバンドルでは、共有のホットモジュールリロード行が無効になっており、リロードのライフサイクルをテストした後に再び有効化するという注記がファイルに記載されていました。0.2.0-rc.2 では、その上書き設定がなくなっています。ベースバンドルはすべてのプロファイルで @deepseek-ai/dsh-hmr を root: [] 付きでマウントします。コメントには「プロファイル設定はデフォルトでリロードし、モジュールルートは明示的に有効化する」とあります。プロファイルと home patch ファイルへの有効な編集は、再起動なしで再適用されるようになりました。プラグイン自身のソースは、そのディレクトリを行の root に追加しない限り、引き続き監視されません。バンドルの追加、削除、更新には、依然として再起動が必要です。クライアント側のリロードチェーンは常にマウントされていますが、ビルドウォッチャーがクライアントバンドルを書き換えるまで待機します。そのため、Node 側には何も作用しません。

オプトインが必要なリロードに頼るのではなく、再起動にかかるコストを下げてください。プラグインは 1 ファイルにまとめます。プロファイルにインストールするのではなく --patch で読み込んでください。これにより、編集から実行までの間にビルド手順も pnpm 手順も不要になります。すべてを ctx 経由で登録してください。そうすれば、再起動後に重複したツールや古いリスナーが残りません。自分で確保するものはすべて、実際に破棄する処理を含む ctx.effect() でラップしてください。破棄処理がない場合によく起きる症状は、1 回目の実行が保持しているポートを使用しようとして、2 回目の実行が失敗することです。

ノート PC ではなくサーバー上で動作するハーネスに対して開発する場合も、上記の点は変わりません。ただし、Web UI のバインドは重要です。ローカルで起動すると Web UI は http://127.0.0.1:3080 で提供され、デフォルトのブラウザーで開きます。--no-open を渡すと、ブラウザーを開きません。SSH 経由で起動した場合はホスト URL が表示されるだけで、手元のマシンでは何も開きません。ポート 3080 のループバックバインドでは、その場合にページが開かない理由と対処方法を説明しています。

ブラウザー側と、その信頼範囲

プラグイン独自のインターフェースが必要な場合だけ追加します。これはバンドルと同じ dsh フィールドで宣言します。

{
  "dsh": {
    "client": {
      "platform": "web",
      "inject": [],
      "external": [],
      "immediately": false
    }
  },
  "exports": {
    ".": "./src/index.ts",
    "./client": "./src/client/apply.ts",
    "./package.json": "./package.json"
  }
}

"platform": "web" は必須です。また、パッケージに ./client エクスポートがないとスキャナーがエラーを発生させるため、export map は単なる利便機能ではなく manifest の一部です。クライアントエントリには、クライアントランタイムの型で拡張された Cordis Context が渡されます。すべての登録は apply 内で ctx.slots.register を通じて行います。ここではモジュールレベルの副作用は許可されません。

import type { Context } from 'cordis'
import type { DshClientContext } from '@deepseek-ai/dsh-client-runtime'

export async function apply(ctx: Context & DshClientContext) {
  ctx.slots.register({ name: 'domain.entry.slot' }, MyComponent)
}

開始する前に、2 つの点を確認してください。クライアント manifest の inject はスケジューリングではなく、ドキュメント用の情報です。パッケージレベルの依存関係を記録しますが、有効化の順序は制御しません。external はベースライン外のモジュール要求を宣言します。ただし、現在のクライアントノートでは、feature plugin の依存関係を指定する仕組みではないと説明されており、インフラストラクチャ、トランスポート、生成アセンブリコード用に予約されています。feature plugin では空のままにしてください。ここは preview の中でも特に変更が速い部分です。そのため、ガイドを読んだ日ではなく、コードを書く当日に harness リポジトリの packages/client/AGENTS.md を確認してください。

公開し、プラグインが変更する範囲を明示する

GitHub リポジトリに dsh-plugin トピックを追加すると、利用者がプラグインを探すときに閲覧する一覧に表示されます。これは見知らぬ利用者からの信頼を得る行為であり、責任を伴います。その責任は、インストール前に dsh プラグインを審査するためのガイドで読者に確認を促している内容の裏返しです。そのチェックリストに沿って記述することが、要件を満たす最も簡単な方法です。

  • 依存関係を固定します。推移的依存関係に caret 付きの範囲指定を使うと、先週は安全だったパッケージが今週は異なるコードを実行する可能性があります。これは、サーバーを狙う npm サプライチェーン攻撃の正確な仕組みです。
  • マニフェストに変更対象を記載します。inject の一覧は、どの harness サービスを利用するかを正直かつ機械可読にまとめたものです。レビュー担当者は数秒で確認し、その内容から評価します。
  • ネットワーク呼び出しを黙って行わないでください。ツールが API を呼び出す場合は、README にホスト名を記載し、エンドポイントを設定可能にします。記載のないサーバーへ接続するプラグインは、こうした点を監査する担当者によって一覧から削除されます。
  • files を最小限に保ちます。作業フォルダー全体を公開すると、残っていた認証情報ファイルがレジストリに到達する可能性があります。
  • git インストーラー向けに、開発時専用の前提に依存せずビルドできる prepare スクリプトを用意します。また、そのビルドをプロファイルの pnpm-workspace.yaml で allowlist に追加する必要があることを README に記載します。
  • ビルドおよびテストしたリリース候補に合わせて README に日付を記載します。プレビュー API の利用者は、どのリリース候補を使用したかを把握する必要があります。

完成したプラグインが外部からどのように見えるかを確認するには、インストールする価値のある dsh プラグインを読み、インストール前に各 README が何を説明しているかに注目してください。別の agent 用の拡張機能を書いた経験がある場合は、Claude Code プラグインの構成方法が有用な比較対象になります。harness はライブなオブジェクトグラフと、元に戻せる登録機構を提供します。これはファイルのマニフェストより強力である一方、それに応じた責任も伴います。

FAQ

dsh プラグインを作成するには npm への公開が必要ですか?

いいえ。cordis.yml overlay 内のファイルシステムパスを dsh web --patch ./scratch-plugin/cordis.yml で読み込めば、harness 内で独自コードを実行できます。パスは絶対パスでなければなりません。パッケージ化が必要になるのは、他のユーザーがプラグインをインストールする場合だけです。その場合でも、dsh plugin --profile demo add ./my-plugin でローカルフォルダーをインストールすれば、registry に触れずにパッケージ化した形式をテストできます。

プラグインは読み込まれるのに、ツールが表示されないのはなぜですか?

最初に dsh --profile demo --dump-config を実行します。その出力に対象の row id がない場合、プラグインは mount されていません。原因はコードではなく composition にあります。row が存在する場合は export const inject = ['tools'] を確認します。Cordis configuration のエントリは同時に開始されるため、ファイルの順序で load order は決まりません。その宣言がないと Cordis は tool registry の準備を待ちません。そのため、apply が ctx.tools に登録できないタイミングで実行されることがあります。

cordis.yml と cordis.patch.yml の違いは何ですか?

cordis.yml は完全な entry list です。cordis.patch.yml はその上に適用する layer で、id で row を指定し、新しい row の挿入や既存 configuration の置換を行います。bundle は package.json 内の dsh.bundle.patch を通じて独自の patch file を指定します。layer は固定された順序で適用されます。まず profile に記載された順序ですべての bundle、次に profile の patch file、その後に $DSH_HOME/cordis.patch.yml、最後に任意の --patch overlay が適用されます。後から適用された layer が優先されます。

agent の実行中に dsh プラグインを hot reload できますか?

0.1.0-rc.7 ではできません。このバージョンでは、web bundle に含まれる共有 hot module reload row が無効になっています。0.2.0-rc.2 では、その row がすべての profile に mount されます。ただし、デフォルトでは profile configuration だけを reload します。module root は root list で opt-in する必要があるため、追加しない限りプラグインコードの変更には restart が必要です。いずれの場合も、restart を高速にできる構成にします。build step なしで --patch から読み込む 1 ファイルにまとめ、すべての registration を ctx 経由で行うことで、実行間で状態が漏れないようにします。Cordis が自動で cleanup できないリソースには、disposer を指定した ctx.effect() を使用します。