# OpenCode Plugin Architecture Documentation for agents implementing OpenCode plugins. Based on analysis of [kdco/opencode-workspace](https://github.com/kdcokenny/opencode-workspace). ## Quick Reference ```typescript import { type Plugin, type Event, tool } from "@opencode-ai/plugin"; export const MyPlugin: Plugin = async ({ $, client, directory }) => { return { tool: { myTool: tool({ description: "What this tool does", args: { param: tool.schema.string().describe("Parameter description"), }, async execute(args) { // Tools return string output return "result"; }, }), }, "session.created": async ({ sessionID }: { sessionID: string }) => { ... }, event: async ({ event }: { event: Event }) => { ... }, }; }; ``` ## Plugin Structure ### Plugin Function Signature ```typescript export const MyPlugin: Plugin = async (ctx) => { const { $, client, directory, worktree } = ctx; // ctx includes all plugin context }; ``` **Context properties:** - `$`: Bun shell API for executing commands - `client`: OpenCode SDK client for API interactions - `directory`: Current working directory - `worktree`: Git worktree path ### Tool Definition ```typescript tool({ description: "Human-readable description of the tool", args: { paramName: tool.schema .string() // string, number, boolean .describe("What this parameter means"), optionalParam: tool.schema .string() .optional() .describe("Optional parameter"), enumParam: tool.schema .enum(["option1", "option2"]) .describe("Enumerated value"), recordParam: tool.schema .record(tool.schema.string(), tool.schema.unknown()) .optional() .describe("Key-value pairs"), }, async execute(args) { const { paramName, optionalParam } = args; return "tool output as string"; }, }); ``` **Schema types:** - `tool.schema.string()` - `tool.schema.number()` - `tool.schema.boolean()` - `tool.schema.enum(["a", "b"])` - `tool.schema.record(keyType, valueType)` - `.optional()` - `.describe()` - `.default(value)` ### Returning Values from Tools Tools must return a **string**. For structured data, return JSON: ```typescript async execute(args) { const result = { status: "success", data: {...} }; return JSON.stringify(result); } ``` ## Lifecycle Hooks ### session.created Called when a new session starts. Use to inject context. ```typescript "session.created": async ({ sessionID }: { sessionID: string }) => { await client.session.prompt({ path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text: "Injected context" }], }, }); } ``` ### event All other events are dispatched through the generic `event` hook. Filter by `event.type`. ```typescript event: async ({ event }: { event: Event }) => { if (event.type !== "session.idle") return; if (!event.sessionID) return; // Handle session.idle }; ``` **Common event types:** - `session.created` - `session.idle` - `session.compacted` - `session.deleted` - `session.diff` - `session.error` - `session.status` - `session.updated` - `tool.execute.before` - `tool.execute.after` - `chat.message` - `command.execute.before` ## Client API ### Logging ```typescript // Structured logging await client.app .log({ body: { service: "my-plugin", level: "debug" | "info" | "warn" | "error", message: "log message", }, }) .catch(() => {}); // Always handle errors ``` ### Session Operations ```typescript // Inject context without triggering AI response await client.session.prompt({ path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text: "context" }], }, }); // Fork a session const forked = await client.session.fork({ path: { id: sessionID }, body: {}, }); // Get session info const session = await client.session.get({ path: { id: sessionID }, }); // Delete a session await client.session.delete({ path: { id: sessionID } }); ``` ### Tool Context in Hooks Tool hooks receive a `toolCtx` parameter: ```typescript async execute(args, toolCtx) { // toolCtx includes: // - sessionID: string | undefined // - directory: string // - worktree: string const sessionID = toolCtx?.sessionID; } ``` ## Shell Execution Use Bun's `$` template literal syntax: ```typescript // Simple command const output = await $`echo hello`.text(); // With arguments (properly escaped) const output = await $`command "${arg.replace(/"/g, '\\"')}"`.text(); // Capture output const result = await $`ls -la`.text(); // Error handling try { await $`command`.text(); } catch (error) { // Command failed } ``` ## TypeScript Patterns ### Result Types ```typescript type Result = | { ok: true; value: T } | { ok: false; error: string }; // Usage const result: Result = ...; if (result.ok) { console.log(result.value); } else { console.error(result.error); } ``` ### Zod Schema Validation Example from workspace-plugin: ```typescript import { z } from "zod"; const PlanSchema = z.object({ frontmatter: z.object({ status: z.enum(["not-started", "in-progress", "complete", "blocked"]), phase: z.number().int().positive(), }), goal: z.string().min(10), }); // Validate at boundary const result = PlanSchema.safeParse(candidate); if (!result.success) { return `Error: ${result.error.message}`; } ``` ### Error Handling ```typescript // Type guard for Node.js errors function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && "code" in error; } // Usage try { await fs.readFile(path); } catch (error) { if (isNodeError(error) && error.code === "ENOENT") { return "File not found"; } throw error; // Re-throw unexpected errors } ``` ## Common Operations ### Reading Files ```typescript import * as fs from "node:fs/promises"; async function readFile(filePath: string): Promise { try { return await fs.readFile(filePath, "utf8"); } catch (error) { if (isNodeError(error) && error.code === "ENOENT") { return ""; // File doesn't exist } throw error; } } ``` ### Writing Files ```typescript import * as fs from "node:fs/promises"; async function writeFile(filePath: string, content: string): Promise { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, content, "utf8"); } ``` ### Path Handling ```typescript import * as path from "node:path"; // Join paths const fullPath = path.join(directory, "subdir", "file.txt"); // Get directory name const dir = path.dirname(filePath); // Get base name const base = path.basename(filePath); // Get extension const ext = path.extname(filePath); ``` ### Git Operations ```typescript async function git( args: string[], cwd: string, ): Promise> { try { const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); if (exitCode !== 0) { return { ok: false, error: stderr.trim() }; } return { ok: true, value: stdout.trim() }; } catch (error) { return { ok: false, error: String(error) }; } } ``` ## Best Practices ### 1. Early Exit Pattern ```typescript async function process(args: Args): Promise { // Validate first if (!args.required) { return { ok: false, error: "Required parameter missing" }; } // Guard against null/undefined if (!value) return defaultValue; // Happy path return { ok: true, value: computed }; } ``` ### 2. Parse Don't Validate Extract data first, validate once: ```typescript function extractMarkdownParts(content: string): RawParts { // Extraction only, no validation const match = content.match(/pattern/); return { data: match?.[1] || null }; } function parsePlan(content: string): ValidPlan { const parts = extractMarkdownParts(content); const result = schema.safeParse(parts); if (!result.success) { throw new Error("Invalid format"); } return result.data; } ``` ### 3. Fail Loud Provide actionable error messages: ```typescript async function execute(args) { const result = await riskyOperation(); if (!result.ok) { return `❌ Operation failed: ${result.error}\n\nHint: Check that X is configured correctly.`; } return "✅ Success!"; } ``` ### 4. Log with Context ```typescript async function log(level: "info" | "error", message: string) { await client.app .log({ body: { service: "my-plugin", level, message: `${message} (session: ${sessionID})`, }, }) .catch(() => {}); } ``` ### 5. Handle Promise Rejection Logging and async operations can reject: ```typescript await client.app.log({...}).catch(() => {}); // Or handle gracefully try { await client.session.prompt({...}); } catch (error) { await log("error", `Failed: ${error}`); } ``` ## File Structure ``` my-plugin/ ├── src/ │ └── index.ts # Main plugin file ├── package.json # Dependencies ├── tsconfig.json # TypeScript config └── docs/ └── opencode-plugin-architecture.md # This file ``` ## References - [OpenCode Plugins Documentation](https://opencode.ai/docs/plugins) - [OpenCode SDK](https://opencode.ai/docs/sdk) - [kdco/opencode-workspace](https://github.com/kdcokenny/opencode-workspace) - [Bun Shell API](https://bun.com/docs/runtime/shell) - [Zod Documentation](https://zod.dev/)