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 plugin’s picture, on its card in Discover (read from npm, or from the repository for one on GitHub) and its row in Installed, and the picture of each of its providers that doesn’t give its own: 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. A plugin that signs in to several providers can give each its own auth.icon and keep magpie.icon for the plugin itself.

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.

An upstream that searches the web

When your plugin sits in front of Anthropic’s or OpenAI’s own API, which runs the hosted web search tool itself, set searches: true on the provider in the config hook. A client’s web search (Claude Code’s WebSearch, Codex’s web_search) then reaches your fetch as it was sent, on the provider’s Anthropic or Responses models, rather than going through magpie’s own search. Its Chat models are unaffected. magpie doesn’t search with it for other models by itself, but it can be named as the searcher in Settings.

A decision API (System One)

A plugin can serve TypeSafe’s System One API for its provider, so its models can be a routing group’s classifier. Set decide: true on the provider in the config hook, and decides: true on each model that answers questions rather than a conversation, in the config hook or on the models hook’s list. magpie then posts each question as POST {baseURL}/systemone through your loader’s fetch, with the account’s sign-in, as it sends any other request. The body is {"model", "questions", "state"}; answer {"model", "answers": {"<question>": {"choice", "score", "confidence"}}}.

A marked model, and one named jev-*, is offered as a classifier and kept from agents: a chat request to it is refused. Without decide: true on the provider, decides is ignored and every model stays one to chat with.

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

A type: "password" question is a text one typed unseen: a masked field in the app, and no echo in magpie plugin login on a terminal. Use it for a token or a personal access token. It is magpie’s own type: OpenCode’s are text and select.

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

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

Async gateway request hooks

A plugin can await I/O before an agent request is routed, for example to recall project context. Declare "magpie": { "hooks": "./hooks.js" } in package.json, or add a *.hooks.js / .mjs / .ts / .mts file (Bun runs TypeScript without a build step). Export a named async onRequest(body, ctx). A gateway-only package needs no OpenCode main/exports.

Optional async onResponse(reply, ctx) receives a best-effort completion notification after output finishes. It cannot change the reply and never holds the live stream. Client tool declarations and tool loops stay with the client.

It runs in Bun once per agent request, before the JS gateway middleware, for every provider, Codex's own ChatGPT sign-in included. body is the agent API object. ctx contains protocol, model, stream, path, agent, session, cwd, query, options and signal. The query is the latest user text, excluding tool results and images. Missing identity fields are empty; authorization headers are never included.

Return an object to replace the request, or undefined to keep it. A hook that changes the model, streaming, the client's tools or its tool choice is rolled back, as a failure, and gets no onResponse for that request. Each hook receives a clone; mutations from failed or undefined hooks are discarded. Successful changes follow enabled plugin order. Each hook defaults to one second; gatewayTimeoutMs accepts integers from 100 to 10000 milliseconds. The chain sums budgets, clamped to 2–10 seconds; startup plus RPC is bounded to 11 seconds. Settings → Plugins offers JSON options and call, latency, failure and outcome diagnostics. ctx.report(code, bytes) accepts ready/empty/unbound/unauthorized/unavailable/invalid/skipped and byte counts only. Statistics reset on reload and retain no private text. Use ctx.signal with fetch. Errors, invalid returns, timeouts and a host busy starting keep the request. Bun plugins have host privileges; install trusted code.

Install with magpie plugin add ./my-plugin; save options with magpie plugin options my-plugin '{"serverUrl":"http://127.0.0.1:8000"}'. On/off and options changes reload the host; source-only edits need a configuration reload. Internal title/classifier requests skip the hook.

Gateway middleware

A plugin can also be gateway middleware: code that magpie’s gateway runs on every request an agent sends it, before the request is routed, and on the reply on its way back. It can give a model another name, add to the system prompt, turn a request away, change or hide what the model says, or keep its own count of what goes through. It works with every provider, a plugin’s or not.

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   # or a package whose package.json has "magpie": { "middleware": "./mw.js" }

Ready-made middleware

The community repository has middleware ready to add, most of it what New API does for its channels, taking the same JSON. They are under Plugins → Discover → Gateway middleware, and every card there says whether it is a provider or middleware.

