Creating OpenCode Plugins
Overview
OpenCode plugins are JavaScript/TypeScript modules that hook into 25+ events across the OpenCode AI assistant lifecycle. Plugins export an async function receiving context (project, client, $, directory, worktree) and return an event handler.
When to Use
Create an OpenCode plugin when:
-
Intercepting file operations (prevent sharing .env files)
-
Monitoring command execution (notifications, logging)
-
Processing LSP diagnostics (custom error handling)
-
Managing permissions (auto-approve trusted operations)
-
Reacting to session lifecycle (cleanup, initialization)
-
Extending tool capabilities (custom tool registration)
-
Enhancing TUI interactions (custom prompts, toasts)
Don't create for:
-
Simple prompt instructions (use agents instead)
-
One-time scripts (use bash tools)
-
Static configuration (use settings files)
Quick Reference
Plugin Structure
export const MyPlugin = async (context) => { // context: { project, client, $, directory, worktree }
return { event: async ({ event }) => { // event: { type: 'event.name', data: {...} }
switch(event.type) {
case 'file.edited':
// Handle file edits
break;
case 'tool.execute.before':
// Pre-process tool execution
break;
}
}
}; };
Event Categories
Category Events Use Cases
command command.executed
Track command history, notifications
file file.edited , file.watcher.updated
File validation, auto-formatting
installation installation.updated
Dependency tracking
lsp lsp.client.diagnostics , lsp.updated
Custom error handling
message message.*.updated/removed
Message filtering, logging
permission permission.replied/updated
Permission policies
server server.connected
Connection monitoring
session session.created/deleted/error/idle/status/updated/compacted/diff
Session management
todo todo.updated
Todo synchronization
tool tool.execute.before/after
Tool interception, augmentation
tui tui.prompt.append , tui.command.execute , tui.toast.show
UI customization
Plugin Manifest (package.json or separate config)
{ "name": "env-protection", "description": "Prevents sharing .env files", "version": "1.0.0", "author": "Security Team", "plugin": { "file": "plugin.js", "location": "global" }, "hooks": { "file": ["file.edited"], "permission": ["permission.replied"] } }
Implementation
Complete Example: Environment File Protection
// .opencode/plugin/env-protection.js
export const EnvProtectionPlugin = async ({ project, client }) => { const sensitivePatterns = [ /.env$/, /.env..+$/, /credentials.json$/, /.secret$/, ];
const isSensitiveFile = (filePath) => { return sensitivePatterns.some(pattern => pattern.test(filePath)); };
return { event: async ({ event }) => { switch (event.type) { case 'file.edited': { const { path } = event.data;
if (isSensitiveFile(path)) {
console.warn(`⚠️ Sensitive file edited: ${path}`);
console.warn('This file should not be shared or committed.');
}
break;
}
case 'permission.replied': {
const { action, target, decision } = event.data;
// Block read/share operations on sensitive files
if ((action === 'read' || action === 'share') &&
isSensitiveFile(target) &&
decision === 'allow') {
console.error(`🚫 Blocked ${action} operation on sensitive file: ${target}`);
// Override permission decision
return {
override: true,
decision: 'deny',
reason: 'Sensitive file protection policy'
};
}
break;
}
}
}
}; };
Example: Command Execution Notifications
// .opencode/plugin/notify.js
export const NotifyPlugin = async ({ project, $ }) => { let commandStartTime = null;
return { event: async ({ event }) => { switch (event.type) { case 'command.executed': { const { command, args, status } = event.data; commandStartTime = Date.now();
console.log(`▶️ Executing: ${command} ${args.join(' ')}`);
break;
}
case 'tool.execute.after': {
const { tool, duration, success } = event.data;
if (duration > 5000) {
// Notify for long-running operations
await $`osascript -e 'display notification "Completed in ${duration}ms" with title "${tool}"'`;
}
console.log(`✅ ${tool} completed in ${duration}ms`);
break;
}
}
}
}; };
Example: Custom Tool Registration
// .opencode/plugin/custom-tools.js
export const CustomToolsPlugin = async ({ client }) => {
// Register custom tool on initialization
await client.registerTool({
name: 'lint',
description: 'Run linter on current file with auto-fix option',
parameters: {
type: 'object',
properties: {
fix: {
type: 'boolean',
description: 'Auto-fix issues'
}
}
},
handler: async ({ fix }) => {
const result = await $eslint ${fix ? '--fix' : ''} .;
return {
output: result.stdout,
errors: result.stderr
};
}
});
return {
event: async ({ event }) => {
// Monitor tool usage
if (event.type === 'tool.execute.before') {
console.log(🔧 Tool: ${event.data.tool});
}
}
};
};
Installation Locations
Location Path Scope Use Case
Global ~/.config/opencode/plugin/
All projects Security policies, global utilities
Project .opencode/plugin/
Current project Project-specific hooks, validators
Common Mistakes
Mistake Why It Fails Fix
Synchronous event handler Blocks event loop Use async handlers
Missing error handling Plugin crashes on error Wrap in try/catch
Heavy computation in handler Slows down operations Defer to background process
Mutating event data directly Causes side effects Return override object
Not checking event type Handles wrong events Use switch/case on event.type
Forgetting context destructuring Missing key utilities Destructure { project, client, $, directory, worktree }
Event Data Structures
// File Events interface FileEditedEvent { type: 'file.edited'; data: { path: string; content: string; timestamp: number; }; }
// Tool Events interface ToolExecuteBeforeEvent { type: 'tool.execute.before'; data: { tool: string; args: Record<string, any>; user: string; }; }
interface ToolExecuteAfterEvent { type: 'tool.execute.after'; data: { tool: string; duration: number; success: boolean; output?: any; error?: string; }; }
// Permission Events interface PermissionRepliedEvent { type: 'permission.replied'; data: { action: 'read' | 'write' | 'execute' | 'share'; target: string; decision: 'allow' | 'deny'; }; }
Testing Plugins
// Test plugin locally before installation import { EnvProtectionPlugin } from './env-protection.js';
const mockContext = { project: { root: '/test/project' }, client: {}, $: async (cmd) => ({ stdout: '', stderr: '' }), directory: '/test/project', worktree: null };
const plugin = await EnvProtectionPlugin(mockContext);
// Simulate event await plugin.event({ event: { type: 'file.edited', data: { path: '.env', content: 'SECRET=123', timestamp: Date.now() } } });
Real-World Impact
Security: Prevent accidental sharing of credentials (env-protection plugin blocks .env file reads)
Productivity: Auto-notify on long-running commands (notify plugin sends system notifications)
Quality: Auto-format files on save (file.edited hook runs prettier)
Monitoring: Track tool usage patterns (tool.execute hooks log analytics)
Claude Code Event Mapping
When porting Claude Code hook behavior to OpenCode plugins, use these event mappings:
Claude Hook OpenCode Event Description
PreToolUse
tool.execute.before
Run before tool execution, can block
PostToolUse
tool.execute.after
Run after tool execution
UserPromptSubmit
message.* events Process user prompts
SessionEnd
session.idle
Session completion
Example: Claude-like Hook Behavior
export const CompatiblePlugin = async (context) => { return { // Equivalent to Claude's PreToolUse hook 'tool.execute.before': async (input, output) => { if (shouldBlock(input)) { throw new Error('Blocked by policy'); } },
// Equivalent to Claude's PostToolUse hook
'tool.execute.after': async (result) => {
console.log(`Tool completed: ${result.tool}`);
},
// Equivalent to Claude's SessionEnd hook
event: async ({ event }) => {
if (event.type === 'session.idle') {
await cleanup();
}
}
}; };
Plugin Composition
Combine multiple plugins using opencode-plugin-compose:
import { compose } from "opencode-plugin-compose";
const composedPlugin = compose([ envProtectionPlugin, notifyPlugin, customToolsPlugin ]); // Runs all hooks in sequence
Non-Convertibility Note
Important: OpenCode plugins cannot be directly converted from Claude Code hooks due to fundamental differences:
-
Event models differ: Claude has 4 hook events, OpenCode has 32+
-
Formats differ: Claude uses executable scripts, OpenCode uses JS/TS modules
-
Execution context differs: Different context objects and return value semantics
When porting Claude hooks to OpenCode plugins, you'll need to rewrite the logic using the OpenCode plugin API.
Schema Reference: packages/converters/schemas/opencode-plugin.schema.json
Documentation: https://opencode.ai/docs/plugins/