A comprehensive guide to writing plugins for OpenCode that extend agent behavior with hooks, custom tools, and event handling.
- Create a TypeScript file in
.opencode/plugin/(project) or~/.config/opencode/plugin/(global) - Export a named plugin function
- Restart OpenCode
import type { Plugin } from "@opencode-ai/plugin"
export const MyPlugin: Plugin = async ({ client }) => {
console.log("Plugin loaded!")
return {
// hooks go here
}
}CRITICAL: The plugin function receives a context object, not individual parameters.
// ✅ CORRECT - destructure what you need
export const MyPlugin: Plugin = async ({ client, project, $, directory }) => {
await client.session.prompt({ ... })
}
// ❌ WRONG - treating context as client
export const MyPlugin: Plugin = async (client) => {
await client.session.prompt({ ... }) // FAILS: context.session.prompt doesn't exist
}| Property | Type | Description |
|---|---|---|
client |
SDK Client | OpenCode SDK for API calls |
project |
Project | Current project info |
directory |
string | Current working directory |
worktree |
string | Git worktree path |
$ |
Shell | Bun's shell API for commands |
Subscribe to system events:
event: async ({ event }) => {
if (event.type === "session.created") {
// New session started
}
if (event.type === "session.idle") {
// Agent finished responding
}
if (event.type === "message.updated") {
// Message added/changed
}
}Event Types:
session.created,session.deleted,session.idle,session.error,session.compactedmessage.updated,message.removed,message.part.updatedtool.execute.before,tool.execute.afterfile.edited,file.watcher.updatedpermission.updated,permission.replied
Intercept agent stop attempts (great for enforcing workflows):
stop: async (input) => {
const sessionId = input.sessionID || input.session_id
// Check if work is complete
if (!workComplete) {
// Prompt agent to continue
await client.session.prompt({
path: { id: sessionId },
body: {
parts: [{ type: "text", text: "Please complete X before stopping." }]
}
})
}
}Intercept tool calls before/after execution:
"tool.execute.before": async (input, output) => {
// Modify or block tool execution
if (input.tool === "bash" && output.args.command.includes("rm -rf")) {
throw new Error("Dangerous command blocked")
}
}
"tool.execute.after": async (input) => {
// React to completed tool calls
if (input.tool === "edit") {
console.log(`File edited: ${input.args.filePath}`)
}
}Inject context into the system prompt:
"experimental.chat.system.transform": async (input, output) => {
output.system.push(`<custom-context>
Important project rules go here.
</custom-context>`)
}Preserve state when sessions are compacted:
"experimental.session.compacting": async (input, output) => {
// Add context to preserve
output.context.push(`<preserved-state>
Task progress: 75%
Files modified: src/main.ts
</preserved-state>`)
// Or replace entire compaction prompt
output.prompt = "Custom compaction instructions..."
}Add tools the agent can use:
import { tool } from "@opencode-ai/plugin"
return {
tool: {
myTool: tool({
description: "Does something useful",
args: {
input: tool.schema.string(),
count: tool.schema.number().optional(),
},
async execute(args, ctx) {
return `Processed: ${args.input}`
}
})
}
}Track state across a session using Maps keyed by session ID:
interface SessionState {
filesModified: string[]
commitMade: boolean
}
const sessions = new Map<string, SessionState>()
function getState(sessionId: string): SessionState {
let state = sessions.get(sessionId)
if (!state) {
state = { filesModified: [], commitMade: false }
sessions.set(sessionId, state)
}
return state
}
export const MyPlugin: Plugin = async ({ client }) => {
return {
event: async ({ event }) => {
const sessionId = (event as any).session_id || (event as any).sessionID
if (event.type === "session.created" && sessionId) {
sessions.set(sessionId, { filesModified: [], commitMade: false })
}
if (event.type === "session.deleted" && sessionId) {
sessions.delete(sessionId)
}
},
"tool.execute.after": async (input) => {
const state = getState(input.sessionID)
if (input.tool === "edit" || input.tool === "write") {
state.filesModified.push(input.args.filePath as string)
}
if (input.tool === "bash" && /git commit/.test(input.args.command as string)) {
state.commitMade = true
}
},
stop: async (input) => {
const sessionId = (input as any).sessionID || (input as any).session_id
const state = getState(sessionId)
if (state.filesModified.length > 0 && !state.commitMade) {
await client.session.prompt({
path: { id: sessionId },
body: {
parts: [{ type: "text", text: "You have uncommitted changes!" }]
}
})
}
}
}
}Add a package.json in your .opencode/ directory:
{
"dependencies": {
"@opencode-ai/plugin": "latest",
"some-npm-package": "^1.0.0"
}
}OpenCode runs bun install at startup.
Use Bun's shell API via $:
export const MyPlugin: Plugin = async ({ $ }) => {
return {
"tool.execute.after": async (input) => {
if (input.tool === "edit" && input.args.filePath.endsWith(".rs")) {
// Run cargo fmt after Rust file edits
const result = await $`cargo fmt --check`.quiet()
if (result.exitCode !== 0) {
console.log("Formatting issues detected")
}
}
}
}
}Use structured logging instead of console.log:
await client.app.log({
service: "my-plugin",
level: "info", // debug, info, warn, error
message: "Something happened",
extra: { key: "value" }
})event: async ({ event }) => {
if (event.type === "message.updated") {
const message = (event as any).properties?.message
if (message?.role === "user") {
const content = JSON.stringify(message.content || "")
if (content.includes("image/") || /\.(png|jpg|jpeg|gif|webp)/i.test(content)) {
// User provided an image
}
}
}
}"tool.execute.after": async (input) => {
if (input.tool === "edit" || input.tool === "write") {
const filePath = input.args.filePath as string
// Track the modification
}
}"tool.execute.before": async (input, output) => {
if (input.tool === "bash" && /git commit/.test(output.args.command as string)) {
if (!state.testsRan) {
throw new Error("Run tests before committing!")
}
}
}- Global config (
~/.config/opencode/opencode.json) - Project config (
opencode.json) - Global plugin directory (
~/.config/opencode/plugin/) - Project plugin directory (
.opencode/plugin/)
All hooks from all plugins run in sequence.
- Plugin not loading? Check for TypeScript errors - syntax errors prevent loading
- Hooks not firing? Verify the hook name matches exactly (case-sensitive)
- State not persisting? Use session-keyed Maps, not global variables
- client.session.prompt failing? Check your destructuring:
async ({ client })notasync (client)
import type { Plugin } from "@opencode-ai/plugin"
interface SessionState {
filesModified: string[]
commitMade: boolean
}
const sessions = new Map<string, SessionState>()
export const CommitReminder: Plugin = async ({ client }) => {
return {
event: async ({ event }) => {
const sessionId = (event as any).session_id
if (event.type === "session.created" && sessionId) {
sessions.set(sessionId, { filesModified: [], commitMade: false })
}
if (event.type === "session.deleted" && sessionId) {
sessions.delete(sessionId)
}
},
"tool.execute.after": async (input) => {
const state = sessions.get(input.sessionID)
if (!state) return
if (input.tool === "edit" || input.tool === "write") {
const path = input.args?.filePath as string
if (path && !state.filesModified.includes(path)) {
state.filesModified.push(path)
}
}
if (input.tool === "bash") {
const cmd = input.args?.command as string
if (/git\s+commit/.test(cmd)) {
state.commitMade = true
}
}
},
stop: async (input) => {
const sessionId = (input as any).sessionID || (input as any).session_id
if (!sessionId) return
const state = sessions.get(sessionId)
if (!state) return
if (state.filesModified.length > 0 && !state.commitMade) {
await client.session.prompt({
path: { id: sessionId },
body: {
parts: [{
type: "text",
text: `## Uncommitted Changes\n\nYou modified ${state.filesModified.length} file(s) but haven't committed. Please commit before stopping.`
}]
}
})
}
}
}
}Last updated: January 2025