Skip to content
cccskills
FreePublished on GitHub

opencode

Use when developing plugins, tools, and extensions for OpenCode v1 - plugin contract, crash-prevention rules, ctx/event APIs, subagent lifecycle (abort races, compaction, orphan detection), MCP integration, testing patterns, or publishing to npm

The English summary is being prepared. Showing the original description.

See how to install

Files included (5)

  • SKILL.md13.8 KB
  • references/config-publishing.md3.3 KB
  • references/mcp-sdk-api.md4.8 KB
  • references/subagents-tools.md11.7 KB
  • references/testing-patterns.md9.6 KB

SKILL.md (original)

Review the instructions the agent will receive before you install.

OpenCode Plugin Development

Complete, field-tested guide for developing plugins for OpenCode (v1.18+) AI coding agent.

Every rule, signature, and pattern below was verified against a production plugin that went through 5 versions and 3 distinct crash classes. The "Hard-won rules" sections are non-negotiable — violating them silently breaks the host.


Extension Types — When to Use What

Extension TypeBest ForExample
Plugin (Hooks)Event-driven automation, custom tools, recovery logicAuto-resume stalled sessions, enforce agent selection
ToolsAI-triggered actions inside a plugintask_complete, commit, custom search
MCP ServersExternal service integrationsDatabases, GitHub, filesystem, remote APIs
SkillsKnowledge/prompt templates (this file is one)Framework patterns, project conventions
CommandsInteractive slash shortcuts/hello, /deploy
ProvidersCustom LLM backendsAlternative API gateways, self-hosted models

The Plugin Contract (v1.18+) — Read This First

A plugin is an async function (input, Options) => Promise<Hooks>. It is NOT an object with name/version/tools fields — that shape is outdated and the host ignores it.

Type definition (from @opencode-ai/plugin/dist/index.d.ts)

export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Hooks>

export interface Hooks {
  dispose?: () => Promise<void>
  event?: (input: { event: Event }) => Promise<void>
  config?: (input: Config) => Promise<void>
  tool?: { [key: string]: ToolDefinition }
  auth?: AuthHook
  provider?: ProviderHook
  "chat.message"?: (input: ChatMessageInput) => Promise<void>
  "tool.execute.before"?: (input: ToolExecInput) => Promise<void>
  "tool.execute.after"?: (input: ToolExecInput) => Promise<void>
  "command.execute.before"?: (input: CommandExecInput) => Promise<void>
  "command.execute.after"?: (input: CommandExecInput) => Promise<void>
}

Correct minimal plugin

import type { Plugin } from "@opencode-ai/plugin"
import { tool } from "@opencode-ai/plugin"

export const MyPlugin: Plugin = async (ctx, options) => {
  return {
    event: async ({ event }) => {
      // handle events
    },
    config: async () => {
      // one-time init
    },
    tool: {
      my_tool: tool({
        description: "Does something useful",
        args: {},
        execute: async (_args, ctx) => "ok",
      }),
    },
  }
}

export default MyPlugin

⚠️ config hook signature: (input: Config) => Promise<void>. It receives a Config object — you may ignore it but the parameter exists.


🚨 Hard-Won Rules (violate these and the host crashes)

These rules were discovered through real production crashes. Each one has a regression test in the reference plugin.

Rule 1 — Only export Plugin-shaped values from the entry module

OpenCode's plugin loader iterates every module export via Object.values(module) and treats each as a Plugin entrypoint:

// Inside opencode's plugin loader (deobfuscated):
for (let N of Object.values(pluginModule)) {
  if (typeof N !== "function") throw TypeError("Plugin export is not a function")
  const hooks = await N(ctx, options)   // called as a Plugin!
  pluginArray.push(hooks)
}
// later:
for (let N of pluginArray) {
  await N.config?.(config)   // crashes if N is null
}

If any export returns null → the host crashes with null is not an object (evaluating 'N.config'), surfaced in the TUI as Unexpected server error. Check server logs for details.

✅ Correct

// src/index.ts — ONLY Plugin exports
export const MyPlugin: Plugin = async (ctx, options) => { ... }
export default MyPlugin

🚫 Wrong — crashes the host

// src/index.ts
export function getLastAssistantError(messages) { ... return null }  // called as Plugin, returns null, host crashes
export function backoffMs(attempt) { return 42 }                       // called as Plugin, returns number, host crashes
export const MyPlugin: Plugin = async (ctx, options) => { ... }
export default MyPlugin

Solution — split utilities into a separate file

// src/index.ts — bundle entry, only Plugin exports
export const MyPlugin: Plugin = async (ctx, options) => { ... }
export default MyPlugin

// src/test-utils.ts — NOT the bundle entry; tests import from here
export function getLastAssistantError(messages) { ... }
export function backoffMs(attempt, base, max) { ... }

Tests import utilities from ./test-utils; the bundled dist/index.js only exposes the Plugin. Verify with:

bun -e 'const m = await import("./dist/index.js"); console.log(Object.keys(m))'
# MUST print only: [ "MyPlugin", "default" ]

Rule 2 — Never let an async handler throw unhandled

The event hook is called fire-and-forget by the host. If handleEvent() rejects, it becomes an unhandled promise rejection → bun process exits → OpenCode disappears from the TUI.

✅ Correct

return {
  event: async ({ event }) => {
    handleEvent(event).catch((e) => {
      console.error("[my-plugin] handleEvent error:", e)
    })
  },
}

Same rule applies to setInterval(async () => { ... }) bodies — wrap in a safe() boundary:

async function safe<T>(fn: () => Promise<T>, label: string): Promise<T | undefined> {
  try { return await fn() }
  catch (e) {
    const msg = e instanceof Error ? e.message : String(e)
    console.error(`[my-plugin] ${label}: ${msg}`)
    return undefined
  }
}

setInterval(() => {
  safe(doPeriodicWork, "periodic timer").catch(() => {})
}, 5000)

Rule 3 — Validate event payloads defensively

OpenCode event payloads are not guaranteed to match the documented shape. Real-world crashes found:

  • todo.updated events arrive with properties.todos as {} (object) or undefined, not an array
  • session.status properties.status is always { type: "idle" | "busy" | "retry" } — never a bare string
  • Session IDs (sessionID) may be missing on some events

✅ Correct — Array.isArray before .map()/.filter()

case "todo.updated": {
  const rawTodos = (event.properties as any)?.todos
  const todos: Array<Record<string, unknown>> = Array.isArray(rawTodos) ? rawTodos : []
  w.todos = todos.map((t) => ({ ... }))   // safe
  break
}

Rule 4 — log() must never rethrow

If the OpenCode log API itself throws (network/server error), your log() helper's catch block must not propagate. Otherwise an error inside an error handler escapes:

async function log(level: "info" | "warn" | "error", msg: string) {
  try {
    await ctx.client.app.log({ body: { service: "my-plugin", level, message: msg } })
  } catch (e) {
    console.error("[my-plugin] log() failed:", e instanceof Error ? e.message : e)
    // do NOT rethrow
  }
}

Rule 5 — Validate sid before every SDK call

Session IDs from events may be empty, undefined, or malformed. The SDK throws Expected 'id' to be a string if you pass garbage:

if (typeof sid !== "string" || !sid.startsWith("ses_")) return
await ctx.client.session.prompt({ path: { id: sid }, body: { ... } })

Rule 6 — ESC/abort race: set pluginAbortInFlight before calling session.abort()

The session.status event with "interrupted" can arrive BEFORE session.error with MessageAbortedError. If you abort during this window, check the flag before treating it as user-cancel:

// In ensureWatch
w.pluginAbortInFlight = false
w.pluginAbortAt = 0

// Before aborting
w.pluginAbortInFlight = true
w.pluginAbortAt = Date.now()
try {
  await ctx.client.session.abort({ path: { id: sid } })
} finally {
  w.pluginAbortInFlight = false
  w.pluginAbortAt = 0
}

// In session.error handler
case "session.error": {
  if (props?.error?.name === "MessageAbortedError") {
    if (w.pluginAbortInFlight && Date.now() - w.pluginAbortAt < 1000) {
      // Plugin-initiated abort, not user ESC
    } else {
      w.userCancelled = true
    }
  }
  break
}

See references/subagents-tools.md for the full pattern.


The Context API (ctx)

The ctx parameter is undocumented but stable across v1.18.x:

// ctx.client — API calls
await ctx.client.app.log({
  body: { service: "my-plugin", level: "info", message: "..." }
})

const { data: sessions } = await ctx.client.session.list()
const { data: statusMap } = await ctx.client.session.status()  // Record<sid, {type: "busy"|"idle"|"retry"}>
const messages = await ctx.client.session.messages({ path: { id: sid } })
await ctx.client.session.abort({ path: { id: sid } })
await ctx.client.session.prompt({
  path: { id: sid },
  body: {
    parts: [{ type: "text", text: "continue" }],
    agent,   // optional: preserve selected agent
    model,   // optional: { providerID, modelID }
  },
})

// ctx.ui — toast notifications (TUI)
await ctx.ui.toast({ title: "Done", message: "...", variant: "success" })

session.status() return shape

// status() returns { data: Record<sid, status> } where status is:
type SessionStatus = { type: "idle" | "busy" | "retry" }
// NEVER a bare string — always access via .type

session.list() does NOT include status

The Session type from session.list() has no status field. To check if a session is busy, call session.status() separately and build a Record<sid, string> map.


Event System

Plugins receive Server-Sent Events via the event hook. Real event types (v1.18+):

