agentleFS
valtterimelkkovaltterimelkko/agent-workflow-skills/skills/opencode-plugin/SKILL.md

opencode-plugin

Skill2 starsChanged 43 days agoMITView on GitHub

Develop, install, troubleshoot, and integrate OpenCode plugins for the opencode CLI. Use when: writing new OpenCode plugins, porting Pi SDK extensions to OpenCode, registering custom tools via the plugin API, fixing plugin load failures, resolving module dependency issues in plugins, installing plugins globally or per-project, understanding the plugin hook system, or any task involving @opencode-ai/plugin, opencode plugin command, or opencode.json plugin config. Covers: plugin format, Hooks API, tool registration, lifecycle hooks, module resolution, installation methods, edge cases, and troubleshooting.

  • Reads credentials

The file

Show its rules and text

We split this skill into the rules it gives an agent

13 rules. About 0 are common, 6 are distinctive, and 7 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈6

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 7

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    When a tool has multiple actions (like "save", "search", "delete"), use an enum parameter and switch on it:

    line 162
  2. Very specific:

    When a plugin is installed via local path, OpenCode loads it from that path.

    line 361
  3. Very specific:

    Use input.$ (BunShell) for running commands:

    line 463
  4. Very specific:

    When porting from Pi SDK's pi.registerTool() / pi.on() pattern:

    line 622
  5. Distinctive:

    State initialization: Do it in the plugin function body, not in a "session_start" handler.

    line 643
  6. Very specific:

    Check logs for ERROR service=plugin:

    line 649
  7. Very specific:

    Check that tool() helper is called correctly (description, args, execute)

    line 665
  8. Distinctive:

    Verify the tool description isn't empty — the LLM won't use tools without descriptions

    line 666
  9. Distinctive:

    Don't do synchronous blocking I/O in hooks

    line 683
  10. Very specific:

    Don't modify output properties that aren't documented

    line 684
  11. Distinctive:

    Don't expose secrets in tool descriptions or metadata

    line 726
  12. Very specific:

    Use ctx.abort to respect cancellation signals

    line 727
  13. Distinctive:

    Don't write to arbitrary paths — validate and sanitize user input

    line 728

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

OpenCode Plugin Development Guide

Complete guide for developing, installing, and troubleshooting plugins for the OpenCode CLI (opencode). Validated against OpenCode v1.14.20 and @opencode-ai/plugin v1.14.19.

Plugin Architecture

OpenCode plugins are ESM JavaScript modules that export an async function. The function receives a PluginInput and returns Promise<Hooks>. Plugins register custom tools, hook into the agent lifecycle, and modify behavior at runtime.

Plugin function called once at load time
  -> returns Hooks object
    -> hooks are called per-event during the session

Plugin File Structure

my-plugin/
├── package.json     # Must have "type": "module", "main": "index.js"
└── index.js         # The plugin code (ESM)

Minimal package.json:

{
  "name": "my-opencode-plugin",
  "version": "1.0.0",
  "type": "module",
  "main": "index.js",
  "peerDependencies": {
    "@opencode-ai/plugin": ">=1.14.0"
  }
}

Minimal Plugin

export default async function MyPlugin(input) {
  return {
    // hooks go here
  };
}

This is a valid plugin that does nothing. Every hook is optional.

The Plugin Function

PluginInput

The plugin function receives a single argument with these fields:

export default async function MyPlugin(input) {
  input.client        // OpenCode SDK client (for server API calls)
  input.project       // Project metadata object
  input.directory     // Project root directory (e.g., "~/my-project")
  input.worktree      // Project worktree root
  input.serverUrl     // URL object for the OpenCode server
  input.$             // BunShell instance for running shell commands
  input.experimental_workspace  // Workspace registration API
}

Key fields you'll use most:

  • input.directory — the project root. Use this to derive project slugs, locate config files, resolve relative paths.
  • input.$ — BunShell for running commands. Usage: await $\git status`.cwd(dir).quiet().text()`
  • input.client — OpenCode SDK client for making API calls to the running server.

Hooks

The plugin returns a Hooks object. Every hook is optional — only implement what you need.

