How to write an OpenCode plugin with the v2 API

By

Build your first OpenCode v2 plugin in TypeScript, register a custom tool, validate its inputs, test the logic, and understand how v1 plugins change.

~~~

An OpenCode plugin gives the coding agent behavior that belongs to your project. That could be a tool that understands your data, a command you repeat, or a hook that adjusts a model request.

We’re going to build a small tool that calculates a price quote. The model supplies a quantity and unit price. Our code validates them and returns the total in cents.

The example is deliberately small. You can check its answer yourself, and we can test the calculation without paying for model calls. Once you understand how it becomes a tool, you can replace the calculation with something useful in your project.

You’ll need a recent Node.js installation and OpenCode. This tutorial uses the v2 plugin API. The package is @opencode/plugin. Examples importing @opencode-ai/plugin belong to the older API.

A plugin runs code on your computer

OpenCode loads the plugin into its server environment. That code has the access available to the server process.

This is different from putting guidance in AGENTS.md. Instructions ask the agent to behave a certain way. A plugin implements behavior in executable code.

For a reusable review prompt, I’d start with a custom command. For “use single quotes,” I’d write a project instruction. A plugin is useful when something needs a programmatic interface or a lifecycle hook.

Our quote tool won’t read files, call a service, or change anything. That’s a useful boundary for a first plugin. You don’t need to solve authentication and permissions while learning registration.

Create the project

Make a fresh directory:

mkdir opencode-quote
cd opencode-quote
npm init -y
npm pkg set type=module
npm install @opencode/plugin
npm install --save-dev typescript tsx @types/node
mkdir -p .opencode/plugins/quote

On Omarchy, install Node.js and npm first with omarchy pkg add nodejs npm if needed. The rest of this example works the same way on macOS and Linux.

We’ll use this structure:

opencode-quote/
  package.json
  tsconfig.json
  quote.ts
  quote.test.ts
  .opencode/
    plugins/
      quote/
        index.ts

The supporting files live at the project root so OpenCode doesn’t try to discover them as separate plugins.

Save this complete configuration in tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["*.ts", ".opencode/plugins/**/*.ts"]
}

We’re checking our TypeScript without generating JavaScript files. tsx will run the tests.

Write the calculation first

A model calling a tool supplies data. We should validate that data at the boundary, even when TypeScript describes what we expect.

Save this in quote.ts:

export function quote(input: unknown) {
  if (typeof input !== 'object' || input === null) {
    throw new TypeError('A quote needs an object')
  }

  const { quantity, unitPriceCents } = input as Record<string, unknown>

  if (
    typeof quantity !== 'number' ||
    !Number.isSafeInteger(quantity) ||
    quantity < 1
  ) {
    throw new RangeError('Quantity must be a positive safe integer')
  }

  if (
    typeof unitPriceCents !== 'number' ||
    !Number.isSafeInteger(unitPriceCents) ||
    unitPriceCents < 0
  ) {
    throw new RangeError('Unit price must be a non-negative safe integer')
  }

  const totalCents = quantity * unitPriceCents

  if (!Number.isSafeInteger(totalCents)) {
    throw new RangeError('The total exceeds the supported integer range')
  }

  return { quantity, unitPriceCents, totalCents }
}

Prices use integer cents. We don’t multiply decimal currency values and hope the floating-point result is suitable for billing.

This is still an illustrative calculator. It doesn’t handle currencies with different minor units, tax, discounts, or a real price catalog. In a shop, the server should look up the authoritative price from a product ID. A model shouldn’t get to choose the amount a customer pays.

Checking the multiplication matters too. Two individually valid integers can produce a total outside JavaScript’s safe integer range.

Register the tool

An OpenCode v2 plugin exports a definition with a stable ID and a setup() function. During setup, we register a transform on the tool catalog.

Save this in .opencode/plugins/quote/index.ts:

import { Plugin } from '@opencode/plugin'
import { quote } from '../../../quote.ts'

export default Plugin.define({
  id: 'tutorial.quote',
  async setup(ctx) {
    await ctx.tool.transform(editor => {
      editor.namespace({
        name: 'shop',
        description: 'Deterministic shop calculations'
      })

      editor.add({
        name: 'quote',
        description: 'Calculate a total from a quantity and unit price in cents',
        input: {
          type: 'object',
          properties: {
            quantity: { type: 'integer', minimum: 1 },
            unitPriceCents: { type: 'integer', minimum: 0 }
          },
          required: ['quantity', 'unitPriceCents'],
          additionalProperties: false
        },
        options: { namespace: 'shop', codemode: true },
        async execute(input) {
          return { content: JSON.stringify(quote(input)) }
        }
      })
    })
  }
})

The plugin API supplies Plugin.define() and the catalog editor. Our calculation remains ordinary TypeScript.

The JSON Schema describes the input to OpenCode and the model. The checks inside quote() protect the implementation when it runs. Declaring a number in a schema doesn’t make every numerical operation safe.

The namespace groups the tool under shop. Its effective tool ID is shop_quote. codemode: true makes it available through Code Mode, where the agent can compose tool calls in JavaScript.

