Files
xiaoshuai 15a1bbcee0 Version OpenCode skills and plugins
Repository sits at the OpenCode config root rather than inside skills/ or
plugins/. OpenCode scans exactly those two subdirectories, and a .git
directory inside either one is picked up as a plugin and fails to load:

  loading plugin id=.../plugins/.git entrypoint=.../plugins/.git/index
  failed to load plugin ... AggregateError: 2 errors building .../plugins/.git/index

The watcher.ignore config option does not help: it only suppresses filesystem
update events, not plugin discovery, and the "-" disable prefix only applies to
plugins listed in opencode.json(c), not auto-discovered ones.

.gitignore admits only skills/ and plugins/ (plus itself). Personal config
(opencode.jsonc, cli.json, service.json) and node_modules stay local.

skills/opencode-plugins  V2-only plugin authoring reference and 7 templates,
                         transcribed from the V2 docs with the URL on every file
plugins/searxng-search   searxng_search tool over a self-hosted SearXNG JSON API
2026-10-08 13:12:10 +08:00

13 KiB

Effect Plugins

Docs: https://opencode.ai/v2/docs/build/plugins/effect/ — fetch this page before writing an Effect plugin. This file is a summary of it. Template: ../templates/effect-plugin.ts

@opencode/plugin/effect is the Effect-native flavor of the server plugin API. Context operations return Effects or Streams, callbacks return Effects, and lifetime is a Scope. Use it when the project already uses Effect. Otherwise use the Promise flavor in SKILL.md.

Install

