632 added, 1211 removed. Audit A to B.
---
name: opencode
- description: "Develop plugins, tools, and extensions for OpenCode AI coding agent with MCP, LSP integration, custom tools, and SDK usage"
+ description: "Develop plugins, tools, and extensions for OpenCode AI coding agent. Covers the real Plugin/Hooks API (v1.18+), tool factory, event system, configuration, build, testing, and the crash-prevention rules learned the hard way."
metadata:
author: mte90
- version: "1.0.0"
+ version: "2.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
+ Complete, field-tested guide for developing plugins for OpenCode (v1.18+) AI coding agent.
- OpenCode is an AI-powered coding agent that supports extensibility through plugins, custom tools, MCP (Model Context Protocol) servers, and skills.
+ > **Every rule, signature, and pattern below was verified against a real plugin
+ > (`opencode-auto-resume`) that went through 5 versions and 3 distinct crash
+ > classes in production.** The "Hard-won rules" sections are non-negotiable —
+ > violating them silently breaks the host.
- ### 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 Types - When to Use What
+ | Extension Type | Best For | Example |
+ |----------------|----------|---------|
+ | **Plugin (Hooks)** | Event-driven automation, custom tools, recovery logic | Auto-resume stalled sessions, enforce agent selection |
+ | **Tools** | AI-triggered actions inside a plugin | `task_complete`, `commit`, custom search |
+ | **MCP Servers** | External service integrations | Databases, GitHub, filesystem, remote APIs |
+ | **Skills** | Knowledge/prompt templates (this file is one) | Framework patterns, project conventions |
+ | **Commands** | Interactive slash shortcuts | `/hello`, `/deploy` |
+ | **Providers** | Custom LLM backends | Alternative API gateways, self-hosted models |
- | 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
+ ## The Plugin Contract (v1.18+) — Read This First
- Before developing OpenCode plugins, ensure you have:
+ A plugin is an **async function** `(input, options) => Promise<Hooks>`. It is NOT an object with `name`/`version`/`tools` fields — that shape is outdated and the host ignores it.
- **Required Software:**
- - Node.js 18+ OR Bun 1.0+
- - TypeScript 5.0+ (recommended)
+ ### Type definition (from `@opencode-ai/plugin/dist/index.d.ts`)
- **Development Environment:**
- - Basic TypeScript knowledge (interfaces, async/await, type inference)
- - Familiarity with package.json and npm/yarn/bun
- - Basic understanding of HTTP APIs
+ ```typescript
+ export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Hooks>
- ### Plugin Package (@opencode-ai/plugin)
- ### Hello World Plugin Example
+ export interface Hooks {
+ dispose?: () => Promise<void>
+ event?: (input: { event: Event }) => Promise<void>
+ config?: (input: Config) => Promise<void>
+ tool?: { [key: string]: ToolDefinition }
+ auth?: AuthHook
+ provider?: ProviderHook
+ "chat.message"?: (input: ChatMessageInput) => Promise<void>
+ "tool.execute.before"?: (input: ToolExecInput) => Promise<void>
+ "tool.execute.after"?: (input: ToolExecInput) => Promise<void>
+ "command.execute.before"?: (input: CommandExecInput) => Promise<void>
+ "command.execute.after"?: (input: CommandExecInput) => Promise<void>
+ }
+ ```
- This is the simplest possible plugin to get started:
+ ### Correct minimal plugin
```typescript
- // hello-world-plugin/index.ts
- import { Plugin, Tool } from '@opencode-ai/plugin'
+ import type { Plugin } from "@opencode-ai/plugin"
+ import { 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'
- }
+ export const MyPlugin: Plugin = async (ctx, options) => {
+ return {
+ event: async ({ event }) => {
+ // handle events
},
- required: ['name']
- },
- execute: async (args, context) => {
- return {
- content: `Hello, ${args.name}! Welcome to OpenCode.`
- }
+ config: async () => {
+ // one-time init
+ },
+ tool: {
+ my_tool: tool({
+ description: "Does something useful",
+ args: {},
+ execute: async (_args, ctx) => "ok",
+ }),
+ },
}
}
- const plugin: Plugin = {
- name: 'hello-world',
- version: '1.0.0',
- description: 'A simple greeting plugin',
- tools: [helloWorldTool]
+ export default MyPlugin
+ ```
+
+ > ⚠️ **`config` hook signature**: `(input: Config) => Promise<void>`. It receives a Config object — you may ignore it but the parameter exists.
+
+ ---
+
+ ## 🚨 Hard-Won Rules (violate these and the host crashes)
+
+ These rules were discovered through real production crashes. Each one has a regression test in the reference plugin.
+
+ ### Rule 1 — Only export Plugin-shaped values from the entry module
+
+ OpenCode's plugin loader iterates **every** module export via `Object.values(module)` and treats each as a Plugin entrypoint:
+
+ ```js
+ // Inside opencode's plugin loader (deobfuscated):
+ for (let N of Object.values(pluginModule)) {
+ if (typeof N !== "function") throw TypeError("Plugin export is not a function")
+ const hooks = await N(ctx, options) // called as a Plugin!
+ pluginArray.push(hooks)
}
+ // later:
+ for (let N of pluginArray) {
+ await N.config?.(config) // crashes if N is null
+ }
+ ```
- export default plugin
+ **If any export returns `null` → the host crashes with `null is not an object (evaluating 'N.config')`, surfaced in the TUI as `Unexpected server error. Check server logs for details.`**
+
+ #### ✅ Correct
+
+ ```typescript
+ // src/index.ts — ONLY Plugin exports
+ export const MyPlugin: Plugin = async (ctx, options) => { ... }
+ export default MyPlugin
```
+ #### 🚫 Wrong — crashes the host
+
```typescript
- // packages/plugin/src/index.ts
- export interface Plugin {
- name: string
- version: string
- description?: string
- tools?: Tool[]
- commands?: Command[]
- providers?: Provider[]
- skills?: Skill[]
- }
+ // src/index.ts
+ export function getLastAssistantError(messages) { ... return null } // called as Plugin, returns null, host crashes
+ export function backoffMs(attempt) { return 42 } // called as Plugin, returns number, host crashes
+ export const MyPlugin: Plugin = async (ctx, options) => { ... }
+ export default MyPlugin
+ ```
- export interface Tool {
- name: string
- description: string
- parameters: Schema
- execute: (args: any, context: ToolContext) => Promise<ToolResult>
- }
+ #### Solution — split utilities into a separate file
- export interface ToolContext {
- session: Session
- provider: Provider
- config: Config
- fs: FileSystem
- }
+ ```typescript
+ // src/index.ts — bundle entry, only Plugin exports
+ export const MyPlugin: Plugin = async (ctx, options) => { ... }
+ export default MyPlugin
- export interface ToolResult {
- content: string | ContentBlock[]
- isError?: boolean
- }
+ // src/test-utils.ts — NOT the bundle entry; tests import from here
+ export function getLastAssistantError(messages) { ... }
+ export function backoffMs(attempt, base, max) { ... }
```
- // my-plugin/index.ts
- import { Plugin, Tool } from '@opencode-ai/plugin'
+ Tests import utilities from `./test-utils`; the bundled `dist/index.js` only exposes the Plugin. Verify with:
- 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
+ ```bash
+ bun -e 'const m = await import("./dist/index.js"); console.log(Object.keys(m))'
+ # MUST print only: [ "MyPlugin", "default" ]
+ ```
+ ### Rule 2 — Never let an async handler throw unhandled
+
+ The `event` hook is called **fire-and-forget** by the host. If `handleEvent()` rejects, it becomes an unhandled promise rejection → bun process exits → OpenCode disappears from the TUI.
+
+ #### ✅ Correct
+
```typescript
- const robustTool: Tool = {
- name: 'safe_tool',
- description: 'Tool with proper error handling',
- parameters: { ... },
- execute: async (args, context) => {
- try {
- // Validate args
- if (!args.required) {
- return {
- content: 'Error: Required parameter missing',
- isError: true
- }
- }
-
- // Execute with timeout
- 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
- }
- }
+ return {
+ event: async ({ event }) => {
+ handleEvent(event).catch((e) => {
+ console.error("[my-plugin] handleEvent error:", e)
+ })
+ },
+ }
+ ```
+
+ Same rule applies to `setInterval(async () => { ... })` bodies — wrap in a `safe()` boundary:
+
+ ```typescript
+ async function safe<T>(fn: () => Promise<T>, label: string): Promise<T | undefined> {
+ try { return await fn() }
+ catch (e) {
+ const msg = e instanceof Error ? e.message : String(e)
+ console.error(`[my-plugin] ${label}: ${msg}`)
+ return undefined
}
}
+
+ setInterval(() => {
+ safe(doPeriodicWork, "periodic timer").catch(() => {})
+ }, 5000)
```
- #### Troubleshooting
+ ### Rule 3 — Validate event payloads defensively
- **Common Issues and Solutions:**
+ OpenCode event payloads are **not guaranteed** to match the documented shape. Real-world crashes found:
- 1. **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
+ - `todo.updated` events arrive with `properties.todos` as `{}` (object) or `undefined`, not an array
+ - `session.status` `properties.status` is always `{ type: "idle" | "busy" | "retry" }` — never a bare string
+ - Session IDs (`sessionID`) may be missing on some events
- 2. **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
+ #### ✅ Correct — `Array.isArray` before `.map()`/`.filter()`
- 3. **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
+ ```typescript
+ case "todo.updated": {
+ const rawTodos = (event.properties as any)?.todos
+ const todos: Array<Record<string, unknown>> = Array.isArray(rawTodos) ? rawTodos : []
+ w.todos = todos.map((t) => ({ ... })) // safe
+ break
+ }
+ ```
- 4. **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
- }
- }
- }
- ]
+ ### Rule 4 — `log()` must never rethrow
+
+ If the OpenCode log API itself throws (network/server error), your `log()` helper's `catch` block must not propagate. Otherwise an error inside an error handler escapes:
+
+ ```typescript
+ async function log(level: "info" | "warn" | "error", msg: string) {
+ try {
+ await ctx.client.app.log({ body: { service: "my-plugin", level, message: msg } })
+ } catch (e) {
+ console.error("[my-plugin] log() failed:", e instanceof Error ? e.message : e)
+ // do NOT rethrow
+ }
}
+ ```
- export default myPlugin
+ ### Rule 5 — Validate `sid` before every SDK call
+
+ Session IDs from events may be empty, `undefined`, or malformed. The SDK throws `Expected 'id' to be a string` if you pass garbage:
+
+ ```typescript
+ if (typeof sid !== "string" || !sid.startsWith("ses_")) return
+ await ctx.client.session.prompt({ path: { id: sid }, body: { ... } })
```
- ## Tool System
+ ---
- ### Tool Definition
+ ## The Context API (`ctx`)
+ The `ctx` parameter is undocumented but stable across v1.18.x:
+
```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>
- }
+ // ctx.client — API calls
+ await ctx.client.app.log({
+ body: { service: "my-plugin", level: "info", message: "..." }
+ })
- // 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'
- }
+ const { data: sessions } = await ctx.client.session.list()
+ const { data: statusMap } = await ctx.client.session.status() // Record<sid, {type: "busy"|"idle"|"retry"}>
+ const messages = await ctx.client.session.messages({ path: { id: sid } })
+ await ctx.client.session.abort({ path: { id: sid } })
+ await ctx.client.session.prompt({
+ path: { id: sid },
+ body: {
+ parts: [{ type: "text", text: "continue" }],
+ agent, // optional: preserve selected agent
+ model, // optional: { providerID, modelID }
},
- required: ['filePath', 'content']
- }
+ })
+
+ // ctx.ui — toast notifications (TUI)
+ await ctx.ui.toast({ title: "Done", message: "...", variant: "success" })
```
- ### Tool Implementation
+ ### `session.status()` return shape
```typescript
- import { Tool, ToolContext, ToolResult } from '@opencode-ai/plugin'
+ // status() returns { data: Record<sid, status> } where status is:
+ type SessionStatus = { type: "idle" | "busy" | "retry" }
+ // NEVER a bare string — always access via .type
+ ```
- 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'
+ ### `session.list()` does NOT include status
+
+ The `Session` type from `session.list()` has **no** `status` field. To check if a session is busy, call `session.status()` separately and build a `Record<sid, string>` map.
+
+ ---
+
+ ## Event System
+
+ Plugins receive Server-Sent Events via the `event` hook. Real event types (v1.18+):
+
+ ```typescript
+ return {
+ event: async ({ event }) => {
+ const type = event.type as string
+ const sid = event.sessionID as string | undefined
+ const props = event.properties as Record<string, unknown> | undefined
+
+ switch (type) {
+ case "session.created":
+ case "session.updated":
+ case "session.idle": // legacy alias of session.status=idle
+ case "session.interrupted": // user pressed ESC
+ break
+
+ case "session.status": {
+ const status = props?.status as { type: string } | undefined
+ // status.type in "idle" | "busy" | "retry" | "interrupted"
+ break
}
- },
- required: ['pattern']
- },
-
- execute: async (args, context: ToolContext) => {
- const { pattern, directory = '.', excludePatterns = [] } = args
-
- try {
- // Access file system through context
- const files = await context.fs.glob(pattern, {
- cwd: directory,
- ignore: excludePatterns
- })
-
- return {
- content: JSON.stringify(files, null, 2)
+
+ case "session.error": {
+ const error = props?.error as { name: string; data?: { message: string } } | undefined
+ // error.name === "MessageAbortedError" → user pressed ESC
+ break
}
- } catch (error) {
- return {
- content: `Error: ${error.message}`,
- isError: true
+
+ case "message.updated":
+ case "message.part.updated":
+ // props?.delta — streaming text delta
+ break
+
+ case "todo.updated": {
+ // ⚠️ props?.todos may be {} or undefined — validate with Array.isArray
+ break
}
+
+ case "tool.call":
+ case "tool.result":
+ case "command.executed":
+ break
}
- }
+ },
}
```
- ### Tool Result Types
+ ---
+ ## 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
- // String result
- const stringResult: ToolResult = {
- content: 'Operation completed successfully'
- }
+ const subagent = await ctx.client.session.create({
+ parentID: ctx.session.id,
+ agent: "fixer",
+ message: "Fix the failing tests in auth.ts",
+ })
+ ```
- // Error result
- const errorResult: ToolResult = {
- content: 'Failed to process file',
- isError: true
- }
+ ### Timer-Based Abort
- // Structured content
- const structuredResult: ToolResult = {
- content: [
- { type: 'text', text: 'File contents:' },
- { type: 'code', language: 'typescript', code: 'const x = 1' }
- ]
- }
+ The critical pattern for preventing runaway subagents:
- // Image result
- const imageResult: ToolResult = {
- content: [
- { type: 'image', url: 'file:///path/to/image.png' }
- ]
- }
+ ```typescript
+ const TIMEOUT_MS = 60000 // 60 seconds
+ const subagent = await ctx.client.session.create({
+ parentID: ctx.session.id,
+ agent: "fixer",
+ message: "Fix failing tests",
+ })
- #### Official Pattern (Recommended for Beginners)
+ 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)
- This is the standard plugin structure. Always start here:
+ // Clear timer when subagent completes
+ 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:
+
```typescript
- // my-plugin/index.ts
- import { Plugin, Tool } from '@opencode-ai/plugin'
+ try {
+ await ctx.client.session.abort({ id: subagent.id })
+ ctx.logger.info(`Subagent ${subagent.id} aborted successfully`)
+ } catch (error) {
+ // Abort may throw if session already ended
+ ctx.logger.warn(`Subagent ${subagent.id} already ended: ${error}`)
+ }
+ ```
- const myPlugin: Plugin = {
- name: 'my-custom-plugin',
- version: '1.0.0',
- description: 'Custom tools for OpenCode',
+ ### State Polling
+
+ Check subagent status:
+
+ ```typescript
+ const status = await ctx.client.session.get({ id: subagent.id })
+ if (status.status === "completed") {
+ // Process results
+ const messages = await ctx.client.session.messages({ id: subagent.id })
+ // ... process messages
+ } else if (status.status === "running") {
+ // Still working
+ } else if (status.status === "aborted") {
+ // Was terminated
+ }
+ ```
+
+ ### Cleanup on Abort
+
+ When a subagent is aborted mid-execution, clean up resources:
+
+ ```typescript
+ async function spawnWithCleanup(ctx: any, config: SubagentConfig) {
+ const subagent = await ctx.client.session.create(config)
+ const tempFiles: string[] = []
- 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
- }
- }
+ // Register cleanup handler
+ 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 }
}
-
- export default myPlugin
```
- This pattern exports a plain object with `name`, `version`, `description`, and optionally `tools`, `commands`, `providers`, or `skills`.
- ### Registering Tools
+ ### 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 |
+
+ ---
+
+ ## Tool System
+
+ Use the `tool()` factory from `@opencode-ai/plugin`. Do NOT hand-roll a `Tool` object.
+
```typescript
- // In plugin definition
- export default {
- name: 'my-tools',
- version: '1.0.0',
- tools: [
- searchFilesTool,
- readFileTool,
- writeFileTool
- ]
- }
+ import { tool } from "@opencode-ai/plugin"
+ import { z } from "zod" // or omit args for no-arg tools
- // Or dynamically register
- import { registerTool } from '@opencode-ai/plugin'
+ const myTool = tool({
+ description: "Search the codebase for a pattern",
+ args: z.object({
+ query: z.string().describe("Search query"),
+ maxResults: z.number().optional().default(10),
+ }),
+ execute: async (args, ctx) => {
+ // args is typed from the schema
+ // ctx.sessionID — the session that called the tool
+ return `Found ${args.maxResults} results for "${args.query}"`
+ },
+ })
+ ```
- registerTool({
- name: 'dynamic_tool',
- description: 'Dynamically registered tool',
- parameters: { type: 'object', properties: {} },
- execute: async () => ({ content: 'Dynamic!' })
+ For no-arg tools:
+
+ ```typescript
+ const taskCompleteTool = tool({
+ description: "Signal that all work is complete",
+ args: {},
+ execute: async (_args, ctx) => "Task completion acknowledged",
})
```
+ Register tools in the `tool` hook:
+
+ ```typescript
+ return {
+ tool: {
+ task_complete: taskCompleteTool,
+ my_search: myTool,
+ },
+ }
+ ```
+
+ ---
+
## 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
```json
// opencode.json
{
"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
```typescript
// Custom 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: {}
}
})
// Register 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}`)
})
// Start server
const transport = new StdioServerTransport()
await server.connect(transport)
```
### MCP Tool Usage
```typescript
// Access MCP tools from OpenCode
import { useMcpTools } from '@opencode-ai/plugin'
const githubTools = await useMcpTools('github')
// List available tools
const tools = await githubTools.list()
// [{ name: 'create_issue', description: '...', inputSchema: {...} }, ...]
// Call a tool
const result = await githubTools.call('create_issue', {
owner: 'myorg',
repo: 'myrepo',
title: 'New Issue',
body: 'Issue description'
})
```
- ## Skills & Commands
-
- ### Skill Files (SKILL.md)
-
- ```markdown
---
- 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
+ ## Configuration
- When asked to perform X:
- 1. Step 1
- 2. Step 2
- 3. Step 3
+ ### Install a plugin
- ## Examples
+ In `~/.config/opencode/opencode.json`:
- Example usage with code samples.
+ ```jsonc
+ {
+ "plugin": [
+ "my-plugin@1.0.0", // from npm
+ "file:///abs/path/to/dist/index.js", // local development
+ ["file:///abs/path/to/dist/index.js", { "enabled": true, "debug": true }] // with options
+ ],
+ "autoupdate": true
+ }
```
- ### Custom Commands
+ Options are passed as the second argument to the Plugin function. With `autoupdate: true`, the highest semver version in the npm registry wins.
+ ### Plugin options pattern
+
```typescript
- // Define slash command
- interface Command {
- name: string
- description: string
- handler: (args: string[], context: CommandContext) => Promise<void>
- }
+ export const MyPlugin: Plugin = async (ctx, options = {}) => {
+ const enabled = options.enabled !== false // default true
+ const timeout = options.timeoutMs ?? 30_000
- const myCommand: Command = {
- name: '/mycommand',
- description: 'Execute custom action',
- handler: async (args, context) => {
- // Command implementation
- await context.session.sendMessage('Command executed!')
+ if (!enabled) {
+ return { event: async () => {}, config: async () => {} }
}
- }
-
- // Register in plugin
- export default {
- name: 'my-commands',
- version: '1.0.0',
- commands: [myCommand]
+ // ...
}
```
- ### Built-in Commands
+ ### Two install locations — know the difference
- ```
- /help - Show available commands
- /clear - Clear conversation
- /reset - Reset session
- /model - Switch model
- /skill - Load a skill
- /config - View/edit configuration
+ OpenCode resolves plugins from **two** places. Updates must touch both or you get stale loads:
+
+ | Location | Purpose |
+ |----------|---------|
+ | `~/.cache/opencode/packages/<name>@<ver>/` | Download cache. Contains `node_modules/`, `dist/`, `package-lock.json`. |
+ | `~/.config/opencode/node_modules/<name>/` | Runtime resolution via `bun.lock` + `package.json` in `~/.config/opencode/`. |
+
+ The `~/.config/opencode/package.json` caret-pins versions (e.g. `"opencode-auto-resume": "^1.0.15"`), and `bun.lock` locks resolution. To force a specific version: edit `package.json`, delete `bun.lock`, run `bun install` in `~/.config/opencode/`.
+
+ To clear all cached versions and force a fresh download:
+
+ ```bash
+ rm -rf ~/.cache/opencode/packages/opencode-auto-resume@*
+ rm -rf ~/.local/share/reflex/bun/install/cache/opencode-auto-resume@*
+ cd ~/.config/opencode && bun install
```
+ ---
+
## SDK Usage
### @opencode-ai/sdk
```typescript
import { OpenCodeClient } from '@opencode-ai/sdk'
// Create client
const client = new OpenCodeClient({
baseUrl: 'http://localhost:3000',
apiKey: 'your-api-key'
})
// Send message
const response = await client.chat({
message: 'Explain this code',
files: ['./src/index.ts']
})
// Stream response
for await (const chunk of client.chatStream({
message: 'Write a function'
})) {
process.stdout.write(chunk.content)
}
// Execute tool
const result = await client.executeTool({
name: 'read_file',
args: { path: './src/main.ts' }
})
// Create session
const session = await client.createSession({
model: 'claude-3-opus',
systemPrompt: 'You are a helpful coding assistant'
})
// Continue conversation
const reply = await session.sendMessage('Add error handling')
```
### SDK Client Options
```typescript
interface ClientOptions {
// API endpoint
baseUrl?: string
// Authentication
apiKey?: string
// Model configuration
model?: string
// Timeout in milliseconds
timeout?: number
// Retry configuration
retries?: number
// Custom headers
headers?: Record<string, string>
}
```
+ ---
+
## HTTP Server & REST API
### Server Configuration
```typescript
// Server setup
import { createServer } from '@opencode-ai/server'
const server = createServer({
port: 3000,
host: '0.0.0.0',
// Authentication
auth: {
type: 'api-key',
keys: ['secret-key-1', 'secret-key-2']
},
// CORS
cors: {
origins: ['http://localhost:5173'],
methods: ['GET', 'POST', 'PUT', 'DELETE']
},
// Rate limiting
rateLimit: {
windowMs: 60000,
max: 100
}
})
await server.start()
```
### REST API Endpoints
```typescript
// Chat endpoint
POST /api/chat
{
"message": "string",
"session_id": "string?",
"files": ["string"]?,
"model": "string?"
}
// Response
{
"content": "string",
"session_id": "string",
"tool_calls": [...]
}
// Streaming
POST /api/chat/stream
// Returns SSE stream
// Tool execution
POST /api/tools/execute
{
"name": "string",
"args": { ... }
}
// Session management
GET /api/sessions
POST /api/sessions
GET /api/sessions/:id
DELETE /api/sessions/:id
// Models
GET /api/models
// Configuration
GET /api/config
PUT /api/config
```
### API Client Example
```typescript
// Using fetch
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
-
- ```typescript
- import { Bus } from '@opencode-ai/plugin'
+ ---
- // Publish event
- Bus.publish('session:created', {
- sessionId: 'abc123',
- model: 'claude-3-opus'
- })
+ ## Build Configuration
- Bus.publish('tool:executed', {
- toolName: 'read_file',
- args: { path: './src/main.ts' },
- result: '...'
- })
+ ```json
+ {
+ "name": "my-opencode-plugin",
+ "version": "1.0.0",
+ "type": "module",
+ "main": "dist/index.js",
+ "scripts": {
+ "build": "bun build src/index.ts --outdir dist --target bun",
+ "dev": "bun build src/index.ts --outdir dist --target bun --watch",
+ "test": "bun test",
+ "prepublishOnly": "bun run build"
+ },
+ "files": ["dist/index.js", "README.md", "LICENSE"],
+ "dependencies": {
+ "@opencode-ai/plugin": "latest"
+ },
+ "devDependencies": {
+ "@opencode-ai/sdk": "latest",
+ "@types/bun": "latest",
+ "typescript": "latest"
+ }
+ }
```
- ### Subscribing to Events
+ > `--target bun` is required — OpenCode runs on bun. The output is a single bundled `dist/index.js` (a virtual filesystem `/$bunfs/root/...`).
- ```typescript
- // Subscribe to events
- const unsubscribe = Bus.subscribe('session:created', (event) => {
- console.log('New session:', event.sessionId)
- })
+ ---
- // Subscribe to all events
- Bus.subscribe('*', (event) => {
- console.log('Event:', event.type, event.data)
- })
+ ## Testing
- // Unsubscribe
- unsubscribe()
- ```
+ ### Unit tests for pure functions
- ### Built-in Events
+ Keep utility functions in `src/test-utils.ts` so they can be tested without importing the plugin entry (which would require mocking `ctx`):
```typescript
- // Session events
- 'session:created' // New session created
- 'session:deleted' // Session deleted
- 'session:message' // Message sent/received
+ // src/index.backoff.test.ts
+ import { backoffMs } from "./test-utils"
+ import { describe, test, expect } from "bun:test"
- // Tool events
- 'tool:started' // Tool execution started
- 'tool:completed' // Tool completed successfully
- 'tool:failed' // Tool execution failed
+ describe("backoffMs()", () => {
+ test("attempt 1 returns base", () => {
+ expect(backoffMs(1, 1000, 8000)).toBe(1000)
+ })
+ test("caps at max", () => {
+ expect(backoffMs(10, 1000, 8000)).toBe(8000)
+ })
+ })
+ ```
- // File events
- 'file:read' // File read
- 'file:written' // File written
- 'file:deleted' // File deleted
+ ### Integration tests with mock ctx
- // Model events
- 'model:switched' // Model changed
- 'model:response' // Model response received
- ## Subagent Lifecycle Management
+ ```typescript
+ import { mock } from "bun:test"
+ import { MyPlugin } from "./index"
- Managing subagents spawned from plugins requires careful attention to timeouts, abort handling, and cleanup to prevent runaway processes.
+ function createMockContext(opts: { sessions?: any[], messages?: Record<string, any[]> }) {
+ const promptCalls: any[] = []
+ const ctx = {
+ client: {
+ app: { log: mock(async () => {}) },
+ session: {
+ list: mock(async () => ({ data: opts.sessions ?? [] })),
+ status: mock(async () => ({ data: {} })),
+ messages: mock(async (cfg: any) => opts.messages?.[cfg.path.id] ?? []),
+ prompt: mock(async (cfg: any) => { promptCalls.push(cfg); return {} }),
+ abort: mock(async () => ({})),
+ },
+ },
+ ui: { toast: mock(async () => {}) },
+ } as any
+ return { ctx, promptCalls }
+ }
- ### Spawning Subagents
+ test("plugin preserves agent on resume", async () => {
+ const { ctx, promptCalls } = createMockContext({
+ messages: { s1: [{ role: "user", agent: "prometheus", parts: [] }] },
+ })
+ const hooks = await MyPlugin(ctx, {})
- Use `ctx.client.session.create` with subagent configuration:
+ await hooks.event({ event: { type: "session.status", sessionID: "s1", properties: { status: { type: "idle" } } } })
- ```typescript
- const subagent = await ctx.client.session.create({
- parentID: ctx.session.id,
- agent: "fixer",
- message: "Fix the failing tests in auth.ts",
+ expect(promptCalls[0]?.agent).toBe("prometheus")
})
```
- ### Timer-Based Abort
+ ### Regression tests for crash-prevention rules
- The critical pattern for preventing runaway subagents:
+ Test the **contract**, not just behavior — bun's test runner does not fatal on unhandled rejections, so behavioral tests alone miss crash bugs. Read the source file and assert structural rules:
```typescript
- const TIMEOUT_MS = 60000 // 60 seconds
+ import { readFileSync } from "node:fs"
+ const SOURCE = readFileSync(join(import.meta.dir, "index.ts"), "utf8")
- const subagent = await ctx.client.session.create({
- parentID: ctx.session.id,
- agent: "fixer",
- message: "Fix failing tests",
+ test("REGRESSION: no non-Plugin exports", () => {
+ expect(SOURCE).not.toMatch(/^export\s+function\s+getLastAssistantError/m)
+ expect(SOURCE).not.toMatch(/^export\s+function\s+backoffMs/m)
})
- 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)
+ test("REGRESSION: event hook wraps handleEvent in .catch()", () => {
+ expect(SOURCE).toMatch(/handleEvent\(event[^)]*\)\.catch\(/)
+ })
- // Clear timer when subagent completes
- ctx.client.session.subscribe(subagent.id, (event) => {
- if (event.type === "session.end" || event.type === "session.error") {
- clearTimeout(timer)
+ test("REGRESSION: todo.updated validates Array.isArray", () => {
+ expect(SOURCE).toMatch(/Array\.isArray\(rawTodos\)/)
+ })
+
+ test("REGRESSION: bundled dist only exports Plugin-shaped values", async () => {
+ const mod = await import("./index")
+ for (const [name, fn] of Object.entries(mod)) {
+ if (typeof fn !== "function") throw new Error(`${name} is not a function`)
+ const result = await (fn as Function)(fakeCtx, {})
+ if (result === null || typeof result !== "object") {
+ throw new Error(`${name} returned ${result} — would crash the host`)
+ }
}
})
```
- ### Abort API
-
- Use `ctx.client.session.abort({ id })` to terminate a subagent:
-
- ```typescript
- try {
- await ctx.client.session.abort({ id: subagent.id })
- ctx.logger.info(`Subagent ${subagent.id} aborted successfully`)
- } catch (error) {
- // Abort may throw if session already ended
- ctx.logger.warn(`Subagent ${subagent.id} already ended: ${error}`)
- }
- ```
+ ---
- ### State Polling
+ ## SDK Types (from `@opencode-ai/sdk`)
- Check subagent status:
+ ### Message types
```typescript
- const status = await ctx.client.session.get({ id: subagent.id })
- if (status.status === "completed") {
- // Process results
- const messages = await ctx.client.session.messages({ id: subagent.id })
- // ... process messages
- } else if (status.status === "running") {
- // Still working
- } else if (status.status === "aborted") {
- // Was terminated
+ interface UserMessage {
+ id: string
+ sessionID: string
+ role: "user"
+ time: { created: number }
+ agent: string // the selected agent; critical for resume
+ model: { providerID: string; modelID: string }
+ tools?: { [key: string]: boolean }
}
- ```
- ### Cleanup on Abort
-
- When a subagent is aborted mid-execution, clean up resources:
+ interface AssistantMessage {
+ id: string
+ sessionID: string
+ role: "assistant"
+ time: { created: number; completed?: number }
+ error?: any // present if the message failed
+ parentID: string
+ modelID: string
+ providerID: string
+ finish?: string // "stop" | "length" | "error" | "unknown"
+ }
- ```typescript
- async function spawnWithCleanup(ctx: any, config: SubagentConfig) {
- const subagent = await ctx.client.session.create(config)
- const tempFiles: string[] = []
-
- // Register cleanup handler
- 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 }
+ interface Message {
+ role: string
+ info?: { role?: string; error?: any } // some messages nest role/error in .info
+ parts?: Part[]
+ error?: { name: string; data?: { message: string }; message?: string }
}
```
- ### 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:
+ ### Part types
```typescript
- export const handleEvent: Plugin.EventHandler = async (ctx, event) => {
- // Only handle events we care about
- if (event.type !== "message.part") return
-
- // Process the event
- const part = event.properties.part
- // ...
- }
+ 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" }
+ | { type: "step-finish"; reason: string; cost: number; tokens: any }
+ | { type: "retry"; attempt: number; error: any }
+ | { type: "compaction"; auto: boolean }
```
- ### checkForToolCallAsText
+ ### Extracting the selected agent
- 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:
+ The agent is on `UserMessage.agent`, **not** on `AssistantMessage`. To preserve the agent across resume:
```typescript
- function checkForToolCallAsText(text: string): ToolCall | null {
- // Pattern: AI writes tool call as text instead of using tool API
- // Look for patterns like JSON tool calls or function call syntax
-
- 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,
- }
+ async function getSessionAgent(sid: string): Promise<string | undefined> {
+ const messages = await getSessionMessages(sid)
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const role = messages[i].role ?? messages[i].info?.role
+ if (role === "user") {
+ const agent = (messages[i] as any).agent
+ if (typeof agent === "string" && agent.length > 0) return agent
}
- } 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) {
- // Intercept and execute the tool call properly
- ctx.logger.info(`Detected tool call in text: ${toolCall.name}`)
- await ctx.client.tool.execute({
- name: toolCall.name,
- parameters: toolCall.parameters,
- })
- }
+ return undefined
}
```
- ### Event Dispatch Flow
+ ---
- Events flow from source → bus → handlers:
- 1. Event is published to the bus
- 2. All registered handlers are called in priority order
- 3. Handlers should not throw (breaks dispatch chain)
- 4. Use `event.properties.source` to filter own events
+ ## Real-World Patterns
- ### Error Handling in Handlers
+ ### Detecting streaming failures
- Wrap handler body in try/catch - never throw:
+ Providers fail mid-stream with errors like `APIError`, `ProviderError`, `StreamError`. Classify by error **name** (exact match) and **message** (regex):
```typescript
- export const handleEvent: Plugin.EventHandler = async (ctx, event) => {
- try {
- // Handler logic
- if (event.type !== "message.part") return
- const part = event.properties.part
- // ... process event
- } catch (error) {
- ctx.logger.error(`Handler failed: ${error}`)
- // Don't rethrow — it breaks the event dispatch chain
- }
+ function isStreamingFailure(errorName: string, errorMessage: string): boolean {
+ const NAMES = ["ProviderError", "APIError", "StreamError", "ConnectionError", "TimeoutError"]
+ const PATTERNS = ["streaming response failed", "stream.*fail", "connection.*reset"]
+ if (NAMES.includes(errorName)) return true
+ const lower = errorMessage.toLowerCase()
+ return PATTERNS.some(p => { try { return new RegExp(p, "i").test(lower) } catch { return lower.includes(p) } })
}
```
- ### Testing Event Handlers
+ ### Active-tool safety guard
- Test handlers with mock events:
+ Never abort a session that has a tool running. Check both the in-flight counter (from hooks) and the SDK status:
```typescript
- 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()
- })
- })
+ async function checkSessionHasActiveTool(sid: string): Promise<boolean> {
+ const statusMap = await getSessionStatusMap()
+ if (statusMap[sid] === "busy") return true
+ const messages = await getSessionMessages(sid)
+ const lastMsg = messages[messages.length - 1]
+ if (!lastMsg || roleOf(lastMsg) !== "assistant") return false
+ const parts = lastMsg.parts as any[] | undefined
+ return parts?.some(p => p.type === "tool-call" || p.type === "tool_use") ?? false
+ }
```
- ### 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
+ ### Hallucination loop detection
- ### Configuration File
+ Track `continue` timestamps per session. If 3+ continues within 10 minutes, abort and restart:
- ```json
- // opencode.json
- {
- "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"
- ]
+ ```typescript
+ function isHallucinationLoop(sid: string): boolean {
+ const w = sessions.get(sid)
+ if (!w) return false
+ const now = Date.now()
+ w.continueTimestamps.push(now)
+ const cutoff = now - 600_000 // 10 min
+ w.continueTimestamps = w.continueTimestamps.filter(t => t >= cutoff)
+ return w.continueTimestamps.length >= 3
}
```
- ### Environment Variables
+ ---
- ```bash
- # API Keys
- ANTHROPIC_API_KEY=sk-ant-xxx
- OPENAI_API_KEY=sk-xxx
+ ## Debugging
- # Configuration
- OPENCODE_CONFIG_PATH=/path/to/opencode.json
- OPENCODE_DATA_DIR=/path/to/data
+ ### "Unexpected server error. Check server logs for details."
- # Server
- OPENCODE_PORT=3000
- OPENCODE_HOST=0.0.0.0
+ This is the generic TUI mask. The real error is in the log:
- # Logging
- OPENCODE_LOG_LEVEL=info
+ ```bash
+ grep "level=ERROR" ~/.local/share/opencode/log/opencode.log | tail -20
```
- ## Testing Plugins
+ Common causes (in order of likelihood):
- ### Unit Tests
+ 1. **Non-Plugin export returns null** → `null is not an object (evaluating 'N.config')` — see Rule 1
+ 2. **Unhandled promise rejection** in `event` hook or `setInterval` — see Rule 2
+ 3. **Event payload not validated** → `todos.filter is not a function` — see Rule 3
+ 4. **Invalid session ID** passed to SDK → `Expected 'id' to be a string` — see Rule 5
- ```typescript
- // tests/tool.test.ts
- import { describe, it, expect, vi } from 'vitest'
- import { myTool } from '../src/tools'
+ ### Plugin not loading
- 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)
- })
- })
+ ```bash
+ grep "failed to load plugin" ~/.local/share/opencode/log/opencode.log
```
- ### Integration Tests
+ Check:
+ - `dist/index.js` exists and has `"main": "dist/index.js"` in `package.json`
+ - The cache directory has the real package, not just a wrapper: `ls ~/.cache/opencode/packages/<name>@<ver>/node_modules/<name>/dist/index.js`
+ - `bun.lock` in `~/.config/opencode/` resolves to the version you expect
- ```typescript
- // tests/integration.test.ts
- import { describe, it, expect, beforeAll, afterAll } from 'vitest'
- import { OpenCodeClient } from '@opencode-ai/sdk'
- import { createServer } from '@opencode-ai/server'
+ ### Verify which version loaded at runtime
- 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()
- })
- })
+ ```bash
+ grep "opencode-auto-resume\|my-plugin" ~/.local/share/opencode/log/opencode.log | tail -5
+ # Look for: path=my-plugin@X.Y.Z
```
+ ---
+
## Publishing & Distribution
### Package Structure
```
my-opencode-plugin/
├── package.json
├── tsconfig.json
├── src/
│ └── index.ts
├── dist/
│ └── index.js
└── README.md
```
### package.json
```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
```bash
# Build
npm run build
# Test
npm test
# Publish
npm publish --access public
```
### Local Development
```bash
# Link for local testing
npm link
# In opencode config
{
"plugins": ["opencode-plugin-mytool"]
}
```
- ## Best Practices
-
- ### 1. Tool Design
-
- ```typescript
- // Good: Clear description and parameters
- 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) => {
- // Implementation
- }
- }
-
- // Bad: Vague description
- const badTool: Tool = {
- name: 'search',
- description: 'Search',
- parameters: { type: 'object' },
- execute: async () => ({ content: '...' })
- }
- ```
-
- ### 2. Error Handling
-
- ```typescript
- const robustTool: Tool = {
- name: 'safe_tool',
- description: 'Tool with proper error handling',
- parameters: { ... },
- execute: async (args, context) => {
- try {
- // Validate args
- if (!args.required) {
- return {
- content: 'Error: Required parameter missing',
- isError: true
- }
- }
-
- // Execute with timeout
- 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
-
- ```typescript
- // Validate file paths
- const safeReadTool: Tool = {
- name: 'safe_read',
- execute: async (args, context) => {
- const path = resolvePath(args.path)
-
- // Check if path is within project
- if (!isWithinProject(path, context.config.projectRoot)) {
- return {
- content: 'Error: Access denied - path outside project',
- isError: true
- }
- }
-
- // Read file
- const content = await context.fs.readFile(path)
- return { content }
- }
- }
- ```
-
- ## References
-
- - **OpenCode Repository**: https://github.com/anomalyco/opencode
- - **MCP Documentation**: https://modelcontextprotocol.io/
- - **TypeScript SDK**: packages/sdk/js
- - **Plugin API**: packages/plugin
-
---
- ## 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:
-
- ```typescript
- // ACTUAL WORKING PATTERN - not object export
- export const AutoResumePlugin = async (ctx: any, options: PluginOptions) => {
- // ctx provides access to OpenCode internals
- return {
- event: async ({ event }: { event: Record<string, unknown> }) => {
- // Handle SSE events
- },
- config: async () => {
- // Called when plugin loads
- }
- }
- }
-
- export default AutoResumePlugin
- ```
-
- ### Context API (ctx) - Undocumented
-
- ```typescript
- // ctx.client - API calls
- ctx.client.app.log({
- body: {
- service: string, // Your plugin name
- level: string, // "debug" | "info" | "warn" | "error"
- message: string
- }
- })
-
- ctx.client.session.list() // Get all sessions
- ctx.client.session.messages({ id: string }) // Get session messages
- ctx.client.session.prompt({ // Send prompt to session
- path: { id: string },
- body: { parts: Array<{ type: string, text: string }> },
- agent?: string // Agent name: "sisyphus", "prometheus"
- })
- ctx.client.session.abort({ id: string })
-
- // ctx.ui - User interface
- 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
-
- ```typescript
- // Get selected agent from session messages
- async function getSessionAgent(ctx: any, sid: string): Promise<string | undefined> {
- const messages = await ctx.client.session.messages({ id: sid })
-
- // Find most recent user message with agent field
- 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**:
-
- ```typescript
- // Real event hook structure
- 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") {
- // Session finished work
- } else if (status?.type === "busy") {
- // Session is working
- } else if (status?.type === "retry") {
- // Retry attempt (has attempt, message, next)
- }
- break
- }
- case "session.error": {
- const error = props?.error as any
- break
- }
- case "message.updated": {
- // Message was updated
- break
- }
- case "message.part.updated": {
- const delta = props?.delta as string | undefined
- // Streaming text delta
- break
- }
- }
- }
- }
- ```
-
- ### SessionStatus Types
-
- ```typescript
- type SessionStatus =
- | { type: "idle" }
- | { type: "busy" }
- | { type: "retry", attempt: number, message: string, next: number }
- ```
-
- ### Message SDK Types (from @opencode-ai/sdk)
-
- ```typescript
- interface UserMessage {
- id: string
- sessionID: string
- role: "user"
- time: { created: number }
- agent: string // SELECTED AGENT - critical for resume
- 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 // Error info if failed
- 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 // "stop" | "length" | "error" | "unknown"
- }
- ```
-
- ### Part Types
-
- ```typescript
- 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**:
-
- ```jsonc
- {
- "plugin": [
- "file:///absolute/path/to/dist/index.js",
- { "option": "value", "enabled": true }
- ]
- }
- ```
-
- Options are passed as second parameter to the async function:
-
- ```typescript
- export const MyPlugin = async (ctx, options: { enabled?: boolean } = {}) => {
- if (options.enabled === false) {
- return { event: async () => {}, config: async () => {} }
- }
- // ... plugin logic
- }
- ```
-
- ### Type Validation - CRITICAL
-
- **Always validate inputs before API calls** to prevent "Expected 'id' to be a string" errors:
-
- ```typescript
- // Bad - will crash if sid is invalid
- await ctx.client.session.prompt({ path: { id: sid }, ... })
-
- // Good - defensive validation
- 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
-
- ```json
- {
- "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:
-
- ```jsonc
- {
- "plugin": [
- "file:///home/user/project/opencode-auto-resume/dist/index.js",
- { "enabled": true }
- ]
- }
- ```
-
- ### Testing Pattern
-
- ```typescript
- 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, {})
-
- // Trigger event
- await hooks.event({
- event: { type: "session.status", sessionID: "s1",
- properties: { status: { type: "idle" } }
- }
- })
-
- // Verify agent was passed
- expect(promptCalls[0]?.agent).toBe("prometheus")
- })
- ```
-
- ### Common Bugs Discovered
-
- 1. **Wrong message type for agent**: Used `AssistantMessage` instead of `UserMessage`
- 2. **Missing type validation**: "Expected 'id' to be a string" when session is in error state
- 3. **Wrong hook format**: Returned object from `ctx.on()` instead of `{ event, config }` hooks
- 4. **Subagent hangs forever**: No timeout set on spawned subagents
- 5. **Event handler crashes**: Handlers throwing errors instead of catching them
-
- ### Real World Patterns
-
- **Auto-resume on idle:**
-
- ```typescript
- async function handleIdleSession(ctx: any, sid: string) {
- const messages = await ctx.client.session.messages({ id: sid })
- const lastMsg = messages[messages.length - 1]
-
- // Check if last message ends with continuation prompt
- 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:**
-
- ```typescript
- 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()
-
- // Reset if more than 10 minutes since last continue
- 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
- }
- ```
-
+ ## Summary Checklist
- - SDK types: `node_modules/@opencode-ai/sdk/dist/index.d.ts`
- - OpenCode core: `https://github.com/anomalyco/opencode`
- - Plugin source: `packages/opencode/src/plugin/`
+ Before publishing a plugin:
+ - [ ] `dist/index.js` exports ONLY `default` (and optionally a named alias of the same function)
+ - [ ] `event` hook wraps async work in `.catch()`
+ - [ ] All `setInterval` async bodies wrapped in try/catch or `safe()`
+ - [ ] `log()` helper never rethrows
+ - [ ] All event payload fields validated (`Array.isArray`, `typeof`, `?.`)
+ - [ ] All SDK calls validate `sid` first
+ - [ ] `session.status()` accessed via `.type`, never compared as bare string
+ - [ ] Tests include source-contract assertions (not just behavioral)
+ - [ ] `bun test` passes with 0 failures
+ - [ ] `bun build` produces `dist/index.js` with only Plugin exports