# How to write an OpenCode plugin with the v2 API

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

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-10-16 | Topics: [AI](https://flaviocopes.com/tags/ai/) | Canonical: https://flaviocopes.com/opencode-plugin/

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](https://flaviocopes.com/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:

```bash
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:

```text
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`:

```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`:

```typescript
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`:

```typescript
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](https://opencode.ai/v2/docs/build/plugins) 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()`.

## Test before opening an agent

Save this in `quote.test.ts`:

```typescript
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:

```bash
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`:

```bash
opencode
```

Files inside `.opencode/plugins/` use [automatic discovery](https://opencode.ai/v2/docs/plugins/). You don't need an additional `plugins` config entry for this layout.

Choose a connected model, then send:

```text
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:

```text
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](https://opencode.ai/v2/docs/build/plugins#permissions) 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](https://opencode.ai/v2/docs/build/plugins/migrate-v1) 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.