return {
  event: async ({ event }) => {
    const type = event.type as string
    const sid = event.sessionID as string | undefined
    const props = event.properties as Record<string, unknown> | undefined

    switch (type) {
      case "session.created":
      case "session.updated":
      case "session.idle":          // legacy alias of session.status=idle
      case "session.interrupted":   // user pressed ESC
        break

      case "session.status": {
        const status = props?.status as { type: string } | undefined
        // status.type in "idle" | "busy" | "retry" | "interrupted"
        break
      }

      case "session.error": {
        const error = props?.error as { name: string; data?: { message: string } } | undefined
        // error.name === "MessageAbortedError" → user pressed ESC
        break
      }

      case "message.updated":
      case "message.part.updated":
        // props?.delta — streaming text delta
        break

      case "todo.updated": {
        // ⚠️ props?.todos may be {} or undefined — validate with Array.isArray
        break
      }

      case "tool.call":
      case "tool.result":
      case "command.executed":
        break
    }
  },
}

Deep Dives

The following reference files contain detailed documentation on specific topics. They are loaded on demand from ../SKILL.md:


Debugging

"Unexpected server error. Check server logs for details."

This is the generic TUI mask. The real error is in the log:

grep "level=ERROR" ~/.local/share/opencode/log/opencode.log | tail -20

Common causes (in order of likelihood):

  1. Non-Plugin export returns null → null is not an object (evaluating 'N.config') — see Rule 1
  2. Unhandled promise rejection in event hook or setInterval — see Rule 2
  3. Event payload not validated → todos.filter is not a function — see Rule 3
  4. Invalid session ID passed to SDK → Expected 'id' to be a string — see Rule 5

Plugin not loading

grep "failed to load plugin" ~/.local/share/opencode/log/opencode.log

Check:

  • dist/index.js exists and has "main": "dist/index.js" in package.json
  • The cache directory has the real package, not just a wrapper: ls ~/.cache/opencode/packages/<name>@<ver>/node_modules/<name>/dist/index.js
  • bun.lock in ~/.config/opencode/ resolves to the version you expect

Verify which version loaded at runtime

grep "my-plugin" ~/.local/share/opencode/log/opencode.log | tail -5
# Look for: path=my-plugin@X.Y.Z

Summary Checklist

Before publishing a plugin:

  • dist/index.js exports ONLY default (and optionally a named alias of the same function)
  • event hook wraps async work in .catch()
  • All setInterval async bodies wrapped in try/catch or safe()
  • log() helper never rethrows
  • All event payload fields validated (Array.isArray, typeof, ?.)
  • All SDK calls validate sid first
  • session.status() accessed via .type, never compared as bare string
  • Tests include source-contract assertions (not just behavioral)
  • bun test passes with 0 failures
  • bun build produces dist/index.js with only Plugin exports

Reviews

No reviews yet. Tell others what you thought.

Comments

No login needed. Share how it worked for you, ask a question, or note where it did not run.

No comments yet. Be the first to write one.

Comments are published right away. Do not include personal information such as email addresses or phone numbers. Up to 2 URLs.

0 / 1,000 characters

This form is protected by reCAPTCHA, and the Google Privacy Policy and Terms of Service apply.

More skills from this repository

Use when building Python async HTTP services or clients with aiohttp - web server routing, middleware, WebSocket, SSE, streaming, client sessions, pytest-aiohttp testing, or troubleshooting SSL and timeout issues

The English summary is being prepared. Showing the original description.

CodeAtCode/oss-ai-skills24Updated Oct 9, 2026

Use when doing structural code search and rewriting - ast-grep linting, refactoring, multi-language patterns

The English summary is being prepared. Showing the original description.

CodeAtCode/oss-ai-skills24Updated Oct 9, 2026

Use when building GBA games with the BPCore Lua engine - entity, sprite and tilemap functions, SRAM save and load, link cable multiplayer protocol, camera and scrolling, or optimization patterns

The English summary is being prepared. Showing the original description.

CodeAtCode/oss-ai-skills24Updated Oct 9, 2026

celery

Free

Use when running background tasks with Celery - worker and broker configuration (Redis, RabbitMQ), task routing by name vs queue, chains/groups/chords, retry patterns (autoretry_for, retry_backoff), acks_late semantics, failure detection, and monitoring with Flower

The English summary is being prepared. Showing the original description.

CodeAtCode/oss-ai-skills24Updated Oct 9, 2026

django

Free

Use when building Django applications - security hardening, authentication and permissions, ORM optimization, PostgreSQL features, Django 6.0, migrations, testing, and ecosystem libraries

The English summary is being prepared. Showing the original description.

CodeAtCode/oss-ai-skills24Updated Oct 9, 2026

Use when customizing Django Admin - save_formset, get_search_results, formsets, queryset optimization, db_index, custom URLs

The English summary is being prepared. Showing the original description.

CodeAtCode/oss-ai-skills24Updated Oct 9, 2026

See all skills by CodeAtCode

Report a problem with this skill