A plugin is a JavaScript or TypeScript module that hooks into OpenCode's lifecycle and event streams by exporting an async function that receives a context object (project, directory, worktree, client, shell) and returns hooks keyed by event name, allowing code to intercept commands, events, and LLM-driven tool execution. Plugins load from npm packages, local directories, or global config; hooks run sequentially in registration order (global config, project config, global plugins, local plugins), and plugins can define custom tools, override built-in tools, register workspace adapters, or modify LLM requests — all governed by a lifecycle (config initialization, event streaming, dispose cleanup) and optional auth/provider customization.
The plugin subsystem lives in packages/opencode/src/plugin/index.ts and exposes a Service Effect context tag keyed @opencode/Plugin, implementing trigger, list, and init on the Interface type.[1] A plugin is a JavaScript or TypeScript module that exports an async function receiving a context object (project, directory, worktree, client, $) and returning a hooks object keyed by event name.[2] TypeScript plugins can import the Plugin type from @opencode-ai/plugin for type-safe hook implementations; in packages/plugin/src/index.ts, Plugin is typed as (input: PluginInput, options?: PluginOptions) => Promise<Hooks>, and a PluginModule wraps it as { id?: string; server: Plugin; tui?: never }.[2][3] The GitHub Copilot plugin in packages/opencode/src/plugin/github-copilot/copilot.ts attaches an X-Interaction-Id HTTP header — populated with the current session ID — to all outgoing Copilot API calls, enabling GitHub's API to attribute requests to their corresponding session for audit and telemetry.
PluginInput in packages/plugin/src/index.ts provides plugins with an @opencode-ai/sdk client, the current project, directory, worktree, a serverUrl URL, access to Bun's shell ($), and an experimental_workspace.register method to register custom WorkspaceAdapter implementations.[3] At runtime, packages/opencode/src/plugin/index.ts constructs the PluginInput client with a createOpencodeClient instance pointed at the running server URL, falling back to http://localhost:4096 and direct Server.Default().app.fetch when no server URL is available.[1] The WorkspaceAdapter interface in packages/plugin/src/index.ts requires implementations to supply a name, description, configure, create, remove, and target method, where target returns either a local directory path or a remote URL with optional headers.[3] A WorkspaceAdapter registered via a plugin allows OpenCode to redirect file and shell operations to non-local environments (such as remote containers or VMs); without a registered adapter, OpenCode operates only on the local filesystem.
Plugins can be loaded from local files placed in .opencode/plugins/ (project-level) or ~/.config/opencode/plugins/ (global); files in these directories are loaded automatically at startup.[2] npm plugins are specified in the plugin config key as an array of package names and are automatically installed using Bun at startup; packages and their dependencies are cached in ~/.cache/opencode/node_modules/.[2] Local plugins that need external npm packages must add a package.json to the config directory (e.g., .opencode/package.json) with the required dependencies; opencode runs bun install at startup to install them.[2] Plugin load order is: global config (~/.config/opencode/opencode.json), project config (opencode.json), global plugin directory (~/.config/opencode/plugins/), project plugin directory (.opencode/plugins/); all hooks from all sources run in sequence.[2] Duplicate npm plugins with the same name and version are loaded once, but a local plugin and an npm plugin with similar names are both loaded separately.[2]
packages/opencode/src/plugin/index.ts uses PluginLoader.loadExternal to install and load user-configured plugins listed in cfg.plugin_origins; the pure runtime flag suppresses all external plugins, and plugins are waited on only after config.waitForDependencies() resolves.[1] The disableDefaultPlugins runtime flag in packages/opencode/src/plugin/index.ts suppresses all internal (built-in) plugins; when set, internalPlugins(flags) is replaced with an empty array.[1] External plugin execution in packages/opencode/src/plugin/index.ts is kept sequential (Effect.tryPromise one at a time in a for-loop) so that hook registration and execution order is deterministic.[1] After all hooks are registered, packages/opencode/src/plugin/index.ts calls each hook's config method with the current config object; errors in config hooks are logged but not fatal (Effect.ignore).[1] packages/opencode/src/plugin/index.ts subscribes to the EventV2Bridge event stream and fans out every event to all registered hooks' event method, filtered to events whose location.directory matches the current workspace directory.[1] On finalization (scope close), packages/opencode/src/plugin/index.ts calls each hook's dispose method sequentially; errors are logged but do not abort disposal of subsequent hooks (Effect.ignore).[1] The experimentalWebSocketsEnabled helper in packages/opencode/src/plugin/index.ts returns true if the enabled flag is set OR the installation channel is one of local, dev, or beta, meaning pre-release builds enable experimental WebSockets by default without an explicit opt-in.[1]
V1 plugin modules are detected by readV1Plugin in packages/opencode/src/plugin/index.ts; if a V1 plugin is found, its server export is called and the returned Hooks object pushed onto the hooks list. If no V1 plugin is found, the module falls back to getLegacyPlugins, which scans all named exports for callable server plugins.[1] The getLegacyPlugins function in packages/opencode/src/plugin/index.ts throws TypeError: Plugin export is not a function if any named export from a legacy plugin module is not a callable server plugin.[1]
The TriggerName type in packages/opencode/src/plugin/index.ts constrains triggerable hook names to only those whose signatures match (input: any, output: any) => Promise<void>, excluding lifecycle hooks like dispose, event, and config.[1] The full list of plugin events includes: command.executed, file.edited, file.watcher.updated, installation.updated, lsp.client.diagnostics, lsp.updated, message.part.removed, message.part.updated, message.removed, message.updated, permission.asked, permission.replied, server.connected, session.created, session.compacted, session.deleted, session.diff, session.error, session.idle, session.status, session.updated, todo.updated, shell.env, tool.execute.after, tool.execute.before, tui.prompt.append, tui.command.execute, and tui.toast.show.[2] The tool.definition hook in packages/plugin/src/index.ts allows plugins to modify the description and parameters of a tool definition sent to the LLM, identified by toolID.[3] The experimental.session.compacting hook in packages/plugin/src/index.ts is called before session compaction starts and allows plugins to append extra context strings or entirely replace the compaction prompt.[3] The experimental.compaction.autocontinue hook in packages/plugin/src/index.ts is called after compaction and before the synthetic auto-continue message is added; setting output.enabled to false suppresses the synthetic user "continue" turn.[3] The AuthHook type in packages/plugin/src/index.ts supports two auth method types: oauth (which calls authorize and returns a URL + callback) and api (which prompts for keys and optionally calls authorize). Both method types support text or select prompt steps with a when Rule condition; the older condition callback is deprecated in favor of when.[3] The ProviderHook type in packages/plugin/src/index.ts requires a string id and an optional models callback that receives a V2 Provider and auth context and returns a record of V2 Model objects, allowing plugins to supply custom model lists.[3] The AuthOuathResult type alias in packages/plugin/src/index.ts is deprecated; AuthOAuthResult (corrected spelling) should be used instead.[3]
Example: a tool.execute.before hook that blocks reading .env files by throwing an error when the read tool targets a path containing .env.[2] Example: a shell.env hook that injects environment variables into all shell executions (both AI tool calls and user terminals).[2]
Plugins can define custom tools using the tool helper from @opencode-ai/plugin; if a plugin tool has the same name as a built-in tool, the plugin tool takes precedence.[2] Custom tools defined in plugins must provide a description, a Zod-schema args definition using tool.schema.* helpers, and an async execute function; the execute function receives args and a context object with agent, sessionID, messageID, directory, and worktree.[2][4] Use context.directory for the current session working directory and context.worktree for the git worktree root inside a custom tool's execute function.[4]
Custom tools defined as TypeScript or JavaScript files (the definition itself must be TS/JS, but can invoke scripts written in any language) are placed in .opencode/tools/ for project-local scope or ~/.config/opencode/tools/ for global scope — see Tools for plugin-system internals.[4] The tool() helper from @opencode-ai/plugin provides type-safety and validation when defining standalone custom tools; tool.schema is Zod, but argument schemas can also be defined by importing Zod directly and returning a plain object without the helper.[4] The filename of a custom tool file becomes the tool name: a default export from database.ts creates a database tool, while named exports add and multiply from math.ts create math_add and math_multiply.[4] If a custom tool shares the same name as a built-in tool, the custom tool takes precedence and replaces the built-in; to disable a built-in without replacing it, use the permissions system instead.[4] Custom tools can invoke scripts in any language by using Bun.$ to shell out; a TypeScript wrapper calls the external script and returns its output.[4]
Canonical pattern for calling a Python script from a custom tool using Bun.$ and context.worktree:
async execute(args, context) {
const script = path.join(context.worktree, ".opencode/tools/add.py")
const result = await Bun.$`python3 ${script} ${args.a} ${args.b}`.text()
return result.trim()
}
The V2 Promise Plugin API (@opencode-ai/plugin/v2/promise) provides the same hook and reload capabilities as the Effect API (@opencode-ai/plugin/v2/effect) but uses Promises instead of Effects for all async boundaries.[5] V2 Promise plugins are defined with define({ id, setup }) from @opencode-ai/plugin/v2/promise; the setup function receives a ctx object and registers hooks imperatively — it does not return a hook object, and per-plugin options are available as ctx.options.[5] V2 Promise transform hooks are available on six domain namespaces in ctx: agent, catalog, command, integration, reference, and skill; each supports a .transform() call and a .reload() call.[5] A V2 Promise transform registration can be removed early by calling registration.dispose(); the returned Registration object holds an async dispose method.[5] To refresh a V2 plugin domain after external data changes, call ctx.<domain>.reload() after updating the data; reload re-executes all registered transform hooks for that domain.[5] The V2 Promise runtime hooks ctx.aisdk.sdk and ctx.aisdk.language allow intercepting AI SDK module loading and language model resolution respectively, enabling plugins to inject custom provider SDK instances.[5]
Canonical V2 Promise plugin definition using define from @opencode-ai/plugin/v2/promise:
import { define } from "@opencode-ai/plugin/v2/promise"
export const Plugin = define({
id: "example",
setup: async (ctx) => {
await ctx.catalog.transform((catalog) => {
catalog.provider.update("example", (provider) => {
provider.name = "Example"
})
})
},
})
The V2 plugin system exposes two entry points: @opencode-ai/plugin/v2/effect (Effect-based) and @opencode-ai/plugin/v2/promise (Promise-based); the Promise API is recommended for codebases that do not already use the Effect library.
Sources