creating-opencode-plugins

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

$ Installieren

git clone https://github.com/pr-pm/prpm /tmp/prpm && cp -r /tmp/prpm/.claude/skills/creating-opencode-plugins ~/.claude/skills/prpm

// tip: Run this command in your terminal to install the skill


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

CategoryEventsUse Cases
commandcommand.executedTrack command history, notifications
filefile.edited, file.watcher.updatedFile validation, auto-formatting
installationinstallation.updatedDependency tracking
lsplsp.client.diagnostics, lsp.updatedCustom error handling
messagemessage.*.updated/removedMessage filtering, logging
permissionpermission.replied/updatedPermission policies
serverserver.connectedConnection monitoring
sessionsession.created/deleted/error/idle/status/updated/compacted/diffSession management
todotodo.updatedTodo synchronization
tooltool.execute.before/afterTool interception, augmentation
tuitui.prompt.append, tui.command.execute, tui.toast.showUI 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

LocationPathScopeUse Case
Global~/.config/opencode/plugin/All projectsSecurity policies, global utilities
Project.opencode/plugin/Current projectProject-specific hooks, validators

Common Mistakes

MistakeWhy It FailsFix
Synchronous event handlerBlocks event loopUse async handlers
Missing error handlingPlugin crashes on errorWrap in try/catch
Heavy computation in handlerSlows down operationsDefer to background process
Mutating event data directlyCauses side effectsReturn override object
Not checking event typeHandles wrong eventsUse switch/case on event.type
Forgetting context destructuringMissing key utilitiesDestructure { 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 HookOpenCode EventDescription
PreToolUsetool.execute.beforeRun before tool execution, can block
PostToolUsetool.execute.afterRun after tool execution
UserPromptSubmitmessage.* eventsProcess user prompts
SessionEndsession.idleSession 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/