PackageWhat it does
param-overrideNew API’s param_override: set, delete, move, copy or rewrite fields of the request, for some models or under conditions, or turn a request away. A param_override copied from New API works unchanged.
model-mapNew API’s model_mapping: send a model under another name, by name or /pattern/. Replies name the model the agent asked for.
system-promptA system prompt of yours on every request, or only some agents’ or models’, in each API’s own place. A request with its own keeps it unless override is on.
word-guardNew API’s sensitive-word filter: turn away or mask words and patterns in what users send, and in replies if you like.
think-tagsTake a <think>…</think> block out of the start of a reply’s text, or put a Chat reply’s reasoning_content into it (New API’s thinking_to_content).
antigravity-cloakBreaks up the words Antigravity turns Claude Code’s and the Agent SDK’s system prompts away for, in what it sends Antigravity only. Other providers are sent the prompt as it was.
shell
magpie plugin add @magpie-community/middleware-model-map
magpie plugin options model-map '{"mapping": {"fast": "deepseek/deepseek-chat"}}'

Options

A middleware reads its settings from ctx.options, which are its entry’s options in plugins.json. Set them in Plugins → Installed with the middleware’s Options button, which edits them as JSON in its row, or with magpie plugin options <name> '<json>' (off clears them). The name can be the package’s, its spec, or its short name: model-map for @magpie-community/middleware-model-map. They apply from the next request on. A package can suggest options with "magpie": { "options": { … } } in its package.json: the editor starts from those when none are set, but a middleware runs with no options until you save some.

How it runs

Middleware isn’t run on Bun. It runs inside the gateway’s own process, in moejs, a JavaScript engine written in Go, because a reply streams thousands of events and each one waits for it. A hook on one event costs about a microsecond; a round trip to Bun would cost tens. Each middleware is compiled once, and each request gets a runtime of its own, so requests run in parallel and a request’s hooks share ctx.state.

The hooks

Each is a named export or a member of export default { … }. Bodies and events are in the agent’s own API (ctx.protocol), not the provider’s. Return the object to send on, or nothing to send it on as it was.

HookIs givenReturns
onRequest(body, ctx)The request body, before routing: a new model routes it elsewhere.The body, or nothing. ctx.reject(status, message) turns the request away with that error, in the agent’s API (403 when no status is given).
onProvider(body, ctx)The request body as it is about to go to one provider; ctx.provider has its id, name, account (the subscription’s kind, such as claude or antigravity; absent for an API key) and model. It is called again for each provider a request is tried on, each time with the body onRequest left, so a change for one provider reaches no other.The body, or nothing. The request is routed by now: it can’t be turned away or sent elsewhere.
onEvent(event, ctx)Each event of a streamed reply: its data: JSON.The event, nothing, or null to leave the event out.
onResponse(body, ctx)A reply that isn’t streamed, whole; ctx.status is its status.The body, or nothing.

export const events = ["content_block_delta"] calls onEvent only for events of those names (Anthropic Messages and Responses name theirs). Chat Completions and Gemini events have no name, so leave events out for them. An event holds a piece of the reply, so a word can be split over two events. A data: [DONE] line and comments pass by unseen. A compressed reply passes as it is.

ctx has protocol (anthropic, chat, responses or gemini), model and stream as the agent asked, path, agent when magpie knows it, options (the plugin’s options) and state, an object of the request’s own.

When a hook fails

A hook has 250 ms for a request, a provider’s try or a whole reply and 50 ms for an event; loading has a second. A hook that throws, runs out of time or returns something else goes on as if it weren’t there: the request or the event is sent as it was. Settings → Plugins shows each middleware’s hooks, how many calls it had, the average time each took and how many failed, with the last error. The hooks see what the agent sent and gets back, before magpie’s redaction on the way out and after it on the way back. magpie’s own requests (thread titles, the router’s classifier) don’t go through middleware.

Routings and usage rows

The same module can also export strategies and onUsage. Such a file can be named *.gateway.js, and a package can say "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

Tools for every agent

