OpenCode V2 Effect Plugin API
The Effect plugin API grants plugins two in-process capabilities:
hookinstalls behavior at an OpenCode extension point.reloadreruns every transform hook for a stateful domain.
Defining A Plugin
import { Plugin } from "@opencode/plugin/effect"
import { Effect } from "effect"
export default Plugin.define({
id: "example",
effect: Effect.fn(function* (ctx) {
yield* ctx.provider.transform((editor) => {
editor.update("example", (provider) => {
provider.name = "Example"
})
})
}),
})
Plugin setup registers hooks imperatively through each domain's hook method.
Configuration supplied for the plugin is available as ctx.options.
Registrations are owned by the plugin scope. Closing the scope removes them automatically; a registration may also be removed early through dispose.
Transform Hooks
Transform hooks contribute to stateful domains. Their editor callbacks are synchronous, so load effectful data before registering a transform or reloading its domain:
yield *
ctx.agent.transform((agent) => {
agent.update("reviewer", (item) => {
item.description = "Reviews code for regressions"
item.mode = "subagent"
})
})
Registry reads rebuild synchronously when registrations changed, applying every transform in registration order to a fresh value; unchanged registries return the previous value. Values read earlier are never mutated. Notifications and resource reconciliation run separately from that materialization.
Available transform hooks are namespaced by domain:
ctx.agent.transform
ctx.command.transform
ctx.integration.transform
ctx.mcp.transform
ctx.model.transform
ctx.provider.transform
ctx.reference.transform
ctx.skill.transform
ctx.tool.transform
ctx.vcs.transform
ctx.websearch.transform
Provider transforms contribute provider settings and immutable model definitions. After provider availability is resolved,
model transforms edit the complete active-provider candidate collection in order. Use ctx.model.transform for runtime
model restrictions; editor.provider.get() reads source templates even when their provider is inactive.
Effect.gen(function* () {
yield* ctx.model.transform((editor) => {
editor
.list()
.filter((model) => model.cost.some((tier) => tier.output > 20))
.forEach((model) => {
editor.remove(model.providerID, model.id)
})
})
})
Runtime Hooks
Runtime hooks intercept live operations rather than rebuilding domain state:
yield *
ctx.aisdk.hook(
"sdk",
Effect.fn(function* (event) {
if (event.package !== "@ai-sdk/xai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/xai"))
event.sdk = mod.createXai(event.options)
}),
)
yield *
ctx.aisdk.hook("language", (event) =>
Effect.sync(() => {
if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.modelID)
}),
)
Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks.
Session context is mutable immediately before provider dispatch:
yield *
ctx.session.hook("context", (event) =>
Effect.sync(() => {
event.tools.read.description = "Read a file using narrow line ranges."
delete event.tools.write
}),
)
yield *
ctx.session.hook("retry", (event) =>
Effect.sync(() => {
if (event.attempt >= 3) event.decision = { retry: false }
}),
)
When a Session finishes model work with no pending input, the experimental.idle hook runs just before it goes idle.
It is experimental: its name and shape may still change. It does not run after interruption, failure, or control-only
work such as a manual compaction. Input the hook admits runs in the same execution instead of the Session going idle,
so a plugin that always admits input must bound its own loop.
event.background lists background shells and subagents still running for the Session or its descendants. When one of
the Session's own shells or subagents finishes, its notice wakes the Session and the hook runs again, so a plugin that
waits for that work can return. Work a descendant owns, such as a shell a subagent left running, wakes that
descendant instead, so it is listed for information only. Notices admitted with resume: false, such as those
recovered after a restart, wait for the next wake instead:
yield *
ctx.session.hook("experimental.idle", (event) =>
event.background.some((work) => work.kind === "subagent" && work.sessionID === event.sessionID)
? Effect.void
: ctx.session
.synthetic({ sessionID: event.sessionID, text: "Run the tests and fix any failures." })
.pipe(Effect.ignore),
)
A continuation runs inside the same execution, so a loop that is still going when the server restarts is resumed by restart recovery and the hook keeps running. Persist your own state if the loop should come back paused. A hook that fails, fails the execution like any other session hook, so handle errors from slow or remote work such as a judge model call.
Reloading A Domain
When data captured by a transform changes, reload the affected domain:
Effect.gen(function* () {
const source = { providers: yield* loadProviders() }
yield* ctx.provider.transform((editor) => {
source.providers.forEach((provider) => editor.add(provider))
})
source.providers = yield* loadProviders()
yield* ctx.provider.reload()
})
loadProviders() returns entries shaped as { info: Provider.Info, models: readonly Model.Info[] }. Provider reloads
also invalidate the active model result, so every model transform runs again with the refreshed definitions. Model
callbacks edit raw overrides; provider defaults are merged once when the result is committed.
Available reload operations are:
ctx.agent.reload()
ctx.command.reload()
ctx.integration.reload()
ctx.mcp.reload()
ctx.model.reload()
ctx.provider.reload()
ctx.reference.reload()
ctx.skill.reload()
ctx.tool.reload()
ctx.vcs.reload()
ctx.websearch.reload()