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 commands
  • client: OpenCode SDK client for API interactions
  • directory: Current working directory
  • worktree: 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.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#

// 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

References#