| name | opencode-plugin |
| description | Develop plugins, tools, and extensions for OpenCode AI coding agent with MCP, LSP integration, custom tools, and SDK usage |
| metadata | {"author":"OSS AI Skills","version":"1.0.0","tags":["opencode","plugin","ai-agent","mcp","tool-development"]} |
OpenCode Plugin Development
Complete guide for developing plugins, tools, and extensions for OpenCode AI coding agent.
Overview
OpenCode is an AI-powered coding agent that supports extensibility through plugins, custom tools, MCP (Model Context Protocol) servers, and skills.
Repository Structure
anomalyco/opencode/
├── packages/
│ ├── opencode/ # Main CLI and server
│ │ └── src/
│ │ ├── plugin/ # Plugin system
│ │ ├── tool/ # Tool system
│ │ ├── mcp/ # MCP integration
│ │ ├── session/ # Session management
│ │ └── provider/ # LLM providers
│ ├── plugin/ # Plugin API package
│ │ └── src/ # @opencode-ai/plugin
│ ├── sdk/ # SDK packages
│ │ └── js/ # @opencode-ai/sdk (TypeScript)
│ └── util/ # Shared utilities
Extension Points
- Tools: Functions the AI can call to perform actions
- MCP Servers: External services exposing tools via MCP protocol
- Skills: Prompt templates and knowledge (SKILL.md files)
- Commands: Custom slash commands
- Providers: Custom LLM provider integrations
Plugin System
Plugin Package (@opencode-ai/plugin)
export interface Plugin {
name: string
version: string
description?: string
tools?: Tool[]
commands?: Command[]
providers?: Provider[]
skills?: Skill[]
}
export interface Tool {
name: string
description: string
parameters: Schema
execute: (args: any, context: ToolContext) => Promise<ToolResult>
}
export interface ToolContext {
session: Session
provider: Provider
config: Config
fs: FileSystem
}
export interface ToolResult {
content: string | ContentBlock[]
isError?: boolean
}
Creating a Plugin
import { Plugin, Tool } from '@opencode-ai/plugin'
const myPlugin: Plugin = {
name: 'my-custom-plugin',
version: '1.0.0',
description: 'Custom tools for OpenCode',
tools: [
{
name: 'my_tool',
description: 'Performs a custom action',
parameters: {
type: 'object',
properties: {
input: {
type: 'string',
description: 'Input to process'
}
},
required: ['input']
},
execute: async (args, context) => {
const result = processInput(args.input)
return {
content: result
}
}
}
]
}
export default myPlugin
Tool System
Tool Definition
interface Tool {
name: string
description: string
parameters: JSONSchema
execute: (args: Record<string, any>, context: ToolContext) => Promise<ToolResult>
}
const toolSchema = {
type: 'object',
properties: {
filePath: {
type: 'string',
description: 'Path to the file'
},
content: {
type: 'string',
description: 'Content to write'
},
encoding: {
type: 'string',
enum: ['utf-8', 'binary'],
default: 'utf-8'
}
},
required: ['filePath', 'content']
}
Tool Implementation
import { Tool, ToolContext, ToolResult } from '@opencode-ai/plugin'
const searchFilesTool: Tool = {
name: 'search_files',
description: 'Search for files matching a pattern',
parameters: {
type: 'object',
properties: {
pattern: {
type: 'string',
description: 'Glob pattern to match files'
},
directory: {
type: 'string',
description: 'Directory to search in',
default: '.'
},
excludePatterns: {
type: 'array',
items: { type: 'string' },
description: 'Patterns to exclude'
}
},
required: ['pattern']
},
execute: async (args, context: ToolContext) => {
const { pattern, directory = '.', excludePatterns = [] } = args
try {
const files = await context.fs.glob(pattern, {
cwd: directory,
ignore: excludePatterns
})
return {
content: JSON.stringify(files, null, 2)
}
} catch (error) {
return {
content: `Error: ${error.message}`,
isError: true
}
}
}
}
Tool Result Types
const stringResult: ToolResult = {
content: 'Operation completed successfully'
}
const errorResult: ToolResult = {
content: 'Failed to process file',
isError: true
}
const structuredResult: ToolResult = {
content: [
{ type: 'text', text: 'File contents:' },
{ type: 'code', language: 'typescript', code: 'const x = 1' }
]
}
const imageResult: ToolResult = {
content: [
{ type: 'image', url: 'file:///path/to/image.png' }
]
}
Registering Tools
export default {
name: 'my-tools',
version: '1.0.0',
tools: [
searchFilesTool,
readFileTool,
writeFileTool
]
}
import { registerTool } from '@opencode-ai/plugin'
registerTool({
name: 'dynamic_tool',
description: 'Dynamically registered tool',
parameters: { type: 'object', properties: {} },
execute: async () => ({ content: 'Dynamic!' })
})
MCP Integration
What is MCP?
Model Context Protocol (MCP) is a standard protocol for connecting AI models to external tools and data sources.
MCP Server Configuration
{
"mcpServers": {
"filesystem": {
"command": "mcp-filesystem",
"args": ["--root", "/home/user/projects"],
"env": {}
},
"github": {
"command": "mcp-github",
"args": [],
"env": {
"GITHUB_TOKEN": "${GITHUB_TOKEN}"
}
},
"database": {
"command": "mcp-postgres",
"args": ["postgresql://localhost/mydb"]
}
}
}
Creating MCP Server
import { Server } from '@modelcontextprotocol/sdk/server'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio'
const server = new Server({
name: 'my-mcp-server',
version: '1.0.0'
}, {
capabilities: {
tools: {}
}
})
server.setRequestHandler('tools/list', async () => {
return {
tools: [
{
name: 'my_tool',
description: 'My custom tool',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string' }
},
required: ['query']
}
}
]
}
})
server.setRequestHandler('tools/call', async (request) => {
const { name, arguments: args } = request.params
if (name === 'my_tool') {
const result = await processQuery(args.query)
return {
content: [{ type: 'text', text: result }]
}
}
throw new Error(`Unknown tool: ${name}`)
})
const transport = new StdioServerTransport()
await server.connect(transport)
MCP Tool Usage
import { useMcpTools } from '@opencode-ai/plugin'
const githubTools = await useMcpTools('github')
const tools = await githubTools.list()
const result = await githubTools.call('create_issue', {
owner: 'myorg',
repo: 'myrepo',
title: 'New Issue',
body: 'Issue description'
})
Skills & Commands
Skill Files (SKILL.md)
---
name: my-skill
description: "Description of what this skill does"
metadata:
author: "Author Name"
version: "1.0.0"
tags:
- tag1
- tag2
---
# My Skill
Instructions for the AI when this skill is loaded.
## Instructions
When asked to perform X:
1. Step 1
2. Step 2
3. Step 3
## Examples
Example usage with code samples.
Custom Commands
interface Command {
name: string
description: string
handler: (args: string[], context: CommandContext) => Promise<void>
}
const myCommand: Command = {
name: '/mycommand',
description: 'Execute custom action',
handler: async (args, context) => {
await context.session.sendMessage('Command executed!')
}
}
export default {
name: 'my-commands',
version: '1.0.0',
commands: [myCommand]
}
Built-in Commands
/help - Show available commands
/clear - Clear conversation
/reset - Reset session
/model - Switch model
/skill - Load a skill
/config - View/edit configuration
SDK Usage
@opencode-ai/sdk
import { OpenCodeClient } from '@opencode-ai/sdk'
const client = new OpenCodeClient({
baseUrl: 'http://localhost:3000',
apiKey: 'your-api-key'
})
const response = await client.chat({
message: 'Explain this code',
files: ['./src/index.ts']
})
for await (const chunk of client.chatStream({
message: 'Write a function'
})) {
process.stdout.write(chunk.content)
}
const result = await client.executeTool({
name: 'read_file',
args: { path: './src/main.ts' }
})
const session = await client.createSession({
model: 'claude-3-opus',
systemPrompt: 'You are a helpful coding assistant'
})
const reply = await session.sendMessage('Add error handling')
SDK Client Options
interface ClientOptions {
baseUrl?: string
apiKey?: string
model?: string
timeout?: number
retries?: number
headers?: Record<string, string>
}
HTTP Server & REST API
Server Configuration
import { createServer } from '@opencode-ai/server'
const server = createServer({
port: 3000,
host: '0.0.0.0',
auth: {
type: 'api-key',
keys: ['secret-key-1', 'secret-key-2']
},
cors: {
origins: ['http://localhost:5173'],
methods: ['GET', 'POST', 'PUT', 'DELETE']
},
rateLimit: {
windowMs: 60000,
max: 100
}
})
await server.start()
REST API Endpoints
POST /api/chat
{
"message": "string",
"session_id": "string?",
"files": ["string"]?,
"model": "string?"
}
{
"content": "string",
"session_id": "string",
"tool_calls": [...]
}
POST /api/chat/stream
POST /api/tools/execute
{
"name": "string",
"args": { ... }
}
GET /api/sessions
POST /api/sessions
GET /api/sessions/:id
DELETE /api/sessions/:id
GET /api/models
GET /api/config
PUT /api/config
API Client Example
const response = await fetch('http://localhost:3000/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer your-api-key'
},
body: JSON.stringify({
message: 'Hello, OpenCode!',
model: 'claude-3-opus'
})
})
const data = await response.json()
console.log(data.content)
Event Bus
Publishing Events
import { Bus } from '@opencode-ai/plugin'
Bus.publish('session:created', {
sessionId: 'abc123',
model: 'claude-3-opus'
})
Bus.publish('tool:executed', {
toolName: 'read_file',
args: { path: './src/main.ts' },
result: '...'
})
Subscribing to Events
const unsubscribe = Bus.subscribe('session:created', (event) => {
console.log('New session:', event.sessionId)
})
Bus.subscribe('*', (event) => {
console.log('Event:', event.type, event.data)
})
unsubscribe()
Built-in Events
'session:created'
'session:deleted'
'session:message'
'tool:started'
'tool:completed'
'tool:failed'
'file:read'
'file:written'
'file:deleted'
'model:switched'
'model:response'
Configuration
Configuration File
{
"model": "claude-3-opus",
"temperature": 0.7,
"maxTokens": 4096,
"providers": {
"anthropic": {
"apiKey": "${ANTHROPIC_API_KEY}"
},
"openai": {
"apiKey": "${OPENAI_API_KEY}"
}
},
"mcpServers": {
"filesystem": {
"command": "mcp-filesystem",
"args": ["--root", "."]
}
},
"tools": {
"enabled": ["read", "write", "search", "execute"],
"disabled": ["dangerous_tool"]
},
"plugins": [
"./plugins/my-plugin",
"@opencode-ai/plugin-example"
]
}
Environment Variables
ANTHROPIC_API_KEY=sk-ant-xxx
OPENAI_API_KEY=sk-xxx
OPENCODE_CONFIG_PATH=/path/to/opencode.json
OPENCODE_DATA_DIR=/path/to/data
OPENCODE_PORT=3000
OPENCODE_HOST=0.0.0.0
OPENCODE_LOG_LEVEL=info
Testing Plugins
Unit Tests
import { describe, it, expect, vi } from 'vitest'
import { myTool } from '../src/tools'
describe('myTool', () => {
it('should process input correctly', async () => {
const mockContext = {
session: {},
fs: {
readFile: vi.fn().mockResolvedValue('content')
}
}
const result = await myTool.execute(
{ input: 'test' },
mockContext as any
)
expect(result.content).toContain('processed')
expect(result.isError).toBeFalsy()
})
it('should handle errors', async () => {
const mockContext = {
fs: {
readFile: vi.fn().mockRejectedValue(new Error('File not found'))
}
}
const result = await myTool.execute(
{ input: 'nonexistent' },
mockContext as any
)
expect(result.isError).toBe(true)
})
})
Integration Tests
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { OpenCodeClient } from '@opencode-ai/sdk'
import { createServer } from '@opencode-ai/server'
describe('Plugin Integration', () => {
let server
let client
beforeAll(async () => {
server = await createServer({ port: 3001 })
await server.start()
client = new OpenCodeClient({
baseUrl: 'http://localhost:3001'
})
})
afterAll(async () => {
await server.stop()
})
it('should execute custom tool', async () => {
const result = await client.executeTool({
name: 'my_custom_tool',
args: { query: 'test' }
})
expect(result).toBeDefined()
})
})
Publishing & Distribution
Package Structure
my-opencode-plugin/
├── package.json
├── tsconfig.json
├── src/
│ └── index.ts
├── dist/
│ └── index.js
└── README.md
package.json
{
"name": "opencode-plugin-mytool",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"peerDependencies": {
"@opencode-ai/plugin": "^1.0.0"
},
"keywords": [
"opencode",
"plugin",
"ai",
"coding-assistant"
]
}
Publishing to npm
npm run build
npm test
npm publish --access public
Local Development
npm link
{
"plugins": ["opencode-plugin-mytool"]
}
Best Practices
1. Tool Design
const goodTool: Tool = {
name: 'search_code',
description: 'Search for code patterns across the project using regex or literal matching',
parameters: {
type: 'object',
properties: {
pattern: {
type: 'string',
description: 'Search pattern (regex supported)'
},
filePattern: {
type: 'string',
description: 'Glob pattern to filter files (e.g., "*.ts")',
default: '**/*'
}
},
required: ['pattern']
},
execute: async (args, context) => {
}
}
const badTool: Tool = {
name: 'search',
description: 'Search',
parameters: { type: 'object' },
execute: async () => ({ content: '...' })
}
2. Error Handling
const robustTool: Tool = {
name: 'safe_tool',
description: 'Tool with proper error handling',
parameters: { ... },
execute: async (args, context) => {
try {
if (!args.required) {
return {
content: 'Error: Required parameter missing',
isError: true
}
}
const result = await Promise.race([
doWork(args),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 30000)
)
])
return { content: result }
} catch (error) {
return {
content: `Error: ${error.message}`,
isError: true
}
}
}
}
3. Security
const safeReadTool: Tool = {
name: 'safe_read',
execute: async (args, context) => {
const path = resolvePath(args.path)
if (!isWithinProject(path, context.config.projectRoot)) {
return {
content: 'Error: Access denied - path outside project',
isError: true
}
}
const content = await context.fs.readFile(path)
return { content }
}
}
References
Advanced Plugin Development (Practical Guide)
This section covers undocumented behaviors discovered through real plugin development.
Plugin Export Pattern (NOT in official docs)
The official docs show an object export, but the real pattern is an async function that returns hooks:
export const AutoResumePlugin = async (ctx: any, options: PluginOptions) => {
return {
event: async ({ event }: { event: Record<string, unknown> }) => {
},
config: async () => {
}
}
}
export default AutoResumePlugin
Context API (ctx) - Undocumented
ctx.client.app.log({
body: {
service: string,
level: string,
message: string
}
})
ctx.client.session.list()
ctx.client.session.messages({ id: string })
ctx.client.session.prompt({
path: { id: string },
body: { parts: Array<{ type: string, text: string }> },
agent?: string
})
ctx.client.session.abort({ id: string })
ctx.ui.toast({
title: string,
message: string,
variant: "info" | "success" | "warning" | "error",
duration?: number
})
Agent Selection - CRITICAL
Where is the agent field?
UserMessage has agent: string field (selected by user)
AssistantMessage does NOT have an agent field
async function getSessionAgent(ctx: any, sid: string): Promise<string | undefined> {
const messages = await ctx.client.session.messages({ id: sid })
const reversed = [...messages].reverse()
const lastUserWithAgent = reversed.find(
m => m.role === "user" && "agent" in m
)
const agent = (lastUserWithAgent as any)?.agent
return typeof agent === "string" && agent.length > 0 ? agent : undefined
}
Event System (Real SSE Events)
The "Event Bus" section uses fake Bus.publish() - the real system uses SSE events:
return {
event: async ({ event }) => {
const type = event.type as string
const props = event.properties as Record<string, unknown> | undefined
const sessionID = event.sessionID as string | undefined
switch (type) {
case "session.status": {
const status = props?.status as any
if (status?.type === "idle") {
} else if (status?.type === "busy") {
} else if (status?.type === "retry") {
}
break
}
case "session.error": {
const error = props?.error as any
break
}
case "message.updated": {
break
}
case "message.part.updated": {
const delta = props?.delta as string | undefined
break
}
}
}
}
SessionStatus Types
type SessionStatus =
| { type: "idle" }
| { type: "busy" }
| { type: "retry", attempt: number, message: string, next: number }
Message SDK Types (from @opencode-ai/sdk)
interface UserMessage {
id: string
sessionID: string
role: "user"
time: { created: number }
agent: string
model: { providerID: string, modelID: string }
tools?: { [key: string]: boolean }
system?: string
summary?: { title?: string, body?: string, diffs: any[] }
}
interface AssistantMessage {
id: string
sessionID: string
role: "assistant"
time: { created: number, completed?: number }
error?: any
parentID: string
modelID: string
providerID: string
mode: string
path: { cwd: string, root: string }
cost: number
tokens: { input: number, output: number, reasoning: number, cache: { read: number, write: number } }
finish?: string
}
Part Types
type Part =
| { type: "text", text: string, synthetic?: boolean }
| { type: "tool", callID: string, tool: string, state: ToolState }
| { type: "reasoning", text: string }
| { type: "file", mime: string, url: string }
| { type: "agent", name: string }
| { type: "step-start", snapshot?: any }
| { type: "step-finish", reason: string, cost: number, tokens: any }
| { type: "subtask", prompt: string, description: string, agent: string }
| { type: "retry", attempt: number, error: string }
| { type: "compaction", auto: boolean }
type ToolState =
| { status: "pending", input: any, raw: any }
| { status: "running", input: any, title?: string, time: { start: number } }
| { status: "completed", input: any, output: any, title: string, metadata: any, time: { start: number, end: number } }
| { status: "error", input: any, error: any, time: { start: number, end: number } }
Configuration (inline options)
The official docs show plugin as object, but options are inline in opencode.jsonc:
{
"plugin": [
"file:///absolute/path/to/dist/index.js",
{ "option": "value", "enabled": true }
]
}
Options are passed as second parameter to the async function:
export const MyPlugin = async (ctx, options: { enabled?: boolean } = {}) => {
if (options.enabled === false) {
return { event: async () => {}, config: async () => {} }
}
}
Type Validation - CRITICAL
Always validate inputs before API calls to prevent "Expected 'id' to be a string" errors:
await ctx.client.session.prompt({ path: { id: sid }, ... })
const validSid = (typeof sid === "string" && sid.length > 0) ? sid : undefined
const validAgent = (typeof agent === "string" && agent.length > 0) ? agent : undefined
if (validSid) {
await ctx.client.session.prompt({
path: { id: validSid },
body: { parts: [...] },
agent: validAgent
})
}
Build Configuration
{
"scripts": {
"build": "bun build src/index.ts --outdir dist --target bun",
"dev": "bun build src/index.ts --outdir dist --target bun --watch",
"prepublishOnly": "bun run build"
}
}
Output goes to dist/index.js - use absolute path in opencode.jsonc:
{
"plugin": [
"file:///home/user/project/opencode-auto-resume/dist/index.js",
{ "enabled": true }
]
}
Testing Pattern
import { mock } from "bun:test"
import type { Session, UserMessage, Message } from "@opencode-ai/sdk"
const createMockContext = () => {
const promptCalls: Array<{ sid: string; agent?: string }> = []
const ctx = {
client: {
app: { log: mock(async () => {}) },
session: {
list: mock(async () => ({ data: sessions })),
messages: mock(async (path) => messages.get(path.id) ?? []),
prompt: mock(async (cfg) => promptCalls.push({
sid: cfg.path.id,
agent: cfg.agent
})),
abort: mock(async () => ({}))
}
},
ui: { toast: mock(async () => {}) }
} as any
return { ctx, promptCalls }
}
test("plugin preserves agent on resume", async () => {
const { ctx, promptCalls } = createMockContext()
const hooks = await AutoResumePlugin(ctx, {})
await hooks.event({
event: { type: "session.status", sessionID: "s1",
properties: { status: { type: "idle" } }
}
})
expect(promptCalls[0]?.agent).toBe("prometheus")
})
Common Bugs Discovered
- Wrong message type for agent: Used
AssistantMessage instead of UserMessage
- Missing type validation: "Expected 'id' to be a string" when session is in error state
- Wrong hook format: Returned object from
ctx.on() instead of { event, config } hooks
- Absolute path required: Plugin config needs
file:/// + absolute path, not relative
Real World Patterns
Auto-resume on idle:
async function handleIdleSession(ctx: any, sid: string) {
const messages = await ctx.client.session.messages({ id: sid })
const lastMsg = messages[messages.length - 1]
if (lastMsg?.role === "assistant" && shouldAutoContinue(lastMsg)) {
const agent = await getSessionAgent(ctx, sid)
await ctx.client.session.prompt({
path: { id: sid },
body: { parts: [{ type: "text", text: "continue" }] },
agent: agent
})
}
}
function shouldAutoContinue(msg: Message): boolean {
const text = (msg as any).parts?.[0]?.text ?? ""
return text.endsWith("Ready to continue with Task") ||
text.includes("Should I continue?") ||
text.includes("What would you like to do next?")
}
Hallucination loop detection:
interface SessionWatch {
status: string
continueCount: number
lastContinueAt: number
}
const sessions = new Map<string, SessionWatch>()
function recordContinue(sid: string) {
const w = sessions.get(sid) ?? { status: "unknown", continueCount: 0, lastContinueAt: 0 }
const now = Date.now()
if (now - w.lastContinueAt > 600000) {
w.continueCount = 0
}
w.continueCount++
w.lastContinueAt = now
sessions.set(sid, w)
}
function isLoopDetected(sid: string): boolean {
const w = sessions.get(sid)
return w !== undefined && w.continueCount >= 3
}
References
- SDK types:
node_modules/@opencode-ai/sdk/dist/index.d.ts
- OpenCode core:
https://github.com/anomalyco/opencode
- Plugin source:
packages/opencode/src/plugin/