knowledge/opencode-plugin-creator/SKILL.md
Build, structure, test, and ship OpenCode CLI plugins — the JS/TS modules that extend OpenCode through the @opencode-ai/plugin API. Use when asked to create an OpenCode plugin, add a plugin hook (event, config, chat.message, chat.params, chat.headers, permission.ask, tool.execute.before/after, shell.env, command.execute.before, tool.definition, auth, provider, dispose, or any experimental.* hook for system-prompt/messages transform or compaction), register a custom tool() backed by a zod schema, scaffold a plugin package.json + tsconfig, wire the `plugin` array in opencode.json, install a local plugin via shim, or publish a plugin to npm. Covers the PluginInput context (client, $, directory, worktree, project, serverUrl), the Hooks return shape, ToolContext/ToolResult, the Bun runtime, and cross-platform gotchas. NOT for OpenCode agents/subagents (use opencode-agent-creator), AGENTS.md files (use agent-md-creator), or portable Agent Skills (use skill-creator).
npx skillsauth add aeondave/malskill opencode-plugin-creatorInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
An OpenCode plugin is a JS/TS module that exports a Plugin function. OpenCode calls it once at startup with a context object (PluginInput) and the function returns a Hooks object. Hooks intercept tools, tune LLM params, add custom tools the model can call, inject system prompts, react to lifecycle events, register auth/providers, and release resources on shutdown.
Plugins run inside OpenCode's Bun runtime: TypeScript executes directly (no build step), top-level await works, and import type { Plugin } from "@opencode-ai/plugin" gives full typing.
The canonical shape — everything else is variations on this:
import type { Plugin } from "@opencode-ai/plugin"
export const MyPlugin: Plugin = async ({ client, $, directory, worktree, project }) => {
// one-time init runs here (load config, start servers, restore state)
return {
// hooks go here — return only the ones you implement
"tool.execute.before": async (input, output) => { /* ... */ },
}
}
export default MyPlugin
Follow these steps in order. Do not skip step 1 — hook names and the input/output split are precise and easy to get wrong from memory.
output argument is mutated in place, and several names are namespaced strings ("tool.execute.before", not toolExecuteBefore).assets/template/ to a new directory and rename. It is a complete, working plugin (package.json + tsconfig + entry + test). Adjust name, description, keywords, and main in package.json.src/plugin/ and import them (see the real plugins' layout). Mutate hook output in place; never reassign it. To block a tool/permission, throw from the relevant hook.bun test against pure helper modules (the real plugins keep logic testable by extracting it out of hook closures). tsc --noEmit (or npm run typecheck) must pass. See references/patterns.md for what to make testable.package.json main/exports/files/peerDependencies per references/publishing.md, then publish to npm or document the shim install.Load references/hooks.md for the full signature of any hook below.
| You want to… | Use |
|---|---|
| React to session / message / tool lifecycle events | event — switch on event.type (SDK event names) |
| Read or rewrite the merged config at startup | config |
| Block or rewrite a tool call before it runs | "tool.execute.before" — mutate output.args, or throw to block |
| Inspect or annotate a tool result | "tool.execute.after" |
| Add a brand-new tool the model can call | tool: { myTool: tool({...}) } — see references/custom-tools.md |
| Change a tool's description/params shown to the model | "tool.definition" |
| Tune temperature / topP / topK / maxTokens / provider options | "chat.params" |
| Add HTTP headers to the provider request | "chat.headers" |
| See or rewrite an incoming user message + parts | "chat.message" |
| Inject extra text into the system prompt | "experimental.chat.system.transform" — push to output.system |
| Rewrite the whole message array sent to the model | "experimental.chat.messages.transform" |
| Auto-allow / auto-deny permission prompts | "permission.ask" — set output.status |
| Inject env vars into the bash tool & terminals | "shell.env" — write to output.env |
| Run code before a slash command executes | "command.execute.before" |
| Customize the compaction prompt / carry context across compaction | "experimental.session.compacting" |
| Skip the synthetic auto-continue after compaction | "experimental.compaction.autocontinue" |
| Post-process completed assistant text | "experimental.text.complete" |
| Register a provider / dynamic model list | provider |
| Add a custom auth method (OAuth / API key) | auth |
| Choose the small/cheap model | "experimental.provider.small_model" |
| Release ports / watchers / timers on reload or shutdown | dispose |
The proven layout from real plugins — entry point is thin, logic lives in siblings, logic is unit-tested:
my-plugin/
├── package.json # name, type:module, main → src entry, peerDeps, "opencode-plugin" keyword
├── tsconfig.json # Bundler resolution, strict, types:["bun"], verbatimModuleSyntax
├── README.md # what/why/install/config; "not affiliated with OpenCode" disclaimer
├── .gitignore # node_modules, build output, .env
└── src/plugin/
├── index.ts # the Plugin export; thin — wires siblings, returns Hooks
├── <feature>.ts # pure logic, imported by index.ts
└── ...
└── test/
└── <feature>.test.ts # bun:test against the pure logic
main and exports point straight at the .ts entry — OpenCode/Bun runs TypeScript directly, so there is no build artifact to ship.
output in place. Reassigning the argument or returning a value (other than for custom-tool execute) is silently ignored."tool.execute.before" or "permission.ask" stops the action; the message reaches the model. Custom-tool execute should usually return an error string as guidance instead of throwing (see references/custom-tools.md).void doWork()), or it delays every session. See references/patterns.md.client.app.log({ body: { service, level, message } }) — call it via the client object (a detached reference loses this and throws). Fall back to console.Hooks catalog: every hook name, its exact (input, output) signature, when it fires, mutate-vs-throw semantics, and a focused example. Also documents PluginInput (client/$/directory/worktree/project/serverUrl) and the SDK event types. Load before writing any hook body.tool() API: zod args via tool.schema, ToolContext (sessionID/messageID/agent/abort/metadata/ask), ToolResult shapes (string / object / attachments), and idioms (return-guidance-not-throw, compact title via the returned {title, output} — ctx.metadata is a no-op for plugin tools). Load when adding a tool: hook.package.json fields, the three install paths (npm plugin array, local files in the plugin dir, local-clone shim), the plugin/ vs plugins/ directory gotcha, .opencode/package.json for tool dependencies, load order, and versioning. Load when packaging, installing, or publishing.dispose cleanup, single-instance servers, the event switch, cross-platform paths, env-var config, testability, and the verification rule. Load while implementing.src/plugin/index.ts, test/index.test.ts). Copy it in step 2 of the workflow as the starting point.development
Auth/lab ref: Unicorn Engine CPU-only emulation for shellcode, decryptors, custom VM handlers, instruction tracing, memory hooks, and register-level experiments.
development
Auth/lab ref: Renode board and SoC simulation for MCU/RTOS firmware, UART/GPIO/peripheral modeling, GDB remote debugging, REPL platforms, and RESC scripts.
development
Auth/lab ref: Qiling OS-layer binary emulation for PE/ELF/Mach-O/UEFI/shellcode with rootfs, syscall/API hooks, filesystem mapping, and runtime patching.
databases
Auth/lab ref: QEMU user-mode and full-system emulation for cross-arch binaries, firmware, kernels, disks, serial consoles, networking, and GDB stubs.