trying to integrate chainlink into opencode
OpenCode Plugin Architecture#
Documentation for agents implementing OpenCode plugins. Based on analysis of kdco/opencode-workspace.
Quick Reference#
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#
export const MyPlugin: Plugin = async (ctx) => {
const { $, client, directory, worktree } = ctx;
// ctx includes all plugin context
};
Context properties:
$: Bun shell API for executing commandsclient: OpenCode SDK client for API interactionsdirectory: Current working directoryworktree: Git worktree path
Tool Definition#
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:
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.
"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.
event: async ({ event }: { event: Event }) => {
if (event.type !== "session.idle") return;
if (!event.sessionID) return;
// Handle session.idle
};
Common event types:
session.createdsession.idlesession.compactedsession.deletedsession.diffsession.errorsession.statussession.updatedtool.execute.beforetool.execute.afterchat.messagecommand.execute.before
Client API#
Logging#
// Structured logging
await client.app
.log({
body: {
service: "my-plugin",
level: "debug" | "info" | "warn" | "error",
message: "log message",
},
})
.catch(() => {}); // Always handle errors
Session Operations#
// 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:
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:
// 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#
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
// Usage
const result: Result<string> = ...;
if (result.ok) {
console.log(result.value);
} else {
console.error(result.error);
}
Zod Schema Validation#
Example from workspace-plugin:
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#
// 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#
import * as fs from "node:fs/promises";
async function readFile(filePath: string): Promise<string> {
try {
return await fs.readFile(filePath, "utf8");
} catch (error) {
if (isNodeError(error) && error.code === "ENOENT") {
return ""; // File doesn't exist
}
throw error;
}
}
Writing Files#
import * as fs from "node:fs/promises";
async function writeFile(filePath: string, content: string): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, "utf8");
}
Path Handling#
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#
async function git(
args: string[],
cwd: string,
): Promise<Result<string, string>> {
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#
async function process(args: Args): Promise<Result> {
// 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:
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:
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#
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:
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