Creating OpenCode Plugins
Create OpenCode plugins using the opencode-ai/plugin SDK. Use when user wants to build a plugin, extend OpenCode, intercept tool execution, add custom tools, react to events, create a hook, block commands, add custom auth, or add automation to OpenCode. Also use for 'opencode plugin', 'custom tool', 'tool hook', 'plugin hook', 'logging for all tool calls', 'restrict what commands the AI can run', 'lifecycle hooks', 'middleware', 'tool.execute.before', 'tool.execute.after', 'auth hook', 'chat.params', 'intercept bash commands', 'block file writes', 'add audit logging', 'custom model provider auth', 'inject context before LLM call', 'fire-and-forget background task', 'watch session events'.
npx skills add github:pantheon-org/tekhne --skill agentic-harness--opencode-toolkit--build-pluginsQuick Start
Plugins live in .opencode/plugins/<name>/index.ts (project) or ~/.config/opencode/plugins/<name>/index.ts (global).
import type { Plugin } from "@opencode-ai/plugin"
export const MyPlugin: Plugin = async ({ project, client, $, directory, worktree }) => {
return {
// Add hooks here — see references/hooks.md for available hooks
}
}
Register in opencode.json:
{ "plugin": ["file:///.opencode/plugins/my-plugin/index.ts"] }
Optional — document plugin purpose in a companion agent file:
---
description: Plugin guard — intercepts tool calls, validates bash commands, blocks dangerous deletions, logs audit trail, hooks session events
---
Then test: opencode run hi
Mindset
Plugins are async factory functions returning hook implementations. They run once on load. Hooks fire at lifecycle points (tool execution, events, config load). Think of them as middleware layers.
Use tool.execute.before to intercept/block, tool.execute.after to log, event: for file edits/session end, auth: for custom model auth, chat.params: to modify LLM params, tool: key to add callable tools.
When to use: Intercept/log tool calls, add custom auth, react to lifecycle events, or extend OpenCode with new callable tools.
When NOT to use: Built-in tool exists (check client.tool.list()). Only need a shortcut (use slash command). Need behavior guidance (use AGENTS.md). Need MCP (use Model Context Protocol).
Verify plugin is loaded: run bun run opencode run "list your tools" and confirm the plugin output appears.
Anti-Patterns
NEVER call client.registerTool() — it does not exist. WHY: The SDK has no such method; calling it throws at runtime.
// BAD - runtime error
client.registerTool("my-tool", { ... })
// GOOD - return the tool from the factory
export const Plugin: Plugin = async ({ client }) => ({
tool: { "my-tool": myTool }
})
NEVER write sync hook handlers. WHY: The plugin loader validates async signatures; a sync handler causes the entire plugin to be rejected at load time with a type error.
// BAD - rejected at load
"tool.execute.before": (input) => { log(input) }
// GOOD - always async
"tool.execute.before": async (input) => { log(input) }
NEVER mutate input.args in tool.execute.before to block a tool — the input is read-only and silently ignored. WHY: Mutations have no effect; to block execution you must throw.
// BAD - silently ignored
"tool.execute.before": async (input) => { input.args.command = "echo safe" }
// GOOD - throw to block
"tool.execute.before": async (input) => {
if (isDangerous(input.args)) throw new Error("Blocked")
}
See references/hook-patterns.md for complete anti-pattern list + full hook/event/tool/UI/testing/publishing reference.
Eval Scenarios
Explicit NEVER/WHY/consequence triplets with side-by-side bad/good examples. Generic warnings score low — concrete failure modes with real impact (security, correctness, performance) score high.
Runtime validation via eval scenarios proving the skill actually changes agent behaviour. Requires evals/ directory, instructions.json, ≥3 scenarios, and ≥80% instruction coverage in summary.json.
Appropriate rigidity for the skill type: strict rules for critical/security skills, balanced steps for process skills, flexible options for tool skills. Mismatched calibration (too rigid or too loose) reduces score.
Expert-only knowledge the AI couldn't derive on its own — domain-specific patterns, production gotchas, decision frameworks, and non-obvious tool behaviour. High scores mean <5% redundancy with what the model already knows.
A clear philosophy plus numbered step-by-step workflows with explicit entry/exit points. Should answer: what is the core principle, how to execute it, and when (and when not) to apply it.
Rich activation keywords and trigger scenarios in the description so the skill fires when needed. Best descriptions read like an exhaustive list of the tasks and concepts that should invoke this skill.
Copy-paste-ready code, complete runnable examples, real file paths and commands. Pseudocode and abstract descriptions score low — concrete, executable content scores high.
SKILL.md as a <100-line navigation hub pointing to focused references/ files. Content should be lazy-loaded on demand, not front-loaded. Includes actionable 'When to Use' conditions per reference.
Proper frontmatter, single-task focus, activation keywords, and cross-harness portability. Skills must work across 40+ agentic harnesses without hardcoded paths or agent-specific references.
Expert-only knowledge the AI couldn't derive on its own — domain-specific patterns, production gotchas, decision frameworks, and non-obvious tool behaviour. High scores mean <5% redundancy with what the model already knows.
A clear philosophy plus numbered step-by-step workflows with explicit entry/exit points. Should answer: what is the core principle, how to execute it, and when (and when not) to apply it.
Explicit NEVER/WHY/consequence triplets with side-by-side bad/good examples. Generic warnings score low — concrete failure modes with real impact (security, correctness, performance) score high.
Proper frontmatter, single-task focus, activation keywords, and cross-harness portability. Skills must work across 40+ agentic harnesses without hardcoded paths or agent-specific references.
SKILL.md as a <100-line navigation hub pointing to focused references/ files. Content should be lazy-loaded on demand, not front-loaded. Includes actionable 'When to Use' conditions per reference.
Appropriate rigidity for the skill type: strict rules for critical/security skills, balanced steps for process skills, flexible options for tool skills. Mismatched calibration (too rigid or too loose) reduces score.
Rich activation keywords and trigger scenarios in the description so the skill fires when needed. Best descriptions read like an exhaustive list of the tasks and concepts that should invoke this skill.
Copy-paste-ready code, complete runnable examples, real file paths and commands. Pseudocode and abstract descriptions score low — concrete, executable content scores high.
Runtime validation via eval scenarios proving the skill actually changes agent behaviour. Requires evals/ directory, instructions.json, ≥3 scenarios, and ≥80% instruction coverage in summary.json.
Expert-only knowledge the AI couldn't derive on its own — domain-specific patterns, production gotchas, decision frameworks, and non-obvious tool behaviour. High scores mean <5% redundancy with what the model already knows.
A clear philosophy plus numbered step-by-step workflows with explicit entry/exit points. Should answer: what is the core principle, how to execute it, and when (and when not) to apply it.
Explicit NEVER/WHY/consequence triplets with side-by-side bad/good examples. Generic warnings score low — concrete failure modes with real impact (security, correctness, performance) score high.
Proper frontmatter, single-task focus, activation keywords, and cross-harness portability. Skills must work across 40+ agentic harnesses without hardcoded paths or agent-specific references.
SKILL.md as a <100-line navigation hub pointing to focused references/ files. Content should be lazy-loaded on demand, not front-loaded. Includes actionable 'When to Use' conditions per reference.
Appropriate rigidity for the skill type: strict rules for critical/security skills, balanced steps for process skills, flexible options for tool skills. Mismatched calibration (too rigid or too loose) reduces score.
Rich activation keywords and trigger scenarios in the description so the skill fires when needed. Best descriptions read like an exhaustive list of the tasks and concepts that should invoke this skill.
Copy-paste-ready code, complete runnable examples, real file paths and commands. Pseudocode and abstract descriptions score low — concrete, executable content scores high.
Runtime validation via eval scenarios proving the skill actually changes agent behaviour. Requires evals/ directory, instructions.json, ≥3 scenarios, and ≥80% instruction coverage in summary.json.
Expert-only knowledge the AI couldn't derive on its own — domain-specific patterns, production gotchas, decision frameworks, and non-obvious tool behaviour. High scores mean <5% redundancy with what the model already knows.
A clear philosophy plus numbered step-by-step workflows with explicit entry/exit points. Should answer: what is the core principle, how to execute it, and when (and when not) to apply it.
Explicit NEVER/WHY/consequence triplets with side-by-side bad/good examples. Generic warnings score low — concrete failure modes with real impact (security, correctness, performance) score high.
Proper frontmatter, single-task focus, activation keywords, and cross-harness portability. Skills must work across 40+ agentic harnesses without hardcoded paths or agent-specific references.
SKILL.md as a <100-line navigation hub pointing to focused references/ files. Content should be lazy-loaded on demand, not front-loaded. Includes actionable 'When to Use' conditions per reference.
Appropriate rigidity for the skill type: strict rules for critical/security skills, balanced steps for process skills, flexible options for tool skills. Mismatched calibration (too rigid or too loose) reduces score.
Rich activation keywords and trigger scenarios in the description so the skill fires when needed. Best descriptions read like an exhaustive list of the tasks and concepts that should invoke this skill.
Copy-paste-ready code, complete runnable examples, real file paths and commands. Pseudocode and abstract descriptions score low — concrete, executable content scores high.
Runtime validation via eval scenarios proving the skill actually changes agent behaviour. Requires evals/ directory, instructions.json, ≥3 scenarios, and ≥80% instruction coverage in summary.json.
No eval scenarios available.
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()
},
}),
},
}
}
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"
},
}
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 |
Implementation Procedure
Step 1: Verify SDK Reference (REQUIRED)
Before creating any plugin, regenerate the API reference to ensure accuracy:
bun run .opencode/skill/opencode-build-plugins/scripts/extract-plugin-api.ts
This generates references/hooks.md, references/events.md, references/tool-helper.md.
Step 2: Validate Feasibility
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, 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
Follow modular design principles from CODING-TS.MD:
- Modular structure: Split complex plugins into multiple focused files
- Single purpose: Each function does ONE thing well
- DRY: Extract common patterns into shared utilities
- Small files: Keep individual files under 150 lines
- No monoliths: MUST NOT put all plugin code in a single
index.ts
Step 4: Plugin Structure (Non-Monolithic)
For complex plugins, use a modular directory structure:
.opencode/plugins/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.ts
Example 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,
}
}
Common Plugin Patterns
Security Guard — Block Dangerous Commands
import type { Plugin } from "@opencode-ai/plugin"
const BLOCKED = ["rm -rf", "git push --force", "DROP TABLE"]
export const SecurityPlugin: Plugin = async () => ({
"tool.execute.before": async (input) => {
if (input.tool === "bash") {
const cmd = input.args?.command ?? ""
if (BLOCKED.some((pattern) => cmd.includes(pattern))) {
throw new Error(`Blocked dangerous command: ${cmd}`)
}
}
}
})
Audit Logger — Log All Tool Calls
import type { Plugin } from "@opencode-ai/plugin"
import { appendFileSync } from "fs"
export const AuditPlugin: Plugin = async ({ project }) => ({
"tool.execute.after": async (input, output) => {
const entry = JSON.stringify({
ts: new Date().toISOString(),
tool: input.tool,
args: input.args,
success: !output.error
})
appendFileSync(".opencode/audit.log", entry + "\n")
}
})
Session Notifier — Toast on Session End
import type { Plugin } from "@opencode-ai/plugin"
export const NotifyPlugin: Plugin = async ({ client }) => ({
event: async ({ event }) => {
if (event.type === "session.completed") {
await client.tui.showToast({
message: "Session complete",
variant: "success"
})
}
}
})
Custom Tool — Expose a New Callable Function
import type { Plugin } from "@opencode-ai/plugin"
import { tool } from "@opencode-ai/plugin"
const lookupUser = tool({
description: "Look up a user by email in the internal directory",
args: {
email: tool.schema.string().describe("User email address")
},
async execute({ email }) {
const result = await fetch(`https://api.example.com/users?email=${email}`)
return JSON.stringify(await result.json())
}
})
export const DirectoryPlugin: Plugin = async () => ({
tool: { "lookup-user": lookupUser }
})
})
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:
-
Package name: What should the npm package be called?
- Unscoped:
opencode-my-plugin - Scoped:
@username/opencode-my-plugin
- Unscoped:
-
npm scope/username: If scoped, what’s their npm username or org?
-
Version: Starting version? (default:
0.1.0) -
License: MIT, Apache-2.0, etc.? (default: MIT)
-
Description: One-line description of what the plugin does
example
Example prompt:
“Before I create the npm package, I need a few details:
- What should the package name be? (e.g.,
opencode-background-processor@yourusername/opencode-background-process)- What’s your npm username/scope if using a scoped package?
- Starting version? (default: 0.1.0)
- License? (default: MIT)”
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:
- Runs
bun add --forcefor each plugin (auto-installs) - Caches pinned versions until user changes config
- For unpinned plugins, resolves
latestand caches actual version
This means the README SHOULD NOT include npm install instructions - just tell users to add the plugin to their config.
checklist
Publishing Checklist
-
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 └── .npmignore -
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
- MUST use
-
example-opencode.json:
{ "$schema": "https://opencode.ai/config.json", "plugin": ["<PACKAGE_NAME>"] } -
README.md Installation Section:
## Installation Add to your `opencode.json`: ```json { "plugin": ["<PACKAGE_NAME>"] } ``` OpenCode automatically installs plugin dependencies at runtime. -
Publish:
For scoped packages (first time):
npm publish --access publicFor unscoped or subsequent publishes:
npm publish
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.
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 |
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 hi
This 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
opencode
Test 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:
- Trigger the condition that shows the notification
- Verify toast appears with correct variant/message
- Check duration is appropriate
- Test error cases (TUI unavailable) - plugin MUST NOT crash
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)
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
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.
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)
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" | "error" |
Yes | Visual style and icon |
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 |
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
}
}
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 {}
}
},
}
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.
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 |
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.
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)
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.
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")
}
},
}
}
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)
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())
}
},
}
}
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.
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 |
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.
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
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)
}
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 plugin
With 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"] }
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 |
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