
Create Opencode Plugin
- 293 installs
- 125 repo stars
- Updated February 4, 2026
- igorwarzocha/opencode-workflows
create-opencode-plugin is an agent skill that scaffolds, implements, tests, and publishes custom OpenCode plugins with the @opencode-ai/plugin SDK for developers who need tools, event hooks, auth providers, or execution
About
create-opencode-plugin is an agent workflow skill for building OpenCode plugins with the @opencode-ai/plugin SDK. Developers invoke it with /create-plugin and a plugin idea when adding custom tools, before/after hooks, auth providers, or tool-execution interceptors under .opencode/plugin/ or ~/.config/opencode/plugin/. The skill enforces a 7-step lifecycle: extract SDK reference via extract-plugin-api.ts, validate feasibility, design hooks and tools, implement TypeScript modules, add optional UI feedback, test against references/testing.md, and publish when ready. Example targets include blocking dangerous shell commands, Jira custom tools, toast notifications on file edits, and git commit validation hooks. create-opencode-plugin keeps plugin work modular with bundled references for hooks, hook-patterns, tool-helper schemas, events, and publishing guidance.
- OpenCode plugin scaffolding
- Workflow extension patterns
- Agent hook integration
- Repo automation packaging
- Repeatable plugin bootstrap
Create Opencode Plugin by the numbers
- 293 all-time installs (skills.sh)
- +6 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #2,287 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/igorwarzocha/opencode-workflows --skill create-opencode-pluginAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 293 |
|---|---|
| repo stars | ★ 125 |
| Last updated | February 4, 2026 |
| Repository | igorwarzocha/opencode-workflows ↗ |
How do you build an OpenCode plugin with hooks?
Scaffold, configure, and ship custom OpenCode workflow plugins that extend agent behavior, hooks, and repository automation without hand-rolling boilerplate each time.
Who is it for?
Developers extending OpenCode with custom tools, policy hooks, auth providers, or repository automation interceptors.
Skip if: Contributors modifying OpenCode core packages instead of authoring plugins in .opencode/plugin/ directories.
When should I use this skill?
A developer wants to build, test, or publish a new OpenCode plugin for tools, hooks, auth, or command interception.
What you get
OpenCode plugin TypeScript source, SDK reference extract, hook or tool implementations, and optional publish-ready package configuration.
- OpenCode plugin TypeScript module
- Hook or custom tool definitions
- Test and publish checklist completion
By the numbers
- Follows a 7-step plugin lifecycle from SDK extract through publish
- Targets .opencode/plugin/ and ~/.config/opencode/plugin/ install paths
Files
Creating OpenCode Plugins
<critical> Re-read this file periodically during plugin development to refresh context and ensure you're following the correct procedure. </critical>
<workflow>
Procedure Overview
| Step | Action | Read |
|---|---|---|
| 1 | Verify SDK reference | Run extract script |
| 2 | Validate feasibility | This file |
| 3 | Design plugin | references/hooks.md, references/hook-patterns.md, references/CODING-TS.MD |
| 4 | Implement | references/tool-helper.md (if custom tools) |
| 5 | Add UI feedback | references/toast-notifications.md, references/ui-feedback.md (if needed) |
| 6 | Test | references/testing.md |
| 7 | Publish | references/publishing.md, references/update-notifications.md (if npm) |
---
Step 1: Verify SDK Reference (REQUIRED)
Before creating any plugin, MUST regenerate the API reference to ensure accuracy:
bun run .opencode/skill/create-opencode-plugin/scripts/extract-plugin-api.tsThis generates:
references/hooks.md- All available hooks and signaturesreferences/events.md- All event types and propertiesreferences/tool-helper.md- Tool creation patterns
---
Step 2: Validate Feasibility (REQUIRED)
MUST determine if the user's concept is achievable with available hooks.
Feasible as plugins:
- Intercepting/blocking tool calls
- Reacting to events (file edits, session completion, etc.)
- Adding custom tools for the LLM
- Modifying LLM parameters (temperature, etc.)
- Custom auth flows for providers
- Customizing session compaction
- Displaying status messages (toasts, inline)
NOT feasible (inform user):
- Modifying TUI rendering or layout
- Adding new built-in tools (requires OC source)
- Changing core agent behavior/prompts
- Intercepting assistant responses mid-stream
- Adding new keybinds or commands
- Modifying internal file read/write
- Adding new permission types
If not feasible, MUST inform user clearly. Suggest:
- OC core changes: contribute to
packages/opencode - MCP tools: use MCP server configuration
- Simple automation: use shell scripts
---
Step 3: Design Plugin
READ: references/hooks.md for available hooks, references/hook-patterns.md for implementation patterns.
READ: references/CODING-TS.MD for code architecture principles. MUST follow these design guidelines:
- Modular structure: Split complex plugins into multiple focused files (types, utilities, hooks, tools)
- Single purpose: Each function does ONE thing well
- DRY: Extract common patterns into shared utilities immediately
- Small files: Keep individual files under 150 lines - split into smaller modules as needed
- No monoliths: MUST NOT put all plugin code in a single
index.tsfile
Plugin Locations
| Scope | Path | Use Case |
|---|---|---|
| Project | .opencode/plugin/<name>/index.ts | Team-shared, repo-specific |
| Global | ~/.config/opencode/plugin/<name>/index.ts | Personal, all projects |
Basic Structure
import type { Plugin } from "@opencode-ai/plugin"
export const MyPlugin: Plugin = async ({ project, client, $, directory, worktree }) => {
// Setup code runs once on load
return {
// Hook implementations - see references/hook-patterns.md
}
}Context Parameters
| Parameter | Type | Description |
|---|---|---|
project | Project | Current project info (id, worktree, name) |
client | SDK Client | OpenCode API client |
$ | BunShell | Bun shell for commands |
directory | string | Current working directory |
worktree | string | Git worktree path |
---
Step 4: Implement
READ: references/hook-patterns.md for hook implementation examples.
READ: references/tool-helper.md if adding custom tools (Zod schemas).
READ: references/events.md if using event hook (event types/properties).
READ: references/examples.md for complete plugin examples.
ALWAYS READ: references/CODING-TS.MD and follow modular design principles.
Plugin Structure (Non-Monolithic)
For complex plugins, MUST use a modular directory structure:
.opencode/plugin/my-plugin/
├── index.ts # Entry point, exports Plugin
├── types.ts # TypeScript types/interfaces
├── utils.ts # Shared utilities
├── hooks/ # Hook implementations
│ ├── event.ts
│ └── tool-execute.ts
└── tools/ # Custom tool definitions
└── my-tool.tsExample modular index.ts:
import type { Plugin } from "@opencode-ai/plugin"
import { eventHooks } from "./hooks/event"
import { toolHooks } from "./hooks/tool-execute"
import { customTools } from "./tools"
export const MyPlugin: Plugin = async ({ project, client }) => {
return {
...eventHooks({ client }),
...toolHooks({ client }),
tool: customTools,
}
}Keep each file under 150 lines. Split as complexity grows.
Common Mistakes
| Mistake | Fix |
|---|---|
Using client.registerTool() | Use tool: { name: tool({...}) } |
| Wrong event property names | Check references/events.md |
| Sync event handler | MUST use async |
| Not throwing to block | throw new Error() in tool.execute.before |
| Forgetting TypeScript types | import type { Plugin } from "@opencode-ai/plugin" |
---
Step 5: Add UI Feedback (Optional)
Only if plugin needs user-visible notifications:
READ: references/toast-notifications.md for transient alerts (brief popups)
READ: references/ui-feedback.md for persistent inline status messages
Choose based on:
| Need | Use |
|---|---|
| Brief alerts, warnings | Toast |
| Detailed stats, multi-line | Inline message |
| Config validation errors | Toast |
| Session completion notice | Toast or inline |
---
Step 6: Test
READ: references/testing.md for full testing procedure.
Quick Test Steps
1. Create test folder with opencode.json:
{
"plugin": ["file:///path/to/your/plugin/index.ts"],
}2. Verify plugin loads:
cd /path/to/test-folder
opencode run hi3. Test interactively:
opencode4. SHOULD recommend specific tests based on hook type used.
---
Step 7: Publish (Optional)
READ: references/publishing.md for npm publishing.
READ: references/update-notifications.md for version update toasts (for users with pinned versions).
</workflow>
<reference_summary>
Reference Files Summary
| File | Purpose | When to Read |
|---|---|---|
hooks.md | Hook signatures (auto-generated) | Step 3-4 |
events.md | Event types (auto-generated) | Step 4 (if using events) |
tool-helper.md | Zod tool schemas (auto-generated) | Step 4 (if custom tools) |
hook-patterns.md | Hook implementation examples | Step 3-4 |
CODING-TS.MD | Code architecture principles | Step 3 (Design) |
examples.md | Complete plugin examples | Step 4 |
toast-notifications.md | Toast popup API | Step 5 (if toasts needed) |
ui-feedback.md | Inline message API | Step 5 (if inline needed) |
testing.md | Testing procedure | Step 6 |
publishing.md | npm publishing | Step 7 |
update-notifications.md | Version toast pattern | Step 7 (for npm plugins) |
</reference_summary>
CODING.md - Development Guidelines
<overview> Core Principles for Clean, Maintainable Code Architecture. Universal development guidelines applicable to any project. Focus on DRY principles, maintainable architecture, and type safety. </overview>
<instructions>
Core Development Principles
DRY PRINCIPLE: Pattern recognition is key - if you see similar code twice, abstract it immediately. Create reusable components, shared utilities, and unified interfaces.
Apply these principles naturally:
- DRY First: Check if something similar exists to extend/reuse before writing new code
- Single Purpose: Each component/function SHOULD do ONE thing well
- Compose, Don't Inherit: Build complex things from simple, reusable pieces
- KISS: Simple solutions beat clever ones - readable code > smart code
- Fail Fast: Throw errors clearly rather than hiding problems with defensive code
- Extract Early: See a pattern emerging? Pull it into a shared utility immediately
- Occam's Razor: Simplest explanation is usually correct - avoid over-engineering
- Pareto Principle: 80% of results come from 20% of effort - focus on high-impact features
</instructions>
<rules>
Code Quality
- File Headers: Every file MUST start with 2-3 sentence comment explaining what it does
- Strategic Comments: Comment major sections and complex logic, not every line
- NO Logging: MUST NOT use console.log, console.error, or any logging - trust TypeScript and DevTools
- Progressive Cleanup: When editing files, replace
anytypes with proper interfaces and delete console statements - technical debt decreases over time - Real Features: Build actual functionality - MUST NOT create fake implementations that pretend to be dynamic but return hardcoded results
- Error Prevention: Catch problems before they happen, not just handle them
- Systems Thinking: Consider how changes affect the bigger picture
- Zero Technical Debt: No quick hacks that compromise system integrity
File Size & Modularity
- Small Files: Files SHOULD NOT exceed 200 lines; files over 300 lines MUST be split
- Single Responsibility: Each file MUST have one clear purpose - if you need "and" to describe it, split it
- Function Length: Functions SHOULD NOT exceed 40 lines; extract helpers for complex logic
- Early Extraction: When a file approaches 150 lines, proactively identify extraction candidates
Barrel Exports
- Index Files: Every module directory MUST have an
index.tsbarrel file - Public API: Barrel files MUST explicitly export only the public interface - internal helpers stay private
- Import Paths: Consumers MUST import from barrel files, not deep paths (e.g.,
import { Thing } from './module'not'./module/thing') - Re-export Pattern: Use
export { ComponentName } from './ComponentName'- SHOULD NOT useexport *to keep API explicit - Flat Imports: Barrel exports enable refactoring internals without breaking consumers
Core Development Rules
1. Domain Separation - Organize code by business domain, not technical layers 2. Configuration Management - Store configuration in persistent storage; MUST NOT use hardcoded values 3. Specialized Modules - Create focused modules for specific business logic 4. Structured Data Validation - MUST validate all external data with proper schemas 5. Pattern Recognition - Extract common patterns into reusable utilities early 6. Function Relationships - Consider how different functions interact and consolidate when patterns emerge 7. Clean Organization - Maintain clear directory structure and avoid code sprawl
</rules>
<workflow>
Development Tool Best Practices
1. Read First: MUST understand existing code before making changes - read files and understand context fully 2. Search Smart: Use appropriate search tools for file patterns and content discovery 3. Batch Operations: Group related operations when possible for better performance 4. Edit Precisely: SHOULD make targeted changes rather than broad rewrites when possible 5. Plan Complex Tasks: Break down multi-step operations into manageable pieces 6. Schema Work: MUST review existing data structures and schemas before modifications
Integration Best Practices
- Data Flow: Design clear data flow patterns (input → validation → processing → output)
- Service Communication: Use well-defined APIs for service-to-service communication
- Frontend Integration: Optimize queries and data fetching for performance
- Error Propagation: Design consistent error handling across all layers
- Testing Strategy: SHOULD implement comprehensive testing at unit, integration, and system levels
</workflow>
<guidelines>
Type Safety Patterns (Modern Approach)
- Trust Inference: Let your type system infer types rather than explicitly typing everything
- Return Types Sparingly: Only add explicit return types when they add value or prevent errors
- Inference > Explicit: Modern type systems are smarter than manual type annotations
- Structured Data: Use strict schemas for API boundaries, flexible types for internal logic
- Type Safety First: Prefer typed languages and SHOULD avoid
anytypes when possible - End-to-End Types: Leverage type safety across your entire stack when available
- Validation: Use runtime validation for external data and API boundaries
Project Documentation
Essential documentation files to maintain:
README.md- Project overview, setup instructions, and getting started guideCHANGELOG.md- Version history and breaking changesCONTRIBUTING.md- Development workflow, coding standards, and contribution guidelinesARCHITECTURE.md- System design, directory structure, and technical decisionsAPI.md- API documentation and endpoint specifications
</guidelines>
<architecture>
Modular Architecture
Modules MUST be self-contained units with clear boundaries:
feature/
├── index.ts # Barrel - public API (REQUIRED)
├── types.ts # Shared types for this module
├── FeatureMain.tsx # Primary component/logic
├── useFeature.ts # Hooks (if React)
└── helpers/ # Internal utilities
├── index.ts # Barrel for helpers
└── validate.ts- Module Boundaries: Each feature MUST be importable via single barrel entry
- Dependency Direction: Modules SHOULD depend on abstractions, not concrete implementations
- Circular Prevention: Modules MUST NOT have circular dependencies - extract shared code to common module
- Colocation: Keep related code together - tests, types, and helpers alongside implementation
System Design Patterns
- Database-First Design: Store configuration and business logic in persistent storage
- Service Separation: Isolate different concerns into separate services when appropriate
- API-First Development: Design clear interfaces between system components
- Real-time Capabilities: Leverage real-time features when user experience benefits
Common Architecture Patterns
- Pipeline Processing: Chain operations in logical sequences (input → process → output)
- Event-Driven Systems: Use events for loose coupling between components
- Caching Strategies: Implement appropriate caching for performance optimization
- Error Handling: Design robust error handling and recovery mechanisms
- Scalability Planning: Consider horizontal and vertical scaling from the start
</architecture>
Events Reference
Auto-generated on 2025-12-26T13:17:55.481Z
Source: packages/sdk/js/src/v2/gen/types.gen.ts<event_types>
Event Union (35 types)
export type Event =
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventProjectUpdated
| EventServerInstanceDisposed
| EventLspClientDiagnostics
| EventLspUpdated
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
| EventMessagePartRemoved
| EventPermissionUpdated
| EventPermissionReplied
| EventFileEdited
| EventTodoUpdated
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventMcpToolsChanged
| EventCommandExecuted
| EventSessionCreated
| EventSessionUpdated
| EventSessionDeleted
| EventSessionDiff
| EventSessionError
| EventFileWatcherUpdated
| EventVcsBranchUpdated
| EventPtyCreated
| EventPtyUpdated
| EventPtyExited
| EventPtyDeleted
| EventServerConnected
| EventGlobalDisposed</event_types>
<quick_reference>
Quick Reference
| Event Type | TypeScript Type |
|---|---|
installation.updated | EventInstallationUpdated |
installation.update-available | EventInstallationUpdateAvailable |
project.updated | EventProjectUpdated |
server.instance.disposed | EventServerInstanceDisposed |
lsp.client.diagnostics | EventLspClientDiagnostics |
lsp.updated | EventLspUpdated |
message.updated | EventMessageUpdated |
message.removed | EventMessageRemoved |
message.part.updated | EventMessagePartUpdated |
message.part.removed | EventMessagePartRemoved |
permission.updated | EventPermissionUpdated |
permission.replied | EventPermissionReplied |
file.edited | EventFileEdited |
todo.updated | EventTodoUpdated |
session.status | EventSessionStatus |
session.idle | EventSessionIdle |
session.compacted | EventSessionCompacted |
tui.prompt.append | EventTuiPromptAppend |
tui.command.execute | EventTuiCommandExecute |
tui.toast.show | EventTuiToastShow |
mcp.tools.changed | EventMcpToolsChanged |
command.executed | EventCommandExecuted |
session.created | EventSessionCreated |
session.updated | EventSessionUpdated |
session.deleted | EventSessionDeleted |
session.diff | EventSessionDiff |
session.error | EventSessionError |
file.watcher.updated | EventFileWatcherUpdated |
vcs.branch.updated | EventVcsBranchUpdated |
pty.created | EventPtyCreated |
pty.updated | EventPtyUpdated |
pty.exited | EventPtyExited |
pty.deleted | EventPtyDeleted |
server.connected | EventServerConnected |
global.disposed | EventGlobalDisposed |
</quick_reference>
<event_definitions>
Events by Category
command
command.executed
export type EventCommandExecuted = {
type: "command.executed"
properties: {
name: string
sessionID: string
arguments: string
messageID: string
}
}file
file.edited
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}file.watcher.updated
export type EventFileWatcherUpdated = {
type: "file.watcher.updated"
properties: {
file: string
event: "add" | "change" | "unlink"
}
}global
global.disposed
export type EventGlobalDisposed = {
type: "global.disposed"
properties: {
[key: string]: unknown
}
}installation
installation.updated
export type EventInstallationUpdated = {
type: "installation.updated"
properties: {
version: string
}
}installation.update-available
export type EventInstallationUpdateAvailable = {
type: "installation.update-available"
properties: {
version: string
}
}lsp
lsp.client.diagnostics
export type EventLspClientDiagnostics = {
type: "lsp.client.diagnostics"
properties: {
serverID: string
path: string
}
}lsp.updated
export type EventLspUpdated = {
type: "lsp.updated"
properties: {
[key: string]: unknown
}
}mcp
mcp.tools.changed
export type EventMcpToolsChanged = {
type: "mcp.tools.changed"
properties: {
server: string
}
}message
message.updated
export type EventMessageUpdated = {
type: "message.updated"
properties: {
info: Message
}
}message.removed
export type EventMessageRemoved = {
type: "message.removed"
properties: {
sessionID: string
messageID: string
}
}message.part.updated
export type EventMessagePartUpdated = {
type: "message.part.updated"
properties: {
part: Part
delta?: string
}
}message.part.removed
export type EventMessagePartRemoved = {
type: "message.part.removed"
properties: {
sessionID: string
messageID: string
partID: string
}
}permission
permission.updated
export type EventPermissionUpdated = {
type: "permission.updated"
properties: Permission
}permission.replied
export type EventPermissionReplied = {
type: "permission.replied"
properties: {
sessionID: string
permissionID: string
response: string
}
}project
project.updated
export type EventProjectUpdated = {
type: "project.updated"
properties: Project
}pty
pty.created
export type EventPtyCreated = {
type: "pty.created"
properties: {
info: Pty
}
}pty.updated
export type EventPtyUpdated = {
type: "pty.updated"
properties: {
info: Pty
}
}pty.exited
export type EventPtyExited = {
type: "pty.exited"
properties: {
id: string
exitCode: number
}
}pty.deleted
export type EventPtyDeleted = {
type: "pty.deleted"
properties: {
id: string
}
}server
server.instance.disposed
export type EventServerInstanceDisposed = {
type: "server.instance.disposed"
properties: {
directory: string
}
}server.connected
export type EventServerConnected = {
type: "server.connected"
properties: {
[key: string]: unknown
}
}session
session.status
export type EventSessionStatus = {
type: "session.status"
properties: {
sessionID: string
status: SessionStatus
}
}session.idle
export type EventSessionIdle = {
type: "session.idle"
properties: {
sessionID: string
}
}session.compacted
export type EventSessionCompacted = {
type: "session.compacted"
properties: {
sessionID: string
}
}session.created
export type EventSessionCreated = {
type: "session.created"
properties: {
info: Session
}
}session.updated
export type EventSessionUpdated = {
type: "session.updated"
properties: {
info: Session
}
}session.deleted
export type EventSessionDeleted = {
type: "session.deleted"
properties: {
info: Session
}
}session.diff
export type EventSessionDiff = {
type: "session.diff"
properties: {
sessionID: string
diff: Array<FileDiff>
}
}session.error
export type EventSessionError = {
type: "session.error"
properties: {
sessionID?: string
error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError | ApiError
}
}todo
todo.updated
export type EventTodoUpdated = {
type: "todo.updated"
properties: {
sessionID: string
todos: Array<Todo>
}
}tui
tui.prompt.append
export type EventTuiPromptAppend = {
type: "tui.prompt.append"
properties: {
text: string
}
}tui.command.execute
export type EventTuiCommandExecute = {
type: "tui.command.execute"
properties: {
command:
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
}
}tui.toast.show
export type EventTuiToastShow = {
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
/**
* Duration in milliseconds
*/
duration?: number
}
}vcs
vcs.branch.updated
export type EventVcsBranchUpdated = {
type: "vcs.branch.updated"
properties: {
branch?: string
}
}</event_definitions>
Complete Plugin Examples
Ready-to-use plugin examples for common use cases
<examples>
Notifications Plugin
Send OS notifications when sessions complete:
import type { Plugin } from "@opencode-ai/plugin"
export const NotifyPlugin: Plugin = async ({ $ }) => {
return {
event: async ({ event }) => {
if (event.type === "session.idle") {
await $`osascript -e 'display notification "Done!" with title "OpenCode"'`
}
},
}
}.env Protection Plugin
Block reading of sensitive environment files:
import type { Plugin } from "@opencode-ai/plugin"
export const EnvProtection: Plugin = async () => {
return {
"tool.execute.before": async (input, output) => {
const isRead = input.tool === "read"
const isEnv = output.args.filePath?.match(/\.env($|\.)/)
if (isRead && isEnv) {
throw new Error("Blocked: .env files cannot be read")
}
},
}
}Temperature Override Plugin
Force specific LLM parameters:
import type { Plugin } from "@opencode-ai/plugin"
export const TempPlugin: Plugin = async () => {
return {
"chat.params": async (input, output) => {
output.temperature = 0.3 // More deterministic
output.topP = 0.9
},
}
}Session Logger Plugin
Log all session events:
import type { Plugin } from "@opencode-ai/plugin"
export const LoggerPlugin: Plugin = async () => {
return {
event: async ({ event }) => {
console.log(`[${new Date().toISOString()}] ${event.type}`, event.properties)
},
}
}Command Blocker Plugin
Block dangerous bash commands:
import type { Plugin } from "@opencode-ai/plugin"
const BLOCKED_PATTERNS = [/rm\s+-rf\s+\//, /sudo\s+rm/, />\s*\/dev\/sd/]
export const CommandBlocker: Plugin = async () => {
return {
"tool.execute.before": async (input, output) => {
if (input.tool !== "bash") return
const command = output.args.command as string
for (const pattern of BLOCKED_PATTERNS) {
if (pattern.test(command)) {
throw new Error(`Blocked dangerous command: ${command}`)
}
}
},
}
}Auto-Approve Plugin
Auto-approve specific permission types:
import type { Plugin } from "@opencode-ai/plugin"
export const AutoApprove: Plugin = async () => {
return {
"permission.ask": async (input, output) => {
// Auto-approve read operations
if (input.type === "read") {
output.status = "allow"
}
// Auto-approve specific tools
if (input.type === "tool" && input.metadata.tool === "glob") {
output.status = "allow"
}
},
}
}Custom Tool Plugin
Add a custom tool for the LLM:
import { type Plugin, tool } from "@opencode-ai/plugin"
export const CustomTool: Plugin = async ({ $ }) => {
return {
tool: {
wordcount: tool({
description: "Count words in a file",
args: {
file: tool.schema.string().describe("Path to file"),
},
async execute(args) {
const result = await $`wc -w ${args.file}`.quiet()
return result.text().trim()
},
}),
},
}
}</examples>
Hook Patterns Reference
All hook implementation patterns with examples
<patterns>
1. Event Hook (Reactive)
Listen to all events, discriminate by type:
return {
event: async ({ event }) => {
switch (event.type) {
case "session.idle":
console.log("Session completed:", event.properties.sessionID)
break
case "file.edited":
console.log("File changed:", event.properties.file)
break
}
},
}2. Custom Tools
Register tools the LLM can call:
import { type Plugin, tool } from "@opencode-ai/plugin"
export const MyPlugin: Plugin = async (ctx) => {
return {
tool: {
lint: tool({
description: "Run ESLint on a file",
args: {
file: tool.schema.string().describe("File path to lint"),
fix: tool.schema.boolean().optional().describe("Auto-fix issues"),
},
async execute(args, context) {
const result = await ctx.$`eslint ${args.fix ? "--fix" : ""} ${args.file}`.quiet()
return result.text()
},
}),
},
}
}3. Tool Execution Hooks
Intercept before/after tool execution:
return {
// Modify args or throw to block
"tool.execute.before": async (input, output) => {
if (input.tool === "read" && output.args.filePath?.includes(".env")) {
throw new Error("Reading .env files is blocked")
}
},
// Modify output/title/metadata
"tool.execute.after": async (input, output) => {
console.log(`Tool ${input.tool} completed`)
// Modify: output.title, output.output, output.metadata
},
}4. Permission Hook
Override permission decisions:
return {
"permission.ask": async (input, output) => {
// input: { id, type, pattern, sessionID, messageID, title, metadata }
// output.status: "ask" | "deny" | "allow"
if (input.type === "bash" && input.metadata.command?.includes("rm -rf")) {
output.status = "deny"
}
},
}5. Chat Hooks
Modify messages or LLM parameters:
return {
// Intercept user messages
"chat.message": async (input, output) => {
// input: { sessionID, agent?, model?, messageID? }
// output: { message: UserMessage, parts: Part[] }
console.log("User message:", output.message)
},
// Modify LLM parameters per request
"chat.params": async (input, output) => {
// input: { sessionID, agent, model, provider, message }
// output: { temperature, topP, topK, options }
if (input.agent === "creative") {
output.temperature = 0.9
}
},
}6. Auth Hook
Add custom provider authentication:
return {
auth: {
provider: "my-provider",
methods: [
{
type: "api",
label: "API Key",
prompts: [
{
type: "text",
key: "apiKey",
message: "Enter your API key",
validate: (v) => (v.length < 10 ? "Key too short" : undefined),
},
],
async authorize(inputs) {
return { type: "success", key: inputs!.apiKey }
},
},
],
},
}7. Compaction Hook
Customize session compaction:
return {
"experimental.session.compacting": async (input, output) => {
// Add context to default prompt
output.context.push("Remember: user prefers TypeScript")
// OR replace entire prompt
output.prompt = "Summarize this session focusing on code changes..."
},
}8. Config Hook
Modify configuration on load:
return {
config: async (config) => {
// Mutate config object
config.theme = "dark"
},
}</patterns>
<quick_reference>
Hook Signature Quick Reference
| Hook | Signature | Mutate |
|---|---|---|
event | ({ event }) => void | Read-only |
config | (config) => void | Mutate config |
tool | Object of tool() definitions | N/A |
auth | AuthHook object | N/A |
chat.message | (input, output) => void | Mutate output |
chat.params | (input, output) => void | Mutate output |
permission.ask | (input, output) => void | Set output.status |
tool.execute.before | (input, output) => void | Mutate output.args |
tool.execute.after | (input, output) => void | Mutate output |
experimental.* | (input, output) => void | Mutate output |
</quick_reference>
Plugin Hooks Reference
Auto-generated on 2025-12-26T13:17:55.481Z
Source: packages/plugin/src/index.ts<api_reference>
Plugin Function Signature
export type PluginInput = {
client: ReturnType<typeof createOpencodeClient>
project: Project
directory: string
worktree: string
$: BunShell
}
export type Plugin = (input: PluginInput) => Promise<Hooks>Hooks Interface
export interface Hooks {
event?: (input: { event: Event }) => Promise<void>
config?: (input: Config) => Promise<void>
tool?: {
[key: string]: ToolDefinition
}
auth?: AuthHook
/**
* Called when a new message is received
*/
"chat.message"?: (
input: { sessionID: string; agent?: string; model?: { providerID: string; modelID: string }; messageID?: string },
output: { message: UserMessage; parts: Part[] },
) => Promise<void>
/**
* Modify parameters sent to LLM
*/
"chat.params"?: (
input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage },
output: { temperature: number; topP: number; topK: number; options: Record<string, any> },
) => Promise<void>
"permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise<void>
"tool.execute.before"?: (
input: { tool: string; sessionID: string; callID: string },
output: { args: any },
) => Promise<void>
"tool.execute.after"?: (
input: { tool: string; sessionID: string; callID: string },
output: {
title: string
output: string
metadata: any
},
) => Promise<void>
"experimental.chat.messages.transform"?: (
input: {},
output: {
messages: {
info: Message
parts: Part[]
}[]
},
) => Promise<void>
"experimental.chat.system.transform"?: (
input: {},
output: {
system: string[]
},
) => Promise<void>
/**
* Called before session compaction starts. Allows plugins to customize
* the compaction prompt.
*
* - `context`: Additional context strings appended to the default prompt
* - `prompt`: If set, replaces the default compaction prompt entirely
*/
"experimental.session.compacting"?: (
input: { sessionID: string },
output: { context: string[]; prompt?: string },
) => Promise<void>
"experimental.text.complete"?: (
input: { sessionID: string; messageID: string; partID: string },
output: { text: string },
) => Promise<void>
}</api_reference>
<hook_categories>
Hook Categories
Event Hook
event: Receives all events, useevent.typeto discriminate
Tool Hook
tool: Register custom tools (see tool-helper.md)
Chat Hooks
chat.message: Intercept/modify user messages before processingchat.params: Modify LLM parameters (temperature, topP, topK)
Permission Hook
permission.ask: Override permission decisions (allow/deny/ask)
Tool Execution Hooks
tool.execute.before: Intercept before tool runs, modify argstool.execute.after: Process tool output, modify title/metadata
Config Hook
config: Modify configuration on load
Auth Hook
auth: Custom provider authentication (OAuth or API key)
Experimental Hooks
experimental.chat.messages.transform: Transform message historyexperimental.chat.system.transform: Modify system promptexperimental.session.compacting: Customize compaction contextexperimental.text.complete: Post-process text output
</hook_categories>
<auth_hook_types>
Auth Hook Types
export type AuthHook = {
provider: string
loader?: (auth: () => Promise<Auth>, provider: Provider) => Promise<Record<string, any>>
methods: (
| {
type: "oauth"
label: string
prompts?: Array<
| {
type: "text"
key: string
message: string
placeholder?: string
validate?: (value: string) => string | undefined
condition?: (inputs: Record<string, string>) => boolean
}
| {
type: "select"
key: string
message: string
options: Array<{
label: string
value: string
hint?: string
}>
condition?: (inputs: Record<string, string>) => boolean
}
>
authorize(inputs?: Record<string, string>): Promise<AuthOuathResult>
}
| {
type: "api"
label: string
prompts?: Array<
| {
type: "text"
key: string
message: string
placeholder?: string
validate?: (value: string) => string | undefined
condition?: (inputs: Record<string, string>) => boolean
}
| {
type: "select"
key: string
message: string
options: Array<{
label: string
value: string
hint?: string
}>
condition?: (inputs: Record<string, string>) => boolean
}
>
authorize?(inputs?: Record<string, string>): Promise<
| {
type: "success"
key: string
provider?: string
}
| {
type: "failed"
}
>
}
)[]
}
export type AuthOuathResult = { url: string; instructions: string } & (
| {
method: "auto"
callback(): Promise<
| ({
type: "success"
provider?: string
} & (
| {
refresh: string
access: string
expires: number
}
| { key: string }
))
| {
type: "failed"
}
>
}
| {
method: "code"
callback(code: string): Promise<
| ({
type: "success"
provider?: string
} & (
| {
refresh: string
access: string
expires: number
}
| { key: string }
))
| {
type: "failed"
}
>
}
)
</auth_hook_types>
Publishing Plugins
How to publish plugins to npm
<instructions>
Before Publishing - Ask the User
Before creating a publishable package, MUST ask the user:
1. Package name: What should the npm package be called?
- Unscoped:
opencode-my-plugin - Scoped:
@username/opencode-my-plugin
2. npm scope/username: If scoped, what's their npm username or org?
3. Version: Starting version? (default: 0.1.0)
4. License: MIT, Apache-2.0, etc.? (default: MIT)
5. Description: One-line description of what the plugin does
<example>
Example prompt:
"Before I create the npm package, I need a few details:
>
1. What should the package name be? (e.g.,opencode-background-processor@yourusername/opencode-background-process)
2. What's your npm username/scope if using a scoped package?
3. Starting version? (default: 0.1.0)
4. License? (default: MIT)"
</example>
How OpenCode Manages Plugins
Users do NOT need to run `npm install` - OpenCode automatically installs plugin dependencies at runtime.
Users simply add the plugin name to their config:
{
"plugin": [
"my-plugin@1.0.0", // Pinned version - won't auto-update
"another-plugin", // No version = "latest" - updates on launch
],
}On launch, OpenCode:
1. Runs bun add --force for each plugin (auto-installs) 2. Caches pinned versions until user changes config 3. For unpinned plugins, resolves latest and caches actual version
This means the README SHOULD NOT include npm install instructions - just tell users to add the plugin to their config.
</instructions>
<checklist>
Publishing Checklist
1. Package structure:
my-plugin/
├── src/
│ └── index.ts # Main plugin entry
├── dist/ # Built output (gitignored)
├── package.json
├── tsconfig.json
├── README.md
├── LICENSE
├── example-opencode.json # Example config for users
├── .gitignore
└── .npmignore2. package.json (replace placeholders with user's answers):
{
"name": "<PACKAGE_NAME>",
"version": "<VERSION>",
"description": "<DESCRIPTION>",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist", "README.md", "LICENSE"],
"keywords": ["opencode", "opencode-plugin", "plugin"],
"license": "<LICENSE>",
"peerDependencies": {
"@opencode-ai/plugin": "^1.0.0"
},
"devDependencies": {
"@opencode-ai/plugin": "^1.0.0",
"@types/bun": "^1.2.0",
"@types/node": "^22.0.0",
"typescript": "^5.7.0"
},
"scripts": {
"clean": "rm -rf dist",
"build": "npm run clean && tsc",
"prepublishOnly": "npm run build"
},
"publishConfig": {
"access": "public"
}
}Notes:
- MUST use
peerDependenciesfor@opencode-ai/plugin- OpenCode provides this at runtime - MUST add
"publishConfig": { "access": "public" }for scoped packages
3. example-opencode.json:
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["<PACKAGE_NAME>"]
}4. README.md Installation Section:
````markdown
Installation
Add to your opencode.json:
{
"plugin": ["<PACKAGE_NAME>"]
}OpenCode automatically installs plugin dependencies at runtime. ````
5. Publish:
For scoped packages (first time):
npm publish --access publicFor unscoped or subsequent publishes:
npm publish</checklist>
<update_notifications>
Update Notifications for Pinned Versions
When users pin to a specific version (e.g., my-plugin@1.0.0), they won't see updates automatically.
SHOULD include an update checker that shows a toast when newer versions are available. See references/update-notifications.md for the full implementation.
</update_notifications>
<common_mistakes>
Common Mistakes
| Mistake | Fix |
|---|---|
Missing type: "module" | Add to package.json |
| Not building before publish | Add prepublishOnly script |
| Wrong main entry | Point to compiled JS, not TS |
| Missing @opencode-ai/plugin dep | Add as peerDependency |
| Scoped package 404 | Add publishConfig.access: "public" |
| Assumed package name | MUST ask user for name/scope first |
</common_mistakes>
Testing Plugins
How to test plugins during development
<workflow>
Step 1: Create Test Environment
Create a test folder with an opencode.json that loads your plugin from source.
If developing within opencode source folder:
mkdir -p /path/to/opencode/test-my-plugin// /path/to/opencode/test-my-plugin/opencode.json
{
"plugin": ["file:///path/to/opencode/.opencode/plugin/my-plugin/index.ts"],
}If developing in a standalone folder:
mkdir -p ~/my-plugin-project/test// ~/my-plugin-project/test/opencode.json
{
"plugin": ["file:///home/user/my-plugin-project/my-plugin/index.ts"],
}The file:// prefix tells OpenCode to load the plugin directly from source (TypeScript or JavaScript) without requiring npm publish.
Step 2: Verify Plugin Loads
Run a quick command to verify the plugin initializes without errors:
cd /path/to/test-folder
opencode run hiThis will:
- Start OpenCode
- Load all plugins (including yours)
- Run "hi" prompt
- Exit
Watch for:
- Plugin initialization errors in output
- Missing dependencies
- TypeScript compilation errors
Step 3: Interactive Testing
Run OpenCode interactively in the test folder:
cd /path/to/test-folder
opencodeTest checklist based on hook type:
| Hook | How to Test |
|---|---|
event | Perform actions that trigger events, check console logs |
tool | Ask the LLM to use your custom tool |
tool.execute.before | Run the tool being intercepted, verify blocking/modification |
tool.execute.after | Run tools, check output modifications |
permission.ask | Trigger permission prompts, verify overrides |
chat.params | Check LLM behavior changes (temperature, etc.) |
config | Verify config mutations take effect |
auth | Run /auth command for your provider |
Step 4: Testing Toasts and UI Feedback
If your plugin shows toasts or inline messages:
1. Trigger the condition that shows the notification 2. Verify toast appears with correct variant/message 3. Check duration is appropriate 4. Test error cases (TUI unavailable) - plugin MUST NOT crash
</workflow>
<example>
Example Test Session
# Create test folder
mkdir -p ~/test-env-plugin
cd ~/test-env-plugin
# Create config pointing to plugin source
cat > opencode.json << 'EOF'
{
"plugin": [
"file:///home/user/my-plugins/env-protection/index.ts"
]
}
EOF
# Verify plugin loads
opencode run hi
# If no errors, test interactively
opencode
# In opencode, test the functionality:
# > Read the .env file
# (Should be blocked if env-protection plugin works)</example>
<unit_testing>
Unit Testing (Optional)
For complex plugins, MAY create unit tests with mocked context:
// test-plugin.ts
import { MyPlugin } from "./my-plugin"
const mockClient = {
tui: {
showToast: async (params: any) => {
console.log("Toast:", params.body)
return true
},
},
session: {
prompt: async (params: any) => {
console.log("Inline message:", params.body.parts[0].text)
},
},
}
const mockContext = {
project: { id: "test", worktree: "/tmp", time: { created: 0, updated: 0 } },
client: mockClient as any,
$: Bun.$ as any,
directory: "/tmp",
worktree: "/tmp",
}
const hooks = await MyPlugin(mockContext)
// Test event hook
await hooks.event?.({
event: { type: "session.idle", properties: { sessionID: "123" } },
})
// Test tool execution hook
await hooks["tool.execute.before"]?.({ tool: "read", sessionID: "123", callID: "abc" }, { args: { filePath: ".env" } })Run with:
bun run test-plugin.ts</unit_testing>
Toast Notifications
Reference for showing toast notifications in OpenCode TUI
<overview>
Plugins can display toast notifications - temporary popup messages that appear in the TUI corner. These are ideal for brief status updates, confirmations, warnings, or alerts that don't need to persist in the chat.
</overview>
<guidelines>
When to Use
Use toasts for:
- Success confirmations ("Settings saved", "File exported")
- Configuration errors or warnings
- Model/provider fallback notices
- Brief status updates
- Non-critical alerts
SHOULD NOT use for:
- Detailed information (use inline messages instead - see
ui-feedback.md) - Persistent status that user needs to reference later
- High-frequency updates (will spam the user)
</guidelines>
<api_reference>
The API
SDK Method
await client.tui.showToast({
body: {
title: "Optional Title", // Optional heading
message: "Toast message", // Required message text
variant: "success", // "info" | "success" | "warning" | "error"
duration: 5000, // Optional: milliseconds
},
})Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
title | string | No | Optional heading for the toast |
message | string | Yes | The main message content |
variant | `"info" \ | "success" \ | "warning" \ |
duration | number | No | Auto-dismiss time in milliseconds |
Variants
| Variant | Use Case | Visual |
|---|---|---|
info | Neutral information, fallback notices | Blue/neutral styling |
success | Successful operations | Green styling with checkmark |
warning | Configuration issues, caution notices | Yellow/orange styling |
error | Failures or critical problems | Red styling |
</api_reference>
<examples>
Complete Example
import type { Plugin } from "@opencode-ai/plugin"
export const ToastPlugin: Plugin = async ({ client }) => {
return {
event: async ({ event }) => {
if (event.type === "session.idle") {
try {
await client.tui.showToast({
body: {
title: "Session Complete",
message: "All tasks finished successfully",
variant: "success",
duration: 4000,
},
})
} catch {
// Ignore toast errors - TUI may not be available
}
}
},
}
}Error Handling
MUST wrap toast calls in try/catch - the TUI MAY not be available (e.g., in headless mode):
async function showToast(
client: any,
message: string,
variant: "info" | "success" | "warning" | "error" = "info",
title?: string,
duration?: number,
): Promise<void> {
try {
await client.tui.showToast({
body: {
title,
message,
variant,
duration,
},
})
} catch {
// Ignore - TUI may not be available
}
}</examples>
<patterns>
Practical Patterns
Delayed Toast (Avoid Blocking Init)
When showing toasts during plugin initialization, SHOULD use setTimeout to avoid blocking:
export const ConfigPlugin: Plugin = async ({ client }) => {
const config = loadConfig()
if (config.hasErrors) {
// Delay toast to avoid blocking plugin init
setTimeout(async () => {
try {
await client.tui.showToast({
body: {
title: "Plugin: Invalid config",
message: `${config.path}\n${config.errorMessage}\nUsing default values`,
variant: "warning",
duration: 7000,
},
})
} catch {}
}, 7000) // Delay allows TUI to fully initialize
}
return {
// ... hooks
}
}Multi-line Messages
Use \n for line breaks in toast messages:
await client.tui.showToast({
body: {
title: "Model Fallback",
message: `anthropic/claude-3-opus failed\nUsing openai/gpt-4 instead`,
variant: "info",
duration: 5000,
},
})Notification on Session Events
return {
event: async ({ event }) => {
switch (event.type) {
case "session.idle":
try {
await client.tui.showToast({
body: {
message: "Session completed",
variant: "success",
},
})
} catch {}
break
case "session.error":
try {
await client.tui.showToast({
body: {
title: "Error",
message: "Session encountered an error",
variant: "error",
},
})
} catch {}
break
}
},
}Configuration Validation Warnings
function showConfigWarning(client: any, configPath: string, errors: string[]): void {
const message = [configPath, ...errors.slice(0, 2), errors.length > 2 ? `(+${errors.length - 2} more errors)` : ""]
.filter(Boolean)
.join("\n")
setTimeout(async () => {
try {
await client.tui.showToast({
body: {
title: "MyPlugin: Invalid config",
message,
variant: "warning",
duration: 7000,
},
})
} catch {}
}, 7000)
}Model/Provider Fallback Notice
return {
"chat.params": async (input, output) => {
const preferredModel = getPreferredModel()
const actualModel = input.model
if (preferredModel && actualModel.id !== preferredModel.id) {
try {
await client.tui.showToast({
body: {
title: "Model Fallback",
message: `${preferredModel.provider}/${preferredModel.id} unavailable\nUsing ${actualModel.providerID}/${actualModel.id}`,
variant: "info",
duration: 5000,
},
})
} catch {}
}
},
}</patterns>
<comparison>
Toast vs Inline Messages
| Aspect | Toast | Inline Message |
|---|---|---|
| Visibility | Temporary popup corner | Persistent in chat |
| Duration | Auto-dismisses | Stays until scrolled away |
| Detail level | Brief (1-3 lines) | Can be multi-line |
| History | Not saved | Visible in session |
| Use case | Quick alerts, warnings | Detailed status with data |
Use toasts for ephemeral alerts and warnings. Use inline messages (see ui-feedback.md) for detailed status that users might want to reference.
</comparison>
<constraints>
Limitations
| Limitation | Details |
|---|---|
| No interactivity | MUST NOT include buttons or inputs |
| Brief content only | SHOULD keep to 1-3 lines |
| No custom styling | Limited to predefined variants |
| TUI only | Won't appear in web or headless mode |
| May fail silently | MUST wrap in try/catch |
| Rate limiting | SHOULD avoid rapid-fire toasts |
</constraints>
Tool Helper Reference
Auto-generated on 2025-12-26T13:17:55.482Z
Source: packages/plugin/src/tool.ts<api_reference>
Tool Definition
import { z } from "zod"
export type ToolContext = {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
}
export function tool<Args extends z.ZodRawShape>(input: {
description: string
args: Args
execute(args: z.infer<z.ZodObject<Args>>, context: ToolContext): Promise<string>
}) {
return input
}
tool.schema = z
export type ToolDefinition = ReturnType<typeof tool>
Usage Pattern
import { type Plugin, tool } from "@opencode-ai/plugin"
export const MyPlugin: Plugin = async (ctx) => {
return {
tool: {
myTool: tool({
description: "What this tool does",
args: {
input: tool.schema.string().describe("Input parameter"),
count: tool.schema.number().optional().describe("Optional count"),
enabled: tool.schema.boolean().default(true),
},
async execute(args, context) {
// args is typed from schema
// context: { sessionID, messageID, agent, abort }
return `Result: ${args.input}`
},
}),
},
}
}</api_reference>
<zod_reference>
Zod Schema Methods
tool.schema is Zod. Common methods:
| Method | Description |
|---|---|
.string() | String argument |
.number() | Number argument |
.boolean() | Boolean argument |
.array(schema) | Array of items |
.object({ ... }) | Nested object |
.enum(["a", "b"]) | Enum values |
.optional() | Make optional |
.default(val) | Default value |
.describe("...") | Add description for LLM |
</zod_reference>
<tool_context>
Tool Context
type ToolContext = {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
}</tool_context>
Inline Status Messages
Reference for displaying persistent status messages in OpenCode chat
<overview>
Plugins can display inline message boxes in the chat using the SDK's session.prompt API with special flags. This creates visible status updates that persist in the chat without triggering LLM responses.
</overview>
<guidelines>
When to Use
Use inline messages when your plugin needs to:
- Show detailed progress or statistics
- Display multi-line status information
- Provide data the user MAY want to reference later
- Confirm actions with details (files processed, tokens saved, etc.)
SHOULD NOT use for:
- Brief alerts (use toast notifications instead - see
toast-notifications.md) - High-frequency updates (will spam the chat)
- Critical errors requiring immediate attention (use toasts)
</guidelines>
<api_reference>
The Technique
Core API Call
await client.session.prompt({
path: {
id: sessionID,
},
body: {
noReply: true, // Prevents LLM from responding
agent: agent, // Optional: specify agent
model: model, // Optional: specify model
parts: [
{
type: "text",
text: message, // Your status message
ignored: true, // Message won't be included in context
},
],
},
})Key Flags
| Flag | Purpose |
|---|---|
noReply: true | Prevents the LLM from generating a response to this message |
ignored: true | Message appears in UI but is excluded from conversation context |
Both flags are REQUIRED for status-only messages.
</api_reference>
<examples>
Complete Example
import type { Plugin } from "@opencode-ai/plugin"
async function sendStatusMessage(
client: any,
sessionID: string,
text: string,
agent?: string,
model?: { providerID: string; modelID: string },
): Promise<void> {
try {
await client.session.prompt({
path: { id: sessionID },
body: {
noReply: true,
agent,
model,
parts: [
{
type: "text",
text,
ignored: true,
},
],
},
})
} catch (error: any) {
console.error("Failed to send status message:", error.message)
}
}
export const StatusPlugin: Plugin = async ({ client }) => {
let currentSessionID: string | null = null
return {
"chat.params": async (input, output) => {
currentSessionID = input.sessionID
},
event: async ({ event }) => {
if (event.type === "session.idle" && currentSessionID) {
await sendStatusMessage(client, currentSessionID, "▣ MyPlugin | Session completed successfully")
}
},
}
}</examples>
<formatting>
Message Formatting Best Practices
Use Visual Prefixes
SHOULD use Unicode symbols as visual markers to distinguish plugin messages:
const message = "▣ MyPlugin | Status message here"Common prefixes:
▣- Filled square (general status)→- Arrow (list items)─- Horizontal line (separators)
Format Statistics
function formatTokenCount(tokens: number): string {
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}K`.replace(".0K", "K") + " tokens"
}
return tokens.toString() + " tokens"
}
const message = `▣ MyPlugin | ~${formatTokenCount(savedTokens)} saved total`Multi-line Messages
const lines = [
"▣ MyPlugin | Operation Complete",
"",
"▣ Details:",
"→ Files processed: 5",
"→ Items removed: 12",
"→ Time: 230ms",
]
const message = lines.join("\n")Truncate Long Paths
function truncate(str: string, maxLen: number = 60): string {
if (str.length <= maxLen) return str
return str.slice(0, maxLen - 3) + "..."
}
function shortenPath(path: string, workingDirectory?: string): string {
if (workingDirectory && path.startsWith(workingDirectory + "/")) {
return path.slice(workingDirectory.length + 1)
}
return path
}
// Usage
const displayPath = truncate(shortenPath(fullPath, ctx.directory), 60)</formatting>
<patterns>
Capturing Session Context
To send messages, you need the sessionID. Capture it from hooks:
import type { Plugin } from "@opencode-ai/plugin"
export const MyPlugin: Plugin = async ({ client }) => {
let currentSessionID: string | null = null
let currentAgent: string | undefined
let currentModel: { providerID: string; modelID: string } | undefined
return {
// Capture session info from chat.params
"chat.params": async (input, output) => {
currentSessionID = input.sessionID
currentAgent = input.agent
currentModel = {
providerID: input.model.providerID,
modelID: input.model.id,
}
},
// Now you can send messages from other hooks
"tool.execute.after": async (input, output) => {
if (currentSessionID && input.tool === "bash") {
await sendStatusMessage(
client,
currentSessionID,
`▣ Command completed: ${output.title}`,
currentAgent,
currentModel,
)
}
},
}
}Detailed Status Pattern
For plugins that track statistics across a session:
type PluginStats = {
itemsProcessed: number
tokensSaved: number
errors: number
}
export const TrackingPlugin: Plugin = async ({ client }) => {
let sessionID: string | null = null
const stats: PluginStats = { itemsProcessed: 0, tokensSaved: 0, errors: 0 }
function formatStats(): string {
const lines = [
`▣ MyPlugin | ${formatTokenCount(stats.tokensSaved)} saved total`,
"",
`▣ Session Stats:`,
`→ Items processed: ${stats.itemsProcessed}`,
`→ Errors: ${stats.errors}`,
]
return lines.join("\n")
}
return {
"chat.params": async (input) => {
sessionID = input.sessionID
},
"tool.execute.after": async (input, output) => {
stats.itemsProcessed++
// ... track other stats
},
event: async ({ event }) => {
if (event.type === "session.idle" && sessionID) {
await sendStatusMessage(client, sessionID, formatStats())
}
},
}
}</patterns>
<comparison>
Inline Messages vs Toasts
| Aspect | Inline Message | Toast |
|---|---|---|
| Visibility | Persistent in chat | Temporary popup |
| Duration | Stays until scrolled away | Auto-dismisses |
| Detail level | Multi-line, detailed | Brief (1-3 lines) |
| History | Visible in session | Not saved |
| Context impact | None (ignored: true) | None |
| Use case | Stats, detailed status | Quick alerts, warnings |
Use inline messages for detailed status with data. Use toasts (see toast-notifications.md) for ephemeral alerts.
</comparison>
<constraints>
Limitations
| Limitation | Details |
|---|---|
| No styling | Plain text only, no colors or formatting |
| No interactivity | MUST NOT receive user input |
| Rate limiting | SHOULD avoid sending too frequently |
| Session required | MUST have valid sessionID |
| No persistence | Messages only visible in current session view |
</constraints>
Update Notifications for Pinned Versions
Pattern for notifying users when a newer plugin version is available
<overview>
When users pin plugins to specific versions (e.g., my-plugin@1.0.0), OpenCode won't auto-update them. Plugins can check npm for newer versions and show a toast notification, letting users decide when to update.
</overview>
<guidelines>
When to Use
Use this pattern when:
- Your plugin is published to npm
- Users MAY pin to specific versions for stability
- You want to inform users about available updates
Not needed for:
- Local/file-based plugins
- Plugins users always run with
@latest
</guidelines>
<implementation>
Implementation
/**
* Update Checker for Pinned Plugin Versions
*
* Checks npm registry for newer versions and shows a toast if available.
* Non-blocking - runs in background and fails silently.
*/
// ============================================================================
// Version Comparison
// ============================================================================
/**
* Compares two semver versions. Returns true if `latest` is newer than `current`.
*/
function isNewerVersion(current: string, latest: string): boolean {
const clean = (v: string) => v.replace(/^v/, "")
const partsA = clean(current).split(".").map(Number)
const partsB = clean(latest).split(".").map(Number)
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
const a = partsA[i] ?? 0
const b = partsB[i] ?? 0
if (a < b) return true
if (a > b) return false
}
return false
}
// ============================================================================
// Registry Fetch
// ============================================================================
/**
* Fetches latest version from npm. Returns null on any error.
*/
async function fetchLatestVersion(packageName: string): Promise<string | null> {
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
const response = await fetch(`https://registry.npmjs.org/${packageName}`, {
headers: { Accept: "application/json" },
signal: controller.signal,
})
clearTimeout(timeout)
if (!response.ok) return null
const data = await response.json()
return data["dist-tags"]?.latest ?? null
} catch {
return null
}
}
// ============================================================================
// Update Checker
// ============================================================================
type UpdateCheckOptions = {
/** npm package name */
packageName: string
/** Current installed version (from package.json) */
currentVersion: string
/** Display name for toast */
pluginName: string
/** SDK client */
client: { tui: { showToast: (params: any) => Promise<unknown> } }
/** Delay before check (ms). Default: 8000 */
delay?: number
}
/**
* Checks for updates and shows toast if newer version exists.
*
* Call during plugin initialization (fire and forget - MUST NOT await).
*/
export function checkForUpdates(options: UpdateCheckOptions): void {
const { packageName, currentVersion, pluginName, client, delay = 8000 } = options
setTimeout(async () => {
try {
const latest = await fetchLatestVersion(packageName)
if (!latest || !isNewerVersion(currentVersion, latest)) return
await client.tui.showToast({
body: {
title: `${pluginName}: Update Available`,
message: `v${currentVersion} → v${latest}\nUpdate config to use @${latest}`,
variant: "info",
duration: 10000,
},
})
} catch {
// Fail silently - update check is non-critical
}
}, delay)
}</implementation>
<examples>
Usage
Basic Usage
import type { Plugin } from "@opencode-ai/plugin"
// Import version from package.json
import pkg from "./package.json" with { type: "json" }
import { checkForUpdates } from "./update-checker"
const plugin: Plugin = async ({ client }) => {
// Fire and forget - MUST NOT await
checkForUpdates({
packageName: "my-opencode-plugin",
currentVersion: pkg.version,
pluginName: "My Plugin",
client,
})
return {
// ... hooks
}
}
export default pluginWith Config Toggle
Let users disable update notifications:
import type { Plugin } from "@opencode-ai/plugin"
import pkg from "./package.json" with { type: "json" }
import { checkForUpdates } from "./update-checker"
type PluginConfig = {
checkForUpdates?: boolean // Default: true
}
const plugin: Plugin = async ({ client }) => {
const config = loadConfig() // Your config loading
if (config.checkForUpdates !== false) {
checkForUpdates({
packageName: "my-opencode-plugin",
currentVersion: pkg.version,
pluginName: "My Plugin",
client,
delay: 10000,
})
}
return { ... }
}Toast Format
┌─────────────────────────────────────┐
│ My Plugin: Update Available │
│ v1.0.0 → v1.2.0 │
│ Update config to use @1.2.0 │
└─────────────────────────────────────┘The message tells users to update their config, since OpenCode manages installation:
// Before
{ "plugin": ["my-plugin@1.0.0"] }
// After
{ "plugin": ["my-plugin@1.2.0"] }</examples>
<best_practices>
Best Practices
| Practice | Reason |
|---|---|
| MUST NOT await | Never block plugin initialization |
| SHOULD use 8-10s delay | Let TUI fully initialize |
| MUST fail silently | Network issues MUST NOT break plugin |
| SHOULD use `info` variant | Updates aren't urgent |
| SHOULD include version numbers | Show what's available |
| MAY add config toggle | Respect user preference |
</best_practices>
<alternative>
Alternative: Reading Version at Runtime
If JSON import isn't available:
import { readFileSync } from "node:fs"
import { join, dirname } from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = dirname(fileURLToPath(import.meta.url))
const pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"))
const version: string = pkg.version</alternative>
#!/usr/bin/env bun
/**
* Extracts current plugin API from SDK source files.
* Generates reference docs in ../references/
*
* Usage: bun run extract-plugin-api.ts [--workspace /path/to/opencode]
*/
import { existsSync } from "node:fs"
import { join, dirname } from "node:path"
const args = process.argv.slice(2)
const workspaceIdx = args.indexOf("--workspace")
const workspace = workspaceIdx !== -1 ? args[workspaceIdx + 1] : findWorkspace()
function findWorkspace(): string {
let dir = process.cwd()
while (dir !== "/") {
if (existsSync(join(dir, "packages/plugin/src/index.ts"))) return dir
dir = dirname(dir)
}
throw new Error("Could not find opencode workspace. Use --workspace flag.")
}
const PLUGIN_INDEX = join(workspace, "packages/plugin/src/index.ts")
const PLUGIN_TOOL = join(workspace, "packages/plugin/src/tool.ts")
const SDK_TYPES = join(workspace, "packages/sdk/js/src/v2/gen/types.gen.ts")
const REFERENCES_DIR = join(dirname(import.meta.dir), "references")
async function extractHooksInterface(): Promise<string> {
const content = await Bun.file(PLUGIN_INDEX).text()
const hooksMatch = content.match(/export interface Hooks \{[\s\S]*?\n\}/m)
if (!hooksMatch) throw new Error("Could not find Hooks interface")
return hooksMatch[0]
}
async function extractPluginInput(): Promise<string> {
const content = await Bun.file(PLUGIN_INDEX).text()
const match = content.match(/export type PluginInput = \{[\s\S]*?\n\}/m)
if (!match) throw new Error("Could not find PluginInput type")
return match[0]
}
async function extractToolDefinition(): Promise<string> {
const content = await Bun.file(PLUGIN_TOOL).text()
return content
}
type EventInfo = { name: string; type: string; fullType: string }
async function extractEvents(): Promise<EventInfo[]> {
const content = await Bun.file(SDK_TYPES).text()
const events: EventInfo[] = []
// Find Event union to get all event type names
const unionMatch = content.match(/export type Event =\s*([\s\S]*?)(?=\n\nexport|\n\n\/\*\*)/m)
if (!unionMatch) return events
const eventTypeNames = unionMatch[1].match(/Event\w+/g) || []
// For each event type, extract its full definition
for (const typeName of eventTypeNames) {
const typeRegex = new RegExp(`export type ${typeName} = \\{([\\s\\S]*?)\\n\\}`, "m")
const typeMatch = content.match(typeRegex)
if (typeMatch) {
// Extract the event type string from the type definition
const typeStringMatch = typeMatch[1].match(/type:\s*"([^"]+)"/)
if (typeStringMatch) {
events.push({
name: typeName,
type: typeStringMatch[1],
fullType: `export type ${typeName} = {${typeMatch[1]}\n}`,
})
}
}
}
return events
}
async function extractEventUnion(): Promise<string[]> {
const content = await Bun.file(SDK_TYPES).text()
const match = content.match(/export type Event =\s*([\s\S]*?)(?=\n\nexport|\n\n\/\*\*)/m)
if (!match) return []
const types = match[1].match(/Event\w+/g) || []
return types
}
async function extractAuthHook(): Promise<string> {
const content = await Bun.file(PLUGIN_INDEX).text()
const authHookMatch = content.match(/export type AuthHook = \{[\s\S]*?\n\}\n/m)
const authResultMatch = content.match(/export type AuthOuathResult[\s\S]*?\n\)\n/m)
return [authHookMatch?.[0] || "", authResultMatch?.[0] || ""].join("\n")
}
function generateHooksReference(hooks: string, pluginInput: string, authHook: string): string {
const timestamp = new Date().toISOString()
return `# Plugin Hooks Reference
> Auto-generated on ${timestamp}
> Source: \`packages/plugin/src/index.ts\`
## Plugin Function Signature
\`\`\`typescript
${pluginInput}
export type Plugin = (input: PluginInput) => Promise<Hooks>
\`\`\`
## Hooks Interface
\`\`\`typescript
${hooks}
\`\`\`
## Hook Categories
### Event Hook
- \`event\`: Receives all events, use \`event.type\` to discriminate
### Tool Hook
- \`tool\`: Register custom tools (see tool-helper.md)
### Chat Hooks
- \`chat.message\`: Intercept/modify user messages before processing
- \`chat.params\`: Modify LLM parameters (temperature, topP, topK)
### Permission Hook
- \`permission.ask\`: Override permission decisions (allow/deny/ask)
### Tool Execution Hooks
- \`tool.execute.before\`: Intercept before tool runs, modify args
- \`tool.execute.after\`: Process tool output, modify title/metadata
### Config Hook
- \`config\`: Modify configuration on load
### Auth Hook
- \`auth\`: Custom provider authentication (OAuth or API key)
### Experimental Hooks
- \`experimental.chat.messages.transform\`: Transform message history
- \`experimental.chat.system.transform\`: Modify system prompt
- \`experimental.session.compacting\`: Customize compaction context
- \`experimental.text.complete\`: Post-process text output
## Auth Hook Types
\`\`\`typescript
${authHook}
\`\`\`
`
}
function generateEventsReference(events: EventInfo[], eventTypes: string[]): string {
const timestamp = new Date().toISOString()
const byCategory = new Map<string, EventInfo[]>()
for (const event of events) {
const category = event.type.split(".")[0]
if (!byCategory.has(category)) byCategory.set(category, [])
byCategory.get(category)!.push(event)
}
let content = `# Events Reference
> Auto-generated on ${timestamp}
> Source: \`packages/sdk/js/src/v2/gen/types.gen.ts\`
## Event Union (${eventTypes.length} types)
\`\`\`typescript
export type Event =
${eventTypes.map((t) => ` | ${t}`).join("\n")}
\`\`\`
## Quick Reference
| Event Type | TypeScript Type |
|------------|-----------------|
${events.map((e) => `| \`${e.type}\` | \`${e.name}\` |`).join("\n")}
## Events by Category
`
const sortedCategories = [...byCategory.keys()].sort()
for (const category of sortedCategories) {
const categoryEvents = byCategory.get(category)!
content += `### ${category}\n\n`
for (const event of categoryEvents) {
content += `#### \`${event.type}\`\n\n`
content += `\`\`\`typescript\n${event.fullType}\n\`\`\`\n\n`
}
}
return content
}
function generateToolReference(toolDef: string): string {
const timestamp = new Date().toISOString()
return `# Tool Helper Reference
> Auto-generated on ${timestamp}
> Source: \`packages/plugin/src/tool.ts\`
## Tool Definition
\`\`\`typescript
${toolDef}
\`\`\`
## Usage Pattern
\`\`\`typescript
import { type Plugin, tool } from "@opencode-ai/plugin"
export const MyPlugin: Plugin = async (ctx) => {
return {
tool: {
myTool: tool({
description: "What this tool does",
args: {
input: tool.schema.string().describe("Input parameter"),
count: tool.schema.number().optional().describe("Optional count"),
enabled: tool.schema.boolean().default(true),
},
async execute(args, context) {
// args is typed from schema
// context: { sessionID, messageID, agent, abort }
return \`Result: \${args.input}\`
},
}),
},
}
}
\`\`\`
## Zod Schema Methods
\`tool.schema\` is Zod. Common methods:
| Method | Description |
|--------|-------------|
| \`.string()\` | String argument |
| \`.number()\` | Number argument |
| \`.boolean()\` | Boolean argument |
| \`.array(schema)\` | Array of items |
| \`.object({ ... })\` | Nested object |
| \`.enum(["a", "b"])\` | Enum values |
| \`.optional()\` | Make optional |
| \`.default(val)\` | Default value |
| \`.describe("...")\` | Add description for LLM |
## Tool Context
\`\`\`typescript
type ToolContext = {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
}
\`\`\`
`
}
async function main() {
console.log("Extracting plugin API from:", workspace)
const [hooks, pluginInput, authHook, events, eventTypes, toolDef] = await Promise.all([
extractHooksInterface(),
extractPluginInput(),
extractAuthHook(),
extractEvents(),
extractEventUnion(),
extractToolDefinition(),
])
console.log(`Found ${events.length} events, ${eventTypes.length} in union`)
const hooksRef = generateHooksReference(hooks, pluginInput, authHook)
const eventsRef = generateEventsReference(events, eventTypes)
const toolRef = generateToolReference(toolDef)
await Promise.all([
Bun.write(join(REFERENCES_DIR, "hooks.md"), hooksRef),
Bun.write(join(REFERENCES_DIR, "events.md"), eventsRef),
Bun.write(join(REFERENCES_DIR, "tool-helper.md"), toolRef),
])
console.log("Generated references:")
console.log(" - references/hooks.md")
console.log(" - references/events.md")
console.log(" - references/tool-helper.md")
console.log("\nDone!")
}
main().catch((e) => {
console.error("Error:", e.message)
process.exit(1)
})
Related skills
How it compares
Pick create-opencode-plugin when extending OpenCode runtime behavior with plugins instead of editing OpenCode core internals.
FAQ
Where does create-opencode-plugin install plugins?
create-opencode-plugin targets plugin directories at .opencode/plugin/ in a repository or ~/.config/opencode/plugin/ globally. The skill uses the @opencode-ai/plugin SDK to implement tools, hooks, auth providers, and execution interceptors rather than modifying OpenCode core sour
What is the first required step in create-opencode-plugin?
create-opencode-plugin requires running the extract-plugin-api.ts script to verify and refresh the SDK reference before design or implementation. The workflow then validates feasibility and proceeds through design, implementation, testing, and optional publishing steps.