Skip to content

Instantly share code, notes, and snippets.

@johnlindquist
Created January 11, 2026 20:10
Show Gist options
  • Select an option

  • Save johnlindquist/0adf1032b4e84942f3e1050aba3c5e4a to your computer and use it in GitHub Desktop.

Select an option

Save johnlindquist/0adf1032b4e84942f3e1050aba3c5e4a to your computer and use it in GitHub Desktop.
OpenCode Plugins Guide - Complete reference for writing plugins with hooks, custom tools, and event handling

OpenCode Plugins Guide

A comprehensive guide to writing plugins for OpenCode that extend agent behavior with hooks, custom tools, and event handling.

Quick Start

  1. Create a TypeScript file in .opencode/plugin/ (project) or ~/.config/opencode/plugin/ (global)
  2. Export a named plugin function
  3. Restart OpenCode
import type { Plugin } from "@opencode-ai/plugin"

export const MyPlugin: Plugin = async ({ client }) => {
  console.log("Plugin loaded!")
  
  return {
    // hooks go here
  }
}

Plugin Function Signature

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
}

Context Object Properties

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

Available Hooks

Event Hook

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.compacted
  • message.updated, message.removed, message.part.updated
  • tool.execute.before, tool.execute.after
  • file.edited, file.watcher.updated
  • permission.updated, permission.replied

Stop Hook

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." }]
      }
    })
  }
}

Tool Execution Hooks

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}`)
  }
}

System Prompt Transform

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>`)
}

Compaction Hook

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..."
}

Custom Tools

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}`
      }
    })
  }
}

Session State Management

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!" }]
          }
        })
      }
    }
  }
}

Using External Dependencies

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.

Running Shell Commands

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")
        }
      }
    }
  }
}

Logging

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" }
})

Common Patterns

Detect User-Provided Images

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
      }
    }
  }
}

Track File Modifications

"tool.execute.after": async (input) => {
  if (input.tool === "edit" || input.tool === "write") {
    const filePath = input.args.filePath as string
    // Track the modification
  }
}

Enforce Verification Before Commit

"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!")
    }
  }
}

Plugin Load Order

  1. Global config (~/.config/opencode/opencode.json)
  2. Project config (opencode.json)
  3. Global plugin directory (~/.config/opencode/plugin/)
  4. Project plugin directory (.opencode/plugin/)

All hooks from all plugins run in sequence.

Debugging Tips

  1. Plugin not loading? Check for TypeScript errors - syntax errors prevent loading
  2. Hooks not firing? Verify the hook name matches exactly (case-sensitive)
  3. State not persisting? Use session-keyed Maps, not global variables
  4. client.session.prompt failing? Check your destructuring: async ({ client }) not async (client)

Example: Complete Commit Reminder Plugin

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment