| name | creating-opencode-plugins |
| description | Use when creating OpenCode plugins that hook into command, file, LSP, message, permission, server, session, todo, tool, or TUI events - provides plugin structure, event API specifications, and implementation patterns for JavaScript/TypeScript event-driven modules |
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/