export default async function MyPlugin(input) {
  return {
    tool: { /* register custom tools */ },
    event: async ({ event }) => { /* raw SSE events */ },
    "experimental.chat.system.transform": async (input, output) => { /* inject into system prompt */ },
    "tool.execute.before": async (input, output) => { /* before tool execution */ },
    "tool.execute.after": async (input, output) => { /* after tool execution */ },
    "experimental.session.compacting": async (input, output) => { /* before context compaction */ },
    "chat.message": async (input, output) => { /* new message received */ },
    "chat.params": async (input, output) => { /* modify LLM parameters */ },
    "permission.ask": async (input, output) => { /* modify permission behavior */ },
    "shell.env": async (input, output) => { /* modify shell environment */ },
  };
}

Registering Custom Tools

Tools are registered in the tool hook as a map of tool name → tool definition.

The tool() Helper

Import from @opencode-ai/plugin/tool:

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

The tool() function takes an object with description, args, and execute:

tool({
  description: "What this tool does. The LLM reads this to decide when to use it.",
  args: {
    action: tool.schema.enum(["create", "delete"]),
    name: tool.schema.string().describe("Name of the thing"),
    count: tool.schema.number().optional().describe("How many"),
  },
  async execute(args, context) {
    // args is typed: { action: "create"|"delete", name: string, count?: number }
    // Return a string or { output: string, metadata?: {...} }
    return `Created ${args.name}`;
  },
})

tool.schema is the Zod library (v4). Available types:

  • tool.schema.string(), tool.schema.number(), tool.schema.boolean()
  • tool.schema.enum(["a", "b", "c"])
  • .optional(), .describe("..."), .default(value)

Tool Execute Context

async execute(args, ctx) {
  ctx.sessionID    // current session ID
  ctx.messageID    // current message ID
  ctx.agent        // agent name (e.g., "build")
  ctx.directory    // project directory for this session
  ctx.worktree     // worktree root
  ctx.abort        // AbortSignal for cancellation
  ctx.metadata({ title: "doing X", metadata: { key: "val" } })  // update display
}

Tool Return Values

// Simple string
return "Done.";

// With metadata
return {
  output: "Created 3 items",
  metadata: { count: 3, items: [...] }
};

Multi-Action Tool Pattern

When a tool has multiple actions (like "save", "search", "delete"), use an enum parameter and switch on it:

tool({
  description: "Manage items. Use 'create' to add, 'list' to view, 'delete' to remove.",
  args: {
    action: tool.schema.enum(["create", "list", "delete"]),
    name: tool.schema.string().optional(),
  },
  async execute(args, ctx) {
    switch (args.action) {
      case "create":
        if (!args.name) return { output: "Error: name required for create", metadata: { error: true } };
        // ... create logic
        return { output: `Created ${args.name}` };
      case "list":
        // ... list logic
        return { output: "Items: ..." };
      case "delete":
        // ... delete logic
        return { output: `Deleted ${args.name}` };
    }
  },
})

Registering Multiple Tools

return {
  tool: {
    my_first_tool: tool({ /* ... */ }),
    my_second_tool: tool({ /* ... */ }),
    my_third_tool: tool({ /* ... */ }),
  },
};

Tool names in the map become the tool IDs the LLM sees.

Key Hooks Reference

"experimental.chat.system.transform"

Inject content into the system prompt every turn. This is how you give the LLM persistent context.

"experimental.chat.system.transform": async (input, output) => {
  // input.sessionID? — current session (may be undefined)
  // input.model — the model being used
  // output.system — string array, push to append
  output.system.push("<my-context>\nImportant info for the LLM\n</my-context>");
},

"tool.execute.after"

Called after every tool execution. Use for logging, extraction, side effects.

"tool.execute.after": async (input, output) => {
  // input.tool — tool name (e.g., "read", "bash", "my_custom_tool")
  // input.sessionID — session ID
  // input.callID — call ID
  // input.args — the arguments passed to the tool
  // output.title — displayed title
  // output.output — the tool's output text
  // output.metadata — the tool's metadata
},

"tool.execute.before"

Called before every tool execution. Use for validation, modification, or logging.

"tool.execute.before": async (input, output) => {
  // input.tool — tool name
  // input.sessionID — session ID
  // input.callID — call ID
  // output.args — you can modify the args before execution
},

"experimental.session.compacting"

Called before context compaction. Use to persist data that should survive compaction.

"experimental.session.compacting": async (input, output) => {
  // input.sessionID — session being compacted
  // output.context — string array, push context strings to preserve
  // output.prompt — if set, replaces the default compaction prompt entirely
  output.context.push("Key info to preserve: ...");
},

"event"

Raw SSE events. Receives every event the OpenCode server emits.

