matrix-plugin/PLUGIN-DOCS-v2.md

23 KiB

OpenCode V2 Plugin Documentation

Сгенерировано: 2026-09-25 | Версия OpenCode: v2.x | @opencode/plugin: v2.x


Table of Contents

  1. Overview
  2. Plugin Loading
  3. Plugin Structure
  4. Plugin API (Promise API)
  5. Context Object
  6. Hooks
  7. Custom Tools
  8. Events
  9. Configuration
  10. External Dependencies
  11. Local Plugin Pitfalls
  12. Debugging
  13. 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 via ctx.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 .ts and .js files are loaded as plugins
  • Immediate subdirectories containing a package.json are 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.location describes where the plugin was loaded, NOT the location of observed sessions or events. Never infer a session's location from ctx.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 install in plugin subdirectories. Dependencies in a plugin's own package.json will 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:

  1. Check for TypeScript errors: tsc --noEmit in the plugin directory
  2. Use client.app.log() for structured logging
  3. Verify the plugin path is correct in opencode.json
  4. Check that @opencode/plugin is 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

  1. Plugin not loading?

    • Check for TypeScript errors: tsc --noEmit
    • Check import paths are correct
    • Verify dependencies are installed
    • Check plugin path in opencode.json
  2. Hooks not firing?

    • Verify hook names match exactly (case-sensitive)
    • Check hook registration syntax
  3. State not persisting?

    • Use session-keyed Maps, not global variables
    • Use ctx.storage for persistent data
  4. ctx.session.prompt() failing?

    • Verify destructuring: async setup(ctx) not async setup(client)
    • Check session ID is valid

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

References