A plugin can give tools, with OpenCode’s tool hook, and magpie offers them to every agent, not only OpenCode: they are an MCP server of magpie’s own at /mcp/magpie/tools (Streamable HTTP). Add that address to any agent that takes an MCP server, and its model can call every tool your plugins that are on give. Each is named <plugin>_<tool>, the plugin’s short name first: weather-tools_now below.

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

export const WeatherTools = async () => ({
  tool: {
    now: tool({
      description: "The weather now in a city.",
      args: { city: tool.schema.string().describe("A city, e.g. Berlin") },
      async execute({ city }, ctx) {
        const r = await fetch(`https://wttr.in/${encodeURIComponent(city)}?format=3`, { signal: ctx.abort })
        return await r.text()  // text; an object is sent as its 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; other agents take the same address

Settings → Plugins lists each plugin’s tools by these names and copies the server’s address. args is a zod shape (tool.schema is zod), a zod object or a JSON Schema object. The agent is shown it, and magpie checks a call’s arguments against it before execute runs; a wrong argument is answered as an error naming it. ctx is OpenCode’s, with agent the agent that called. A tool has 55 seconds, and ctx.abort fires when they are up.

The built-in plugin Plugin tools serves the server. It stays on while a plugin that is on gives a tool; off, no tool is listed and a call says so. A call made with a gateway key counts against that key’s limits. A tool runs in the plugin host with your rights, as a provider plugin does, and its answer goes only to the agent that called it.

Agent plugins

A plugin can also add an agent: one magpie has no setup of its own for. It says where the agent keeps its model and what else to write there for the agent to use magpie’s gateway. The agent is then on the Agents page like magpie’s own: pick one of magpie’s models in its row and magpie writes it into the agent’s config, with the gateway’s address and a key of the agent’s own; switch it off and everything goes back to how the user had it. The example below adds Aider.

agent.js
// agent.js, named by "magpie": { "agent": "./agent.js" } in package.json
export const agent = {
  id: "aider", name: "Aider", bin: "aider",
  config: "~/.aider.conf.yml",  // the file magpie edits
  model: "model",                 // the key of the model in it
  prefix: "openai/",              // written before magpie's model 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   # or a single *.agent.js file

The agent export

FieldWhat it says
idIts id, as the Agents page, usage and the CLI name it: lower-case letters, digits, ., _ and -. One of magpie’s own agents’ ids (codex, claude, …) is refused: a plugin adds an agent, it doesn’t replace one.
nameWhat the page calls it. The id when left out.
configThe file magpie edits. ~ and $VAR are expanded. It is made when it isn’t there.
formatjson, yaml or env, when the file’s name doesn’t say it (.json/.jsonc, .yaml/.yml, .env). TOML isn’t supported yet.
modelThe key of the model in the file, as a dotted path for JSON and YAML (llm.model). Leave it out for an agent whose model is picked in the agent itself, among the models connect writes (Raycast): the row is then a switch with no model picker, and connect, which such a plugin must have, is called without model.
prefixWritten before magpie’s model id: openai/ for an agent that reaches OpenAI-compatible endpoints through LiteLLM. The id itself is <provider>/<model>, as the gateway takes it.
bin, dirThe command on PATH and the folder that say the agent is installed.
uaWords of the agent’s User-Agent, to name its requests in Usage when they come without its key.
iconA picture for its row: an icon name, an https:// or data: URL. The plugin’s own icon, else a generic one, when left out.
noticeSaid after its model is changed, as for magpie’s own agents.
libraryWhere the agent reads what the Library gives: instructions (a Markdown file it reads before every conversation), mcp (its MCP servers file) with mcpFormat, the agent whose file it is shaped like (alma, antigravity, claude, cline, codex, commandcode, copilot, crush, cursor, devin, gemini, goose, grok, hermes, kimi, omp, opencode, zed; claude is {"mcpServers": {…}}), and skills (a folder of SKILL.md folders). Each is optional, with ~ and $VAR expanded: library: { instructions: "~/.toy/AGENTS.md", mcp: "~/.toy/mcp.json", mcpFormat: "claude", skills: "~/.toy/skills" }. Without it the agent is given nothing from the Library, and giving it an item is refused, naming the key its plugin would set.

connect

connect(input) returns the other keys to write beside the model, by key path, each with its value; it may be async. It is given gateway (url, v1 — the URL ending in /v1 — and key, the agent’s own key, which counts its requests under its id), model (id, name, context, output, images, efforts, and as /v1/models names them native_endpoints, the gateway paths a request for it goes on untranslated such as ["/v1/chat/completions"], and pricing, USD a token as decimal strings: prompt, completion, input_cache_read, input_cache_write; each left out when not known, never empty or 0), models (every model magpie offers the agent, the same way) and agent (id, config). A plugin without connect writes only the model. It is called again when magpie’s models change, so a list it writes stays current.

What magpie does with it

Pages in the window

A plugin can add a page of its own to magpie’s window. Once it is on, the page has a tab in the sidebar’s Plugins group, after magpie’s own pages. The page is plain HTML, CSS and JavaScript in the plugin’s folder, and needs no Bun and no build step. The example below shows today’s usage.

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

The Plugins page shows the page, and what it may read, on the plugin’s row before it is turned on. page.file and page.icon are paths inside the plugin. The page’s files are what sits in file’s folder; a path out of it, or a link that leads out, isn’t served. A package can be a page and a provider, middleware or an agent too. A plugin that is only a page doesn’t start the plugin host. page.titles (and a panel’s titles) names the tab in other languages, by language code (zh, zh-TW, ja, de); title is used for any other.

What the page leaves unstyled already looks like magpie: the bridge adds a stylesheet for the font, colours, headings, buttons, fields and tables, with no weight (:where()), so any rule of the page’s own wins. Use the --magpie-* variables to match the window in your own styles.

What the page can ask

Field / callWhat it gives
magpie.readyA promise of the window’s theme. It resolves once the window has answered. Until then the page has said nothing to magpie, and a page that doesn’t say it in 10 seconds is shown as not answering.
magpie.theme, magpie.onTheme(fn){ dark, lang, tokens }. The bridge also sets data-theme, lang, color-scheme and --magpie-bg, --magpie-fg, --magpie-accent and so on on <html>, and again on each change.
magpie.options.get(), .set(o)The plugin’s own options in plugins.json, never another plugin’s. set takes an object and answers with the options saved. It can be called at most once a second.
magpie.read(name, params)A read that magpie.permissions declares. Anything else is refused, by the window and again by magpie. usage:summary (period: today, 7d or 30d) gives calls, errors, tokens and cost, with the top 20 models. providers:list gives each provider’s id and name. No read gives keys, accounts, sign-ins or request bodies.

What the page can’t do

Panels in magpie’s own pages

A plugin can also put panels into the pages magpie draws itself. Each one is a small page of its own in the plugin’s folder, with the same bridge, reads, options and sandbox as a page. It sits in a card under a heading with its title, as tall as it draws.

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"]
  }
}
slotWhere the panel goesmagpie.context
agentsThe end of the Agents page{}
usageThe end of Usage’s Overview{}
settingsA Plugins tab in Settings, there only while a plugin has a panel in it{}
providerEach saved provider’s editor{ provider: { id, name } }

A plugin has at most 8 panels. A panel’s height follows its <html> (up to 2400px), so let the page size itself and don’t give html or body a fixed height. A panel that throws, or doesn’t answer in 10 seconds, shows {name} stopped working with Try again in its own place; the page around it and the panels beside it go on.

Taking a built-in page’s place

A page can take the place of one of magpie’s own: add "replaces" to magpie.page, one of sessions, library, routing or usage. Its tab then stands where the built-in’s was, and links to that page open the plugin’s. When two plugins ask for the same page, the first in the Plugins list takes it and the other is a page of its own. Turn the plugin off and the built-in page is back as it was.

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

Items in magpie’s menus

A plugin with a page can add items to the menus of magpie’s rows: a provider’s (right-click it) and an agent’s (its handle, or right-click). They come after magpie’s own, named in the window’s language like a page’s title. Picking one opens the plugin’s page and tells it which item, and the row’s id and name, nothing else of it. The page handles it with magpie.onAction; an item picked before the page gave its handler waits for it.

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 is provider or agent. An id is lower-case letters, digits and dashes, up to 40. A plugin has at most 8 items, and an item with no title takes its page’s. A plugin without a page adds no items. The Plugins page says which menus a plugin adds to.

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.