The Nightly Build.
Technical

OpenCode 2 skips any plugin whose default export lacks an id and a setup or effect function

OpenCode 2.0.22 decodes a plugin's default export against { id, setup } or { id, effect }, so a v1 export with only server() fails the check and is skipped.

A plugin is the default export of one file

.opencode/plugins/example.ts · illustrativets
import { Plugin } from "@opencode/plugin"

export default Plugin.define({
  id: "example",
  async setup(ctx) {
    await ctx.storage.set("loaded", true)
  },
})
Illustrative minimal plugin for OpenCode 2. It type-checks under tsc --strict --module nodenext against @opencode/plugin 2.0.22.1

That file is a complete OpenCode 2 plugin. OpenCode is an open-source terminal coding agent, and a plugin is a TypeScript module its CLI imports at startup to add tools or to change behavior at hooks, the named points where the host calls plugin code. The default export is the object that Plugin.define returns, and define returns its argument unchanged. In @opencode/plugin 2.0.22 the compiled function is export function define(plugin) { return plugin; }, so it exists only to type the object.1 The id names the plugin. The migration guide says plugin storage is scoped by it and that status and diagnostics identify the plugin with it.2 setup(ctx) runs when the plugin loads and may return a cleanup function that runs when the plugin unloads.3 The ctx argument is the context object. Its members are the host's domains, each the API for one part of the host: storage above, and also tool, shell, session, event and others.1

The package is recent. @opencode/cli 2.0.0 was published to npm on 2026-09-11 and @opencode/plugin 2.0.0 on 2026-09-12 (UTC), and both had reached 2.0.22 by 2026-10-02, after 23 stable versions in 21 days.45 The project's GitHub release list shows only 1.18.x releases and no announcement of 2.0.0 was found, so the npm publish times are the only release dates this article has.6 Use is real and partial. Eight dated issues and pull requests in six third-party repositories record plugins that moved, planned to move or broke between 2026-08-15 and 2026-10-01. In the week to 2026-10-01 npm counted 174,464 downloads of @opencode/plugin against 25,688,212 of its v1 predecessor, @opencode-ai/plugin.7

What the loader checks before setup runs

Conceptual flow chart of the OpenCode 2 plugin loader. Config plugins entries and files in the plugin or plugins directory are resolved to an entrypoint by file URL or npm.add, imported, then decoded: the default export must be an object with an id and either an effect or a setup function. A failing plugin is skipped. A setup plugin is wrapped with fromPromise, and the plugin is registered with its options.
Fig. 1 · The load path for one plugin entry, read from the loader source at commit 907b3bc. The diagram is conceptual and shows what the source does, not the output of a run.8

The loader is a function in the repository at packages/core/src/config/plugin/external.ts, 91 lines at commit 907b3bc. The CLI binary contains it, and the @opencode/cli package on npm is only a launcher that installs the platform binary.85 Plugins reach the loader in two ways. A config document lists them under the key "plugins", each item a package name or an object { package, options }. A file:// item becomes a file path, and items starting with ./ or ../ resolve against the config file's directory. A config directory contributes the files matched by {plugin,plugins}/*.{ts,js}, which means .ts and .js files directly inside the directory, sorted.8

An absolute path is imported as a file URL. Anything else goes through npm.add(ref.package), which returns an entrypoint. How it chooses a version was not read.8 The loader then runs import(entrypoint) and decodes the module against a schema that requires default to be a struct of one of two shapes, { id: string, effect: function } or { id: string, setup: function }. If the value has an effect key it is used directly. Otherwise it is wrapped with PluginPromise.fromPromise, so the shape the host runs is always the Effect one.89 The registered plugin is { id, effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }) }, which is where ctx.options gets the options from the config entry.8

Each plugin's load is wrapped in Effect.ignoreCause. A plugin that fails to import or fails the schema is skipped, and neither the CLI nor the other plugins stop.8 The loader file does not show what gets logged on a skip. One user reports that the host logs a warning only.10 One detail is documented but could not be confirmed in that file. The docs say a plugin at .opencode/plugins/example/index.ts loads automatically, but the glob matches only files directly in the directory, and the code path that would load a subdirectory entrypoint was not found, so the examples here use .opencode/plugins/example.ts.38

The Effect form puts the plugin lifetime in a Scope

.opencode/plugins/example.ts · illustrative · Effect formts
import { Plugin } from "@opencode/plugin/effect"
import { Effect } from "effect"

export default Plugin.define({
  id: "example",
  effect: (ctx) =>
    Effect.gen(function* () {
      const storage = ctx.storage
      yield* storage.set("loaded", true)
    }),
})
The same plugin in the Effect form. It type-checks under tsc --strict --module nodenext against @opencode/plugin 2.0.22 with effect 4.0.0-rc.112.1

The import path is @opencode/plugin/effect, and the docs install it with bun add @opencode/plugin effect.11 The type declaration is Plugin<R = Scope.Scope> with effect: (context: Context) => Effect.Effect<void, never, R>.1 The error channel is never, so an effect that can fail has to handle the failure itself. The docs put the idea this way: "@opencode/plugin/effect is the Effect-native version of the OpenCode plugin API. Its context operations return Effects or Streams, callbacks return Effects, and plugin lifetime is represented by Scope."11 The package depends on effect 4.0.0-rc.112, a release candidate.4

Since the loader runs only the Effect shape, the Promise form is a wrapper. fromPromise(plugin) returns define({ id, effect }), and its doc comment says: "Hook registrations created during the async setup attach to the plugin's scope, so unloading the plugin disposes them." The same comment says the captured fiber context keeps boot-time batching, so Promise transforms coalesce into one reload per domain.9 A Promise plugin therefore gets the scope-bound cleanup of its hook registrations without any Effect code.

A tool and a hook, registered through ctx

.opencode/plugins/greeting.ts · illustrativets
import { Plugin } from "@opencode/plugin"

export default Plugin.define({
  id: "greeting",
  async setup(ctx) {
    await ctx.tool.transform((editor) => {
      editor.add({
        name: "greeting",
        description: "Create a greeting",
        input: {
          type: "object",
          properties: { name: { type: "string" } },
          required: ["name"],
          additionalProperties: false,
        },
        async execute(input) {
          return { content: `Hello ${(input as { name: string }).name}!` }
        },
      })
    })
    await ctx.tool.hook("execute.before", (event) => {
      console.log(event.tool)
    })
  },
})
A tool and a tool hook in one plugin. The code follows the docs' Tools and Tool Hooks sections and the migration guide, and type-checks against @opencode/plugin 2.0.22.321

ctx.tool.transform takes a callback that receives an editor, and editor.add registers a tool with a name, a description, an input written as JSON Schema and an execute function that returns { content }.12 The example casts input to { name: string } to read it. The declared transform type is (callback: (input: Input) => void) => Promise<Registration>, so the callback is synchronous and the call is awaited. A Registration is { dispose(): Promise<void> }.1 The docs add that transforms are replayed when a registry rebuilds, and that a plugin calls the domain's reload() after external inputs change. That is documented, and was not exercised here.3

ctx.tool.hook("execute.before", callback) registers a hook, and the callback receives one event. The 2.0.22 type declaration lists its fields as tool, sessionID, agent, messageID, id and input. The "execute.after" event has the same fields plus a status, which is "completed" with a result or "error" with an error.1 The migration guide says v2 hook callbacks receive one mutable event where v1 callbacks received (input, output).2

Only some hook names appear in the 2.0.22 type declarations. The table separates those from names that appear only in the docs, which this article could not check against the package.

Hook names by domain and where each is confirmed.13
DomainHook namesEvidence
toolexecute.before execute.afterIn the 2.0.22 type declarations, with the event fields listed above.
shellcreate.beforeIn the type declarations. The event has command, cwd, timeout, shell and env, all mutable.
sessionmodel.request http.request http.responseIn the type declarations, with three experimental.ws.* hooks.
sessionprompt context compaction generate title retryDocumented only.
permissionevaluateDocumented only.

Porting a v1 plugin takes a new export and a new config key

A v1 plugin in the project's v1 documentation is a named export, an async function that receives { project, client, $, directory, worktree } and returns the hooks, typed with import type { Plugin } from "@opencode-ai/plugin" and listed in config under "plugin".12 The v1 package's root types in 1.18.34 declare Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Hooks> and PluginModule = { id?: string; server: Plugin; tui?: never }.13

The two plugin APIs at package level, as of 2026-10-04.1342
v1v2
Package@opencode-ai/plugin 1.18.34, published 2026-09-30@opencode/plugin 2.0.22, published 2026-10-02
EntryA named export of type Plugin, or an object { id?, server }A default export { id, setup } or { id, effect }
Config keypluginplugins
Hook callback(input, output)One mutable event

The v1 package keeps the v1 shape at its root and still ships: 1.18.34 came out on 2026-09-30. It also exports ./v2/effect, ./v2/promise and ./v2/effect/plugin subpaths.13 The migration guide says config can be normalized automatically and implementation code must be ported: "V1 plugin implementations do not run in V2. Moving a file or renaming its config entry is not enough." The config change is the key, "plugin" to "plugins", and the [path, options] tuple becomes { package, options }.2

What a v1 plugin does under v2 comes from three user reports, since the binary was not run for this article. A v1 plugin has no default export carrying setup or effect. The named-export form has no default at all and the object form has only server, so by the loader's schema either fails the decode and is skipped. Reporters quote the same error in each: "Plugin must export a default definition with an id and an effect or setup function." In Honcho's tracker, @honcho-ai/opencode-honcho 0.1.4 declares @opencode-ai/plugin ^1.18.23 and fails to load on @opencode/cli 2.0.12.14 In Gortex's, v0.64.5 under OpenCode 2.0.15 prints the error with (cause: SchemaError(Missing key at ["default"])). Its deny, enrich, nudge and session-briefing hooks go inactive while its MCP tools keep working.10 The oh-my-openagent issue lists the changes its author sees as required and states that no workaround exists, so users stay on v1 until the port is done.15 The error string was not found in the source that was read. The Missing key suffix fits the schema decode in the loader, so the string is reported here, not source-confirmed.8

opencode-plugin/index.ts · illustrative · diffdiff
+import { Plugin } from "@opencode/plugin"
+
 export default {
+  ...Plugin.define({
+    id: "example",
+    async setup(ctx) {
+      await ctx.tool.hook("execute.before", () => { console.log("A tool is about to run") })
+    },
+  }),
   async server() {
     return { "tool.execute.before": async () => { console.log("A tool is about to run") } }
   },
 }
A port that keeps both versions in one file. The v1 half is the object form with server(), and the v2 half is the migration guide's dual export.2

The diff adds a v2 definition and removes nothing. Both sets of keys sit on one object, so OpenCode 2 reads id and setup and an OpenCode 1.18 reads server.2 The guide says the object form with server() is supported in OpenCode 1.18.29 and newer, and that older v1 releases may expect function exports, so a plugin supporting them needs separate package versions or entrypoints.2 Gortex's pull request 852 drops the @opencode/plugin dependency as well, since define is an identity function: export default { id: "gortex", setup, server };. Its description says the one file works on OpenCode 1.18 and 2.0.21, and the pull request was open when read.161 It followed pull request 851, whose maintainer listed three faults. A fallback exported undefined as the default when @opencode/plugin could not be resolved, and OpenCode 1.18 rejected that. The code read event fields as event.callID and event.output where the real ones are event.id and event.result. And the dependency was one the file did not need.17 The 2.0.22 declarations give id and result for the tool hook event.1

The merged port in opencode-parser, from 2026-09-29, replaced the @opencode-ai/plugin dependency with @opencode/plugin, added a Plugin.define entry whose setup registers a parse tool through ctx.tool.transform, and kept the v1 tool alongside. Its README tells v1 users to keep the "plugin" key and v2 users to use "plugins".18

The hooks need a mapping, and the migration guide supplies one. The third column says where each v2 name is confirmed.

