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
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
SessionHookstype listscontext,compaction,generate,title,model.request,http.request,http.response, the threeexperimental.ws.*, andretry. It has noprompthook. Do not carry the Promisepromptexample over without confirming it on this page. - Provider scoping (
{ providerID: "anthropic" }as a third argument) is documented here formodel.request,experimental.ws.handshake, andexperimental.ws.send. The simplifiedHooks<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:
experimentalis only{ terminal: Pick<ExperimentalApi<unknown>["persistentPty"], "read"> }. The narrowed member isexperimental.terminal, notexperimental.pluginis onlyPick<PluginApi<unknown>, "list">;listreports active, failed, and resolved plugins, so filter in a pipeline when you need only active ones. The page's example filters onitem.status === "active", but the shippedPlugin.Infonests that asstate.status— trust the type.vcsomitsinitas 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
effectsubpaths are provided. Importing aninternalmodule 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