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.
configdeclares the provider and its default models.authgives the sign-in methods, plus aloaderwhosefetchmakes each request.provider.modelslists the models one account has.auth.usagesays how much of the plan is used. It is magpie’s own hook, and OpenCode ignores it.auth.refreshrenews an account’s token before it runs out, once at a time. It is magpie’s own hook too.
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.
{
"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 }
}
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:
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.
HOME of its own, as Testing shows.How magpie runs plugins
- Bun. magpie downloads Bun the first time a plugin is added, and runs every plugin on it. Bun runs TypeScript as is, so an entry can be
index.ts. - One host process. Every plugin that is on loads into one Bun process, started the first time one is needed. magpie and the host talk over stdin and stdout, one JSON message a line. Anything a plugin prints,
console.logandprocess.stdoutincluded, is sent to stderr. magpie logs it asplugin: …. - Installing.
- An npm package is installed with
bun add, with its install scripts left unrun, as OpenCode installs plugins. A dependency that needs apostinstallbuild won’t work. - A git repository is fetched by Bun:
github:owner/repo[#ref],git+https://…orowner/repo. - A path to a folder or file is loaded where it is.
- An npm package is installed with
- Restarts. Adding, removing, updating or turning a plugin on or off restarts the host. A reply still streaming through the old host finishes first. Each
magpiecommand in a terminal starts a host of its own, so it always loads a folder plugin as it is now. In the app, turn the plugin off and on to load your edits. - Proxy. Every fetch a plugin makes for a provider or an account goes through the proxy the user set for that provider or account. Otherwise it goes through magpie’s own proxy, unless the setting is direct. Loopback addresses never go through a proxy. A fetch that passes its own
proxyoption is left alone. - Updates.
- The community’s packages (
@magpie-community/*) update by themselves a little after magpie starts, then every six hours. - Any other package with a newer version shows a dot on Plugins, and the user updates it with a click or
magpie plugin update. - A spec pinned to a version (
name@1.2.3) stays on it.
- The community’s packages (
What magpie keeps, in its config folder (~/.config/magpie, or $XDG_CONFIG_HOME/magpie):
| File | What it holds |
|---|---|
plugins.json | The 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.json | The 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.json | The 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:
exports["./server"], thenmain, thenexports["."](orexportswhen it is a string), inpackage.json.- Otherwise
index.ts,index.tsx,index.js,index.mjsorindex.cjs.
A path to a single file loads that file.
What the module exports
export default { id, server }(OpenCode v1):serveris the plugin.- Otherwise, every function the module exports is called as a plugin, as is every exported
{ server }object.
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
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.
| Field | Meaning |
|---|---|
magpie.icon | The provider’s picture: an https:// URL or a data:image/… URI, at most 1 MB. SVG, PNG and the like. |
magpie.maxConcurrency | How 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: 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:
| npm | API | Path after the base |
|---|---|---|
@ai-sdk/anthropic, @ai-sdk/google-vertex/anthropic | Anthropic Messages | /messages |
@ai-sdk/openai, @ai-sdk/azure | OpenAI Responses | /responses |
@ai-sdk/google | Gemini | /models/<id>:streamGenerateContent?alt=sse |
@ai-sdk/github-copilot | Responses 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:
- the loader’s
baseURL; - the model’s
provider.api; - the provider’s
api; - 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: {
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 calls it once for each account, in that account’s scope and through its proxy. Each account serves only the models listed for it, so an account on a smaller plan isn’t sent a model it doesn’t have.
- A model is OpenCode’s
Model:id,name;api: {id, url, npm};limit: {context, input?, output};capabilities: {reasoning, toolcall, attachment, input: {text, image, …}};cost: {input, output, cache: {read, write}};variants,release_date,status.
provider.modelsand changing a few fields is the easy way. A model whosestatusis"deprecated"isn’t shown. - magpie also reads these fields on a model, which OpenCode ignores:
free: true: the plan serves it at no cost to its allowance.rate: the credits a request costs, as a multiple, given as a number (0.5) or as the vendor writes it ("x0.5").rateWas: the rate before a discount that is running now.
- The keys of
variantsbecome the model’s reasoning levels when it reasons.
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:
- returns
provider.modelsunchanged after a fetch that failed; - throws;
- returns a list of its own with
list[Symbol.for("magpie.fellBack")] = true.
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
{ type: "api", label: "Acme API key", placeholder: "acme-…" }
labeltitles the key’s field. A label that only says “API key” is shown as “Acme API key”.placeholderis magpie’s own field, a hint inside the field.- Without an
authorizefunction, the key is saved as is:{type: "api", key, metadata?}. Answers topromptsgo inmetadata. - With
authorize(inputs), the plugin checks or exchanges the key itself. It returns{type: "success", key, metadata?}or{type: "failed", error?}.
A browser or device sign-in
{
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 }
},
}
},
}
- A success with
refreshis saved as{type: "oauth", refresh, access, expires, …}, with every other field you return kept beside them (accountId,enterpriseUrl, your own). - A success with
keyis saved as an API key. provider: "other-id"on the result saves the sign-in to another provider the plugin signs in to.erroron a failure is magpie’s own field: the user sees why (up to 500 characters). Without it, they see only that the sign-in failed.inputsis passed only to a method withprompts. A method without prompts getsundefined, as OpenCode’s TUI calls it. An empty object is how OpenCode’s CLI calls, and some plugins then ask on the terminal.
Questions before signing in
A method’s prompts are asked in order before authorize, each only when its condition holds:
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
- Where they are kept. A provider can be signed in to more than once. The first account is kept under the provider’s id, as OpenCode keeps it. Others are kept under
id#xxxxxx. - How the user tells them apart. magpie names an account by
accountId, elsemetadata.email, elseemail. Save one of them, an email where you have one. - Signing in again. A new sign-in replaces an existing account when both have the same
accountId. Where both also have auid(the vendor’s own id, for vendors whose names can repeat), it must match too. Without an id, the same secret matches. Otherwise the sign-in becomes a new account. - Scope. Every call magpie makes for an account runs in that account’s scope:
getAuth()returns that account.client.auth.set({ path: { id: "acme" }, body })saves to that account.
- Refreshing. Give
auth.refreshand magpie renews the token for you (see Renewing a sign-in). Otherwise, when a token runs out, refresh it infetchand save the whole object withclient.auth.set. What you give replaces what was kept; nothing is merged.
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: {
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
}
- When it runs. For an OAuth account whose
expiresis less thanrefreshLeadaway, before the account’s loader,provider.models,auth.usageor a request. An account withexpires0 or none is never renewed. - Once at a time. Requests that find the token due wait for one renewal, so a vendor that spends a refresh token once never sees it twice. They wait up to 15 seconds; a renewal that takes longer is still saved when it ends.
- What is saved. The fields you return are kept over the account’s old ones (merged, unlike
client.auth.set), and the account’s mark is cleared. Return nothing when there is nothing to renew. - When it fails. The account is left as it was and the request goes on with the old token, for the vendor’s answer to tell. An error with
signIn: "expired"marks the account (see Sign-in state), and magpie doesn’t try again until it is signed in again. Any other error is tried again after 30 seconds. - OpenCode and older magpie. They ignore the hook. Keep the check in your
fetchtoo if the plugin runs there: under magpie it finds the token fresh and does nothing.
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:
| Field | What magpie does with it |
|---|---|
baseURL | Where requests go, ahead of the model’s and provider’s api. |
apiKey | Sent 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. |
headers | Added to every request. |
fetch | Makes the request. Without it, magpie’s host uses the standard fetch. |
The request your fetch(input, init) receives:
inputis the full URL: the base plus the path for the model’s API.init.methodis usuallyPOST.init.headersare set in this order, each overriding the one before:- the key header from
apiKey; - the loader’s
headers; - the gateway’s request headers;
- what
chat.headershooks add.
- the key header from
init.bodyis the JSON string in that API’s shape (bytes when it isn’t UTF-8 text).modelin it is the API id, andstreamis whatever the agent asked for.init.signalaborts when the agent gives up on the request.
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
- A non-2xx response is the vendor’s answer. The gateway handles it as it would any provider’s: a rate limit or a server error moves the request to the next account or model, and a 401 marks the account (see Sign-in state).
- A thrown error fails the request, and failover moves on.
- Answer in the API’s own error shape where you can, so the agent shows the vendor’s message.
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": 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:
| Value | Meaning |
|---|---|
expired | The sign-in was refused, whatever the status (a vendor that answers 403 or 502 for it). |
kept | Leave the account as it is (a 401 that is about something else). |
renewed | The 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:
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:
{
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:
| Field | In magpie |
|---|---|
client.auth.set / .remove | Saves or forgets the sign-in of the account in scope. |
client.app.log | Writes to magpie’s log as plugin [level]: [service] message. |
client.tui.showToast | Written to magpie’s log. |
client.config.get | The config after every plugin’s config hook. |
any other client.* | Answers { data: undefined }. |
directory, worktree | magpie’s config folder, a place to keep files. |
project | { id: "magpie", worktree }. |
$ | Bun’s shell. |
serverUrl | A 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
- Agents name a plugin’s model as
<id>/<model>, where the id is the plugin’sauth.provider. - When the id is taken in magpie, by a built-in subscription, a preset (
google,openai,anthropic, …) ormagpieitself, the plugin’s provider is named<id>-plugin. Pick an id that is only yours. - The community’s plugins for magpie’s deprecated built-in subscriptions are the exception. They use the built-in’s id (
cursor,zed, …) and take it over once the user moves onto the plugin, so agents keep working unchanged.
Testing
Run magpie in a sandbox, so your own magpie, its agents and sign-ins are never touched:
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
- A plugin that fails to load says why in
magpie plugin. - Hook errors and everything a plugin prints are in the output, as
plugin: …lines. - For unit tests, run
bun testagainst the functions you put on_internal. - The community repository has a few scripts:
bun scripts/check.mjsloads each package as magpie does and checks its hooks.scripts/try.sh <name>runs it in a sandbox like the one above.
Publishing and listing
- npm. Publish the package as usual. Users add it by name (
magpie plugin add opencode-acme-auth), by a version (…@0.2.0), from git, or from a folder. - Search. The search on Plugins asks npm for
<query> opencode. It shows a package whose name or keywords holdopencodeand one ofauth,pluginorprovider. Keywords like["opencode", "opencode-plugin", "<vendor>"]do it. It also asks forkeywords:pi-package <query>and shows a pi package whose name or keywords mentionauthorprovider. - README. A plugin’s page in magpie shows its npm README. Say what it signs in to, how, where the sign-in is kept, and which models it has.
- The list. Plugins suggests the plugins in
registry.jsonof the community repository. magpie fetches it at most every six hours and builds in a copy for when it can’t. To be listed, open a pull request adding an entry:registry.json{ "package": "opencode-acme-auth", "name": "Acme", "icon": "https://acme.example/icon.svg", "providers": ["acme"], "summary": { "en": "Your Acme Coding Plan.", "zh": "使用你的 Acme Coding Plan。" } }MAGPIE_PLUGIN_MARKET=<url>points magpie at another list, which is useful to check yours, andoffkeeps to the built-in copy. - The community packages. To contribute one to magpie-community/plugins:
- Put it under
packages/<name>, named@magpie-community/opencode-<name>-auth, with no runtime dependencies unless one is really needed. - Pushing a version bump to
mainpublishes it. - These packages update by themselves in every magpie.
- Put it under
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).
- What makes a package pi’s. Its
package.jsonhas apimanifest ("pi": { "extensions": [...] }), thepi-packagekeyword, or@earendil-works/pi-coding-agent(or the older@mariozechner/pi-coding-agent) among itsdependenciesorpeerDependencies. A single file counts when it imports pi. - Loading. magpie installs pi beside the package and loads it with pi’s own loader, in the same plugin host. The providers it registers become magpie providers, with the models pi lists for them (an account’s own list when pi can fetch one). Its commands, tools, renderers and event handlers are left alone.
- Signing in. An OAuth login runs as pi runs it: its questions (
onPrompt) are asked in magpie’s sign-in, its page (onAuth) opens in the browser, andrefreshTokenrenews it. The dialogs a package shows withctx.ui(select,confirm,input,editor) and the terminal components it givesctx.ui.customare asked as magpie’s questions too. A provider that takes an API key gets a key field. Each sign-in is an account, so a pi provider can have several. - pi’s files. pi’s settings, its
auth.json(each provider’s first account, as pi keeps it) and the files a package writes are inpi/under magpie’s config directory, not the user’s~/.pi, unlessPI_CODING_AGENT_DIRis set. magpie keeps the accounts inplugin-auth.json, like any plugin’s. - Requests. A pi provider’s models take Anthropic Messages: the gateway translates what the agent sends, magpie streams it through pi, and the answer keeps its thinking, signatures and tool calls. A vendor error is passed on with its status, so failover works as with any plugin.
- Ids. A pi provider’s id follows the same rule as any plugin’s; see Provider ids.
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}}.
| Method | Does |
|---|---|
init | Loads the plugins; runs their config hooks. Every other call waits for it. |
providers | Each provider, its methods, accounts and each account’s models. |
prompt, validate | A method’s next question; an answer checked. |
authorize, callback, apiKey | Signing in. |
load | The loader’s options for an account. |
fetch | One 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, check | An account’s usage; an account tried as a request would. |
import, take, signOut, reload | Accounts 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.