OpenCode provides effect, effect/*, and @opencode/plugin at runtime. Do not bundle them. The page says to install effect and @opencode/plugin as peer/dev dependencies for local type-checking and testing.

bun add --peer @opencode/plugin effect
{
  "peerDependencies": { "effect": ">=4.0.0-rc.112" }
}

If you bundle with a bundler, mark effect and effect/* external. OpenCode redirects these imports (including from your node_modules) to the host's runtime so fibers, loggers, and Schema parsers share one copy. Dependencies built on another Effect major (e.g. Effect 3) are not supported. Importing a private effect/internal/* path fails to load with an error naming the path.

Shape

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

Use effect:, not setup:. Exporting from .opencode/plugins/ loads it automatically; packages use the same plugins config as Promise plugins.

Lifecycle

The scope closes on reload or unload, releasing registrations, scoped fibers, and finalizers together.

effect: (ctx) =>
  Effect.gen(function* () {
    yield* Effect.addFinalizer(() => Effect.logInfo("unloaded"))
  })

Background work uses scoped fibers; the scope interrupts them at cleanup.

yield* Effect.repeat(
  storage.set("heartbeat", { time: Date.now() }),
  { schedule: Schedule.spaced("1 minute") },
).pipe(Effect.forkScoped)

Effect.repeat takes an Effect value, not a function.

Key differences from Promise plugins

Aspect Promise Effect
Entry setup(ctx) returning Promise/cleanup fn effect(ctx) returning Effect
Reads await ctx.model.list() yield* ctx.model.list()
Transforms await ctx.model.transform(cb) — cb sync yield* ctx.model.transform(cb) — cb still sync
Cleanup return () => {} Effect.addFinalizer / forkScoped
Events for await (…) + manual AbortController Stream + forkScoped
Tool I/O raw JSON Schema Effect Schema
Hooks no rejection API documented for tool hooks Effect.fail(new Tool.Error(...))
Registration await reg.dispose() yield* reg.dispose

Hooks<Spec, Failures> and Transform<Input> are the shared registration types. Both return an Effect that requires the plugin scope, which is why transform/hook/dispose are all yieldable and scoped:

type Hooks<Spec, Failures extends Record<keyof Spec, unknown> = Record<keyof Spec, never>> = <Name extends keyof Spec>(
  name: Name,
  callback: (input: Spec[Name]) => Effect.Effect<void, Failures[Name]>,
) => Effect.Effect<Registration, never, Scope.Scope>

type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Registration, never, Scope.Scope>

These are the page's simplified shared types. They are not the complete signature for the session domain: the page's session hook type is SessionHookDomain { readonly hook: ModelHooks<SessionHooks> }, ModelHooks is not defined on the page, and the page's own examples pass a third { providerID } argument that the Hooks type above does not accept. Trust the page's examples over the simplified type.

The transform callback is still synchronous in both flavors. It must not return an Effect or a Promise.

Result shape is per operation, not per flavor. Some reads are enveloped and some are not, in both flavors — check the doc example for the call you are using.

// enveloped
const models = yield* ctx.model.list().pipe(Effect.orDie)
models.data.length

// returned directly
const created = yield* ctx.session.create({ title: "Review" }).pipe(Effect.orDie)
created.id
const settings = yield* ctx.storage.get("settings")

The page states direct returns for storage ("Storage accepts Effect's JSON type and returns Effects directly"), for integration.connection.active (Effect<ConnectionInfo | undefined>) and integration.connection.resolve (a Credential.Value | undefined), and for ToolDomain.list (Effect<readonly (Tool.Info & { id: string })[]>); editor list() calls also return plain arrays. For everything else, mirror the page's own example rather than assuming either shape.

Many examples in the page pipe reads through Effect.orDie to drop the error channel, then read .data — copy that shape when the example uses it.

Transforms

effect: (ctx) =>
  Effect.gen(function* () {
    const provider = ctx.provider
    const source = { providers: yield* loadFromSource() }

    yield* provider.transform((editor) => {
      source.providers.forEach((p) => editor.add(p))
    })

    yield* Effect.repeat(
      Effect.gen(function* () {
        source.providers = yield* loadFromSource()
        yield* provider.reload()
      }),
      { schedule: Schedule.spaced("1 minute") },
    ).pipe(Effect.forkScoped)
  })

Rebuild, replay-order, and no-implicit-watch semantics are identical to the Promise flavor. See SKILL.md.

Tools

Effect plugins register tools with Effect Schema instead of raw JSON Schema, and the executor returns typed output.

effect: (ctx) =>
  Effect.gen(function* () {
    yield* ctx.tool.transform((editor) => {
      editor.namespace({ name: "acme", description: "Customer account tools" })
      editor.add({
        name: "greeting",
        description: "Create a greeting",
        input: Schema.Struct({ name: Schema.String }),
        output: Schema.Struct({ greeting: Schema.String }),
        options: { namespace: "acme", codemode: true },
        execute: ({ name }, context) =>
          Effect.gen(function* () {
            yield* context.progress({ status: "greeting" })
            return { output: { greeting: `Hello ${name}!` } }
          }),
      })
    })
  })

Return { output } for typed output — that is the shape the page's executor example uses. The page also says an executor may return "typed output, display content, or metadata" without showing those other shapes, so confirm on the page before relying on a { content } form in an Effect plugin. Effective-name rules (acme_greeting), update/remove semantics, snapshot behavior, and dispose are the same as the Promise flavor.

Hooks

Callbacks return Effects. Registration stays in the plugin scope.

effect: (ctx) =>
  Effect.gen(function* () {
    const session = ctx.session

    yield* session.hook("context", (event) =>
      Effect.sync(() => {
        event.system.push({ type: "text", text: "Keep the review focused on correctness." })
        delete event.tools.write
      }),
    )

    yield* session.hook("compaction", (event) =>
      Effect.map(summarize(event.messages), (summary) => {
        event.result = { summary }
      }),
    )
  })

Failing a tool hook with Tool.Error:

yield* ctx.tool.hook("execute.before", (event) =>
  event.tool === "write"
    ? Effect.fail(new Tool.Error({ message: "Writes are disabled" }))
    : Effect.logDebug("tool input", { tool: event.tool, input: event.input }),
)

Hook names, event fields, ordering, and override precedence are the same where the Effect page documents them — but the two flavors do not have the same hook set:

  • The Effect SessionHooks type lists context, compaction, generate, title, model.request, http.request, http.response, the three experimental.ws.*, and retry. It has no prompt hook. Do not carry the Promise prompt example over without confirming it on this page.
  • Provider scoping ({ providerID: "anthropic" } as a third argument) is documented here for model.request, experimental.ws.handshake, and experimental.ws.send. The simplified Hooks<Spec, Failures> type quoted earlier in this file does not show that third parameter, so do not treat it as the complete signature.

For the request-hook rules that both flavors share, see SKILL.md.

Effect callbacks use Effect interruption instead of signal; the other experimental.ws.* and retry hooks behave as documented there.

Events

ctx.event.subscribe() is a Stream. Fork it in scope for automatic interruption — no AbortController needed.

yield* ctx.event.subscribe().pipe(
  Stream.tap((item) => Effect.logDebug("OpenCode event", { type: item.type })),
  Stream.runDrain,
  Effect.forkScoped,
)

yield* ctx.event.subscribe().pipe(
  Stream.filter((item) => item.type === "config.updated"),
  Stream.runForEach(() => Effect.logInfo("configuration changed")),
  Effect.forkScoped,
)

Branded values

The re-exported Effect schema constructors produce branded IDs rather than bare strings. Narrow and construct with them:

const providerID = Provider.ID.make("acme")
const modelID = Model.ID.make("claude-sonnet-4-5")

const created = yield* ctx.session.create({ title: "Review" }).pipe(Effect.orDie)

yield* ctx.session
  .switchModel({ sessionID: created.id, model: { providerID, id: modelID } })
  .pipe(Effect.orDie)

yield* ctx.skill.transform((editor) => {
  editor.add(Skill.Info.make({
    id: Skill.ID.make("review"),
    name: Skill.Name.make("Review"),
    description: "Review the current changes",
    path: "/workspace/.opencode/skills/review/SKILL.md",
    content: "Review the current changes for correctness and missing tests.",
  }))
})

Commands

The command transform is add-only here too. The executor returns an Effect.

yield* command.transform((editor) => {
  editor.add({
    name: "security-review",
    description: "Review changes for security issues",
    execute: (input) =>
      session.prompt({
        ...input.prompt,
        sessionID: input.sessionID,
        text: `Review these changes for security issues.\n\n${input.prompt.text}`,
        delivery: input.delivery,
      }).pipe(Effect.asVoid),
  })
})

Integrations

OAuth method callbacks are Effects and may acquire scoped resources.

yield* integration.transform((editor) => {
  editor.method.update({
    integrationID: "acme",
    method: { id: "device", type: "oauth", label: "Sign in with Acme" },
    authorize: () =>
      Effect.succeed({
        mode: "code",
        url: "https://acme.example/device",
        instructions: "Enter the displayed code",
        callback: (code) => exchangeCode(code),
      }),
  })
})

authorize returns mode: "auto" (callback is the Effect that performs the flow) or mode: "code" (callback takes the code). The Effect's R may include Scope.Scope, so it can hold scoped resources for the attempt's duration.

Context shape

Context declares app, location, options, and these domains: agent, aisdk, command, event, experimental, integration, mcp, model, generate, permission, plugin, provider, reference, rpc, session, shell, skill, storage, tool, vcs, websearch, worktree.

Three narrowed members worth noting:

  • experimental is only { terminal: Pick<ExperimentalApi<unknown>["persistentPty"], "read"> }. The narrowed member is experimental.terminal, not experimental.
  • plugin is only Pick<PluginApi<unknown>, "list">; list reports active, failed, and resolved plugins, so filter in a pipeline when you need only active ones. The page's example filters on item.status === "active", but the shipped Plugin.Info nests that as state.status — trust the type.
  • vcs omits init as a client method, but a registered VCS definition may still implement it.

This is the Effect Context. Do not assume it matches the Promise context member for member — this page does not state that they are identical, and aisdk appears here with no Promise-page counterpart.

The context exposes the Effect client plus plugin-only transforms, hooks, storage, reloads, and options. It does not expose OpenCode's private Core services.

Plugin type

interface Plugin<R = Scope.Scope> {
  readonly id: string
  readonly effect: (context: Context) => Effect.Effect<void, never, R>
}

The R defaults to Scope.Scope, so registrations and scoped fibers are released when the plugin scope closes.

Two runtime caveats:

  • Only public effect subpaths are provided. Importing an internal module fails to load with an error naming the path.
  • The compiled OpenCode binary omits the Scalar/Swagger UI assets used by Effect's HTTP API doc pages; serving those from a plugin shows a notice.

Publish

Declare effect as a peerDependency and export the Effect implementation from the main entrypoint.

{
  "name": "opencode-acme-effect-plugin",
  "version": "1.0.0",
  "type": "module",
  "exports": { ".": "./src/index.ts" },
  "dependencies": { "@opencode/plugin": "latest" },
  "peerDependencies": { "effect": ">=4.0.0-rc.112" }
}

Test the installed tarball, not only a workspace link:

bun pm pack
bun add ./opencode-acme-effect-plugin-1.0.0.tgz