Docs · Plugins

Writing a plugin

A plugin signs in to a service and makes its requests. magpie turns it into a provider that every agent can use, with several accounts, failover, usage and the rest. magpie runs OpenCode’s provider plugins, so a plugin written for OpenCode works here unchanged, and it runs pi’s packages that register a provider too (see pi packages). This page covers what a plugin author needs: the hooks magpie calls, what it does with their answers, and how to test, publish and list a plugin.

In one paragraph. A plugin is an npm package, a git repository or a folder. Its module exports an async function in OpenCode’s v1 plugin shape. magpie loads every plugin into one Bun process, the plugin host. The function returns hooks:
  • config declares the provider and its default models.
  • auth gives the sign-in methods, plus a loader whose fetch makes each request.
  • provider.models lists the models one account has.
  • auth.usage says how much of the plan is used. It is magpie’s own hook, and OpenCode ignores it.
  • auth.refresh renews an account’s token before it runs out, once at a time. It is magpie’s own hook too.
An agent can send in any API. The gateway translates the request into the API the model speaks (chat completions, Responses, Anthropic Messages or Gemini), hands it to the plugin’s fetch, and streams the answer back.

A plugin in five minutes

This example signs in to Acme, a made-up coding plan. Acme takes an API key, has an OpenAI-compatible endpoint, and reports each account’s models and usage. A real plugin differs mainly in how it signs in and signs its requests. This whole example was run against magpie v0.1.676 and a local stand-in for Acme’s 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 }]))
    },
  },
})

Add the folder to magpie, sign in, and try it:

Terminal
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

Agents now reach its models as acme/acme-coder, the same way as any other provider’s. In the app, open Plugins, click Add a plugin and give it the folder. Then sign in from the provider’s row.

Try it in a sandbox. A plugin you are writing should never touch your real magpie. Run magpie with a HOME of its own, as Testing shows.

How magpie runs plugins

What magpie keeps, in its config folder (~/.config/magpie, or $XDG_CONFIG_HOME/magpie):

FileWhat it holds
plugins.jsonThe plugins added: {"plugins": [{"spec", "off"?, "options"?}], "config"?}. options is the plugin’s second argument, as OpenCode’s ["name", {…}] gives it. config is the OpenCode config the plugins are handed.
plugins/The installed npm and git packages, a Bun project of their own.
plugin-auth.jsonThe sign-ins (mode 600), in the same shape as OpenCode’s auth.json, so a sign-in can be moved between the two.
plugin-providers.jsonThe providers and models the plugins listed last, so magpie can start without asking them.

The package

Where magpie loads it from

magpie looks for the server side of a plugin in the same places OpenCode does, and loads the first file that exports a plugin:

  1. exports["./server"], then main, then exports["."] (or exports when it is a string), in package.json.
  2. Otherwise index.ts, index.tsx, index.js, index.mjs or index.cjs.

A path to a single file loads that file.

What the module exports

So don’t export helper functions from the entry file. Put what your tests need on an object instead, as the community plugins do with export const _internal = { … }.

The function

Shape
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"? }

A plugin may return more than one provider by exporting more than one function. Each one’s hooks are separate. When two plugins have an auth hook for the same provider, the one loaded last wins, as in OpenCode.

magpie’s fields in package.json

OpenCode ignores the magpie object, so these are safe to add to an OpenCode plugin.

FieldMeaning
magpie.iconThe provider’s picture: an https:// URL or a data:image/… URI, at most 1 MB. SVG, PNG and the like.
magpie.maxConcurrencyHow many requests each account takes at once, a whole number from 1 to 1000. A limit the user sets on the provider wins over it.

The same fields can be set on the auth hook (auth.icon, auth.maxConcurrency), where they win over package.json.

Provider and models

Declaring the provider

The provider’s id is auth.provider. If models.dev lists that id, magpie starts from its entry, as OpenCode does. Otherwise, declare the provider in a config hook. Use ??=, so a provider the user configured in plugins.json isn’t written over:

