23 KiB
OpenCode V2 Plugin Documentation
Сгенерировано: 2026-09-25 | Версия OpenCode: v2.x | @opencode/plugin: v2.x
Table of Contents
- Overview
- Plugin Loading
- Plugin Structure
- Plugin API (Promise API)
- Context Object
- Hooks
- Custom Tools
- Events
- Configuration
- External Dependencies
- Local Plugin Pitfalls
- Debugging
- Complete Examples
Overview
OpenCode V2 plugins extend the agent by adding custom tools, hooks, event handlers, and integrations. A plugin is a TypeScript/JavaScript module that uses Plugin.define() from @opencode/plugin to register capabilities.
Key Concepts
- Plugin: A module that calls
Plugin.define({ id, setup(ctx) })and exports it as default. - setup(ctx): An async function that receives a context object and returns a cleanup function.
- ctx: The plugin context providing access to all OpenCode domains (session, tool, event, storage, etc.).
- Hooks: Functions registered on domain-specific APIs (e.g.,
ctx.session.hook(),ctx.tool.hook()). - Events: Subscribable event streams via
ctx.event.subscribe(). - Custom Tools: Tools defined with
tool()helper and registered viactx.tool.transform().
Plugin Loading
Discovery Locations
OpenCode discovers plugins from multiple sources, loaded in this order:
| Source | Path | Auto-discovered |
|---|---|---|
| Global config | ~/.config/opencode/opencode.json |
N/A (explicit) |
| Project config | opencode.json |
N/A (explicit) |
| Global plugin dir | ~/.config/opencode/plugins/ |
Yes |
| Project plugin dir | .opencode/plugins/ |
Yes |
Discovery Rules
Global plugin directory (~/.config/opencode/plugins/):
- Direct
.tsand.jsfiles are loaded as plugins - Immediate subdirectories containing a
package.jsonare loaded as plugin packages - Nested directories are NOT auto-discovered
Project plugin directory (.opencode/plugins/):
- Same rules as global
Explicit Configuration
Plugins can also be explicitly listed in opencode.json:
{
"plugins": [
// npm package
"opencode-acme-plugin",
// npm package with version
"opencode-acme-plugin@1.2.0",
// scoped npm package
"@acme/opencode-plugin",
// local file path (relative to config file)
"./plugins/local",
// absolute file path
"/home/user/plugin.ts",
// file:// URL
"file:///home/user/plugins/local",
// package with options
{
"package": "@acme/opencode-plugin",
"options": { "agent": "reviewer", "strict": true }
}
]
}
Plugin Control
Prefix an ID or wildcard with - to disable:
{
"plugins": [
"*", // enable all
"-opencode.provider.*", // disable all providers
"opencode.provider.openai", // re-enable OpenAI
"-acme.reviewer" // disable specific plugin
]
}
Two built-in plugins always ignore removals: opencode.config.policy and opencode.provider.opencode.
Plugin Structure
Minimal Plugin
// ~/.config/opencode/plugins/my-plugin.ts
export default {
id: "my-plugin",
async setup(ctx) {
console.log("Plugin loaded!")
// cleanup function (optional)
return () => {
console.log("Plugin cleaning up")
}
},
}
Plugin with Package.json (Directory Plugin)
~/.config/opencode/plugins/my-plugin/
├── package.json # Required: defines name, dependencies
├── index.ts # Entry point (or main field in package.json)
├── helper.ts # Additional modules
└── node_modules/ # Dependencies (manually installed)
package.json:
{
"name": "my-opencode-plugin",
"version": "1.0.0",
"main": "index.ts",
"dependencies": {
"@opencode/plugin": "^2.0.16",
"some-npm-package": "^1.0.0"
}
}
index.ts:
import { Plugin, tool } from "@opencode/plugin"
export default Plugin.define({
id: "my-opencode-plugin",
async setup(ctx) {
// Plugin initialization
const result = await someNpmPackage.doSomething()
console.log(`Result: ${result}`)
return {
// hooks
}
},
})
Plugin API (Promise API)
The Promise API is the primary way to write V2 plugins:
import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "my-plugin",
async setup(ctx) {
// Register hooks, tools, etc.
// ...
// Return cleanup function (optional)
return () => {
// Release resources
}
},
})
Plugin.define() Signature
Plugin.define({
id: string, // Unique plugin identifier (required)
async setup(ctx): PluginContext // Setup function (required)
}): PluginDefinition
Effect API (Alternative)
For plugins that need Effect-TS:
import { Plugin } from "@opencode/plugin/effect"
Context Object
The ctx parameter in setup() provides access to all OpenCode domains:
Core Properties
| Property | Type | Description |
|---|---|---|
ctx.location |
Location | Where this plugin was loaded (NOT where observed sessions are) |
ctx.location.directory |
string | Working directory of the plugin's location |
ctx.location.project |
ProjectInfo | Project info at plugin load time |
Domain APIs
| Domain | Purpose |
|---|---|
ctx.app |
Application-level operations (logging, options) |
ctx.session |
Session management, prompts, hooks |
ctx.tool |
Tool hooks, transforms, custom tools |
ctx.event |
Event subscription |
ctx.storage |
Persistent key-value storage |
ctx.shell |
Shell command execution |
ctx.command |
Command transforms |
ctx.permission |
Permission hooks |
ctx.provider |
Provider transforms |
ctx.model |
Model transforms |
ctx.integration |
Integration/connection APIs |
ctx.mcp |
MCP server management |
ctx.skill |
Skill management |
ctx.agent |
Agent configuration |
ctx.vcs |
Version control operations |
ctx.websearch |
Web search |
ctx.rpc |
RPC calls |
ctx.reference |
Reference management |
ctx.worktree |
Git worktree operations |
Important: ctx.location vs Observed Events
CRITICAL:
ctx.locationdescribes where the plugin was loaded, NOT the location of observed sessions or events. Never infer a session's location fromctx.location. Use event/session data instead.
Hooks
Session Hooks
// Prompt hook - called before a prompt is sent
ctx.session.hook("prompt", (event) => {
// Modify event before sending
event.metadata = { ...event.metadata, plugin: "my-plugin" }
})
// Context hook - modify context sent to LLM
ctx.session.hook("context", (input, output) => {
output.system.push(`<custom-context>Rules go here</custom-context>`)
})
// Compaction hook - customize context preservation
ctx.session.hook("compaction", (input, output) => {
output.context.push(`## Preserved state\n- Current task: ...`)
})
Tool Hooks
// Before tool execution
ctx.tool.hook("execute.before", (input, output) => {
if (input.tool === "bash" && output.args.command.includes("rm -rf")) {
throw new Error("Dangerous command blocked")
}
})
// After tool execution
ctx.tool.hook("execute.after", (input) => {
console.log(`Tool ${input.tool} completed`)
})
Shell Hooks
// Modify shell creation
ctx.shell.hook("create.before", (input, output) => {
output.env.MY_API_KEY = "secret"
output.env.PROJECT_ROOT = input.cwd
})
Permission Hooks
ctx.permission.hook("evaluate", (permission, output) => {
if (permission.type === "read_file") {
output.status = "allow"
}
})
Event Subscription
const controller = new AbortController()
void (async () => {
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
if (event.type === "session.created") {
console.log(`New session: ${event.sessionID}`)
}
if (event.type === "session.idle") {
console.log("Session idle")
}
if (event.type === "message.updated") {
console.log("Message updated")
}
}
})()
// Cleanup
return () => {
controller.abort()
}
Available Events
| Event Type | Description |
|---|---|
session.created |
New session created |
session.updated |
Session updated |
session.deleted |
Session deleted |
session.error |
Session error |
session.idle |
Session became idle |
session.compacted |
Session compacted |
message.updated |
Message updated |
message.removed |
Message removed |
file.edited |
File edited |
permission.asked |
Permission requested |
permission.replied |
Permission responded |
server.connected |
Server connected |
tool.execute.before |
Tool about to execute |
tool.execute.after |
Tool finished executing |
Custom Tools
import { tool } from "@opencode/plugin"
// In setup():
ctx.tool.transform((editor) => {
editor.add({
name: "my-tool",
description: "Does something useful",
async execute({ sessionID }, delivery) {
// Tool implementation
return "Result"
},
})
})
Using the tool() Helper
import { tool } from "@opencode/plugin"
const myTool = tool({
description: "Search the web",
args: {
query: tool.schema.string().describe("Search query"),
maxResults: tool.schema.number().optional().describe("Max results"),
},
async execute(args, context) {
const { sessionID, agent } = context
// Implementation using ctx
return `Results for: ${args.query}`
},
})
Events
Subscribing to Events
const eventController = new AbortController()
void (async () => {
for await (const event of ctx.event.subscribe({ signal: eventController.signal })) {
// Handle event
console.log(`Event: ${event.type}`)
}
})()
Event Update Types
When handling session.updated events, the event.update field can be:
| Type | Description |
|---|---|
agent_message_chunk |
Streaming text from the agent |
tool_call |
Tool invocation |
tool_call_update |
Tool progress/completion |
user_message_chunk |
User message fragment |
agent_thought_chunk |
Agent reasoning/thought |
Configuration
Plugin Options
Configure via opencode.json:
{
"plugins": [
{
"package": "./plugins/my-plugin",
"options": {
"apiKey": "secret",
"enabled": true
}
}
]
}
Access in plugin:
export default Plugin.define({
id: "my-plugin",
async setup(ctx) {
const apiKey = ctx.options.apiKey
const enabled = ctx.options.enabled
},
})
Structured Logging
await ctx.app.log({
body: {
service: "my-plugin",
level: "info", // debug, info, warn, error
message: "Plugin initialized",
extra: { key: "value" },
},
})
External Dependencies
How Dependencies Work
OpenCode V2 does NOT automatically install dependencies for local plugins. You must manually install them.
For Global Plugin Directory (~/.config/opencode/plugins/)
Dependencies must be installed in the config directory:
// ~/.config/opencode/package.json
{
"dependencies": {
"@opencode/plugin": "^2.0.16",
"some-npm-package": "^1.0.0"
}
}
OpenCode runs bun install at startup for this file.
For Local Plugin Package Directory
If your plugin is in a subdirectory with its own package.json:
~/.config/opencode/plugins/my-plugin/
├── package.json
├── index.ts
└── node_modules/ # <-- You must create this!
You must manually run bun install (or npm install) inside the plugin directory:
cd ~/.config/opencode/plugins/my-plugin
bun install
CRITICAL: OpenCode does NOT automatically run
bun installin plugin subdirectories. Dependencies in a plugin's ownpackage.jsonwill NOT be available unless you manually install them.
For npm Package Plugins
Dependencies are installed automatically when the plugin is added via opencode plugin add.
Local Plugin Pitfalls
Pitfall 1: Dependencies Not Auto-Installed
Problem: Plugin has package.json with dependencies, but they're not available at runtime.
Solution: Run bun install in the plugin directory:
cd ~/.config/opencode/plugins/my-plugin
bun install
Pitfall 2: Wrong Import Path
Problem: Using @opencode-ai/plugin (V1) instead of @opencode/plugin (V2).
Solution: Use the V2 import:
// V2 (correct)
import { Plugin } from "@opencode/plugin"
// V1 (wrong for V2)
import type { Plugin } from "@opencode-ai/plugin"
Pitfall 3: Mixing CommonJS and ESM
Problem: Using require() and module.exports in a TypeScript file loaded as ESM.
Solution: Use ES module syntax throughout:
// Wrong - CommonJS in ESM context
const { MatrixClient } = require("matrix-bot-sdk")
module.exports = { MatrixBotClient }
// Correct - ES modules
import { MatrixClient } from "matrix-bot-sdk"
export class MatrixBotClient { ... }
Pitfall 4: Plugin Not Loading (Silent Failure)
Problem: Plugin has a syntax error or import error, but no log is written.
Solution:
- Check for TypeScript errors:
tsc --noEmitin the plugin directory - Use
client.app.log()for structured logging - Verify the plugin path is correct in
opencode.json - Check that
@opencode/pluginis installed in the dependency location
Pitfall 5: Context Destructuring Error
Problem: Treating ctx as the client directly:
// Wrong
export default Plugin.define({
id: "my-plugin",
async setup(client) {
await client.session.prompt(...) // FAILS: client.session.prompt doesn't exist
},
})
// Correct
export default Plugin.define({
id: "my-plugin",
async setup(ctx) {
await ctx.session.prompt(...) // Correct
},
})
Pitfall 6: Hook Name Case Sensitivity
Problem: Using incorrect hook names.
Solution: Verify exact hook names from the API reference. Hook names are case-sensitive.
Pitfall 7: Missing Cleanup
Problem: Event subscriptions or intervals not cleaned up on plugin unload.
Solution: Return a cleanup function:
export default Plugin.define({
id: "my-plugin",
async setup(ctx) {
const interval = setInterval(() => { /* ... */ }, 60000)
const controller = new AbortController()
// Subscribe to events
void (async () => {
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
// handle
}
})()
// Cleanup
return () => {
controller.abort()
clearInterval(interval)
}
},
})
Pitfall 8: Using ctx.location for Session Data
Problem: Inferring session location from ctx.location:
// Wrong
const sessionDir = ctx.location.directory // This is where plugin was loaded!
// Correct
const sessionDir = event.directory // Use event/session data
Debugging
Checklist
-
Plugin not loading?
- Check for TypeScript errors:
tsc --noEmit - Check import paths are correct
- Verify dependencies are installed
- Check plugin path in
opencode.json
- Check for TypeScript errors:
-
Hooks not firing?
- Verify hook names match exactly (case-sensitive)
- Check hook registration syntax
-
State not persisting?
- Use session-keyed Maps, not global variables
- Use
ctx.storagefor persistent data
-
ctx.session.prompt()failing?- Verify destructuring:
async setup(ctx)notasync setup(client) - Check session ID is valid
- Verify destructuring:
Logging
Use structured logging via ctx.app.log():
await ctx.app.log({
body: {
service: "my-plugin",
level: "info",
message: "Plugin initialized",
extra: { key: "value" },
},
})
Levels: debug, info, warn, error.
Verbose Mode
Run OpenCode with --verbose for more detailed plugin loading output.
Complete Examples
Example 1: Simple Notification Plugin
import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "notification-plugin",
async setup(ctx) {
void (async () => {
for await (const event of ctx.event.subscribe({
signal: new AbortController().signal,
})) {
if (event.type === "session.idle") {
await ctx.app.log({
body: {
service: "notification",
level: "info",
message: "Session completed",
},
})
}
}
})()
return () => {}
},
})
Example 2: Plugin with Matrix Integration
import { Plugin } from "@opencode/plugin"
import { MatrixClient } from "matrix-bot-sdk"
import { marked } from "marked"
export default Plugin.define({
id: "matrix-bot",
async setup(ctx) {
const options = ctx.options
if (!options.homeserver) {
await ctx.app.log({
body: { service: "matrix", level: "warn", message: "Disabled (no homeserver)" },
})
return () => {}
}
const client = new MatrixClient(options.homeserver)
const sessions = new Map<string, any>()
// Login
if (options.accessToken) {
await client.startWithToken("m.login.token", {
user_id: options.userId,
access_token: options.accessToken,
})
} else {
await client.startWithPassword(options.userId, options.password)
}
const userId = await client.getUserId()
await ctx.app.log({
body: { service: "matrix", level: "info", message: `Started as ${userId}` },
})
// Listen for messages
client.on("room.message", async (roomId, event) => {
if (event.type !== "m.room.message" || event.content.msgtype !== "m.text") return
if (event.sender === userId) return
const text = event.content.body?.trim()
if (!text?.startsWith("!oc ")) return
const query = text.slice(4).trim()
const sessionId = roomId
// Send prompt
const result = await ctx.session.prompt({
sessionID: sessionId,
text: query,
})
// Send response
await client.sendText(roomId, result)
})
// Event subscription for response streaming
const controller = new AbortController()
void (async () => {
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
if (event.type === "session.updated" && event.update?.type === "agent_message_chunk") {
// Stream response to Matrix
}
}
})()
return () => {
controller.abort()
client.stop()
}
},
})
Example 3: Plugin with Custom Tool
import { Plugin, tool } from "@opencode/plugin"
export default Plugin.define({
id: "custom-tools",
async setup(ctx) {
ctx.tool.transform((editor) => {
editor.add({
name: "git-status",
description: "Show git status of the current project",
async execute(_, delivery) {
const result = await ctx.shell.execute("git status --porcelain")
return result.stdout
},
})
editor.add({
name: "search-files",
description: "Search for files matching a pattern",
async execute(args: { pattern: string }, delivery) {
const result = await ctx.shell.execute(`find . -name "${args.pattern}" -type f`)
return result.stdout
},
})
})
return () => {}
},
})
Example 4: Plugin with Persistent Storage
import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "state-tracker",
async setup(ctx) {
// Initialize from storage
const version = await ctx.storage.get("state-tracker/version")
if (!version) {
await ctx.storage.set("state-tracker/version", "1.0.0")
await ctx.storage.set("state-tracker/started_at", new Date().toISOString())
}
// Track sessions
const sessions = new Map<string, { count: number }>()
const controller = new AbortController()
void (async () => {
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
if (event.type === "session.created" && event.sessionID) {
sessions.set(event.sessionID, { count: 0 })
}
if (event.type === "session.deleted" && event.sessionID) {
sessions.delete(event.sessionID)
}
}
})()
return () => {
controller.abort()
}
},
})
Migration from V1 to V2
Import Changes
| V1 | V2 |
|---|---|
import type { Plugin } from "@opencode-ai/plugin" |
import { Plugin } from "@opencode/plugin" |
export const MyPlugin = async (ctx) => ({ ...hooks }) |
export default Plugin.define({ id, async setup(ctx) { ... } }) |
Hook Mapping
| V1 Hook | V2 API |
|---|---|
event: async ({ event }) => {} |
ctx.event.subscribe() |
"tool.execute.before" |
ctx.tool.hook("execute.before", ...) |
"tool.execute.after" |
ctx.tool.hook("execute.after", ...) |
"chat.message" |
ctx.session.hook("prompt", ...) |
"chat.params" |
ctx.session.hook("context", ...) |
permission.ask |
ctx.permission.hook("evaluate", ...) |
dispose |
return cleanup from setup |
config |
transforms on affected domains |
Context Changes
| V1 | V2 |
|---|---|
ctx.client |
Domain APIs on ctx |
ctx.project |
ctx.location.project |
ctx.directory |
ctx.location.directory |
ctx.$ |
Explicitly imported shell helpers |
Quick Reference
File: ~/.config/opencode/opencode.json
{
"plugins": [
// npm package
"opencode-helicone-session",
// local directory
"./plugins/local",
// with options
{
"package": "./plugins/matrix-plugin",
"options": {
"homeserver": "https://matrix.org",
"userId": "@bot:matrix.org",
"password": "secret"
}
}
]
}
File: ~/.config/opencode/plugins/my-plugin/package.json
{
"name": "my-opencode-plugin",
"version": "1.0.0",
"main": "index.ts",
"dependencies": {
"@opencode/plugin": "^2.0.16"
}
}
File: ~/.config/opencode/plugins/my-plugin/index.ts
import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "my-opencode-plugin",
async setup(ctx) {
// Initialization
await ctx.app.log({
body: { service: "my-plugin", level: "info", message: "Loaded" }
})
// Event subscription
const controller = new AbortController()
void (async () => {
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
if (event.type === "session.idle") {
await ctx.app.log({
body: { service: "my-plugin", level: "info", message: "Session idle" }
})
}
}
})()
return () => {
controller.abort()
}
},
})
Install Dependencies
cd ~/.config/opencode/plugins/my-plugin
bun install