event: async ({ event }) => {
  // event.type — event type string
  // event.properties — event payload
},

"shell.env"

Modify environment variables for shell commands.

"shell.env": async (input, output) => {
  // input.cwd — working directory
  // output.env — env var map, modify in place
  output.env["MY_VAR"] = "my_value";
},

"chat.message"

Called when a new user message is received.

"chat.message": async (input, output) => {
  // input.sessionID
  // input.agent, input.model, input.messageID
  // output.message — UserMessage object
  // output.parts — Part[] array
},

"permission.ask"

Modify permission behavior for tool execution.

"permission.ask": async (input, output) => {
  // input — the Permission object
  // output.status — "ask" (default), "deny", or "allow"
  // Set to "allow" to auto-approve, "deny" to auto-deny
},

Plugin Installation

Installing a Plugin

# Install from npm
opencode plugin my-plugin-package

# Install from local path
opencode plugin /path/to/my-plugin

# Install globally (in ~/.config/opencode/)
opencode plugin /path/to/my-plugin -g

# Force reinstall
opencode plugin /path/to/my-plugin -g --force

What opencode plugin Does

  1. Installs the package via npm into the config directory's node_modules/
  2. Adds the plugin reference to opencode.json under the "plugin" key
  3. The plugin loads on next opencode serve start

Plugin Config Format

In opencode.json:

{
  "plugin": [
    "npm-package-name",
    "/absolute/path/to/local/plugin",
    ["plugin-with-options", { "debug": true }]
  ]
}

Plugins can be:

  • An npm package name (string)
  • An absolute local path (string starting with /)
  • A tuple of [name/path, options] where options are passed as the second argument to the plugin function

Global vs Project Config

  • Global: ~/.config/opencode/opencode.json — applies to all projects
  • Project: <project>/.opencode/opencode.json — project-specific

Module Resolution (Critical Edge Case)

This is the #1 source of plugin failures.

The Problem

When a plugin is installed via local path, OpenCode loads it from that path. But import statements like import { tool } from "@opencode-ai/plugin/tool" resolve relative to the plugin's directory, NOT relative to the config directory's node_modules/. This causes Cannot find module errors.

You will see this in logs as:

ERROR service=plugin path=file:///path/to/plugin error=Cannot find module '@opencode-ai/plugin/tool'

The Fix: Create Symlinks

For each local plugin, create a node_modules directory with symlinks to the config directory's packages:

# For each plugin that imports from @opencode-ai/plugin or zod:
mkdir -p /path/to/my-plugin/node_modules
ln -sf ~/.config/opencode/node_modules/@opencode-ai /path/to/my-plugin/node_modules/@opencode-ai
ln -sf ~/.config/opencode/node_modules/zod /path/to/my-plugin/node_modules/zod

This ensures all imports resolve correctly regardless of where the plugin code lives.

Alternative: Inline the Helper

The tool() helper is trivial — it just returns its input and attaches Zod:

// Instead of: import { tool } from "@opencode-ai/plugin/tool"
import { z } from "zod";
function tool(input) { return input; }
tool.schema = z;

But you still need zod to resolve, so the symlink approach is cleaner.

What's Available Without Symlinks

These Node.js built-in modules always work (no imports needed beyond Node):

  • node:fs/promises, node:path, node:os, node:crypto
  • node:child_process, node:util, node:stream
  • Any standard Node.js built-in

State Management

Per-Session State

Plugins are loaded once. Use closure variables keyed by sessionID for per-session state:

const sessions = new Map();

function getSession(sessionID) {
  if (!sessions.has(sessionID)) {
    sessions.set(sessionID, {
      turnCount: 0,
      data: [],
    });
  }
  return sessions.get(sessionID);
}

export default async function MyPlugin(input) {
  return {
    "tool.execute.after": async (input) => {
      const session = getSession(input.sessionID);
      session.turnCount++;
    },
  };
}

Global State

State in the plugin closure survives across sessions within the same opencode serve process. It's lost on server restart. Persist important data to disk:

import * as fs from "node:fs/promises";
import * as path from "node:path";

async function saveData(filePath, data) {
  await fs.mkdir(path.dirname(filePath), { recursive: true });
  await fs.writeFile(filePath, JSON.stringify(data), "utf-8");
}

async function loadData(filePath) {
  try { return JSON.parse(await fs.readFile(filePath, "utf-8")); }
  catch { return null; }
}