The return value is a tool result containing text. JSON.stringify() keeps the fields easy to inspect. Returning a raw string directly from execute() would use the old style rather than this v2 result shape.

Why the transform callback is synchronous

OpenCode can rebuild a catalog by replaying its registered transforms. Treat the callback as a description of the tool definition.

Don’t make an HTTP request inside it. Don’t charge an account, start a timer, or append a row to a database there. A rebuild could repeat that work.

If a tool depends on configuration fetched from a service, load that configuration before registering the transform. For a changing catalog, the API also provides reload methods. Keep that complexity out until your plugin needs it.

Our callback only declares a namespace and a tool. The calculation runs later, when something invokes execute().

Fig 1 · 1/3Plugin lifecycle
OpenCode loads the plugin module.

Test before opening an agent

Save this in quote.test.ts:

import test from 'node:test'
import assert from 'node:assert/strict'
import { quote } from './quote.ts'

test('calculates a quote in cents', () => {
  assert.deepEqual(quote({ quantity: 3, unitPriceCents: 1250 }), {
    quantity: 3,
    unitPriceCents: 1250,
    totalCents: 3750
  })
})

test('allows a zero price', () => {
  assert.equal(quote({ quantity: 2, unitPriceCents: 0 }).totalCents, 0)
})

test('rejects invalid quantities', () => {
  for (const quantity of [0, -1, 1.5, NaN, Infinity]) {
    assert.throws(() => quote({ quantity, unitPriceCents: 1250 }))
  }
})

test('rejects invalid prices and overflowing totals', () => {
  assert.throws(() => quote({ quantity: 1, unitPriceCents: -1 }))
  assert.throws(() => quote({ quantity: 2, unitPriceCents: Number.MAX_SAFE_INTEGER }))
  assert.throws(() => quote(null))
})

Run the type checker and tests:

npx tsc
npx tsx --test quote.test.ts

The type checker covers the plugin definition as well as the calculation. The tests cover our behavior without involving a model.

They don’t prove that your OpenCode installation discovered the plugin. That’s the next check.

Load it in OpenCode

Start OpenCode from opencode-quote:

opencode

Files inside .opencode/plugins/ use automatic discovery. You don’t need an additional plugins config entry for this layout.

Choose a connected model, then send:

Use the shop quote tool to calculate 3 items at 1250 cents each.
Report the total in cents. Don't modify any files.

Look for an invocation of the custom tool. The expected total is 3750 cents. A model answering “3750” from its own arithmetic hasn’t demonstrated that the plugin loaded.

Ask another question that calls it twice:

Use the shop quote tool for 3 items at 1250 cents and for 5 items
at 900 cents. Compare the totals. Don't modify files.

Those quotes should return 3750 and 4500. The agent may compose the calls through Code Mode. Which orchestration it chooses depends on the session and model.

If the tool doesn’t appear

Check the executable with opencode --version. A working v1 CLI won’t load this implementation correctly. If you keep both installations, the v2 installer also provides opencode2.

Next, check the path. .opencode/plugins/quote/index.ts is discovered; a project-root plugins/quote/index.ts needs explicit configuration.

Inspect startup errors. A TypeScript import failure can prevent setup from completing. Our relative import needs to reach the root quote.ts, so moving just index.ts breaks the example. The package-management command opencode plugin list is useful for configured packages; an automatically discovered local file may not appear there.

Watched plugin changes normally reload automatically. If a supporting dependency doesn’t refresh, restart the service with opencode service restart. That affects the shared service, so finish other active work first.

For a published plugin, keep its package dependencies compatible with the OpenCode release you support. Commit the lockfile in your development project so another person can reproduce your checks.

Adding access to a real service

I would use a plugin for a small project-specific operation with a clear input and a result I can verify. For example, it could read a product record from an internal catalog.

I would keep a purchase operation separate from a quote. Reading a price and charging a card need different permission boundaries. A tool that does both makes it harder for the agent and the person reviewing it to understand what a call will do.

Plugin code doesn’t automatically inherit a safe boundary because a tool has a friendly name. When adding external access, use the permission API for the action you perform, validate resource identifiers, and pass the execution context’s cancellation signal to cancellable requests.

Don’t put an API key in the source file or return it in a tool result. The model only needs the result of the operation, and a failed request shouldn’t expose the request headers.

Porting an older plugin

The v1 migration guide is useful if you’re adapting an existing package. Moving the file into a new directory doesn’t port its implementation.

The main change is where registration happens. A v1 plugin returned an object of tools and hooks. A v2 plugin registers them through ctx during setup. Hook callbacks also have different event shapes, so blindly renaming the import won’t work.

For a first port, keep the useful calculation or service client. Write tests for that code, then replace the thin layer that connects it to OpenCode. Our quote() function wouldn’t need to know which plugin API calls it.

Once the local version works, package it for another project and test that installed package. Relative imports and files omitted from the package can fail even when the development folder works perfectly.

Tagged: AI · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about ai: