# Plugin must export a default definition: OpenCode fix

OpenCode 2.x rejects any plugin whose default export is not an object with a string id and an effect or setup function. The three causes and the fix.

The error means OpenCode 2.x loaded your plugin module, looked at its default export, and did not find an object with a string `id` and an `effect` or `setup` function. The fix is to default-export `Plugin.define({ id, setup })` from `@opencode/plugin`. If the plugin was written for OpenCode 1.x, the export shape and the hook names both changed, so it needs a port, not a rename.

```
Plugin must export a default definition with an id and an effect or setup function.
```

Below: the check the loader runs, the three ways plugins fail it, a minimal valid plugin, and how to confirm which file OpenCode loaded.

## What the loader checks

The message comes from `packages/core/src/plugin/module.ts` in the [OpenCode repository](https://github.com/anomalyco/opencode) (formerly `sst/opencode`; the old URL redirects). We read it at tag `v2.0.29`, the same tag our [OpenCode 2.0 plugin API post](/blog/opencode-2-plugin-api-changes) is based on.

The loader decodes the module against a schema that has one key, `default`, and accepts exactly two shapes for it:

| Shape | `id` | Entry point |
|---|---|---|
| Effect plugin | string | `effect`, a function |
| Promise plugin | string | `setup`, a function |

Both entry points are checked with `typeof === "function"`. If the decode fails, the loader raises a `LoadError` with the message above. Named exports are not consulted. A `setup` plugin is wrapped into the `effect` form after it passes, so both end up running the same way.

There is a second message from the same file that people sometimes confuse with this one:

```
Plugin entrypoint not found: <target>
```

That one means OpenCode could not find a server entry to load at all. The default-definition error means it found one, imported it, and did not like what it exported.

## The three ways plugins get it wrong

### 1. A 1.x-style export

OpenCode 1.x plugins are a function that returns an object of named hooks. [OpenCode's V1 migration guide](https://opencode.ai/docs/build/plugins/migrate-v1/) describes the old shape as a function that receives `{ directory, project, client }` and returns "one object containing tools and hooks":

```ts
import type { Plugin } from "@opencode-ai/plugin"

export default (async ({ directory, project, client }) => ({
  "tool.execute.before": async (input, output) => { /* ... */ },
})) satisfies Plugin
```

Under 2.x the default export is a function. It has no `id` property and no `setup` or `effect` property, so it fails the schema. The guide says so directly: "V1 plugin implementations do not run in V2. Moving a file or renaming its config entry is not enough."

### 2. A named export only

The migration guide shows the V1 plugin as a named export (`export const MyPlugin = ...`). Ported to 2.x without changing the export line, it looks like this:

```ts
import { Plugin } from "@opencode/plugin"

export const MyPlugin = Plugin.define({
  id: "my-plugin",
  async setup(ctx) { /* ... */ },
})
```

The definition is correct. The export is not. The loader reads `default` only, finds nothing, and throws. Add `export default`, or re-export:

```ts
export default MyPlugin
```

### 3. A missing or non-string id

```ts
export default Plugin.define({
  async setup(ctx) { /* ... */ },
})
```

TypeScript will usually flag this, but a plugin shipped as plain `.js`, or built with type checks off, reaches the loader as-is. The schema requires `id` to be a string. The migration guide also says "Every V2 plugin needs a stable `id`", because it names the plugin in status and diagnostics.

The same failure happens if `setup` is present but is not a function: a 1.x-style hooks object dropped into the definition, or `setup` set to the result of calling a function instead of the function itself.

## A minimal valid 2.x plugin

From [OpenCode's plugin authoring docs](https://opencode.ai/docs/build/plugins/), placed at `.opencode/plugins/example/index.ts`:

```ts
import { Plugin } from "@opencode/plugin"

export default Plugin.define({
  id: "example",
  async setup(ctx) {
    await ctx.storage.set("loaded", true)
  },
})
```

If you prefer Effect, the [Effect plugin docs](https://opencode.ai/docs/build/plugins/effect/) use a different import and an `effect` field instead of `setup`:

```ts
import { Plugin } from "@opencode/plugin/effect"
import { Effect } from "effect"

export default Plugin.define({
  id: "example",
  effect: (ctx) =>
    Effect.gen(function* () {
      yield* ctx.storage.set("loaded", true)
    }),
})
```

Both pass the loader. The schema is a union, so supply `setup` or `effect`, not both.

## How the plugin API changed from 1.x to 2.x

The short list, from the [migration guide](https://opencode.ai/docs/build/plugins/migrate-v1/) and our reading of the `v2.0.29` source. The full hook-by-hook table is in [OpenCode 2.0 rewrites the plugin API](/blog/opencode-2-plugin-api-changes).

| | 1.x | 2.x |
|---|---|---|
| Package | `@opencode-ai/plugin` | `@opencode/plugin` |
| Export | function returning a hooks object | `export default Plugin.define({ id, setup })` |
| Config key | `plugin` | `plugins` |
| Options | `[path, { ...options }]` tuple | `{ "package": path, "options": { ... } }` |
| Permission hook | `permission.ask` | `ctx.permission.hook("evaluate", ...)` |
| Tool hooks | `tool.execute.before` / `.after` | `ctx.tool.hook("execute.before" \| "execute.after", ...)` |
| Shell env | `shell.env` | `ctx.shell.hook("create.before", ...)` |
| Events | `event` | `ctx.event.subscribe(...)` |
| Cleanup | `dispose` | function returned from `setup` |

The guide calls these "migration destinations, not always exact renames." For the 1.x hook semantics themselves, see the [OpenCode hooks reference](/blog/opencode-hooks-reference).

A 1.x config entry still resolves and the module still imports. Only then does the shape check fail, which is why the error is easy to miss.

One package can serve both versions. The guide shows a default export that spreads `Plugin.define({...})` alongside an `async server()` method returning 1.x hooks, and notes "V1 object entrypoints are supported in OpenCode `1.18.29` and newer." The other route, which our own `acp-opencode` package uses, is a separate `./server` export: the [authoring docs](https://opencode.ai/docs/build/plugins/) describe the server part as `.` or `./server`, and 1.x never asks for the subpath.

## Where OpenCode 2.x looks for plugins

From [OpenCode's plugin docs](https://opencode.ai/docs/plugins/):

- **Auto-discovered:** every `.opencode/plugins/` directory, which loads "direct `.ts` and `.js` files and immediate plugin package directories", and the global `~/.config/opencode/plugins/`. The migration guide adds that 2.x also discovers the 1.x directory `.opencode/plugin/`, and says to use `.opencode/plugins/` for 2.x files.
- **Configured:** the `plugins` array in `opencode.json` or `opencode.jsonc`. Entries can be npm names, versioned names, scoped packages, relative or absolute paths, or `file://` URLs. "Relative paths resolve from the config file containing the entry."
- **Merge order:** config arrays merge, lowest to highest precedence: `~/.config/opencode/opencode.jsonc`, then `./opencode.jsonc`, then `./.opencode/opencode.jsonc`.
- **Not discovered:** a `plugins/` folder next to a project-root `opencode.json`. Reference it explicitly or move it under `.opencode/`.

```jsonc
{
  "$schema": "https://opencode.ai/config.json",
  "plugins": [
    "@acme/opencode-plugin",
    "./plugins/local",
    { "package": "@acme/opencode-plugin", "options": { "strict": true } }
  ]
}
```

Two path rules matter for this error. Configured local paths "must be directories. A path to a single file is skipped." So `"./plugins/local.ts"` in config does not produce the default-definition error; it produces a log warning and no plugin. And inside a local plugin directory without package metadata, the authoring docs say OpenCode picks the server entry by file name: `server`, else `index`, with `.ts`, `.tsx` or `.js`. If both `server.ts` and `index.ts` exist, `server.ts` is the file being validated.

## How to confirm which file OpenCode loaded

1. **Check the plugin list.** The [migration guide](https://opencode.ai/docs/build/plugins/migrate-v1/) makes this step one of verifying a port: "Confirm its ID and source appear in the active plugin list." Package plugins are managed with `opencode plugin add`, `list`, `check`, `update` and `remove`.

   ```bash
   opencode plugin list
   ```

2. **Read the log.** The single-file skip and the load error both land in OpenCode's log. The [CLI docs](https://opencode.ai/docs/cli/) give the command that prints its location:

   ```bash
   opencode debug paths log
   ```

3. **Check what the entry resolves to.** For a package, open its `package.json` and look at `exports["."]` and `exports["./server"]`. For a local directory, look for `server.*` before `index.*`. Then open that file and find its `export default`.

4. **Check the installed copy, not your source.** A build step can change what a module exports. The authoring docs say to "test the installed package, not only a workspace-linked copy."

5. **Prove it is running.** A plugin that passes the loader but registers nothing looks identical to one that failed. Make it do something observable in `setup`. For a permission plugin, run one call it should deny and see whether it executes.

## What this does not cover

The 2.x plugin API is still moving. As of 6 November 2026, the release marked latest on GitHub, published 28 October, is [`v1.19.5`](https://github.com/anomalyco/opencode/releases/latest), while `v2.2.5` (30 October) and `v2.2.6` (6 November) are published releases on the 2.x line. The loader schema and hook names above are what tag `v2.0.29` and the current docs show. A later tag can move them; the error text is the most stable part.

This page does not cover TUI-only plugins, which live in a `cli.json` `plugins` array, or the desktop `./gui-experimental` export. It also does not cover failures after the shape check passes, such as a `setup` that throws. Those produce different errors.

For a permission or audit plugin, the quiet failure is the bad one. When the plugin is rejected, the session keeps running and OpenCode's own permission rules still prompt. Nothing on screen says the plugin is missing, so its checks and logging are simply absent. ACP's OpenCode plugin, `acp-opencode`, ships a separate `./server` export for 2.x. To confirm it loaded, run `opencode plugin list` and look for it.

## Frequently asked questions



## Where to read more

- [OpenCode plugins: loading and config](https://opencode.ai/docs/plugins/)
- [Building OpenCode plugins](https://opencode.ai/docs/build/plugins/)
- [Effect plugins](https://opencode.ai/docs/build/plugins/effect/)
- [Migrating a V1 plugin](https://opencode.ai/docs/build/plugins/migrate-v1/)
- [OpenCode CLI reference](https://opencode.ai/docs/cli/)
- [anomalyco/opencode on GitHub](https://github.com/anomalyco/opencode)
- [OpenCode 2.0 rewrites the plugin API](/blog/opencode-2-plugin-api-changes) and the [OpenCode install guide](/integrations/opencode)