config hook
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
      },
    },
  }
}

A model can also say provider: { npm, api } to use another API or address than the provider’s, headers, options, and disabled: true to hide it.

The API a model speaks

The AI SDK package named by npm, on the model or on the provider, decides the API. That decides the shape of the request your fetch receives. Whatever the agent sent, magpie’s gateway has already translated it:

npmAPIPath after the base
@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-copilotResponses for GPT-5 and later (not gpt-5-mini), chat completions for the rest/responses or /chat/completions
anything else (@ai-sdk/openai-compatible, xai, groq, deepseek, …)Chat completions/chat/completions

The base is chosen in this order:

  1. the loader’s baseURL;
  2. the model’s provider.api;
  3. the provider’s api;
  4. the AI SDK package’s own default (https://api.anthropic.com/v1, https://api.openai.com/v1, …).

The body’s model is the model’s API id: its id, else its key.

Each account’s models

When the models depend on the account, add a provider hook:

provider hook
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" } }
  },
}

When the vendor can’t be reached

magpie keeps the list it already had, as it does for a built-in subscription whose list couldn’t be fetched, when the hook does any of these:

If what failed was the sign-in, throw an error with signIn: "expired" on it. The account is then marked as needing a new sign-in (see Sign-in state).

Signing in

The auth hook is { provider, methods, loader, usage?, refresh?, refreshLead?, icon?, maxConcurrency? }. Each method is a way to sign in that the user can pick. In the app the methods are buttons; in a terminal, magpie plugin login <id> [<method>] asks which one.

An API key

api method
{ type: "api", label: "Acme API key", placeholder: "acme-…" }

A browser or device sign-in

oauth method
{
  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 }
      },
    }
  },
}

Questions before signing in

A method’s prompts are asked in order before authorize, each only when its condition holds:

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

Accounts

Renewing a sign-in

From magpie 0.1.684, a plugin can leave renewing its tokens to magpie with auth.refresh, magpie’s own hook (OpenCode ignores it):

auth hook
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
}

Requests

loader(getAuth, provider) runs once per account and is run again after its sign-in changes. It returns what OpenCode would give the AI SDK:

FieldWhat magpie does with it
baseURLWhere requests go, ahead of the model’s and provider’s api.
apiKeySent as the AI SDK package sends it: x-api-key for Anthropic, x-goog-api-key for Google, Authorization: Bearer otherwise. For an API-key sign-in, it defaults to the saved key.
headersAdded to every request.
fetchMakes the request. Without it, magpie’s host uses the standard fetch.

The request your fetch(input, init) receives:

Return a Response. Its status, headers and body go back as they come, streamed chunk by chunk. If you rewrite a streaming body, drop content-length and content-encoding from the headers.

Failures and failover

chat.headers

OpenCode’s chat.headers hook runs for every request. Its input.sessionID is magpie’s conversation id, which stays the same across a conversation’s turns. Use it for a vendor’s session or cache header:

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

Sign-in state

magpie marks an account whose sign-in the vendor refused. It shows the mark to the user and stops sending to the account until it works again. By default a 401 marks the account and a success clears the mark. A plugin can say more precisely what an answer means with the X-Magpie-Sign-In response header, which magpie removes before the answer goes on:

ValueMeaning
expiredThe sign-in was refused, whatever the status (a vendor that answers 403 or 502 for it).
keptLeave the account as it is (a 401 that is about something else).
renewedThe sign-in renewed: clear the mark, whatever the request then met.

From fetch or provider.models, throw an error with signIn: "expired" on it for the same effect:

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

Usage

auth.usage(getAuth, provider) is magpie’s own hook. It says how much of an account’s allowance is used. It runs in the account’s scope, so a token it refreshes is saved to that account. magpie shows its answer on the account in Providers, in magpie quota and at GET /v1/magpie/quotas on the gateway. Every field is optional:

usage result
{
  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",
    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
  }],
  resets: { count: 2, until: "…", byWindow: false, fiveHour: 1, weekly: 1 }, // resets the account may spend
  signIn: "kept",               // as X-Magpie-Sign-In
}

Times are ISO strings or epoch numbers, in seconds or in milliseconds. Without signIn, an error that says to sign in again marks the account, and a clean read clears the mark.

What a plugin is given

magpie hands a plugin what OpenCode’s PluginInput has, as far as it has meaning without an OpenCode server:

FieldIn magpie
client.auth.set / .removeSaves or forgets the sign-in of the account in scope.
client.app.logWrites to magpie’s log as plugin [level]: [service] message.
client.tui.showToastWritten to magpie’s log.
client.config.getThe config after every plugin’s config hook.
any other client.*Answers { data: undefined }.
directory, worktreemagpie’s config folder, a place to keep files.
project{ id: "magpie", worktree }.
$Bun’s shell.
serverUrlA placeholder; no OpenCode server is behind it.

The hooks magpie calls are config, auth (its methods, loader and usage), provider.models and chat.headers. A plugin’s other hooks (tool, event, chat.params, …) are not called. The agent runs its own tools.

Provider ids

Testing

Run magpie in a sandbox, so your own magpie, its agents and sign-ins are never touched:

Terminal
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

Publishing and listing

pi packages

magpie also runs pi’s packages. A pi extension that registers a provider with pi.registerProvider works in magpie as it does in pi, with nothing to change, and is added like any plugin (magpie plugin add pi-antigravity).

The host protocol

Writing a plugin doesn’t need this section. It is for people working on magpie itself, or debugging the host. magpie writes {id, method, params} to the host’s stdin, one per line. The host answers {id, result} or {id, error: {message}}.

MethodDoes
initLoads the plugins; runs their config hooks. Every other call waits for it.
providersEach provider, its methods, accounts and each account’s models.
prompt, validateA method’s next question; an answer checked.
authorize, callback, apiKeySigning in.
loadThe loader’s options for an account.
fetchOne request. It streams {id, event: "head", status, headers}, then {id, event: "chunk", data} (base64), then {id, result: null}. {method: "abort", params: {id}} cancels it.
usage, checkAn account’s usage; an account tried as a request would.
import, take, signOut, reloadAccounts moved in and out, forgotten, their loaders run again.

The host also sends events of its own: log, toast, auth (a sign-in saved or refreshed) and signIn (an account’s sign-in state, as above). The source is internal/plugin/host.js.

FAQ

Does my OpenCode plugin work in magpie unchanged?

Yes, as long as it signs in and makes requests through auth, config, provider and chat.headers. What magpie adds (auth.usage, auth.refresh, magpie.icon, maxConcurrency, free, rate, X-Magpie-Sign-In) is ignored by OpenCode, so one package serves both.

Does my pi package work in magpie?

Yes, if it signs in and serves models through pi.registerProvider. magpie uses only the providers it registers; see pi packages. Give it the pi-package keyword so the search finds it.

Why is my provider called …-plugin?

Its id is one magpie already has. See Provider ids.

Some of my helper functions run as plugins.

Every function the entry module exports is called as a plugin. Export only the plugin function, or export default { id, server }.

Can I use npm dependencies?

Yes. They are installed with the plugin, but their install scripts aren’t run. Bun’s fetch, crypto, fs and $ cover most needs.

Can my plugin run the vendor’s CLI?

Yes, with Bun’s $ or child_process. Several community plugins sign in through the vendor’s own CLI. Expect it to be missing on some machines, and say how to install it in the error.

What does a plugin see of the user’s work?

Everything in the requests it makes: the conversation, the tools and their results. Send it only where the user asked it to go, and don’t log it.

How do I make magpie see my edits?

A magpie command in a terminal loads the folder as it is now. In the app, turn the plugin off and on in Plugins.

Questions, or a plugin to share? Ask on Discord, or open an issue in magpie-community/plugins.