OpenCode Builder
📚 Official Docs: For the latest information, always refer to the official documentation: For the latest SDK docs: https://opencode.ai/docs/sdk/. For plugins: https://opencode.ai/docs/plugins/.
A comprehensive skill for building on top of OpenCode — an AI-powered coding assistant. This skill covers both the JavaScript/TypeScript SDK for programmatic control and the plugin architecture for extending OpenCode's behavior.
Table of Contents
- Quick Start: Choose Your Path
- SDK Path: Programmatic Control
- Plugin Path: Extending OpenCode
- Configuration Deep-Dive
- Common Patterns
- Advanced Orchestration
- Agent Skills
- Custom Commands
- Debugging & Troubleshooting
- Best Practices
Quick Start: Choose Your Path
OpenCode offers two primary extension surfaces:
| Path | Use When | Key Packages |
|---|---|---|
SDK (@opencode-ai/sdk) |
You want to control OpenCode from an external app, script, or service. Build integrations, automate workflows, or create UIs that interact with OpenCode. | @opencode-ai/sdk |
Plugin (@opencode-ai/plugin) |
You want to run code inside OpenCode, hook into its lifecycle, add custom AI tools, or modify behavior. | @opencode-ai/plugin |
You can use both in the same project. A plugin can internally use the SDK client to call back into OpenCode.
Decision Table
| Scenario | Recommended Path |
|---|---|
| Build a CI/CD integration | SDK |
| Add a custom tool the AI can call | Plugin |
| Create a webhook listener that triggers sessions | SDK |
| Intercept and modify tool calls | Plugin |
| Build a dashboard showing session status | SDK |
| Protect sensitive files from being read | Plugin |
| Automate multi-agent workflows | SDK + Plugin |
| Add notifications on session completion | Plugin |
| Create an external UI for OpenCode | SDK |
| Inject environment variables into all shell commands | Plugin |
SDK Path: Programmatic Control
Installation
npm install @opencode-ai/sdk
# or
bun add @opencode-ai/sdk
Two Client Modes
Mode 1: Full Lifecycle — starts a local server + client
import { createOpencode } from "@opencode-ai/sdk"
const { client, server } = await createOpencode({
hostname: "127.0.0.1",
port: 4096,
timeout: 5000, // ms to wait for server start
config: { // overrides / merges with opencode.json
model: "anthropic/claude-sonnet-4-5",
},
})
// Use client...
await server.close()
Mode 2: Client Only — connects to an already-running OpenCode server
import { createOpencodeClient } from "@opencode-ai/sdk"
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
throwOnError: false, // return error objects instead of throwing
responseStyle: "fields", // or "data"
})
Response Styles
"fields"(default) — Each field is a separate result property"data"— Nested data object
Error Handling
// With throwOnError: false (default)
const result = await client.session.create({ body: {} })
if (result.error) {
console.error("Failed:", result.error)
}
// With throwOnError: true
const session = await client.session.create({
body: {},
throwOnError: true, // throws on error
})
Core Workflows
1. Session Management
Sessions are the primary unit of interaction in OpenCode.
// Create a session
const session = await client.session.create({
body: { title: "My automation task" },
})
// Initialize (analyzes project, creates AGENTS.md context)
await client.session.init({ path: { id: session.id } })
// Send a prompt and get AI response
const result = await client.session.prompt({
path: { id: session.id },
body: {
parts: [{ type: "text", text: "Refactor the auth module" }],
},
})
// result.data.info — assistant message metadata
// result.data.parts — message parts (text, tool calls, etc.)
// Inject context WITHOUT triggering a response
await client.session.prompt({
path: { id: session.id },
body: {
noReply: true,
parts: [{ type: "text", text: "You are working in a Next.js project." }],
},
})
// Run a shell command through the session
const shellResult = await client.session.shell({
path: { id: session.id },
body: { command: "npm test" },
})
// List all messages
const messages = await client.session.messages({ path: { id: session.id } })
// Get session children (subagent sessions)
const children = await client.session.children({ path: { id: session.id } })
// Get todo list
const todos = await client.session.todo({ path: { id: session.id } })
// Abort a running session
await client.session.abort({ path: { id: session.id } })
// Fork a session
const forked = await client.session.fork({
path: { id: session.id },
body: { messageID: "optional-msg-id" },
})
// Share / unshare
await client.session.share({ path: { id: session.id } })
await client.session.unshare({ path: { id: session.id } })
// Get diff
const diff = await client.session.diff({ path: { id: session.id } })
// Summarize session
await client.session.summarize({
path: { id: session.id },
body: { providerID: "anthropic", modelID: "claude-sonnet-4-5" },
})
// Revert a message
await client.session.revert({
path: { id: session.id },
body: { messageID: "msg-123" },
})
// Delete session
await client.session.delete({ path: { id: session.id } })
2. Structured Output
Request validated JSON from the model using JSON Schema:
const result = await client.session.prompt({
path: { id: sessionId },
body: {
parts: [{ type: "text", text: "Analyze this codebase" }],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
summary: { type: "string" },
techStack: { type: "array", items: { type: "string" } },
entryPoints: { type: "array", items: { type: "string" } },
},
required: ["summary", "techStack"],
},
retryCount: 2,
},
},
})
console.log(result.data.info.structured_output)
// { summary: "...", techStack: ["React", "TypeScript"], entryPoints: [...] }
if (result.data.info.error?.name === "StructuredOutputError") {
console.error("Failed after", result.data.info.error.retries, "attempts")
}
Best practices:
- Provide clear
descriptionfields on schema properties - Keep schemas focused; deeply nested schemas are harder to fill
- Increase
retryCountfor complex schemas - Always handle
StructuredOutputErrorgracefully
3. File Operations
// Read a file
const file = await client.file.read({ query: { path: "src/index.ts" } })
// Search for text across files
const matches = await client.find.text({
query: { pattern: "function.*handler" },
})
// Find files by name
const files = await client.find.files({
query: { query: "*.ts", type: "file", limit: 50 },
})
// Find directories
const dirs = await client.find.files({
query: { query: "components", type: "directory" },
})
// Find workspace symbols
const symbols = await client.find.symbols({
query: { query: "UserService" },
})
// Get git status for tracked files
const status = await client.file.status()
4. TUI Control
await client.tui.appendPrompt({ body: { text: "npm install lodash" } })
await client.tui.submitPrompt()
await client.tui.clearPrompt()
await client.tui.showToast({
body: { message: "Done!", variant: "success" },
})
await client.tui.openHelp()
await client.tui.openSessions()
await client.tui.openModels()
await client.tui.openThemes()
await client.tui.executeCommand({ body: { command: "/clear" } })
5. Event Streaming
const events = await client.event.subscribe()
for await (const event of events.stream) {
console.log(event.type, event.properties)
// Types: "session.updated", "message.part.updated", "tool.execute.after", etc.
}
6. Authentication
await client.auth.set({
path: { id: "anthropic" },
body: { type: "api", key: "sk-ant-api03-..." },
})
7. Logging
await client.app.log({
body: {
service: "my-integration",
level: "info",
message: "Deployment started",
extra: { env: "production", version: "1.2.3" },
},
})
SDK Type Safety
import type { Session, Message, Part, Config, Project } from "@opencode-ai/sdk"
Plugin Path: Extending OpenCode
Installation
npm install @opencode-ai/plugin
# or
bun add @opencode-ai/plugin
Plugin Basics
A plugin is a JS/TS module that exports a function receiving a context object and returning hooks:
import type { Plugin } from "@opencode-ai/plugin"
export const MyPlugin: Plugin = async ({ project, client, $, directory, worktree }) => {
console.log("Plugin loaded for project:", project?.name)
return {
// Hook implementations go here
}
}
Context object properties:
| Property | Type | Description |
|---|---|---|
project |
Project | null |
Current project metadata |
directory |
string |
Current working directory |
worktree |
string |
Git worktree path |
client |
OpencodeClient |
SDK client for calling back into OpenCode |
$ |
BunShell |
Bun's shell API for executing commands |
Loading Plugins
1. Local files (auto-loaded at startup)
.opencode/plugins/— project-level~/.config/opencode/plugins/— global
2. npm packages (installed automatically via Bun)
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-helicone-session", "@my-org/custom-plugin"]
}
Load order: Global config → Project config → Global plugin dir → Project plugin dir
Dependencies for local plugins: Add a package.json to .opencode/:
{
"dependencies": {
"shescape": "^2.1.0"
}
}
All Plugin Hooks
Tool Hooks
return {
// BEFORE any tool executes — modify args or block actions
"tool.execute.before": async (input, output) => {
if (input.tool === "read" && output.args.filePath.includes(".env")) {
throw new Error("Blocked: do not read .env files")
}
if (input.tool === "bash") {
const { escape } = await import("shescape")
output.args.command = escape(output.args.command)
}
},
// AFTER tool execution — inspect or modify results
"tool.execute.after": async (input, output) => {
console.log(`Tool ${input.tool} finished:`, output.result)
},
}
Shell Hooks
return {
"shell.env": async (input, output) => {
output.env.MY_API_KEY = process.env.MY_API_KEY
output.env.PROJECT_ROOT = input.cwd
},
}
Session Hooks
return {
"session.created": async ({ event }) => {
console.log("New session:", event.properties.sessionId)
},
"session.updated": async ({ event }) => {},
"session.idle": async ({ event }) => {
// AI finished responding — great for notifications
},
"session.error": async ({ event }) => {
console.error("Session error:", event.properties.error)
},
"session.compacted": async ({ event }) => {},
"session.deleted": async ({ event }) => {},
"session.diff": async ({ event }) => {},
"session.status": async ({ event }) => {},
}
Message Hooks
return {
"message.updated": async ({ event }) => {},
"message.removed": async ({ event }) => {},
"message.part.updated": async ({ event }) => {},
"message.part.removed": async ({ event }) => {},
}
TUI Hooks
return {
"tui.prompt.append": async ({ event }) => {},
"tui.command.execute": async ({ event }) => {},
"tui.toast.show": async ({ event }) => {},
}
File Hooks
return {
"file.edited": async ({ event }) => {},
"file.watcher.updated": async ({ event }) => {},
}
Permission Hooks
return {
"permission.asked": async ({ event }) => {},
"permission.replied": async ({ event }) => {},
}
LSP Hooks
return {
"lsp.client.diagnostics": async ({ event }) => {},
"lsp.updated": async ({ event }) => {},
}
Other Hooks
return {
"command.executed": async ({ event }) => {},
"installation.updated": async ({ event }) => {},
"todo.updated": async ({ event }) => {},
"server.connected": async ({ event }) => {},
}
Compaction Hooks (Experimental)
return {
"experimental.session.compacting": async (input, output) => {
// Inject additional context
output.context.push(`
## Project State
- Currently working on: auth refactor
- Important decisions: using JWT, not sessions
- Active files: src/auth.ts, src/middleware.ts
`)
// OR replace the entire compaction prompt:
output.prompt = `
You are generating a continuation prompt for a multi-agent session.
Summarize:
1. Current task and status
2. Files being modified
3. Blockers or dependencies
4. Next steps
`
},
}
Custom Tools via Plugins
import { type Plugin, tool } from "@opencode-ai/plugin"
export const CustomToolsPlugin: Plugin = async (ctx) => {
return {
tool: {
deploy_to_vercel: tool({
description: "Deploy the current project to Vercel",
args: {
preview: tool.schema.boolean().optional(),
environment: tool.schema.enum(["production", "staging"]).optional(),
},
async execute(args, context) {
const { directory, worktree } = context
const { $ } = ctx
const result = await $`cd ${directory} && vercel ${args.preview ? "--preview" : ""}`
return `Deployed! Output: ${result.stdout}`
},
}),
},
}
}
Tool schema helpers:
tool.schema.string(),.number(),.boolean(),.enum([...]),.array(itemSchema).optional()— make any field optional.describe("help text")— add field descriptions
Tool precedence: Plugin tools override built-in tools with the same name.
Standalone Custom Tools (.opencode/tools/)
You can also define custom tools as standalone TypeScript/JS files in .opencode/tools/. These are loaded automatically without needing a plugin wrapper:
.opencode/tools/
my-tool.ts
another-tool.js
// .opencode/tools/my-tool.ts
import { tool } from "@opencode-ai/plugin"
export default tool({
description: "A standalone custom tool",
args: {
query: tool.schema.string().describe("Input query"),
},
async execute(args, context) {
return `Result for: ${args.query}`
},
})
Standalone tools are simpler for single-function tools where you don't need lifecycle hooks or plugin context. For more complex tools that need access to the plugin context ($, client, directory), use plugin-defined tools instead.
Event Hook Pattern
Generic event listener for any event type:
return {
event: async ({ event }) => {
if (event.type === "session.idle") {
await ctx.client.app.log({
body: { service: "my-plugin", level: "info", message: "Session completed" },
})
}
},
}
Logging from Plugins
Always use client.app.log() instead of console.log:
await ctx.client.app.log({
body: {
service: "my-plugin",
level: "info",
message: "Plugin initialized",
extra: { version: "1.0.0" },
},
})
Levels: debug, info, warn, error
Configuration Deep-Dive
opencode.json Schema
{
"$schema": "https://opencode.ai/config.json",
"model": "anthropic/claude-sonnet-4-5",
"small_model": "anthropic/claude-haiku-4-5",
"provider": {
"anthropic": {
"options": {
"timeout": 600000,
"chunkTimeout": 30000
}
}
},
"plugin": ["opencode-helicone-session"],
"mcp": {
"filesystem": {
"type": "local",
"command": ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/Users/me/projects"]
}
},
"tools": {
"write": false,
"bash": false
},
"permission": {
"*": "allow",
"bash": "ask"
},
"agent": {
"code-reviewer": {
"description": "Reviews code for best practices",
"mode": "subagent",
"model": "anthropic/claude-sonnet-4-5",
"permission": { "edit": "deny" }
}
},
"default_agent": "build",
"share": "manual",
"formatter": true,
"lsp": true,
"snapshot": true,
"autoupdate": true,
"instructions": ["CONTRIBUTING.md", "docs/guidelines.md"],
"disabled_providers": [],
"enabled_providers": [],
"experimental": {},
"attachment": {
"image": {
"auto_resize": true,
"max_width": 2000,
"max_height": 2000,
"max_base64_bytes": 5242880
}
},
"shell": "zsh",
"server": {
"port": 4096,
"hostname": "0.0.0.0",
"mdns": true,
"cors": ["http://localhost:5173"]
},
"watcher": {
"ignore": ["node_modules/**", "dist/**", ".git/**"]
},
"compaction": {
"auto": true,
"prune": true,
"reserved": 10000
}
}
Config Precedence (8 tiers, later wins)
- Remote config (
.well-known/opencode) - Global config (
~/.config/opencode/opencode.json) - Custom config (
OPENCODE_CONFIGenv var) - Project config (
opencode.jsonin project root) .opencodedirectories- Inline config (
OPENCODE_CONFIG_CONTENTenv var) - Managed config files (
/Library/Application Support/opencode/etc.) - macOS managed preferences (
.mobileconfigvia MDM)
Configs are merged, not replaced. Non-conflicting settings from all configs are preserved.
Variable Substitution
{
"model": "{env:OPENCODE_MODEL}",
"provider": {
"openai": {
"options": {
"apiKey": "{file:~/.secrets/openai-key}"
}
}
}
}
{env:VARIABLE_NAME}— environment variable{file:path/to/file}— file contents (relative to config or absolute)
tui.json
{
"$schema": "https://opencode.ai/tui.json",
"theme": "tokyonight",
"leader_timeout": 2000,
"keybinds": {
"leader": "ctrl+x",
"command_list": "ctrl+p"
},
"scroll_speed": 3,
"scroll_acceleration": { "enabled": true },
"diff_style": "auto",
"mouse": true,
"attention": {
"enabled": true,
"notifications": true,
"sound": true,
"volume": 0.4
}
}
Common Patterns
Pattern 1: Background Service Plugin
import { Plugin } from "@opencode-ai/plugin"
import { spawn } from "node:child_process"
const PORT = 18921
const PROXY = `http://localhost:${PORT}`
async function startProxy(): Promise<void> {
try {
const res = await fetch(`${PROXY}/health`, { signal: AbortSignal.timeout(1000) })
if (res.ok) return
} catch { /* not running */ }
const child = spawn("node", ["proxy.js", String(PORT)], {
stdio: "ignore",
detached: true,
})
child.unref()
for (let i = 0; i < 10; i++) {
try {
const res = await fetch(`${PROXY}/health`, { signal: AbortSignal.timeout(1000) })
if (res.ok) return
} catch {}
await new Promise(r => setTimeout(r, 500))
}
}
export const ServicePlugin: Plugin = async (ctx) => {
await startProxy()
return {
"shell.env": async (_input, output) => {
output.env.MY_PROXY_URL = PROXY
},
}
}
Pattern 2: Notification on Session Completion
import { Plugin } from "@opencode-ai/plugin"
export const NotificationPlugin: Plugin = async ({ client }) => {
return {
event: async ({ event }) => {
if (event.type === "session.idle") {
await client.app.log({
body: { service: "notify", level: "info", message: "Session completed" },
})
}
},
}
}
Pattern 3: File Protection Plugin
import { Plugin } from "@opencode-ai/plugin"
const BLOCKED_PATTERNS = [/\.env/, /\.ssh/, /secret/i, /token/i]
export const FileProtectionPlugin: Plugin = async () => {
return {
"tool.execute.before": async (input, output) => {
if (input.tool !== "read") return
const path = output.args.filePath as string
if (BLOCKED_PATTERNS.some(p => p.test(path))) {
throw new Error(`Blocked: ${path} is on the sensitive files list`)
}
},
}
}
Pattern 4: MCP Server Integration
{
"mcp": {
"filesystem": {
"type": "local",
"command": ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/Users/me/projects"]
},
"github": {
"type": "local",
"command": ["npx", "-y", "@modelcontextprotocol/server-github"]
}
}
}
MCP servers run as separate processes (stdio). Use MCP for wrapping CLIs, SaaS APIs, community tools. Use plugins for lifecycle hooks, custom logic, intercepting behavior.
Pattern 5: Custom Model Provider
{
"provider": {
"ollama": {
"npm": "@ai-sdk/openai-compatible",
"name": "Ollama Local",
"options": { "baseURL": "http://localhost:11434/v1" },
"models": {
"llama3.2": { "name": "Llama 3.2" }
}
}
},
"model": "ollama/llama3.2"
}
Pattern 6: SDK + Plugin Combo
import { Plugin } from "@opencode-ai/plugin"
export const OrchestratorPlugin: Plugin = async ({ client }) => {
return {
"session.created": async ({ event }) => {
const sessionId = event.properties.sessionId
await client.session.init({ path: { id: sessionId } })
await client.session.prompt({
path: { id: sessionId },
body: {
noReply: true,
parts: [{ type: "text", text: "This is a Next.js 14 App Router project." }],
},
})
},
}
}
Pattern 7: Usage Tracking
import { Plugin } from "@opencode-ai/plugin"
import { readFile, writeFile } from "node:fs/promises"
const USAGE_FILE = "/tmp/opencode-usage.json"
export const UsagePlugin: Plugin = async () => {
return {
"tool.execute.after": async (input, output) => {
const data = JSON.parse(await readFile(USAGE_FILE, "utf-8").catch(() => '{"tools":{}}'))
data.tools[input.tool] = (data.tools[input.tool] ?? 0) + 1
await writeFile(USAGE_FILE, JSON.stringify(data))
},
}
}
Pattern 8: Environment Variable Injection
import { Plugin } from "@opencode-ai/plugin"
export const InjectEnvPlugin: Plugin = async () => {
return {
"shell.env": async (input, output) => {
output.env.MY_API_KEY = "secret"
output.env.PROJECT_ROOT = input.cwd
},
}
}
Advanced Orchestration
When building large-scale systems on top of OpenCode — multi-agent workflows, automated refactoring pipelines, deep research systems, or CI/CD integrations — you need patterns for orchestrating many OpenCode sessions as worker agents.
Key Concepts
| Concept | What It Means on OpenCode |
|---|---|
| Session-as-Agent | Each session.create() spawns an isolated agent with its own context, tools, and message history |
| External Orchestrator | A standalone Node.js/Bun app using @opencode-ai/sdk to spawn and coordinate sessions |
| Plugin Orchestrator | An OpenCode plugin that hooks into session lifecycle and renders TUI dashboards |
| State Management | SQLite (bun:sqlite) or file-based checkpoints — sessions are ephemeral |
| Parallel Execution | Promise.all with concurrency limits (3–5 sessions recommended) |
| Checkpoint/Resume | Save step, results, and metadata after each phase; resume by reconciling completed agents |
| Adversarial Review | Spawn multiple agents from different angles, synthesizer merges findings |
| Quality Gates | Validate agent outputs with tests (session.shell()) before accepting |
Quick Example: Parallel Code Review
import { createOpencodeClient } from "@opencode-ai/sdk"
const client = createOpencodeClient({ baseUrl: "http://localhost:4096" })
async function parallelReview(files: string[]): Promise<string[]> {
const reviews = await Promise.all(
files.map(async (file) => {
const session = await client.session.create({ body: { title: `review-${file}` } })
await client.session.init({ path: { id: session.id } })
const result = await client.session.prompt({
path: { id: session.id },
body: {
parts: [{ type: "text", text: `Review ${file} for bugs and style issues.` }],
},
})
const text = result.data.parts
?.filter((p: any) => p.type === "text")
.map((p: any) => p.text)
.join("\n") || ""
await client.session.delete({ path: { id: session.id } })
return text
})
)
return reviews
}
When to Use Orchestration
| Scenario | Approach |
|---|---|
| Security audit across 200+ files | Parallel review agents + synthesizer |
| Framework migration (React 17 → 18) | Parallel migration agents + test validation gates |
| Deep research with cross-checking | Parallel researchers + adversarial verification + synthesis |
| CI/CD pipeline integration | External orchestrator + webhook triggers |
| Real-time TUI dashboard | Plugin with session.idle hook + SQLite state |
Agent Skills
Skills are reusable instruction sets that agents load on-demand. Create a SKILL.md file:
.opencode/skills/my-skill/SKILL.md
---
name: my-skill
description: Does something useful for the user
---
## What I do
- Step 1
- Step 2
## When to use me
Use this when the user asks about X.
Skills are discovered from:
.opencode/skills/<name>/SKILL.md(project)~/.config/opencode/skills/<name>/SKILL.md(global).claude/skills/<name>/SKILL.md(Claude-compatible).agents/skills/<name>/SKILL.md(agent-compatible)
Custom Commands
Create slash commands via markdown files:
.opencode/commands/test.md
---
description: Run tests with coverage
agent: build
model: anthropic/claude-haiku-4-5
---
Run the full test suite with coverage report and show any failures.
Focus on the failing tests and suggest fixes.
Placeholders: $ARGUMENTS, $1, $2, !`command`, @filename
Debugging & Troubleshooting
Plugin Not Loading
- Check file location:
.opencode/plugins/or~/.config/opencode/plugins/ - Check for syntax errors — OpenCode silently skips broken plugins
- Verify exports:
export const MyPlugin = async (ctx) => ({ ... }) - Use
client.app.log()for debugging - For npm plugins: ensure listed in
opencode.json, restart OpenCode
SDK Connection Issues
- Verify server is running on expected port (default: 4096)
- Check
baseUrlmatches actual server URL - Use
client.global.health()to verify connectivity - Increase
timeoutif server takes long to start
TypeScript Errors
- Ensure
@opencode-ai/pluginis installed - Import types:
import type { Plugin } from "@opencode-ai/plugin" - Use
moduleResolution: "bundler"in tsconfig
Tool Not Appearing
- Plugin must return
tool: { myTool: tool({ ... }) } - Tool names must be unique (plugin tools override built-ins)
- Restart OpenCode after plugin changes
Hook Not Firing
- Verify event name is exactly correct (case-sensitive)
- Some hooks are experimental:
experimental.session.compacting - Add a log in the plugin body to confirm loading
Best Practices
- Use TypeScript — both packages provide full type definitions
- Use
client.app.log()— neverconsole.login plugins; logs are structured and actionable - Handle errors gracefully — plugins that throw can break OpenCode workflows
- Keep plugin initialization fast — OpenCode waits for all plugins to load at startup
- Use
noReply: truefor context injection — don't trigger unnecessary AI responses - Validate tool args — the
tool.schema.*helpers do runtime validation - Be careful with
tool.execute.before— throwing errors blocks the tool entirely - Use the SDK inside plugins — the
clientin plugin context is the same SDK client - Respect user config — read from
opencode.json, don't hardcode credentials - Document your plugin — include a README with setup instructions and
opencode.jsonexample - Use config merging — don't overwrite entire config objects, let OpenCode merge
- Test plugins in isolation — use a fresh project directory for testing
- Use meaningful plugin names — npm package names should be
opencode-<name> - Clean up resources — close file handles, child processes, and connections in plugin cleanup