Abridged v1 to v2 mapping from the migration guide.2
v1v2v2 name confirmed in 2.0.22 declarations
tool.execute.beforectx.tool.hook("execute.before")Yes1
shell.envctx.shell.hook("create.before")Yes1
chat.headersctx.session.hook("model.request")Yes1
chat.paramsctx.session.hook("context")Documented only
chat.messagectx.session.hook("prompt")Documented only
permission.askctx.permission.hook("evaluate")Documented only
eventctx.event.subscribe()Domain exists; event types not checked1
disposeThe cleanup function that setup returnsYes, as the Cleanup type1
tool() definitionseditor.add inside ctx.tool.transform, with JSON Schema inputYes, as in the example above1

Four v1 hooks have no direct v2 equivalent in the guide: experimental.compaction.autocontinue, experimental.provider.small_model, experimental.text.complete and command.execute.before. The $ Bun shell helper has none either.2 The port request for opencode-vision calls the work "mostly mechanical" and lists Bun globals to be replaced with Node fs, a v1 FilePart.url base64 value to become a v2 MediaPart, and tool.definition to become ctx.tool.transform().19 Whether that is mechanical depends on which of those a plugin uses.

v1 is still installed about 147 times as often

Downloads in the 7 days from 2026-09-25 to 2026-10-01. npm counts every install, including CI and mirrors, so these are not counts of developers.7
PackageDownloads
@opencode-ai/plugin (v1)25,688,212
@opencode/cli (v2)183,569
@opencode/plugin (v2)174,464

v2 has not displaced v1. The v1 plugin package was downloaded about 147 times as often as the v2 one, and it kept releasing, with 1.18.34 on 2026-09-30.713 The migration is real and early. The eight items below run from a plan written during the v2 beta to a merged port, in six repositories. They are a sample of the ones that were opened, not a count of migrations.

Dated migration issues and pull requests, with the state shown when each page was read.
DateItemWhat it recordsState
2026-08-15opencode-pty issue 5520A plan to keep the v1 entry point and add a separate v2 export, written before the npm release. Its test is that the plugin appears in opencode2 api get /api/plugin.Closed with PR 59
2026-09-19oh-my-openagent issue 848915v4.19.4 and v5.0.0-beta.78 fail to load on v2.Closed
2026-09-21opencode-honcho issue 46140.1.4 fails on @opencode/cli 2.0.12.Closed, linked to PR 50
2026-09-26opencode-vision issue 519A port request with named mappings.Open
2026-09-28gortex issue 83010v0.64.5 hooks inactive on OpenCode 2.0.15.Open, linked to PR 852
2026-09-29opencode-parser PR 218A port that keeps both versions in one package.Merged
2026-10-01gortex PRs 851 and 8521716Two dual-export attempts, the second dependency-free.Both open

The failure in these items is quiet. Gortex's MCP tools kept working while its hooks went inactive, and the host logged a warning only.10 The opencode-pty plan names a check that does not depend on the log: after loading, the plugin should appear in the output of opencode2 api get /api/plugin.20

Sources

  1. npm · @opencode/plugin 2.0.22 package tarball (type declarations)
  2. OpenCode · Migrate a v1 plugin (v2 documentation)
  3. OpenCode · Plugins (v2 documentation)
  4. npm · @opencode/plugin package metadata
  5. npm · @opencode/cli package metadata
  6. GitHub · anomalyco/opencode releases
  7. npm · weekly download counts (api.npmjs.org)
  8. anomalyco/opencode · packages/core/src/config/plugin/external.ts at 907b3bc
  9. anomalyco/opencode · packages/core/src/plugin/promise.ts at 907b3bc
  10. zzet/gortex · issue 830
  11. OpenCode · Effect plugins (v2 documentation)
  12. OpenCode · Plugins (v1 documentation)
  13. npm · @opencode-ai/plugin 1.18.34 package
  14. plastic-labs/opencode-honcho · issue 46
  15. code-yeongyu/oh-my-openagent · issue 8489
  16. zzet/gortex · pull request 852
  17. zzet/gortex · pull request 851
  18. TejasS1233/opencode-parser · pull request 2
  19. JochenYang/opencode-vision · issue 5
  20. shekohex/opencode-pty · issue 55