2026년 4월 15일 작성
OpenCode Plugin 만들기
OpenCode plugin은 npm install 한 줄로 가져다 쓸 수 있는 기능 묶음이며, 직접 만들 때는 async factory 함수 하나를 export하는 TypeScript module로 작성합니다.
Plugin의 최소 구조
- OpenCode plugin은
Plugintype((input) => Promise<Hooks>)을 만족하는 async factory 함수를 export하는 module입니다.- factory 함수는 context를 받아 hook object를 반환합니다.
- 가장 작은 plugin은 file 하나, 함수 하나로 끝납니다.
- 여러 plugin 함수를 한 module에서 export할 수 있고, runtime은
{ server: Plugin }형태의 module object도 entrypoint로 인식합니다.
import type { Plugin } from "@opencode-ai/plugin"
export const MyPlugin: Plugin = async (ctx) => {
return {
event: async ({ event }) => {
if (event.type === "session.idle") {
console.log("Session is idle")
}
},
}
}
- factory 함수가 받는 context object(
ctx)는 project 정보, SDK client, shell API를 포함합니다.
| Field | 설명 |
|---|---|
project |
현재 project 정보 |
directory |
실행 directory 경로 |
worktree |
git worktree 경로 |
client |
opencode SDK client (session, tui, app 등의 API) |
$ |
Bun shell API |
Project 구조
- plugin은
.opencode/plugins/에 file을 두는 local plugin과, npm package로 배포하는 npm plugin 두 방식으로 만듭니다.- local plugin은 빠르게 시작하기 좋고, npm plugin은 여러 project에서 재사용할 때 적합합니다.
Local Plugin 구조
.opencode/plugins/에.ts또는.jsfile을 두면 별도 설정 없이 동작합니다.- 가장 단순한 형태는 file 하나로 끝납니다.
- 기능이 커지면 helper module을 분리하고
package.json을 추가합니다.
my-project/
├── .opencode/
│ ├── plugins/
│ │ └── my-plugin.ts # plugin entry point
│ └── package.json # 외부 dependency가 있을 때만 필요
├── opencode.json
└── ...
-
~/.config/opencode/plugins/에 두면 모든 project에서 global하게 적용됩니다. -
local plugin이 외부 npm package를 사용하려면 config directory(
.opencode/또는~/.config/opencode/)에package.json을 둡니다.- OpenCode가 startup 시
bun install을 실행해 의존성을 설치합니다.
- OpenCode가 startup 시
npm Plugin 구조 : 단일 기능
- 단일 기능을 하는 plugin은
src/에 source를 두고dist/로 build하는 구조가 일반적입니다.- wakatime, smart-title, model-announcer 등 대부분의 community plugin이 이 구조를 따릅니다.
opencode-my-plugin/
├── src/
│ ├── index.ts # plugin factory export
│ ├── logger.ts # logging utility
│ └── config.ts # config loading
├── dist/ # build output
├── package.json
├── tsconfig.json
└── README.md
package.json에서@opencode-ai/plugin은 보통dependencies에 둡니다.- 공식 plugin template과 community package 대부분이 이 방식을 사용합니다.
npm Plugin 구조 : 대규모
- oh-my-opencode처럼 여러 기능을 묶는 대규모 plugin은 역할별로 directory를 분리합니다.
- entry point에서 config, manager, tool, hook, interface를 순서대로 조립하는 pipeline 구조가 됩니다.
opencode-my-harness/
├── src/
│ ├── index.ts # plugin factory : pipeline 조립
│ ├── plugin-config.ts # config load, merge, validation
│ ├── plugin-interface.ts # 내부 hook을 OpenCode handler로 mapping
│ ├── plugin-dispose.ts # unload 시 정리 logic
│ ├── create-managers.ts # manager instance 생성
│ ├── create-hooks.ts # hook 조합
│ ├── create-tools.ts # tool registry 구성
│ ├── agents/ # specialized agent 정의
│ ├── hooks/ # 개별 hook 구현
│ ├── tools/ # custom tool 구현
│ ├── features/ # background task, tmux, skill 등
│ ├── config/schema/ # Zod schema 정의
│ ├── mcp/ # 내장 MCP server
│ └── shared/ # logger, validator 등 공통 utility
├── dist/
├── package.json
├── tsconfig.json
└── README.md
- 복잡한 state를 가진 기능은 manager class로 분리하고, factory 함수 안에서 조립합니다.
- manager 간 직접 의존을 피하고 callback이나 shared state로 통신하면 순환 의존이 생기지 않습니다.
- hook은 tier별로 나누어 조합합니다.
- core hook(session lifecycle, tool guard, context transform), continuation hook(loop 재개, todo enforcement), skill hook(skill 주입) 등으로 분리합니다.
- plugin unload 시 background task abort, MCP shutdown, LSP 종료 등을 한 곳에서 처리하는 dispose 함수를 둡니다.
Hook 구현
- plugin이 걸 수 있는 hook은 크게 event, tool, shell, config, auth/provider, chat, permission, command, compaction 계열로 나뉩니다.
- 필요한 hook만 선택적으로 구현하면 됩니다.
- 전체 hook은
@opencode-ai/plugin의Hookstype에서 확인합니다.
| Hook | 용도 |
|---|---|
event |
모든 runtime lifecycle event 수신 |
config |
runtime config 동적 수정 (custom command 등록 등) |
tool |
custom tool 등록 |
tool.execute.before / tool.execute.after |
tool 실행 전후 개입 |
tool.definition |
tool 정의 변형 |
shell.env |
shell 실행 시 env 주입 |
auth / provider |
인증 / provider 등록 |
chat.message / chat.params / chat.headers |
LLM 호출 직전 message·param·header 수정 |
permission.ask |
권한 요청 처리 |
command.execute.before |
command 실행 전 개입 |
experimental.chat.messages.transform |
message 변환 |
experimental.chat.system.transform |
system prompt 변환 |
experimental.session.compacting |
압축 시 보존 컨텍스트 지정 |
experimental.compaction.autocontinue |
압축 후 자동 재개 제어 |
experimental.text.complete |
text completion 개입 |
Event Hook
eventhandler는 runtime 전반의 lifecycle event를 수신합니다.event.type으로 분기하여 원하는 event에만 반응합니다.- session, message, file, command, permission, TUI, todo, LSP 등 대부분의 runtime 동작에 대응하는 event가 있습니다.
event: async ({ event }) => {
if (event.type === "session.idle") {
// session이 idle 상태가 되었을 때
}
if (event.type === "session.created") {
// 새 session이 생성되었을 때
}
}
Tool Hook
tool.execute.before와tool.execute.after로 tool 실행 전후에 개입합니다.- before에서 argument를 수정하거나 error를 throw해 실행을 차단합니다.
- after에서 output을 검증하거나 후처리합니다.
// .env file 읽기 차단
"tool.execute.before": async (input, output) => {
if (input.tool === "read" && output.args.filePath.includes(".env")) {
throw new Error("Do not read .env files")
}
}
- vibeguard plugin처럼
tool.execute.before에서 argument 값을 복원(restore)하는 용도로도 사용합니다.- LLM에게는 난독화된 값을 보여주고, 실제 실행 시에는 원래 값을 넣는 구조입니다.
Shell Hook
shell.env로 agent와 user terminal의 shell 실행에 environment variable을 주입합니다.
"shell.env": async (input, output) => {
output.env.MY_API_KEY = "secret"
output.env.PROJECT_ROOT = input.cwd
}
Config Hook
confighandler는 runtime config를 동적으로 수정합니다.- custom slash command 등록이 대표적인 사용처입니다.
async config(config) {
config.command = config.command ?? {}
config.command["my-command"] = {
template: "Do something specific",
description: "My custom command",
}
}
Compaction Hook
experimental.session.compacting으로 context 압축 시 보존할 정보를 지정합니다.
"experimental.session.compacting": async (input, output) => {
output.context.push("## Preserved State\nImportant info to keep across compaction")
}
Chat Transform Hook (Experimental)
experimental.chat.messages.transform으로 LLM에 보내기 전 message를 변환합니다.- model-announcer plugin은 이 hook으로 현재 model 정보를 context에 주입합니다.
- vibeguard plugin은 이 hook으로 민감 정보를 난독화합니다.
- experimental prefix가 붙어 있어 향후 API 변경 가능성이 있습니다.
Custom Tool 등록
@opencode-ai/plugin의toolhelper로 LLM이 호출할 수 있는 custom tool을 정의합니다.description은 LLM이 tool 호출 시점을 판단하는 근거이므로 구체적으로 작성합니다.args는tool.schema로 기술하며,.describe()로 각 argument의 용도를 설명합니다.execute는args와context를 받아 string을 반환하는 async 함수입니다.
import { type Plugin, tool } from "@opencode-ai/plugin"
export const MyPlugin: Plugin = async (ctx) => {
return {
tool: {
my_tool: tool({
description: "Describe what this tool does for the LLM",
args: {
query: tool.schema.string().describe("Search query"),
limit: tool.schema.number().optional().describe("Max results"),
},
async execute(args, context) {
const { directory, worktree } = context
return `Result for ${args.query}`
},
}),
},
}
}
- built-in tool과 이름이 충돌하면 plugin의 custom tool이 우선합니다.
- 이를 이용해 built-in tool을 override할 수 있지만, 신중하게 사용해야 합니다.
Community Plugin에서 발견한 Best Practice
- community plugin들의 source code를 살펴보면 export 방식, logging, config 관리, event handler 비동기 처리 등에서 공통된 practice가 보입니다.
Export 방식
- named export와 default export 모두 사용됩니다.
- wakatime, smart-title은
export default방식을 사용합니다. - model-announcer, plugin-template은 named export를 사용합니다.
- 어느 방식이든 OpenCode runtime이 인식합니다.
- wakatime, smart-title은
Logging
console.log대신client.app.log()를 사용해 구조화된 log를 남기는 plugin이 많습니다.service,level,message,extrafield로 구분됩니다.
await client.app.log({
body: {
service: "my-plugin",
level: "info",
message: "Plugin initialized",
extra: { config: "loaded" },
},
})
- debug mode 환경 변수를 두고 debug log를 분리하는 방식도 흔합니다.
- vibeguard, smart-title 등이 이 방식을 사용합니다.
Config 관리
- 복잡한 plugin은 자체 JSONC config file을 사용합니다.
- user level(
~/.config/opencode/)과 project level(.opencode/)을 분리하고 merge하는 방식이 관례입니다. - JSONC는 comment를 허용하는 JSON으로, 사용자가 설정에 주석을 달 수 있어 편리합니다.
- user level(
Fire-and-Forget Pattern
- event handler 안에서 무거운 작업을 할 때는
.catch()로 감싸서 non-blocking으로 실행합니다.- event handler가 오래 걸리면 OpenCode runtime 전체가 느려질 수 있습니다.
event: async ({ event }) => {
if (event.type === "session.idle") {
updateSessionTitle(client, sessionId, logger, config).catch((error) => {
logger.error("event", "Title update failed", { error: error.message })
})
}
}
Toast Notification
- 사용자에게 상태를 알릴 때
client.tui.showToast()를 사용합니다.
await client.tui.showToast({
body: {
title: "My Plugin",
message: "Something happened",
variant: "info",
duration: 5000,
},
})
Lazy Import
- 무거운 dependency는 plugin 초기화 시점이 아닌 실제 필요한 시점에 dynamic import합니다.
- plugin load 시간을 줄여 OpenCode startup에 영향을 주지 않습니다.
const { generateText } = await import("ai")
Subagent Session 판별
- background agent나 subagent session에서는 특정 동작을 skip해야 하는 경우가 많습니다.
client.session.get()으로parentID존재 여부를 확인합니다.
Plugin Load 순서와 등록
- plugin은 여러 source에서 함께 load되며, hook은 등록 순서대로 순차 실행됩니다.
- global config(
~/.config/opencode/opencode.json)에 등록된 npm plugin. - project config(
opencode.json)에 등록된 npm plugin. - global plugin directory(
~/.config/opencode/plugins/). - project plugin directory(
.opencode/plugins/).
- 동일한 이름과 version의 npm package는 한 번만 load합니다.
- local plugin과 npm plugin은 이름이 같아도 별개로 취급합니다.
- npm plugin은
opencode.json의pluginarray에 package 이름을 넣어 등록합니다.- OpenCode가 startup 시 Bun으로 자동 설치하고 cache합니다.
- scoped package(
@org/name)도 지원합니다.
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
"opencode-wakatime",
"@tarquinen/opencode-smart-title"
]
}
Reference
- https://opencode.ai/docs/plugins
- https://opencode.ai/docs/custom-tools
- https://opencode.ai/docs/ecosystem#plugins
- https://github.com/awesome-opencode/awesome-opencode
- https://github.com/zenobi-us/opencode-plugin-template