| name | opencode |
| description | Develop plugins, tools, and extensions for OpenCode AI coding agent with MCP, LSP integration, custom tools, and SDK usage |
| metadata | {"author":"mte90","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 Types - When to Use What
| Extension Type | Best For | Example Use Case |
|---|
| Tools | AI-triggered actions | Search files, read/write data, execute code |
| MCP Servers | External service integrations | Connect to databases, GitHub, filesystem |
| Skills | Knowledge/prompt templates | Coding patterns, best practices, frameworks |
| Commands | Interactive shortcuts | /hello, /search, custom slash commands |
| Providers | Custom LLM backends | Alternative API providers, custom models |
Prerequisites
Before developing OpenCode plugins, ensure you have:
Required Software:
- Node.js 18+ OR Bun 1.0+
- TypeScript 5.0+ (recommended)
Development Environment:
- Basic TypeScript knowledge (interfaces, async/await, type inference)
- Familiarity with package.json and npm/yarn/bun
- Basic understanding of HTTP APIs
Plugin Package (@opencode-ai/plugin)
Hello World Plugin Example
This is the simplest possible plugin to get started:
import { Plugin, Tool } from '@opencode-ai/plugin'
const helloWorldTool: Tool = {
name: 'greet_user',
description: 'Greet a user by name',
parameters: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Name to greet'
}
},
required: ['name']
},
execute: async (args, context) => {
return {
content: `Hello, ${args.name}! Welcome to OpenCode.`
}
}
}
const plugin: Plugin = {
name: 'hello-world',
version: '1.0.0',
description: 'A simple greeting plugin',
tools: [helloWorldTool]
}
export default 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
}
// my-plugin/index.ts
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']
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
}
}
}
}
Troubleshooting
Common Issues and Solutions:
-
Plugin Not Loading
- Check file path is absolute with
file:/// prefix in config
- Verify plugin exports default correctly:
export default plugin
- Ensure TypeScript compilation succeeded without errors
- Check OpenCode logs for import errors
-
Build Errors
- Ensure you're using Bun/Node compatible TypeScript
- Check that
dist directory exists and contains output
- Verify all dependencies are installed:
npm install
- Common errors: missing type definitions, wrong module system
-
Configuration Issues
- Plugin paths must be absolute:
file:///home/user/...
- Required environment variables must be set
- MCP server connections require valid credentials
- Test config with minimal settings first
-
Permission Errors
- File system access requires appropriate permissions
- Check if directory is writable
- Some operations may require elevated permissions
- Use sandbox directories for testing
const result = processInput(args.input)
return {
content: result
}
}
}
]
}
export default myPlugin
## Tool System
### Tool Definition
```typescript
// Tool interface
interface Tool {
// Unique identifier
name: string
// Description for the AI model
description: string
// JSON Schema for parameters
parameters: JSONSchema
// Handler function
execute: (args: Record<string, any>, context: ToolContext) => Promise<ToolResult>
}
// Parameter schema example
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' }
]
}
#### Official Pattern (Recommended for Beginners)
This is the standard plugin structure. Always start here:
```typescript
// my-plugin/index.ts
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) => {
// Tool implementation
const result = processInput(args.input)
return {
content: result
}
}
}
]
}
export default myPlugin
This pattern exports a plain object with name, version, description, and optionally tools, commands, providers, or skills.
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'
## Subagent Lifecycle Management
Managing subagents spawned from plugins requires careful attention to timeouts, abort handling, and cleanup to prevent runaway processes.
### Spawning Subagents
Use `ctx.client.session.create` with subagent configuration:
```typescript
const subagent = await ctx.client.session.create({
parentID: ctx.session.id,
agent: "fixer",
message: "Fix the failing tests in auth.ts",
})
Timer-Based Abort
The critical pattern for preventing runaway subagents:
const TIMEOUT_MS = 60000
const subagent = await ctx.client.session.create({
parentID: ctx.session.id,
agent: "fixer",
message: "Fix failing tests",
})
const timer = setTimeout(async () => {
try {
await ctx.client.session.abort({ id: subagent.id })
ctx.logger.warn(`Subagent ${subagent.id} aborted after timeout`)
} catch (error) {
ctx.logger.error(`Failed to abort subagent: ${error}`)
}
}, TIMEOUT_MS)
ctx.client.session.subscribe(subagent.id, (event) => {
if (event.type === "session.end" || event.type === "session.error") {
clearTimeout(timer)
}
})
Abort API
Use ctx.client.session.abort({ id }) to terminate a subagent:
try {
await ctx.client.session.abort({ id: subagent.id })
ctx.logger.info(`Subagent ${subagent.id} aborted successfully`)
} catch (error) {
ctx.logger.warn(`Subagent ${subagent.id} already ended: ${error}`)
}
State Polling
Check subagent status:
const status = await ctx.client.session.get({ id: subagent.id })
if (status.status === "completed") {
const messages = await ctx.client.session.messages({ id: subagent.id })
} else if (status.status === "running") {
} else if (status.status === "aborted") {
}
Cleanup on Abort
When a subagent is aborted mid-execution, clean up resources:
async function spawnWithCleanup(ctx: any, config: SubagentConfig) {
const subagent = await ctx.client.session.create(config)
const tempFiles: string[] = []
const cleanup = async () => {
for (const file of tempFiles) {
await ctx.client.fs.remove({ path: file }).catch(() => {})
}
}
ctx.client.session.subscribe(subagent.id, async (event) => {
if (event.type === "session.end" || event.type === "session.error" || event.type === "session.aborted") {
await cleanup()
}
})
return { subagent, cleanup }
}
Common Pitfalls
| Issue | Cause | Solution |
|---|
| Subagent hangs forever | No timeout set | Always set a timer with abort |
| Timer fires after completion | Not cleared on success | Clear timer in completion handler |
| Abort throws | Session already ended | Wrap abort in try/catch |
| Parent waits forever | No abort on parent exit | Register cleanup handler on parent end |
Event Handler Patterns
Event handlers are critical for plugins that need to react to AI behavior, especially for auto-resume and tool-call interception.
Handler Registration
Use handleEvent pattern for subscribing to specific event types:
export const handleEvent: Plugin.EventHandler = async (ctx, event) => {
if (event.type !== "message.part") return
const part = event.properties.part
}
checkForToolCallAsText
Detecting tool calls embedded in assistant text output. This pattern is used by auto-resume plugins to intercept when the AI tries to call a tool by writing it as text instead of using the tool API:
function checkForToolCallAsText(text: string): ToolCall | null {
const toolCallPattern = /```(?:json)?\s*(\{[^}]*"tool"[^}]*\})\s*```/
const match = text.match(toolCallPattern)
if (!match) return null
try {
const parsed = JSON.parse(match[1])
if (parsed.tool && parsed.parameters) {
return {
name: parsed.tool,
parameters: parsed.parameters,
}
}
} catch (e) {
return null
}
return null
}
export const handleEvent: Plugin.EventHandler = async (ctx, event) => {
if (event.type !== "message.part") return
const part = event.properties.part
if (part.type !== "text") return
const toolCall = checkForToolCallAsText(part.text)
if (toolCall) {
ctx.logger.info(`Detected tool call in text: ${toolCall.name}`)
await ctx.client.tool.execute({
name: toolCall.name,
parameters: toolCall.parameters,
})
}
}
Event Dispatch Flow
Events flow from source → bus → handlers:
- Event is published to the bus
- All registered handlers are called in priority order
- Handlers should not throw (breaks dispatch chain)
- Use
event.properties.source to filter own events
Error Handling in Handlers
Wrap handler body in try/catch - never throw:
export const handleEvent: Plugin.EventHandler = async (ctx, event) => {
try {
if (event.type !== "message.part") return
const part = event.properties.part
} catch (error) {
ctx.logger.error(`Handler failed: ${error}`)
}
}
Testing Event Handlers
Test handlers with mock events:
import { describe, it, expect, vi } from 'vitest'
describe('checkForToolCallAsText', () => {
it('detects JSON tool call in code block', () => {
const text = 'Here is the tool call:\n```json\n{"tool":"read_file","parameters":{"path":"test.ts"}}\n```'
const result = checkForToolCallAsText(text)
expect(result).not.toBeNull()
expect(result!.name).toBe('read_file')
})
it('returns null for plain text', () => {
expect(checkForToolCallAsText('just some text')).toBeNull()
})
})
describe('handleEvent', () => {
it('processes message.part events', async () => {
const mockCtx = { client: { tool: { execute: vi.fn() } }, logger: { info: vi.fn() } }
const event = {
type: 'message.part',
properties: { part: { type: 'text', text: '```json\n{"tool":"read","parameters":{}}\n```' } }
}
await handleEvent(mockCtx as any, event as any)
expect(mockCtx.client.tool.execute).toHaveBeenCalled()
})
})
Common Pitfalls
| Issue | Cause | Solution |
|---|
| Handler not called | Wrong event type check | Verify event.type matches exactly |
| Event dispatch breaks | Handler throws | Wrap handler body in try/catch |
| Duplicate tool execution | Handler + tool API both fire | Use checkForToolCallAsText to intercept only text-based calls |
| Handler runs on own events | No source filter | Check event.properties.source !== "self" |
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.
Pattern Comparison: Official vs Advanced
Official Pattern (above) - for stability, documentation, and beginners
Advanced Pattern (below) - for real-world production use, auto-resume, event handling
Choose based on your needs:
- Official pattern: Stable, documented, easier to maintain, better for learning
- Advanced pattern: More powerful, real-world patterns, auto-resume, event handling
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
- Subagent hangs forever: No timeout set on spawned subagents
- Event handler crashes: Handlers throwing errors instead of catching them
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
}
- SDK types:
node_modules/@opencode-ai/sdk/dist/index.d.ts
- OpenCode core:
https://github.com/anomalyco/opencode
- Plugin source:
packages/opencode/src/plugin/