What Persists Across Restarts

  • Disk files — anything written to ~/.opencode/ or project dirs
  • Config — opencode.json plugin entries
  • Git state — worktrees, branches, etc.

What Doesn't Persist Across Restarts

  • In-memory Maps and state variables
  • The worktree registry (but actual git worktrees survive on disk)
  • Session state cache (reload from disk on startup)

Shell Commands in Plugins

Use input.$ (BunShell) for running commands:

export default async function MyPlugin(input) {
  const $ = input.$;

  return {
    tool: {
      mytool: tool({
        description: "Runs things",
        args: { cmd: tool.schema.string() },
        async execute(args, ctx) {
          // Simple command
          const output = await $\`git status\`.cwd(ctx.directory).quiet().text();

          // With environment
          const result = await $.env({ PATH: "/usr/bin" })\`echo hello\`.quiet().text();

          // Don't throw on non-zero exit
          const out = await $\`some-command\`.nothrow().quiet().text();

          return output;
        },
      }),
    },
  };
}

Alternatively, use Node.js child_process:

import { exec as execCb } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(execCb);

const { stdout } = await execAsync("git status", { cwd: directory, maxBuffer: 10 * 1024 * 1024 });

Node exec is often simpler for dynamic arguments. BunShell's template literal doesn't handle variable interpolation in command arguments well.

Complete Plugin Examples

Example 1: Simple Note-Taking Tool

import { tool } from "@opencode-ai/plugin/tool";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as os from "node:os";

const NOTES_DIR = path.join(os.homedir(), ".opencode", "notes");

async function ensureDir(d) { await fs.mkdir(d, { recursive: true }); }

export default async function NotesPlugin(input) {
  return {
    tool: {
      notes: tool({
        description: "Manage project notes. Save and retrieve notes for this project.",
        args: {
          action: tool.schema.enum(["save", "read", "list", "delete"]),
          title: tool.schema.string().optional().describe("Note title"),
          content: tool.schema.string().optional().describe("Note content"),
        },
        async execute(args, ctx) {
          const projectNotes = path.join(NOTES_DIR, path.basename(ctx.directory));
          await ensureDir(projectNotes);

          switch (args.action) {
            case "save": {
              if (!args.title || !args.content) return "Error: title and content required";
              const filePath = path.join(projectNotes, `${args.title}.md`);
              await fs.writeFile(filePath, args.content);
              return `Saved note: ${args.title}`;
            }
            case "read": {
              if (!args.title) return "Error: title required";
              const content = await fs.readFile(
                path.join(projectNotes, `${args.title}.md`), "utf-8"
              ).catch(() => null);
              return content || `Note "${args.title}" not found`;
            }
            case "list": {
              const files = await fs.readdir(projectNotes).catch(() => []);
              return files.length > 0
                ? `Notes:\n${files.map(f => `- ${f.replace(".md", "")}`).join("\n")}`
                : "No notes yet";
            }
            case "delete": {
              if (!args.title) return "Error: title required";
              await fs.unlink(path.join(projectNotes, `${args.title}.md`)).catch(() => {});
              return `Deleted note: ${args.title}`;
            }
          }
        },
      }),
    },
  };
}

Example 2: Auto-Documentation Tool with System Prompt Injection

import { tool } from "@opencode-ai/plugin/tool";
import * as fs from "node:fs/promises";
import * as path from "node:path";

const docBuffer = new Map();

export default async function AutoDocPlugin(input) {
  const projectDir = input.directory;
  const docPath = path.join(projectDir, "AUTO_API_DOC.md");

  async function loadDoc() {
    try { return await fs.readFile(docPath, "utf-8"); }
    catch { return ""; }
  }

  return {
    tool: {
      document_api: tool({
        description: "Document an API endpoint or function. Records documentation that persists across sessions.",
        args: {
          name: tool.schema.string().describe("Function/endpoint name"),
          description: tool.schema.string().describe("What it does"),
          params: tool.schema.string().optional().describe("Parameters as JSON"),
          returns: tool.schema.string().optional().describe("Return value"),
        },
        async execute(args, ctx) {
          let doc = (await loadDoc()) || "# API Documentation\n\n";
          const entry = `\n## ${args.name}\n${args.description}\n` +
            (args.params ? `\n**Params**: ${args.params}\n` : "") +
            (args.returns ? `\n**Returns**: ${args.returns}\n` : "");
          doc += entry;
          await fs.writeFile(docPath, doc);
          return `Documented: ${args.name}`;
        },
      }),
    },

    "experimental.chat.system.transform": async (input, output) => {
      const doc = await loadDoc();
      if (doc) {
        output.system.push(`<api-docs>\n${doc}\n</api-docs>`);
      }
    },

    "experimental.session.compacting": async (input, output) => {
      const doc = await loadDoc();
      if (doc) output.context.push(doc);
    },
  };
}

