Skip to content
Agentic Control Plane

Plugin must export a default definition: OpenCode fix

David Crowe David Crowe · · Updated · 13 min read
opencode plugins reference
Share X HN LinkedIn

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 (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 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 describes the old shape as a function that receives { directory, project, client } and returns “one object containing tools and hooks”:

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:

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:

export default MyPlugin

3. A missing or non-string id

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, placed at .opencode/plugins/example/index.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 use a different import and an effect field instead of setup:

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 and our reading of the v2.0.29 source. The full hook-by-hook table is in OpenCode 2.0 rewrites the plugin API.

  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.

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

  • 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/.
{
  "$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 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.

    opencode plugin list
    
  2. Read the log. The single-file skip and the load error both land in OpenCode’s log. The CLI docs give the command that prints its location:

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

plugin must export a default definition with an id and an effect or setup function

OpenCode 2.x validates the default export of every plugin module. It must be an object with a string id and either an effect function or a setup function, which is what Plugin.define from @opencode/plugin produces. Anything else, including a 1.x plugin, fails with this error.

Why does my OpenCode 1.x plugin not load in OpenCode 2?

A 1.x plugin exports a function that returns an object of hooks. The 2.x loader only reads the default export and requires an id plus setup or effect, so the 1.x function is rejected. The 1.x hook names were also replaced, so the code has to be ported, not just re-exported.

Does OpenCode 2 read named exports from a plugin?

No. The 2.x loader decodes only the default export of the module. A plugin that exports Plugin.define under a name such as export const plugin, with no default export, fails with the same error.

Where does OpenCode 2 look for plugins?

Every .opencode/plugins/ directory and the global ~/.config/opencode/plugins/ load automatically. Packages and other paths go in the plugins array of opencode.json or opencode.jsonc. Configured local paths must be directories; a path to a single file is skipped with a warning in the log.

Where to read more

Running opencode? One command adds a plugin. Each tool call it sees is recorded, and a force-push, destructive SQL or curl | sh is blocked until you approve it on the console or from the email.

macOS · Linux · WSL
curl -fsSL https://agenticcontrolplane.com/install.sh | bash
Windows · PowerShell
irm https://agenticcontrolplane.com/install.ps1 | iex

What the installer writes  ·  opencode guide  ·  free up to 5 agents

Share X HN LinkedIn
Get the next data drop
What agents actually cost, new tool-surface captures, and the occasional incident post-mortem — sent when we publish something worth your inbox, not on a schedule. Unsubscribe anytime.
Share: Twitter LinkedIn
Related posts

← back to blog