ドキュメント · プラグイン

プラグインを書く

プラグインはサービスにサインインし、そのサービスへのリクエストを送ります。magpie はそれを、すべてのエージェントが使えるプロバイダに変えます。複数アカウント、フェイルオーバー、使用量表示なども備わります。magpie は OpenCode のプロバイダプラグインを動かすので、OpenCode 向けに書いたプラグインはそのままここで動きます。プロバイダを登録する pi のパッケージも動きます(pi パッケージを参照)。このページでは、プラグイン作者に必要なこと、つまり magpie が呼び出すフック、その戻り値を magpie がどう扱うか、そしてプラグインのテスト・公開・掲載の方法を説明します。

ひとことで言うと。プラグインは npm パッケージ、git リポジトリ、またはフォルダです。そのモジュールは OpenCode v1 プラグイン形式の async 関数をエクスポートします。magpie はすべてのプラグインをひとつの Bun プロセス、つまりプラグインホストに読み込みます。関数はフックを返します:
  • config はプロバイダとそのデフォルトのモデルを宣言します。
  • auth はサインイン方法と、各リクエストを送る fetch を持つ loader を提供します。
  • provider.models はアカウントごとのモデルを列挙します。
  • auth.usage はプランをどれだけ使ったかを返します。magpie 独自のフックで、OpenCode は無視します。
  • auth.refresh はアカウントのトークンを期限切れ前に、一度にひとつずつ更新します。これも magpie 独自のフックです。
エージェントはどの API で送ってきても構いません。ゲートウェイはリクエストをモデルが話す API(chat completions、Responses、Anthropic Messages、Gemini)に変換し、プラグインの fetch に渡して、応答をストリームで返します。

5 分でプラグインを作る

この例は架空のコーディングプラン Acme にサインインします。Acme は API キーを受け取り、OpenAI 互換のエンドポイントを持ち、アカウントごとのモデルと使用量を返します。実際のプラグインとの違いは主に、サインインの方法とリクエストへの署名の仕方です。この例はすべて magpie v0.1.676 と、Acme の API のローカルな代用品で実際に動かしています。

package.json
{
  "name": "opencode-acme-auth",
  "version": "0.1.0",
  "description": "OpenCode provider plugin: Acme Coding Plan",
  "type": "module",
  "main": "./index.mjs",
  "files": ["index.mjs", "README.md"],
  "keywords": ["opencode", "opencode-plugin", "acme"],
  "license": "MIT",
  "magpie": { "maxConcurrency": 4 }
}
index.mjs
const PROVIDER = "acme"
const BASE = "https://api.acme.example/v1"

export const AcmeAuthPlugin = async ({ client }) => ({
  // the provider and the models it has before anyone signs in
  config: async (cfg) => {
    cfg.provider ??= {}
    cfg.provider[PROVIDER] ??= {
      name: "Acme",
      npm: "@ai-sdk/openai-compatible", // chat completions
      api: BASE,
      models: {
        "acme-coder": { name: "Acme Coder", limit: { context: 200000, output: 32000 }, tool_call: true },
      },
    }
  },

  auth: {
    provider: PROVIDER,
    methods: [{ type: "api", label: "Acme API key", placeholder: "acme-…" }],
    // what every request of this account is sent with
    async loader(getAuth) {
      const auth = await getAuth()
      if (auth?.type !== "api") return {}
      return {
        baseURL: BASE,
        async fetch(input, init) {
          const headers = new Headers(init?.headers)
          headers.set("authorization", `Bearer ${auth.key}`)
          headers.set("x-acme-client", "opencode-acme-auth/0.1.0")
          return fetch(input, { ...init, headers })
        },
      }
    },
    // magpie's own hook: how much of the plan is used
    async usage(getAuth) {
      const auth = await getAuth()
      const res = await fetch(`${BASE}/usage`, { headers: { authorization: `Bearer ${auth.key}` } })
      if (!res.ok) return { error: `Acme answered ${res.status}`, windows: [] }
      const u = await res.json()
      return {
        plan: u.plan,
        windows: [{ name: "5 hours", used: u.percent, resetsAt: u.resets_at, span: 5 * 3600 }],
      }
    },
  },

  // the models this account has, asked of Acme
  provider: {
    id: PROVIDER,
    async models(provider, { auth } = {}) {
      if (auth?.type !== "api") return provider.models
      const res = await fetch(`${BASE}/models`, { headers: { authorization: `Bearer ${auth.key}` } })
      if (!res.ok) return provider.models // magpie keeps the list it had
      const { data } = await res.json()
      const base = provider.models["acme-coder"]
      return Object.fromEntries(data.map((m) => [m.id, { ...base, id: m.id, name: m.name ?? m.id }]))
    },
  },
})

フォルダを magpie に追加し、サインインして試してみます:

ターミナル
magpie plugin add ./opencode-acme-auth   # a folder is loaded where it is; nothing is installed
magpie plugin login acme                 # asks for the key
magpie plugin                            # acme (Acme, 2 models)  signed in
magpie provider test acme                # ✓ chat  7 ms · acme-coder
magpie quota                             # acme · Pro · 5h 37% ↻59m

これでエージェントは、ほかのプロバイダと同じように acme/acme-coder としてそのモデルを使えます。アプリでは プラグイン を開き、プラグインを追加 をクリックしてフォルダを指定します。その後、プロバイダの行からサインインします。

サンドボックスで試しましょう。開発中のプラグインが普段使いの magpie に触れることがあってはいけません。テストにあるように、専用の HOME で magpie を動かしてください。

magpie がプラグインを動かす仕組み

magpie が設定フォルダ(~/.config/magpie または $XDG_CONFIG_HOME/magpie)に保存するもの:

ファイル内容
plugins.json追加したプラグイン:{"plugins": [{"spec", "off"?, "options"?}], "config"?}。options はプラグインの第 2 引数で、OpenCode の ["name", {…}] が渡すものと同じです。config はプラグインに渡される OpenCode の設定です。
plugins/インストールされた npm と git のパッケージ。独立した Bun プロジェクトです。
plugin-auth.jsonサインイン情報(モード 600)。OpenCode の auth.json と同じ形式なので、両者の間でサインインを移せます。
plugin-providers.jsonプラグインが最後に列挙したプロバイダとモデル。magpie はこれを使い、プラグインに問い合わせずに起動できます。

パッケージ

magpie がどこから読み込むか

magpie はプラグインのサーバー側を OpenCode と同じ場所から探し、プラグインをエクスポートしている最初のファイルを読み込みます:

  1. package.json の exports["./server"]、次に main、次に exports["."](文字列なら exports)。
  2. それがなければ index.ts、index.tsx、index.js、index.mjs、index.cjs。

単一ファイルへのパスなら、そのファイルを読み込みます。

モジュールがエクスポートするもの

ですから、エントリファイルからヘルパー関数をエクスポートしないでください。テストに必要なものはオブジェクトにまとめましょう。コミュニティのプラグインは export const _internal = { … } としています。

関数

形式
async (input, options) => hooks
// input:   { client, project, directory, worktree, serverUrl, $, experimental_workspace }
// options: the entry's "options" in plugins.json, else undefined
// hooks:   { config?, auth?, provider?, "chat.headers"? }

複数の関数をエクスポートすれば、ひとつのプラグインで複数のプロバイダを返せます。それぞれのフックは独立しています。同じプロバイダに対して 2 つのプラグインが auth フックを持つ場合、OpenCode と同じく最後に読み込まれたものが勝ちます。

package.json の magpie 用フィールド

OpenCode は magpie オブジェクトを無視するので、OpenCode プラグインに追加しても安全です。

フィールド意味
magpie.iconプラグイン自身の画像。「発見」のカード(npm から、GitHub のものはリポジトリから読み込み)と「インストール済み」の行に表示され、自分の画像を持たないプロバイダにも使われます:https:// URL または data:image/… URI、最大 1 MB。SVG、PNG など。
magpie.maxConcurrency各アカウントが同時に受けるリクエスト数。1 から 1000 の整数。ユーザーがプロバイダに設定した上限が優先されます。

同じフィールドは auth フックにも設定でき(auth.icon、auth.maxConcurrency)、そちらが package.json より優先されます。複数のプロバイダにサインインするプラグインは、プロバイダごとに auth.icon を設定し、magpie.icon をプラグイン自身のものにできます。

プロバイダとモデル

プロバイダを宣言する

プロバイダの id は auth.provider です。models.dev にその id があれば、magpie は OpenCode と同じくそのエントリから始めます。なければ config フックでプロバイダを宣言します。ユーザーが plugins.json で設定したプロバイダを上書きしないよう、??= を使ってください:

config フック
config: async (cfg) => {
  cfg.provider ??= {}
  cfg.provider["acme"] ??= {
    name: "Acme",
    npm: "@ai-sdk/anthropic",          // the API its models speak (table below)
    api: "https://api.acme.example/v1", // where requests go, unless the loader says otherwise
    models: {
      "acme-large": {
        name: "Acme Large",
        id: "acme-large-2026-09",          // the id the API takes, when it isn't the key
        limit: { context: 400000, output: 64000 },
        reasoning: true,
        tool_call: true,
        modalities: { input: ["text", "image"] },
        cost: { input: 3, output: 15, cache_read: 0.3 }, // US$ per million tokens; leave out for a plan
        variants: { low: {}, medium: {}, high: {} },        // reasoning levels
      },
    },
  }
}

モデルには、プロバイダとは別の API やアドレスを使うための provider: { npm, api }、headers、options、そして非表示にするための disabled: true も指定できます。

自分でウェブ検索する上流

プラグインが Anthropic や OpenAI 自身の API(ホスト型のウェブ検索ツールを自分で実行する)の前に立つ場合は、config フックでプロバイダに searches: true を設定します。するとクライアントのウェブ検索(Claude Code の WebSearch、Codex の web_search)は、そのプロバイダの Anthropic または Responses のモデルでは送られたまま fetch に届き、magpie 自身の検索を通りません。Chat のモデルは変わりません。magpie が他のモデルのために自動でこれを使って検索することはありませんが、設定で検索モデルとして指定できます。

判定 API(System One)

プラグインは自分のプロバイダ向けに TypeSafe の System One API を提供でき、そのモデルをルーティンググループの分類器にできます。config フックでプロバイダに decide: true を、会話ではなく質問に答えるモデルごとに decides: true を設定します(config フックでも models フックのリストでも構いません)。すると magpie は各質問を POST {baseURL}/systemone としてローダーの fetch に送ります。アカウントのサインイン情報は他のリクエストと同じく付きます。本文は {"model", "questions", "state"}、返答は {"model", "answers": {"<質問>": {"choice", "score", "confidence"}}} です。

decides を付けたモデルと jev-* という名前のモデルは分類器として提示され、エージェントには見せません。それらへのチャットリクエストは拒否されます。プロバイダに decide: true がなければ decides は無視され、どのモデルも普通にチャットに使えます。

モデルが話す API

モデルまたはプロバイダの npm で指定した AI SDK パッケージが API を決めます。それによって、あなたの fetch が受け取るリクエストの形が決まります。エージェントが何を送ってきても、magpie のゲートウェイがすでに変換済みです:

npmAPIベース以降のパス
@ai-sdk/anthropic, @ai-sdk/google-vertex/anthropicAnthropic Messages/messages
@ai-sdk/openai, @ai-sdk/azureOpenAI Responses/responses
@ai-sdk/googleGemini/models/<id>:streamGenerateContent?alt=sse
@ai-sdk/github-copilotGPT-5 以降(gpt-5-mini を除く)は Responses、それ以外は chat completions/responses または /chat/completions
それ以外すべて(@ai-sdk/openai-compatible, xai, groq, deepseek, …)Chat completions/chat/completions

ベースは次の順で決まります:

  1. loader の baseURL;
  2. モデルの provider.api;
  3. プロバイダの api;
  4. AI SDK パッケージ自身のデフォルト(https://api.anthropic.com/v1、https://api.openai.com/v1、…)。

本文の model はモデルの API id です。id があればそれ、なければキーです。

アカウントごとのモデル

モデルがアカウントによって変わる場合は、provider フックを追加します:

provider フック
provider: {
  id: "acme",
  async models(provider, { auth }) {
    // provider.models: what models.dev and the config hook give, keyed by id
    // auth: this account's sign-in, as plugin-auth.json keeps it
    return { "acme-large": { ...provider.models["acme-large"], name: "Acme Large" } }
  },
}

ベンダーに接続できないとき

フックが次のいずれかをした場合、magpie は一覧を取得できなかった組み込みサブスクリプションと同じく、すでに持っている一覧を保持します:

失敗の原因がサインインなら、signIn: "expired" を付けたエラーを投げてください。するとアカウントは再サインインが必要と記録されます(サインイン状態を参照)。

サインイン

auth フックは { provider, methods, loader, usage?, refresh?, refreshLead?, icon?, maxConcurrency? } です。各メソッドはユーザーが選べるサインイン方法です。アプリではメソッドがボタンになり、ターミナルでは magpie plugin login <id> [<method>] がどれを使うか尋ねます。

API キー

api メソッド
{ type: "api", label: "Acme API key", placeholder: "acme-…" }

ブラウザやデバイスでのサインイン

oauth メソッド
{
  type: "oauth",
  label: "Sign in with Acme",
  async authorize(inputs) {
    const d = await startDeviceCode()
    return {
      url: d.verification_uri,                    // magpie opens it
      instructions: `Enter the code ${d.user_code}`, // and shows this
      method: "auto",                              // "auto": callback() resolves on its own
      async callback() {                           // "code": callback(code), the code the user pastes
        const t = await pollForToken(d)
        if (!t) return { type: "failed", error: "The code expired" }
        return { type: "success", refresh: t.refresh_token, access: t.access_token,
                 expires: Date.now() + t.expires_in * 1000, accountId: t.email }
      },
    }
  },
}

サインイン前の質問

メソッドの prompts は authorize の前に順番に尋ねられます。それぞれ条件を満たすときだけです:

prompts
prompts: [
  { type: "select", key: "region", message: "Region",
    options: [{ label: "Global", value: "global" }, { label: "China", value: "cn", hint: "acme.cn" }] },
  { type: "text", key: "team", message: "Team id", placeholder: "t-…",
    when: { key: "region", op: "eq", value: "global" },        // or condition: (inputs) => boolean
    validate: (v) => (/^t-/.test(v) ? undefined : "Starts with t-") },
]

type: "password" の質問は text と同じですが、入力が表示されません。アプリではマスクされた入力欄、ターミナルの magpie plugin login ではエコーなしです。トークンや個人アクセストークン(PAT)に使います。magpie 独自の型で、OpenCode にあるのは text と select だけです。

アカウント

サインインの更新

magpie 0.1.684 以降、プラグインは magpie 独自のフック auth.refresh でトークンの更新を magpie に任せられます(OpenCode はこれを無視します):

auth フック
auth: {
  provider: "acme",
  methods: [ … ],
  // optional: how long before expires to renew (ms), 5 minutes by default
  refreshLead: 5 * 60 * 1000,
  async refresh(auth, provider) {  // auth is the account's saved sign-in
    const t = await exchange(auth.refresh)
    if (t.error === "invalid_grant")
      throw Object.assign(new Error("Acme refused the sign-in"), { signIn: "expired" })
    return { access: t.access_token, refresh: t.refresh_token ?? auth.refresh,
             expires: Date.now() + t.expires_in * 1000 }
  },
  loader: async (getAuth) => ({ … }),  // getAuth() already gives the renewed token
}

リクエスト

loader(getAuth, provider) はアカウントごとに 1 回実行され、サインインが変わると再実行されます。OpenCode が AI SDK に渡すものを返します:

フィールドmagpie での扱い
baseURLリクエストの送り先。モデルやプロバイダの api より優先されます。
apiKeyAI SDK パッケージと同じ方法で送られます:Anthropic なら x-api-key、Google なら x-goog-api-key、それ以外は Authorization: Bearer。API キーでのサインインでは、保存されたキーがデフォルトです。
headersすべてのリクエストに追加されます。
fetchリクエストを送ります。なければ magpie のホストは標準の fetch を使います。

あなたの fetch(input, init) が受け取るリクエスト:

Response を返してください。そのステータス、ヘッダー、本文はそのまま、チャンクごとにストリームで返されます。ストリーミングの本文を書き換える場合は、ヘッダーから content-length と content-encoding を取り除いてください。

失敗とフェイルオーバー

chat.headers

OpenCode の chat.headers フックはリクエストごとに実行されます。その input.sessionID は magpie の会話 id で、会話のターンをまたいで同じままです。ベンダーのセッションヘッダーやキャッシュヘッダーに使ってください:

chat.headers
"chat.headers": async (input, output) => {
  if (input.model.providerID === "acme") output.headers["x-acme-session"] = input.sessionID
}

サインイン状態

magpie は、ベンダーにサインインを拒否されたアカウントに記録を付けます。その記録をユーザーに表示し、再び使えるようになるまでそのアカウントには送信しません。デフォルトでは 401 で記録が付き、成功すると消えます。プラグインは X-Magpie-Sign-In レスポンスヘッダーで、応答の意味をより正確に伝えられます。このヘッダーは応答が先に渡される前に magpie が取り除きます:

値意味
expiredステータスにかかわらず、サインインが拒否された(そのために 403 や 502 を返すベンダー)。
keptアカウントをそのままにする(別の理由による 401)。
renewedサインインが更新された:リクエストがその後どうなっても記録を消す。

fetch や provider.models から signIn: "expired" を付けたエラーを投げても同じ効果があります:

index.mjs
throw Object.assign(new Error("Acme refused the sign-in; sign in again"), { signIn: "expired" })

使用量

auth.usage(getAuth, provider) は magpie 独自のフックです。アカウントの枠をどれだけ使ったかを返します。アカウントのスコープで実行されるので、ここでリフレッシュしたトークンはそのアカウントに保存されます。magpie はその結果を プロバイダ のアカウント、magpie quota、ゲートウェイの GET /v1/magpie/quotas に表示します。フィールドはすべて省略可能です:

usage の戻り値
{
  plan: "Pro",
  user: "ada@example.com",     // the account as the vendor names it
  until: "2026-11-01T00:00:00Z", // the plan's end
  renew: "auto",                // or "off"
  balance: "$12.40",
  error: "",                    // shown in place of the windows
  windows: [{
    name: "5 hours",
    used: 37,                   // percent; past 100 when overspent
    resetsAt: "2026-10-03T18:00:00Z", // or resetSecs: 3600
    span: 18000,                // seconds the window runs
    display: "370 / 1000 credits",
    amount: 370, limit: 1000, unit: "credits", // its own count, when it counts amounts
    model: "opus",              // counts only models whose ids hold this word
    models: [], notModels: [],  // or exactly these ids, or all but these
    aside: false,               // true: using it up doesn't stop the account
    runsOutAt: "2026-10-05T00:00:00Z", runsOut: 120, // when the soonest credits left are lost, and how many
  }],
  resets: { count: 2, until: "…", byWindow: false, fiveHour: 1, weekly: 1 }, // resets the account may spend
  signIn: "kept",               // as X-Magpie-Sign-In
}

時刻は ISO 文字列か、秒またはミリ秒のエポック数値です。signIn がない場合、再サインインを求める error はアカウントに記録を付け、問題なく読み取れれば記録は消えます。

プラグインに渡されるもの

magpie は OpenCode の PluginInput の内容を、OpenCode サーバーなしで意味をなす範囲でプラグインに渡します:

フィールドmagpie では
client.auth.set / .removeスコープ内のアカウントのサインインを保存または削除します。
client.app.logmagpie のログに plugin [level]: [service] message として書き込みます。
client.tui.showToastmagpie のログに書き込まれます。
client.config.getすべてのプラグインの config フックを経た後の設定。
そのほかの client.*{ data: undefined } を返します。
directory, worktreemagpie の設定フォルダ。ファイルの保存場所に使えます。
project{ id: "magpie", worktree }。
$Bun のシェル。
serverUrlプレースホルダーです。背後に OpenCode サーバーはありません。

magpie が呼び出すフックは config、auth(そのメソッド、loader、usage)、provider.models、chat.headers です。プラグインのほかのフック(tool、event、chat.params、…)は呼び出されません。ツールはエージェント自身が実行します。

プロバイダ id

テスト

magpie はサンドボックスで動かしましょう。普段の magpie、そのエージェントやサインインに触れずに済みます:

ターミナル
sb=$(mktemp -d)
m() { env HOME=$sb XDG_CONFIG_HOME=$sb/.config XDG_CACHE_HOME=$sb/.cache MAGPIE_ADDR=127.0.0.1:3499 magpie "$@"; }
m plugin add ./opencode-acme-auth </dev/null
m plugin --json                     # what the plugin lists: methods, accounts, models, load errors
m plugin login acme                 # each method, each prompt
m provider test acme                # one tiny request per API
m provider test acme acme-coder     # or to the models named
m quota                             # the usage hook
m plugin logout acme

公開と掲載

非同期ゲートウェイリクエストフック

プラグインはエージェントのリクエストをルーティングする前に I/O を待てます。package.json に "magpie": { "hooks": "./hooks.js" } を宣言するか、*.hooks.js / .mjs / .ts / .mts を追加し(Bun は TypeScript をビルドなしで実行します)、名前付きの非同期 onRequest(body, ctx) をエクスポートします。専用パッケージに OpenCode main/exports は不要です。

任意の非同期 onResponse(reply, ctx) は出力完了後にベストエフォートの通知を受け取ります。応答やライブストリームは変更せず、ツール宣言と実行ループはクライアントが担当します。

Bun でリクエストごとに一度、JS ゲートウェイミドルウェアの前に実行し、Codex 自身の ChatGPT サインインを含むすべてのプロバイダーに適用します。body はエージェント API のオブジェクトです。ctx は protocol、model、stream、path、agent、session、cwd、query、options、signal を持ちます。query は最後のユーザーテキストで、ツール結果と画像を除きます。ない識別情報は空で、Authorization は渡しません。

オブジェクトを返すと置き換え、undefined は元を保ちます。モデル、ストリーミング、クライアントのツールやツール選択を変えたフックは取り消され、失敗として扱われ、そのリクエストでは onResponse が呼ばれません。コピーを渡すので失敗や undefined の直接変更は破棄します。成功した変更は有効な順に渡ります。各フックは既定で 1 秒です。gatewayTimeoutMs は 100〜10000 ミリ秒の整数です。全体は合計予算を 2〜10 秒に制限し、起動と RPC を含めて 11 秒です。設定 → プラグインでオプションと呼び出し回数、時間、失敗、最新の結果を確認できます。ctx.report(code, bytes) は ready/empty/unbound/unauthorized/unavailable/invalid/skipped とバイト数のみを受け付けます。私的なテキストは保存せず、再読み込みで統計をリセットします。fetch に ctx.signal を使ってください。例外、不正な戻り値、タイムアウト、起動中のホストは元を保ちます。Bun はホスト権限で動くので信頼できるコードを使ってください。

magpie plugin add ./my-plugin で追加し、magpie plugin options my-plugin '{"serverUrl":"http://127.0.0.1:8000"}' で設定します。on/off と設定変更は再読み込みします。ソースだけの変更は設定の再読み込みが必要です。内部のタイトルや分類リクエストは対象外です。

ゲートウェイミドルウェア

プラグインはゲートウェイミドルウェアにもなれます。エージェントが magpie のゲートウェイに送るすべてのリクエストに対して、ルーティングの前に実行され、戻ってくる応答に対しても実行されるコードです。モデルに別名を付ける、システムプロンプトに追記する、リクエストを断る、モデルの出力を書き換えたり隠したりする、通ったものを独自に数える、といったことができます。プラグインのものかどうかに関係なく、すべてのプロバイダで動きます。

alias.middleware.js
// alias.middleware.js
export const events = ["message_start"]

export function onRequest(body, ctx) {
  ctx.state.asked = body.model
  if (body.model === "fast") body.model = "deepseek/deepseek-chat"
  if ((ctx.options.blocked ?? []).includes(body.model)) return ctx.reject(403, "not this model")
  return body
}

export function onEvent(ev, ctx) {
  ev.message.model = ctx.state.asked  // the agent sees the name it asked for
  return ev
}
shell
magpie plugin add ./alias.middleware.js   # または package.json に "magpie": { "middleware": "./mw.js" } を書いたパッケージ

すぐ使えるミドルウェア

コミュニティリポジトリには、そのまま追加できるミドルウェアがあります。多くは New API がチャネルに対して行うことで、同じ JSON を受け取ります。プラグイン → 発見 → ゲートウェイミドルウェア にあり、そこのカードにはプロバイダかミドルウェアかが表示されます。

パッケージすること
param-overrideNew API の param_override:リクエストのフィールドを設定・削除・移動・コピー・書き換えします。特定のモデルや条件に絞ることも、リクエストを断ることもできます。New API からコピーした param_override はそのまま動きます。
model-mapNew API の model_mapping:モデルを別の名前で送ります。名前か /パターン/ で指定します。応答にはエージェントが指定したモデル名が入ります。
system-promptすべてのリクエスト(または特定のエージェントやモデルのもの)に自分のシステムプロンプトを、各 API の決まった場所に加えます。リクエストに独自のものがあれば、override を有効にしない限りそのままにします。
word-guardNew API の禁止語フィルタ:ユーザーが送る内容にある語やパターンを断るか伏せ字にします。応答も伏せ字にできます。
think-tags応答本文の先頭にある <think>…</think> を取り除くか、Chat の応答の reasoning_content を本文に入れます(New API の thinking_to_content)。
antigravity-cloakAntigravity が Claude Code と Agent SDK のシステムプロンプトを断る理由になる語を、Antigravity に送る分だけで分割します。他のプロバイダには元のプロンプトのまま送ります。
shell
magpie plugin add @magpie-community/middleware-model-map
magpie plugin options model-map '{"mapping": {"fast": "deepseek/deepseek-chat"}}'

オプション

ミドルウェアは設定を ctx.options から読みます。これは plugins.json のそのエントリの options です。プラグイン → インストール済み でミドルウェアの オプション ボタンを押すとその行で JSON として編集でき、magpie plugin options <名前> '<json>' でも設定できます(off で消去)。名前はパッケージ名、spec、短い名前のどれでもかまいません:@magpie-community/middleware-model-map なら model-map です。次のリクエストから反映されます。パッケージは package.json の "magpie": { "options": { … } } でオプションの例を示せます。未設定のときエディタはそれから始まりますが、保存するまでミドルウェアはオプションなしで動きます。

動き方

ミドルウェアは Bun では動きません。ゲートウェイ自身のプロセスの中で、Go で書かれた JavaScript エンジン moejs によって動きます。応答は数千のイベントとしてストリームされ、各イベントがミドルウェアの処理を待つからです。1 イベントでのフック呼び出しは約 1 マイクロ秒で済みますが、Bun との往復だと数十マイクロ秒かかります。各ミドルウェアのコンパイルは一度だけで、リクエストごとに専用のランタイムを使います。そのためリクエストは並行に処理され、同じリクエストのフック同士は ctx.state を共有します。

フック

各フックは名前付きエクスポートか、export default { … } のメンバーです。本文とイベントはプロバイダではなく、エージェント自身の API の形式(ctx.protocol)です。送り出すオブジェクトを返すか、何も返さなければ元のまま送られます。

フック渡されるもの戻り値
onRequest(body, ctx)ルーティング前のリクエスト本文。model を変えると別の宛先にルーティングされます。本文、または何も返さない。ctx.reject(status, message) はリクエストを断り、そのエラーをエージェントの API の形式で返します(ステータスを省くと 403)。
onProvider(body, ctx)あるプロバイダに送る直前のリクエスト本文。ctx.provider にはその id、name、account(サブスクリプションの種類。claude や antigravity など。API キーにはありません)と model があります。リクエストを試すプロバイダごとに呼ばれ、毎回 onRequest が残した本文を受け取るので、あるプロバイダ向けの変更は他のプロバイダに届きません。本文、または何も返さない。この時点でルーティングは済んでいるので、拒否も行き先の変更もできません。
onEvent(event, ctx)ストリーム応答の各イベントの data: JSON。イベント、何も返さない、またはイベントを落とす null。
onResponse(body, ctx)ストリームでない応答の全体。ctx.status がそのステータスです。本文、または何も返さない。

export const events = ["content_block_delta"] と書くと、onEvent はその名前のイベントだけで呼ばれます(Anthropic Messages と Responses のイベントには名前があります)。Chat Completions と Gemini のイベントには名前がないので、それらでは events を書かないでください。1 つのイベントは応答の一部なので、1 つの単語が 2 つのイベントに分かれることがあります。data: [DONE] の行とコメントはフックを通らずにそのまま流れます。圧縮された応答もそのまま流れます。

ctx には次のものがあります:protocol(anthropic・chat・responses・gemini)、エージェントが指定した model と stream、path、magpie が把握している場合の agent、options(そのプラグインのオプション)、そしてリクエストごとのオブジェクト state。

フックが失敗したとき

フックの持ち時間は、リクエスト、プロバイダへの 1 回の試行、または応答全体で 250 ms、1 イベントで 50 ms、読み込みで 1 秒です。例外を投げたフック、時間切れになったフック、想定外の値を返したフックは、なかったものとして扱われます。つまりリクエストやイベントは元のまま送られます。設定 → プラグインには、ミドルウェアごとのフック、呼び出し回数、1 回あたりの平均時間、失敗回数と直近のエラーが表示されます。フックが見るのは、エージェントが送ったものと受け取るものそのものです。送信時は magpie のマスキングより前、受信時はマスク解除の後です。magpie 自身のリクエスト(スレッドのタイトル、ルーターの分類器)はミドルウェアを通りません。

ルーティングと使用量の行

同じモジュールは strategies と onUsage もエクスポートできます。そのファイルは *.gateway.js と名付けてもよく、パッケージなら "magpie": { "gateway": "./gw.js" } と書けます。

spread.gateway.js
// spread.gateway.js
export const strategies = {
  "fewest-tokens": (pool, seats) =>
    seats.map((_, i) => i).sort((a, b) =>
      (seats[a].resting - seats[b].resting) || (seats[a].tokens - seats[b].tokens)),
}

let slow = 0

export function onUsage(row, options) {
  if (row.ms > (options.slowMs ?? 30000)) {
    slow++
    console.log(`slow: ${row.provider} ${row.model} ${row.ms} ms (${slow} so far)`)
  }
}
shell
magpie plugin add ./spread.gateway.js
magpie group set fast routing=spread/fewest-tokens

すべてのエージェントのツール

プラグインは OpenCode の tool フックでツールを提供でき、magpie はそれを OpenCode だけでなくすべてのエージェントに提供します。ツールは magpie 自身の MCP サーバー /mcp/magpie/tools(Streamable HTTP)になります。MCP サーバーを使えるエージェントにこのアドレスを追加すると、そのモデルはオンのプラグインが提供するすべてのツールを呼び出せます。名前は <プラグイン>_<ツール> で、プラグインの短い名前が先頭に付きます(下の例では weather-tools_now)。

index.js
import { tool } from "@opencode-ai/plugin"

export const WeatherTools = async () => ({
  tool: {
    now: tool({
      description: "ある都市の今の天気。",
      args: { city: tool.schema.string().describe("都市名(例: Berlin)") },
      async execute({ city }, ctx) {
        const r = await fetch(`https://wttr.in/${encodeURIComponent(city)}?format=3`, { signal: ctx.abort })
        return await r.text()  // テキスト。オブジェクトは JSON で返ります
      },
    }),
  },
})
shell
magpie plugin add ./weather-tools
claude mcp add --transport http magpie-tools http://127.0.0.1:3425/mcp/magpie/tools   # Claude Code。ほかのエージェントも同じアドレスを使います

設定 → プラグインには各プラグインのツールがこの名前で表示され、サーバーのアドレスをコピーできます。args は zod のフィールド定義(tool.schema は zod)、zod オブジェクト、または JSON Schema オブジェクトです。エージェントにはこれが示され、magpie は execute の前に呼び出しの引数をこれで検証します。引数が正しくなければ、その引数を示すエラーを返します。ctx は OpenCode と同じで、agent は呼び出したエージェントです。ツールの実行時間は 55 秒までで、時間切れになると ctx.abort が発火します。

このサーバーは組み込みプラグイン プラグインのツール が提供します。オンのプラグインがツールを提供している間はオンのままで、オフのときはツールが一覧に出ず、呼び出しにはオフであることが返ります。ゲートウェイキーでの呼び出しはそのキーの上限に数えられます。ツールはプロバイダープラグインと同じくあなたの権限でプラグインホスト内で動き、結果は呼び出したエージェントにだけ返ります。

エージェントプラグイン

プラグインはエージェントを追加することもできます。magpie に組み込みの設定がないエージェントです。そのエージェントがモデルをどこに書くか、magpie のゲートウェイを使うために他に何を書くかを伝えます。するとエージェントは magpie 自身のものと同じようにエージェントページに並びます。その行で magpie のモデルを選ぶと、magpie はモデルをゲートウェイのアドレスとエージェント専用のキーと一緒に設定へ書き込み、スイッチを切るとすべてユーザーの元の状態に戻します。下の例は Aider を追加します。

agent.js
// agent.js(package.json の "magpie": { "agent": "./agent.js" } で指定)
export const agent = {
  id: "aider", name: "Aider", bin: "aider",
  config: "~/.aider.conf.yml",  // magpie が編集するファイル
  model: "model",                 // その中のモデルのキー
  prefix: "openai/",              // magpie のモデル id の前に付ける
  ua: ["aider", "litellm"],
  notice: "Aider reads its settings when it starts: restart it to use the new model.",
}

export function connect({ gateway, model }) {
  return {
    "openai-api-base": gateway.v1,
    "openai-api-key": gateway.key,
    "show-model-warnings": false,
  }
}
shell
magpie plugin add ./agent-aider   # 単一の *.agent.js ファイルでも可

agent エクスポート

フィールド意味
idエージェントページ、使用量、CLI で使う id。小文字、数字、.、_、- のみ。magpie 組み込みのエージェントの id(codex、claude など)は拒否されます。プラグインはエージェントを追加するもので、置き換えるものではありません。
nameページでの表示名。省略すると id。
configmagpie が編集するファイル。~ と $VAR は展開されます。なければ作成します。
formatファイル名から分からないときの json、yaml、env(.json/.jsonc、.yaml/.yml、.env)。TOML はまだ対応していません。
modelファイル内のモデルのキー。JSON と YAML ではドット区切りのパス(llm.model)。モデルをエージェント自身で、connect が書くモデルの中から選ぶ場合(Raycast など)は省略します。その行は接続スイッチだけになってモデルの選択欄は出ず、connect(このプラグインには必須)は model なしで呼ばれます。
prefixmagpie のモデル id の前に書くもの。LiteLLM 経由で OpenAI 互換エンドポイントを使うエージェントなら openai/。id 自体はゲートウェイが受け付ける <プロバイダー>/<モデル> です。
bin、dirエージェントがインストール済みかを示す PATH 上のコマンドとフォルダー。
uaキーなしで来たリクエストを使用量で名付けるための、エージェントの User-Agent に含まれる語。
icon行のアイコン。アイコン名、https:// または data: URL。省略するとプラグインのアイコン、それもなければ汎用のもの。
noticeモデルを変えた後に表示する案内。組み込みのエージェントと同じです。
libraryライブラリが渡すものをエージェントがどこで読むか:instructions(毎回の会話の前に読む Markdown ファイル)、mcp(MCP サーバーのファイル)とその書き方を表す mcpFormat(どのエージェントのファイルと同じ形か:alma, antigravity, claude, cline, codex, commandcode, copilot, crush, cursor, devin, gemini, goose, grok, hermes, kimi, omp, opencode, zed。claude は {"mcpServers": {…}})、skills(SKILL.md のフォルダを置くフォルダ)。どれも省略でき、~ と $VAR は展開されます:library: { instructions: "~/.toy/AGENTS.md", mcp: "~/.toy/mcp.json", mcpFormat: "claude", skills: "~/.toy/skills" }。書かなければライブラリからは何も渡されず、渡そうとするとプラグインが書くべきキーを示して断られます。

connect

connect(input) はモデルの横に書き込む他のキーを、キーのパスと値で返します。async でも構いません。受け取るのは gateway(url、/v1 で終わる URL の v1、エージェント専用のキー key。リクエストはその id で数えられます)、model(id、name、context、output、images、efforts、そして /v1/models と同じ名前の native_endpoints(リクエストが変換されずにそのまま通るゲートウェイのパス、例えば ["/v1/chat/completions"])と pricing(1 トークンあたりの USD、10 進の文字列:prompt、completion、input_cache_read、input_cache_write。分からないものは含まれず、空や 0 にはなりません))、models(magpie がエージェントに出すすべてのモデル、同じ形)、agent(id、config)です。connect がないプラグインはモデルだけを書きます。magpie のモデルが変わると connect がもう一度呼ばれるので、書いたモデル一覧は最新のまま保たれます。

magpie がすること

ウィンドウのページ

プラグインは magpie のウィンドウに自分のページを追加できます。オンにすると、サイドバーのプラグイングループの、magpie 自身のページの後ろにタブが出ます。ページはプラグインのフォルダにある HTML、CSS、JavaScript だけで、Bun もビルドも要りません。下の例は今日の使用量を表示します。

package.json
{
  "name": "magpie-usage-glance",
  "version": "0.1.0",
  "magpie": {
    "page": {
      "title": "Usage glance", "file": "page/index.html", "icon": "page/icon.svg",
      "titles": { "zh": "用量速览", "ja": "使用量の概要" }
    },
    "permissions": ["usage:summary"]
  }
}
page/index.html
<!doctype html>
<link rel="stylesheet" href="style.css">
<script src="_magpie/bridge.js"></script>   <!-- served beside every page -->
<script src="app.js" defer></script>
<p id="calls">…</p>
page/app.js
await magpie.ready                                  // the window's theme, painted
const s = await magpie.read("usage:summary", { period: "today" })
document.getElementById("calls").textContent = `${s.calls} calls`
const opts = await magpie.options.get()            // this plugin's own options
await magpie.options.set({ ...opts, hideCost: true })

プラグインページの行には、オンにする前からページと読めるものが表示されます。page.file と page.icon はプラグイン内のパスです。ページのファイルは file のフォルダにあるものだけで、外を指すパスやリンクは配信されません。1 つのパッケージがページとプロバイダ、ミドルウェア、エージェントを兼ねることもできます。ページだけのプラグインはプラグインホストを起動しません。page.titles(パネルでは titles)は、言語コード(zh、zh-TW、ja、de)ごとのタブ名です。ほかの言語では title を使います。

ページがスタイルを付けていないところは、最初から magpie の見た目になります。bridge がフォント、色、見出し、ボタン、入力欄、表のスタイルシートを重みなし(:where())で加えるので、ページ自身のルールが常に勝ちます。自分のスタイルでは --magpie-* 変数を使うとウィンドウに揃います。

ページが頼めること

フィールド / 呼び出し返すもの
magpie.readyウィンドウが応答すると、ウィンドウのテーマが得られます。それまでページは magpie と何も話していません。10 秒以内に話さないページは応答なしと表示されます。
magpie.theme, magpie.onTheme(fn){ dark, lang, tokens }。bridge は <html> に data-theme、lang、color-scheme と --magpie-bg、--magpie-fg、--magpie-accent なども設定し、変わるたびに設定し直します。
magpie.options.get(), .set(o)plugins.json にあるそのプラグイン自身のオプションです。ほかのプラグインのものは扱えません。set はオブジェクトを受け取り、保存されたオプションを返します。呼べるのは 1 秒に 1 回までです。
magpie.read(name, params)magpie.permissions で宣言した読み取りです。それ以外はウィンドウが拒否し、magpie ももう一度拒否します。usage:summary(period:today、7d、30d)は呼び出し数、エラー数、トークン、費用と上位 20 モデルを、providers:list は各プロバイダの id と名前を返します。キー、アカウント、サインイン、リクエスト本文を返す読み取りはありません。

ページにできないこと

magpie 自身のページに入るパネル

プラグインは、magpie 自身が描くページにパネルを加えることもできます。パネルはそれぞれプラグインのフォルダーにある小さなページで、ブリッジ、読み取り、オプション、サンドボックスはページと同じです。タイトルの見出しの下のカードに収まり、高さは描いた内容に合わせて決まります。

package.json
{
  "name": "magpie-provider-notes",
  "version": "0.1.0",
  "magpie": {
    "panels": [
      { "slot": "provider", "file": "ui/notes.html", "title": "Notes" },
      { "slot": "usage", "file": "ui/spend.html", "title": "Top models today" }
    ],
    "permissions": ["usage:summary"]
  }
}
slotパネルが入る場所magpie.context
agents「エージェント」ページの末尾{}
usage使用量の「概要」の末尾{}
settings設定の「プラグイン」タブ(プラグインがそこにパネルを置いているときだけ表示){}
provider保存した各プロバイダーの編集画面{ provider: { id, name } }

パネルは 1 つのプラグインにつき 8 個まで。パネルの高さは <html> に合わせて決まる(最大 2400px)ので、ページ自身に高さを任せ、html や body に固定の高さを付けないでください。パネルがエラーを出すか 10 秒以内に応答しないと、その場所に「{name} は動作を停止しました」と「再試行」が表示されます。周りのページと隣のパネルはそのまま動き続けます。

組み込みページの代わりになる

ページは magpie 自身のページの代わりになれます。magpie.page に "replaces" を加え、sessions、library、routing、usage のどれかを指定します。そのタブが組み込みページのあった場所に入り、そのページへのリンクはプラグインのページを開きます。2 つのプラグインが同じページを指定したときは、プラグイン一覧で先にあるほうが代わりになり、もう一方は独立したページになります。プラグインをオフにすると、組み込みページが元どおりに戻ります。

package.json
"page": { "title": "My library", "file": "page/index.html", "replaces": "library" }

magpie のメニューに入る項目

ページを持つプラグインは、magpie の行のメニューに項目を加えられます。プロバイダの行(右クリック)とエージェントの行(ハンドル、または右クリック)です。項目は magpie 自身の項目のあとに並び、ページのタイトルと同じくウィンドウの言語で表示されます。項目を選ぶとプラグインのページが開き、どの項目か、その行の id と名前だけが伝えられます。ページは magpie.onAction で受け取ります。ハンドラーを渡す前に選ばれた項目は、渡されるまで待ちます。

package.json
"page": { "title": "Balance", "file": "page/index.html" },
"actions": [
  { "id": "check", "menu": "provider", "title": "Check balance", "titles": { "zh": "查余额" } }
]
page/app.js
magpie.onAction(({ id, provider }) => {
  // id: "check"; provider: { id, name }
});

menu は provider か agent です。id は小文字、数字、ハイフンで、40 文字まで。項目はプラグインごとに最大 8 個で、title のない項目はページのタイトルを使います。ページのないプラグインは項目を加えません。「プラグイン」ページには、プラグインがどのメニューに項目を加えるかが表示されます。

pi パッケージ

magpie は pi のパッケージも動かします。pi.registerProvider でプロバイダを登録する pi 拡張は、何も変えずに pi と同じように magpie で動き、ほかのプラグインと同じ方法で追加できます(magpie plugin add pi-antigravity)。

ホストプロトコル

プラグインを書くのにこの節は不要です。magpie 自体の開発や、ホストのデバッグをする人向けです。magpie はホストの stdin に {id, method, params} を 1 行にひとつずつ書き込みます。ホストは {id, result} または {id, error: {message}} で応答します。

メソッド動作
initプラグインを読み込み、その config フックを実行します。ほかのすべての呼び出しはこれを待ちます。
providers各プロバイダとそのメソッド、アカウント、アカウントごとのモデル。
prompt, validateメソッドの次の質問、回答のチェック。
authorize, callback, apiKeyサインイン。
loadアカウントに対する loader のオプション。
fetch1 件のリクエスト。{id, event: "head", status, headers}、次に {id, event: "chunk", data}(base64)、最後に {id, result: null} をストリームで返します。{method: "abort", params: {id}} で取り消せます。
usage, checkアカウントの使用量、リクエストと同じ方法でのアカウントの確認。
import, take, signOut, reloadアカウントの取り込みと取り出し、削除、loader の再実行。

ホストは独自のイベントも送ります:log、toast、auth(サインインが保存またはリフレッシュされた)、signIn(アカウントのサインイン状態、前述のとおり)。ソースは internal/plugin/host.js です。

よくある質問

OpenCode のプラグインは magpie でそのまま動きますか?

はい。auth、config、provider、chat.headers を通してサインインとリクエストを行っていれば動きます。magpie が追加するもの(auth.usage、auth.refresh、magpie.icon、maxConcurrency、free、rate、X-Magpie-Sign-In)は OpenCode が無視するので、ひとつのパッケージで両方に対応できます。

pi のパッケージは magpie で動きますか?

はい。pi.registerProvider でサインインとモデルを提供していれば動きます。magpie が使うのは登録されたプロバイダだけです。pi パッケージを参照してください。pi-package キーワードを付ければ検索で見つかります。

プロバイダの名前が …-plugin になるのはなぜですか?

その id が magpie ですでに使われているからです。プロバイダ idを参照してください。

ヘルパー関数の一部がプラグインとして実行されます。

エントリモジュールがエクスポートする関数はすべてプラグインとして呼び出されます。プラグイン関数だけをエクスポートするか、export default { id, server } を使ってください。

npm の依存を使えますか?

はい。プラグインと一緒にインストールされますが、インストールスクリプトは実行されません。Bun の fetch、crypto、fs、$ でたいていは足ります。

プラグインからベンダーの CLI を実行できますか?

はい。Bun の $ か child_process を使います。いくつかのコミュニティプラグインはベンダー自身の CLI でサインインします。CLI が入っていないマシンもあると想定し、エラーでインストール方法を伝えてください。

プラグインにはユーザーの作業のどこまでが見えますか?

プラグインが送るリクエストに含まれるものすべて、つまり会話、ツールとその結果です。ユーザーが指定した送り先にだけ送り、ログには残さないでください。

編集内容を magpie に反映させるには?

ターミナルの magpie コマンドは、フォルダをその時点の内容で読み込みます。アプリでは、プラグイン でプラグインをオフにしてからオンにしてください。

質問や、共有したいプラグインがありますか? Discord で聞くか、magpie-community/plugins に issue を立ててください。