Example 3: Porting a Pi SDK Extension

When porting from Pi SDK's pi.registerTool() / pi.on() pattern:

Pi SDKOpenCode Plugin
pi.registerTool({ name: "x", ... })tool: { x: tool({ ... }) }
pi.registerCommand("x", ...)No equivalent — use tools instead
pi.on("session_start", fn)Load state in plugin function body
pi.on("before_agent_start", fn)"experimental.chat.system.transform" hook
pi.on("turn_end", fn)"tool.execute.after" hook (approximate)
pi.on("tool_call", fn)"tool.execute.before" hook
pi.on("tool_result", fn)"tool.execute.after" hook
pi.on("session_before_compact", fn)"experimental.session.compacting" hook
ctx.sessionManager.getSessionFile()Use ctx.sessionID + disk paths
ctx.cwdinput.directory or ctx.directory (in tool execute)
Pi TypeBox schemas (Type.Object)Zod schemas (tool.schema)
pi-tui rendering (Box, Text)Not available — tools return strings

Key differences when porting:

  • No slash commands: OpenCode plugins can't register /commands. Use tools instead.
  • No TUI rendering: Return plain strings from tools, not TUI components.
  • Schema change: TypeBox (Type.Object({...})) → Zod ({ key: tool.schema.string() })
  • State initialization: Do it in the plugin function body, not in a "session_start" handler.

Troubleshooting

Plugin Not Loading

Check logs for ERROR service=plugin:

# If running via opencode serve, check stderr for:
# ERROR service=plugin path=... error=Cannot find module '...'

Common causes:

  1. Module resolution failure — see "Module Resolution" section above
  2. Missing "type": "module" in package.json — plugins MUST be ESM
  3. Syntax error in plugin code — test with node -e "import('/path/to/plugin/index.js').then(m => console.log(typeof m.default))"
  4. Wrong export — must export default as an async function

Plugin Loads But Tools Don't Appear

  • Tool names in the tool map must match exactly what's returned
  • Check that tool() helper is called correctly (description, args, execute)
  • Verify the tool description isn't empty — the LLM won't use tools without descriptions

Module Resolution Errors

The error looks like:

Cannot find module '@opencode-ai/plugin/tool' from '/path/to/plugin/index.js'

Fix: create symlinks (see "Module Resolution" section). Verify with:

node -e "import('/path/to/plugin/index.js').then(m => console.log('OK:', typeof m.default)).catch(e => console.error('FAIL:', e.message))"

Plugin Causes OpenCode to Crash

  • Wrap all hook bodies in try/catch — a thrown error in a hook can crash the server
  • Don't do synchronous blocking I/O in hooks
  • Don't modify output properties that aren't documented

Verifying Plugin Installation

# Check config
cat ~/.config/opencode/opencode.json | grep -A5 plugin

# Check the plugin loads
node -e "import('/path/to/plugin/index.js').then(m => console.log('Plugin loads, type:', typeof m.default))"

# List installed packages
ls ~/.config/opencode/node_modules/ | grep -i plugin-name

Testing Without Restarting

For local path plugins, you can edit the plugin code in place. Changes take effect on the next opencode serve start. There's no hot reload.

Running Without Plugins

opencode serve --pure --port 4096

The --pure flag disables all external plugins. Useful for isolating plugin-caused issues.

Installation Persistence

Plugins survive:

  • opencode upgrade — config file and local paths aren't touched
  • System reboots — config is on disk, systemd restarts the service
  • Pi Web UI restarts — Pi Web UI starts a new opencode serve which re-reads config

Plugins DON'T survive:

  • In-memory state — lost on any process restart (persist to disk)
  • API breaking changes — major OpenCode version bumps may change the Hooks interface

Security Considerations

  • Plugins run in the same process as OpenCode — they have full access
  • Validate inputs in tool execute functions before passing to shell commands
  • Don't expose secrets in tool descriptions or metadata
  • Use ctx.abort to respect cancellation signals
  • Don't write to arbitrary paths — validate and sanitize user input

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.