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 2OpenCode 1
npm plugins in config"plugins": [...]"plugin": [...]
Local plugins, loaded automatically.opencode/plugins/, ~/.config/opencode/plugins/Same folders
Plugin shapeDefault export of Plugin.define({ id, setup })Exported function that returns hooks
Types package@opencode/plugin@opencode-ai/plugin
Before a tool runsctx.tool.hook("execute.before", …)"tool.execute.before"
Eventsctx.event.subscribe()event hook
Manage from the terminalopencode plugin add, list, update, removeEdit 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:

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:

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 ctxUse 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, locationKeep 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()
  },
})

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

Porting to OpenCode 2 is mostly a lookup:

OpenCode 1OpenCode 2
eventctx.event.subscribe()
tool.execute.before / afterctx.tool.hook("execute.before") / ("execute.after")
shell.envctx.shell.hook("create.before")
permission.askctx.permission.hook("evaluate")
chat.messagectx.session.hook("prompt")
chat.paramsctx.session.hook("context")
tool mapctx.tool.transform()
directory, projectctx.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

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:

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.

More guides