OpenCode plugins: how to write and install one
Updated 24 October 2026
A plugin is a JavaScript or TypeScript module that OpenCode loads into its server. It can watch events, change or block tool calls, set environment variables for shell commands, and add tools. OpenCode 2 replaced the plugin API, so a plugin written for OpenCode 1 doesn’t run in OpenCode 2. This guide covers installing plugins, writing one for OpenCode 2 with three working examples, the OpenCode 1 version, and debugging.
The short answer
| OpenCode 2 | OpenCode 1 | |
|---|---|---|
| npm plugins in config | "plugins": [...] | "plugin": [...] |
| Local plugins, loaded automatically | .opencode/plugins/, ~/.config/opencode/plugins/ | Same folders |
| Plugin shape | Default export of Plugin.define({ id, setup }) | Exported function that returns hooks |
| Types package | @opencode/plugin | @opencode-ai/plugin |
| Before a tool runs | ctx.tool.hook("execute.before", …) | "tool.execute.before" |
| Events | ctx.event.subscribe() | event hook |
| Manage from the terminal | opencode plugin add, list, update, remove | Edit the config |
Check your version with opencode --version.
1. Install a plugin from npm
Add the package name to your config. Use ~/.config/opencode/opencode.json for every project, or opencode.json in a project for that project only:
opencode.jsonc (OpenCode 2){
"$schema": "https://opencode.ai/config.json",
"plugins": [
"opencode-acme-plugin",
"@acme/opencode-plugin",
{
"package": "@acme/other-plugin",
"options": { "strict": true }
}
]
}
The object form passes options to the plugin. Lists from different config files are combined, not replaced. OpenCode installs missing packages in the background and checks unpinned ones for updates; exact versions stay pinned.
OpenCode 2 can also manage global package plugins for you. add installs the package and writes it into your global config:
Terminalopencode plugin add opencode-acme-plugin@1.2.0
opencode plugin add github:acme/opencode-plugin
opencode plugin list
opencode plugin check
opencode plugin update
opencode plugin remove opencode-acme-plugin@1.2.0
To switch a plugin off without uninstalling it, add its ID with a leading -, for example "plugins": ["*", "-acme.reviewer"]. Entries are processed in order, and a later ID turns it back on.
In OpenCode 1 the key is "plugin", and options go in a two-item array, ["name", { … }]. OpenCode 1 installs npm plugins with Bun at startup and caches them in ~/.cache/opencode/node_modules/.
Plugins run as you. A plugin has the same access to your files, network and shell as OpenCode itself. Install only packages whose code you’d be happy to run yourself.
2. Where local plugins live
For your own plugins, skip npm and drop a file in a plugin folder. OpenCode loads it automatically:
.opencode/plugins/in a project, for that project~/.config/opencode/plugins/for every project
Both versions also accept the singular plugin/ for older setups, but use plugins/. OpenCode 2 loads each .ts or .js file directly in that folder, and each subfolder that holds a plugin package, starting from its index or server module.
Two OpenCode 2 traps:
- A
plugins/folder next to a project-rootopencode.jsonis not scanned. It has to be inside.opencode/, or listed inplugins. - A local path in the
pluginslist has to be a folder, such as"./plugins/local", which loads itsindex.ts. A path to a single file is skipped, with only a warning in the log.
3. How an OpenCode 2 plugin is put together
An OpenCode 2 plugin default-exports a definition with a stable id and a setup function. setup runs when the plugin loads, registers whatever it needs on the context, and can return a cleanup function that runs when it unloads:
.opencode/plugins/hello.tsimport { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "local.hello",
setup(ctx) {
console.log(`Loaded in OpenCode ${ctx.app.version} for ${ctx.location.directory}`)
return () => console.log("Unloaded")
},
})
OpenCode supplies @opencode/plugin when it loads the file, so a single-file plugin needs no package.json. For type checking and autocomplete in your editor, install it as a dev dependency with npm install -D @opencode/plugin, matching your OpenCode version.
The context, ctx, is an OpenCode client plus plugin-only methods. The parts you’ll reach for first:
On ctx | Use it to |
|---|---|
tool.hook("execute.before") | Inspect or change a tool’s input, or throw to stop the call |
tool.hook("execute.after") | See the result or the error after a tool runs |
shell.hook("create.before") | Change a shell command’s working directory, timeout or environment |
permission.hook("evaluate") | Turn an allow or ask decision into another effect; configured deny rules never reach it |
session.hook("prompt"), session.hook("context") | Rewrite your prompt, or the system prompt and options sent to the model |
event.subscribe() | React to events such as session.idle and permission.asked |
tool.transform() | Add, change or remove tools |
storage, options, location | Keep JSON state, read options from config, and see which directory the plugin loaded for |
Hooks get one mutable event object. When several plugins register the same hook, they run in plugin order, and each sees the changes made before it.
4. Example: notify when OpenCode is done or waiting
OpenCode 2’s terminal app has built-in alerts you can turn on in cli.json. A plugin is the way to do something else, like post to a chat or run your own script. This one shows a desktop notification on macOS and Linux:
~/.config/opencode/plugins/notify.tsimport { execFile } from "node:child_process"
import { Plugin } from "@opencode/plugin"
function notify(message: string) {
if (process.platform === "darwin") {
execFile("osascript", ["-e", `display notification "${message}" with title "OpenCode"`])
} else if (process.platform === "linux") {
execFile("notify-send", ["OpenCode", message])
}
}
export default Plugin.define({
id: "local.notify",
setup(ctx) {
const controller = new AbortController()
void (async () => {
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
if (event.location && event.location.directory !== ctx.location.directory) continue
if (event.type === "session.idle") notify("Done. Your turn.")
if (event.type === "permission.asked") notify(`Needs permission: ${event.data.action}`)
}
})()
return () => controller.abort()
},
})
session.idlefires when a session finishes and waits for you.permission.askedcarries theactionandresourcesbeing requested, such asshelland the command.- The stream carries events for the whole server, not only this project. The
locationcheck keeps one plugin instance from announcing another project’s sessions, so you don’t get the same alert twice. - Aborting the stream in the cleanup function stops the old listener when the plugin reloads or unloads.
- On macOS,
osascriptnotifications appear as Script Editor. If nothing shows up, allow Script Editor in System Settings → Notifications. On Linux,notify-sendneeds a notification daemon, which SSH sessions don’t have.
5. Example: block reads of .env files
Throwing from execute.before stops the tool call, and the error message goes back to the model. OpenCode 2’s read tool takes its file in input.path:
.opencode/plugins/env-guard.tsimport { Plugin } from "@opencode/plugin"
const isEnvFile = (path: string) =>
/(^|[\\/])\.env(\.|$)/.test(path) && !path.endsWith(".env.example")
export default Plugin.define({
id: "local.env-guard",
async setup(ctx) {
await ctx.tool.hook("execute.before", (event) => {
if (event.tool !== "read") return
const input = event.input as { path?: string }
if (input.path && isEnvFile(input.path)) {
throw new Error("Reading .env files is blocked by the env-guard plugin.")
}
})
},
})
Be honest with yourself about what this covers. It stops the read tool only; cat .env through the shell tool still works. For a plain path rule, a deny in your permission rules does the same job with no code. A plugin earns its place when the check needs logic a wildcard can’t express, such as looking inside the file or checking the branch you’re on.
6. Example: log every tool call
execute.after runs once a tool finishes, with status set to "completed" or "error". This plugin appends one JSON line per call to .opencode/tool-calls.jsonl in the project:
.opencode/plugins/tool-log.tsimport { appendFile } from "node:fs/promises"
import { join } from "node:path"
import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "local.tool-log",
async setup(ctx) {
const file = join(ctx.location.directory, ".opencode", "tool-calls.jsonl")
await ctx.tool.hook("execute.after", async (event) => {
const line = {
time: new Date().toISOString(),
session: event.sessionID,
agent: event.agent,
tool: event.tool,
status: event.status,
input: event.input,
}
await appendFile(file, JSON.stringify(line) + "\n")
})
},
})
Read it with tail -f .opencode/tool-calls.jsonl. Tool input includes whole file contents for write and every shell command, so add the file to .gitignore. Hooks run as part of each tool call, so keep them fast.
7. Writing a plugin for OpenCode 1
An OpenCode 1 plugin exports one or more async functions. Each receives a context and returns an object of hooks:
.opencode/plugins/env-guard.ts (OpenCode 1)import type { Plugin } from "@opencode-ai/plugin"
export const EnvGuard: Plugin = async ({ project, client, $, directory, worktree }) => {
return {
"tool.execute.before": async (input, output) => {
if (input.tool === "read" && /(^|[\\/])\.env(\.|$)/.test(output.args.filePath)) {
throw new Error("Reading .env files is blocked by the env-guard plugin.")
}
},
}
}
- Context:
project,directory,worktree,client(an OpenCode SDK client) and$, Bun’s shell for running commands. - Hooks:
event,tool.execute.beforeandtool.execute.after,shell.env,permission.ask,chat.message,chat.params,command.execute.before, atoolmap for custom tools, and a fewexperimental.*ones. - Tool arguments are in
output.args, and OpenCode 1 names its tools differently: the read tool’s path isfilePath, and shell commands come from thebashtool. - Events include
session.idle,session.error,permission.asked,permission.replied,file.editedandtodo.updated. The OpenCode notifications guide has an OpenCode 1 notification plugin built on them. - Dependencies: add a
package.jsonto.opencode/(or your global config folder), and OpenCode 1 runsbun installat startup. - Logging: use
client.app.log()with aservice,levelandmessageinstead ofconsole.log.
Porting to OpenCode 2 is mostly a lookup:
| OpenCode 1 | OpenCode 2 |
|---|---|
event | ctx.event.subscribe() |
tool.execute.before / after | ctx.tool.hook("execute.before") / ("execute.after") |
shell.env | ctx.shell.hook("create.before") |
permission.ask | ctx.permission.hook("evaluate") |
chat.message | ctx.session.hook("prompt") |
chat.params | ctx.session.hook("context") |
tool map | ctx.tool.transform() |
directory, project | ctx.location.directory, ctx.location.project |
$ | Your own process code, such as node:child_process |
A package can ship both by default-exporting an object that spreads Plugin.define(…) and adds an OpenCode 1 server() function that returns the old hooks. OpenCode 2 calls setup and ignores server; OpenCode 1 does the opposite, from v1.18.29.
8. Debugging a plugin
- Is it loaded? In OpenCode 2, run
opencode plugin listfrom the project folder. It prints each plugin’s ID, version and source;--builtinadds OpenCode’s own. - Read the log. OpenCode 2 writes to
~/.local/share/opencode/log/opencode.log. Plugin, tool and permission activity comes from the server, so filter withgrep 'role=server' ~/.local/share/opencode/log/opencode.log, ortail -fit while you reproduce the problem. Load errors and skipped paths show up there. - Reload. OpenCode 2 reloads plugins when files in its watched config folders change. If you changed something it doesn’t watch, such as a dependency, run
opencode service restart. OpenCode 1 loads plugins at startup, so restart it. - See output directly.
opencode --standalone --print-logs --log-level debugruns a private server and prints its logs to the terminal, so you don’t have to dig through the shared one. - Plugin loads but does nothing. Check the version first: OpenCode 2 doesn’t run OpenCode 1 plugin code. Then check names: the tool is
shell, notbash, in OpenCode 2, and inputs differ between versions. Logevent.toolandevent.inputonce to see what you actually get. - Same alert twice. You may have the plugin both globally and in the project, or an npm notifier plus OpenCode 2’s built-in alerts. Disable one.
Skip writing a notification plugin: use Agent Pets
If the plugin you were about to write is “tell me when OpenCode needs me”, Agent Pets already does that and lets you answer, too:
- Each OpenCode session becomes a named pet that walks along the bottom of every Chrome page, next to your Claude Code, Codex and omp sessions.
- When OpenCode asks for permission, its pet bounces. Click it and answer Allow, Always or Deny from the page you’re on.
- A capsule over each pet says Thinking, Your Turn or Done, and you can read OpenCode’s latest answer and send its next message from any tab.
npx getagentpets init installs its OpenCode plugin for OpenCode 1 and 2. It runs entirely on your computer, and OpenCode’s own prompt keeps working as usual.
Get Agent Pets — was $25, now $6Get Agent Pets — $25
Questions
Where do OpenCode plugins go?
Local plugins go in .opencode/plugins/ in a project or ~/.config/opencode/plugins/ for every project, and OpenCode loads them on its own. npm packages go in the plugins list in opencode.json, or plugin in OpenCode 1.
Why doesn’t my OpenCode 1 plugin work in OpenCode 2?
OpenCode 2 has a new plugin API. A plugin that exports a function returning hooks only runs in OpenCode 1. Rewrite it with Plugin.define from @opencode/plugin and register its hooks in setup.
How do I see which OpenCode plugins are loaded?
In OpenCode 2, run opencode plugin list from your project. It shows each plugin’s ID, version and source. Add --builtin to include OpenCode’s own plugins.
Can an OpenCode plugin block a tool call?
Yes. Throw an error from the execute.before tool hook in OpenCode 2, or from tool.execute.before in OpenCode 1, and the call doesn’t run. For simple path or command rules, a deny permission rule is easier.
Sources: OpenCode 2 documentation on configuring plugins, building plugins, migrating V1 plugins, tools and troubleshooting; OpenCode 1 documentation on plugins; the type definitions in @opencode/plugin and @opencode-ai/plugin; opencode --help for v2.0.24.