Effect
@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. Install effect with the
plugin package.
bun add @opencode/plugin effect
Export an Effect plugin from .opencode/plugins/ to load it automatically.
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)
}),
})Published packages and plugin directories outside .opencode/plugins/ use the same plugins configuration as other server
plugins.
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
"opencode-acme-effect-plugin",
"opencode-acme-effect-plugin@1.2.0",
"@acme/opencode-effect-plugin",
"./plugins/local-effect",
{
"package": "@acme/opencode-effect-plugin",
"options": { "agent": "reviewer", "strict": true },
},
],
}See Configure plugins for enablement, package resolution, and configuration precedence.
import { Plugin } from "@opencode/plugin/effect"
import { Effect } from "effect"
export default Plugin.define({
id: "local-effect",
effect: (ctx) =>
Effect.gen(function* () {
yield* Effect.logInfo("Effect plugin loaded", { version: ctx.app.version })
}),
})Lifecycle
The effect runs when the plugin loads. Its scope closes when the plugin reloads or unloads, so registrations, scoped
fibers, and finalizers are released together.
import { Plugin } from "@opencode/plugin/effect"
import { Effect } from "effect"
export default Plugin.define({
id: "lifecycle",
effect: (ctx) =>
Effect.gen(function* () {
yield* Effect.logInfo("loaded", { version: ctx.app.version })
yield* Effect.addFinalizer(() => Effect.logInfo("unloaded"))
}),
})
Use scoped fibers for background work. The plugin scope interrupts the fiber during cleanup.
effect: (ctx) =>
Effect.gen(function* () {
const storage = ctx.storage
yield* Effect.repeat(
storage.set("heartbeat", { time: Date.now() }),
{ schedule: Schedule.spaced("1 minute") },
).pipe(Effect.forkScoped)
}),
Context
The context exposes the Effect client for the connected OpenCode server plus plugin-only transforms, hooks, storage, reloads, and options. It does not expose OpenCode’s private Core services.
effect: (ctx) =>
Effect.gen(function* () {
const plugins = ctx.plugin
const active = yield* plugins.list().pipe(Effect.orDie)
yield* Effect.logInfo("active plugins", { count: active.data.length })
}),
The plugin definition and complete context are declared as follows.
interface Context {
readonly app: App
readonly location: Location.Info
readonly options: PluginOptions
readonly agent: AgentDomain
readonly aisdk: AISDKDomain
readonly command: CommandDomain
readonly event: EventDomain
readonly experimental: {
readonly terminal: Pick<ExperimentalApi<unknown>["persistentPty"], "read">
}
readonly integration: IntegrationDomain
readonly mcp: MCPDomain
readonly model: ModelDomain
readonly generate: GenerateApi<unknown>
readonly permission: PermissionDomain
readonly plugin: Pick<PluginApi<unknown>, "list">
readonly provider: ProviderDomain
readonly reference: ReferenceDomain
readonly rpc: RpcDomain
readonly session: SessionDomain
readonly shell: ShellDomain
readonly skill: SkillDomain
readonly storage: StorageDomain
readonly tool: ToolDomain
readonly vcs: VcsDomain
readonly websearch: WebSearchDomain
readonly worktree: WorktreeDomain
}
interface Plugin<R = Scope.Scope> {
readonly id: string
readonly effect: (context: Context) => Effect.Effect<void, never, R>
}
Options
Pass options with the object form in opencode.json(c).
{
"plugins": [
{
"package": "./plugins/company-effect",
"options": { "strict": true },
},
],
}Read options from ctx.options. Narrow unknown values before use.
import { Plugin } from "@opencode/plugin/effect"
import { Effect } from "effect"
export default Plugin.define({
id: "company",
effect: (ctx) =>
Effect.gen(function* () {
const strict = ctx.options.strict === true
yield* Effect.logInfo("company plugin configured", { strict })
}),
})Transforms
Transforms synchronously edit state through an editor. OpenCode applies transforms in registration order within each domain. Sources contribute providers and immutable model definitions first; model transforms then edit the active providers’ candidates. Yielding the registration keeps it in the plugin scope.
import { Model, Plugin, Provider } from "@opencode/plugin/effect"
import { Effect } from "effect"
export default Plugin.define({
id: "company.models",
effect: (ctx) =>
Effect.gen(function* () {
const providerID = Provider.ID.make("acme")
const models = [{ ...Model.Info.default(providerID, Model.ID.make("reasoner")), name: "Acme Reasoner" }]
yield* ctx.provider.transform((editor) => {
editor.add({
info: {
...Provider.Info.empty(providerID),
name: "Acme",
activation: "enabled",
package: "@opencode/ai/providers/openai-compatible",
settings: { baseURL: "http://127.0.0.1:8000/v1" },
},
models,
})
})
}),
})A model transform can enforce policy across the complete active-provider candidate collection.
effect: (ctx) =>
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)
})
})
}),Call reload when external state used by a transform changes. Here, loadFromSource() returns
{ info: Provider.Info, models: readonly Model.Info[] } entries; provider reloads also invalidate the entire model result.
effect: (ctx) =>
Effect.gen(function* () {
const provider = ctx.provider
const source = { providers: yield* loadFromSource() }
yield* provider.transform((editor) => {
source.providers.forEach((provider) => editor.add(provider))
})
yield* Effect.repeat(
Effect.gen(function* () {
source.providers = yield* loadFromSource()
yield* provider.reload()
}),
{ schedule: Schedule.spaced("1 minute") },
).pipe(Effect.forkScoped)
}),API
Agent
Read all agents or fetch one by ID. Client responses include the resolved location and schema data.
effect: (ctx) =>
Effect.gen(function* () {
const agent = ctx.agent
const agents = yield* agent.list().pipe(Effect.orDie)
const build = yield* agent.get({ agentID: Agent.ID.make("build") }).pipe(Effect.orDie)
yield* Effect.logInfo("agents", { count: agents.data.length, build: build.data.name })
}),
Transform or reload agents.
effect: (ctx) =>
Effect.gen(function* () {
const agent = ctx.agent
yield* agent.transform((editor) => {
editor.default("build")
editor.update("build", (item) => (item.description = "Builds features and fixes bugs"))
editor.remove("legacy")
})
yield* agent.reload()
}),
Schema: Agent.Info.
interface AgentEditor {
list(): readonly Types.DeepMutable<Agent.Info>[]
get(id: string): Types.DeepMutable<Agent.Info> | undefined
default(id: string | undefined): void
update(id: string, update: (agent: Types.DeepMutable<Agent.Info>) => void): void
remove(id: string): void
}
interface AgentDomain extends AgentApi<unknown> {
readonly transform: Transform<AgentEditor>
readonly reload: () => Effect.Effect<void>
}
Providers
Read available providers or inspect one provider’s metadata by ID.
effect: (ctx) =>
Effect.gen(function* () {
const provider = ctx.provider
const providers = yield* provider.list().pipe(Effect.orDie)
const anthropic = yield* provider.get({ providerID: Provider.ID.make("anthropic") }).pipe(Effect.orDie)
yield* Effect.logInfo("providers", {
providers: providers.data.length,
anthropic: anthropic.data.name,
})
}),
Provider transforms edit provider settings and source definitions. Editor reads include inactive providers and expose
immutable definitions; models.update edits an owned copy of a source model.
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.provider.transform((editor) => {
editor.update("anthropic", (provider) => {
provider.headers = { ...provider.headers, "x-company": "engineering" }
})
editor.models.update("anthropic", "claude-sonnet-4-5", (model) => (model.name = "Team Sonnet"))
editor.models.remove("anthropic", "legacy-model")
editor.remove("legacy-provider")
})
}),
Replace a source inventory with models.set, then reload after its captured definitions change.
effect: (ctx) =>
Effect.gen(function* () {
const provider = ctx.provider
const source = { models: yield* loadModels() }
yield* provider.transform((editor) => editor.models.set("acme", source.models))
source.models = yield* loadModels()
yield* provider.reload()
}),
For account-specific discovery, pass the connection used to load the inventory as sourceConnection. OpenCode excludes
that provider while another connection is selected, until your plugin publishes its refreshed inventory.
const connection = yield * ctx.integration.connection.active("acme")
if (!connection) return
const inventory = yield * loadInventory(connection)
yield *
ctx.provider.transform((editor) => {
editor.add({ info: inventory.provider, models: inventory.models, sourceConnection: connection })
})
Schemas: Provider.Info, Model.Info.
interface ProviderRecord {
readonly provider: Provider.Info
readonly models: ReadonlyMap<string, Model.Info>
readonly sourceConnection?: ConnectionInfo
}
interface ProviderEditor {
list(): readonly ProviderRecord[]
get(providerID: string): ProviderRecord | undefined
add(input: { info: Provider.Info; models: readonly Model.Info[]; sourceConnection?: ConnectionInfo }): void
update(providerID: string, update: (provider: Types.DeepMutable<Provider.Info>) => void): void
remove(providerID: string): void
readonly models: {
set(providerID: string, models: readonly Model.Info[]): void
update(providerID: string, modelID: string, update: (model: Types.DeepMutable<Model.Info>) => void): void
remove(providerID: string, modelID: string): void
}
}
interface ProviderDomain extends ProviderApi<unknown> {
readonly transform: Transform<ProviderEditor>
readonly reload: () => Effect.Effect<void>
}
Models
Read available models and the default selection.
effect: (ctx) =>
Effect.gen(function* () {
const model = ctx.model
const models = yield* model.list().pipe(Effect.orDie)
const selected = yield* model.default().pipe(Effect.orDie)
yield* Effect.logInfo("models", { count: models.data.length, selected: selected.data?.name })
}),
Model transforms edit the entire active-provider candidate collection. Disabled candidates remain editable until all
transforms finish; update can add a model only under an available provider.
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.model.transform((editor) => {
editor.list("anthropic").forEach((model) => {
editor.update(model.providerID, model.id, (draft) => {
draft.enabled = draft.capabilities.tools
})
})
editor.default.set("anthropic", "claude-sonnet-4-5")
editor.remove("anthropic", "legacy-model")
})
}),
Callbacks edit raw model overrides. Provider defaults are merged once when OpenCode commits the result; unchanged reads
reuse that result. editor.provider reads immutable source definitions, including inactive templates, rather than
model-transform output.
effect: (ctx) =>
Effect.gen(function* () {
const model = ctx.model
yield* model.transform((editor) => {
const source = editor.provider.get("anthropic")?.models.get("claude-sonnet-4-5")
if (!source) return
editor.update("anthropic", source.id, (draft) => {
draft.limit.context = Math.min(draft.limit.context, source.limit.context)
})
})
yield* model.reload()
}),
Schema: Model.Info.
interface ModelEditor {
list(providerID?: string): readonly Types.DeepMutable<Model.Info>[]
get(providerID: string, modelID: string): Types.DeepMutable<Model.Info> | undefined
update(providerID: string, modelID: string, update: (model: Types.DeepMutable<Model.Info>) => void): void
remove(providerID: string, modelID: string): void
readonly default: {
get(): { providerID: string; modelID: string } | undefined
set(providerID: string, modelID: string): void
}
readonly provider: {
list(): readonly ProviderRecord[]
get(providerID: string): ProviderRecord | undefined
}
}
interface ModelDomain extends ModelApi<unknown> {
readonly transform: Transform<ModelEditor>
readonly reload: () => Effect.Effect<void>
}
Commands
Read commands available at the current location.
effect: (ctx) =>
Effect.gen(function* () {
const command = ctx.command
const commands = yield* command.list().pipe(Effect.orDie)
yield* Effect.logInfo("commands", { names: commands.data.map((item) => item.name) })
}),
The current command transform is add-only. An executor receives the session, prompt attachments, and delivery mode.
effect: (ctx) =>
Effect.gen(function* () {
const command = ctx.command
const session = ctx.session
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),
})
})
yield* command.reload()
}),
Schemas: Command.Info,
Session.Inbox.Delivery.
interface CommandInvocation {
readonly sessionID: Session.ID
readonly prompt: PromptInput.Prompt
readonly delivery: SessionInbox.Delivery
}
interface CommandDefinition {
readonly name: string
readonly description?: string
readonly execute: (input: CommandInvocation) => Effect.Effect<void, unknown>
}
interface CommandEditor {
add(definition: CommandDefinition): void
}
interface CommandDomain extends Pick<CommandApi<unknown>, "list"> {
readonly transform: Transform<CommandEditor>
readonly reload: () => Effect.Effect<void>
}
Integrations
Read integrations and resolve the active credential when one exists.
effect: (ctx) =>
Effect.gen(function* () {
const integration = ctx.integration
const integrations = yield* integration.list().pipe(Effect.orDie)
const github = yield* integration.get({ integrationID: Integration.ID.make("github") }).pipe(Effect.orDie)
const connection = yield* integration.connection.active("github")
const credential = connection ? yield* integration.connection.resolve(connection).pipe(Effect.orDie) : undefined
yield* Effect.logInfo("integration", {
count: integrations.data.length,
github: github.data.name,
credential: credential?.type,
})
}),
Connect with a key or drive an OAuth attempt through the connected API.
effect: (ctx) =>
Effect.gen(function* () {
const integration = ctx.integration
const token = yield* Config.redacted("GITHUB_TOKEN").pipe(Effect.orDie)
yield* integration.connect
.key({ integrationID: Integration.ID.make("github"), key: Redacted.value(token) })
.pipe(Effect.orDie)
const attempt = yield* integration.oauth
.connect({
integrationID: Integration.ID.make("acme"),
methodID: Integration.MethodID.make("oauth"),
})
.pipe(Effect.orDie)
const status = yield* integration.oauth
.status({
integrationID: Integration.ID.make("acme"),
attemptID: attempt.data.attemptID,
})
.pipe(Effect.orDie)
yield* Effect.logInfo("OAuth status", { status: status.data })
}),
Transform integrations and authentication methods. OAuth method callbacks are Effects and may acquire scoped resources.
effect: (ctx) =>
Effect.gen(function* () {
const integration = ctx.integration
yield* integration.transform((editor) => {
editor.update("acme", (item) => (item.name = "Acme"))
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),
}),
})
})
yield* integration.reload()
}),
Schemas: Integration.Info, Integration.Method,
Connection.Info, Form.Answer.
type IntegrationOAuthAuthorization = {
readonly url: string
readonly instructions: string
readonly expiresAt?: number
} & (
| { readonly mode: "auto"; readonly callback: Effect.Effect<Credential.OAuth, unknown> }
| { readonly mode: "code"; readonly callback: (code: string) => Effect.Effect<Credential.OAuth, unknown> }
)
type IntegrationOAuthMethodRegistration = {
readonly integrationID: string
readonly method: IntegrationOAuthMethod
readonly authorize: (answer: Form.Answer) => Effect.Effect<IntegrationOAuthAuthorization, unknown, Scope.Scope>
readonly refresh?: (credential: Credential.OAuth) => Effect.Effect<Credential.OAuth, unknown>
readonly label?: (credential: Credential.OAuth) => string | undefined
}
interface IntegrationEditor {
list(): readonly IntegrationRef[]
get(id: string): IntegrationRef | undefined
update(id: string, update: (integration: IntegrationRef) => void): void
remove(id: string): void
readonly method: {
list(integrationID: string): readonly IntegrationMethod[]
update(input: IntegrationMethodRegistration): void
remove(integrationID: string, method: IntegrationMethod): void
}
}
interface IntegrationDomain extends Omit<IntegrationApi<unknown>, "wellknown"> {
readonly transform: Transform<IntegrationEditor>
readonly reload: () => Effect.Effect<void>
readonly connection: {
readonly active: (integrationID: string) => Effect.Effect<ConnectionInfo | undefined>
readonly resolve: (connection: ConnectionInfo) => Effect.Effect<Credential.Value | undefined, unknown>
}
}
MCP
List MCP servers and their current connection state.
effect: (ctx) =>
Effect.gen(function* () {
const mcp = ctx.mcp
const servers = yield* mcp.list().pipe(Effect.orDie)
yield* Effect.logInfo("MCP servers", { count: servers.data.length })
}),
Plugins manage MCP servers only through transforms. Use editor.set to add or replace a server, editor.update to
change its configuration, and editor.remove to remove it. Inspect configuration with editor.list and editor.get.
effect: (ctx) =>
Effect.gen(function* () {
const mcp = ctx.mcp
yield* mcp.transform((editor) => {
const servers = editor.list()
const docs = editor.get("docs")
editor.set("docs", { type: "remote", url: "https://mcp.example.com" })
editor.update("docs", (server) => (server.disabled = false))
editor.remove("legacy")
})
}),
Set disabled: true in a transform to disable a server and disconnect it, or disabled: false to enable it and allow
OpenCode to connect. OpenCode reconciles server lifecycle from the transformed configuration.
Call reload() after external state used by a transform changes to reapply transforms and reconcile the servers.
effect: (ctx) =>
Effect.gen(function* () {
const mcp = ctx.mcp
yield* mcp.reload()
}),
Schemas: Mcp.Server, Mcp.LocalConfigEncoded,
Mcp.RemoteConfigEncoded.
interface MCPEditor {
list(): readonly [string, Types.DeepMutable<Mcp.ServerConfig>][]
get(name: string): Types.DeepMutable<Mcp.ServerConfig> | undefined
set(name: string, config: Mcp.ServerConfig): void
update(name: string, update: (config: Types.DeepMutable<Mcp.ServerConfig>) => void): void
remove(name: string): void
}
interface MCPDomain extends Pick<McpApi<unknown>, "list"> {
readonly transform: Transform<MCPEditor>
readonly reload: () => Effect.Effect<void>
}
Plugins
List active, failed, and resolved plugins at the current location.
effect: (ctx) =>
Effect.gen(function* () {
const plugin = ctx.plugin
const plugins = yield* plugin.list().pipe(Effect.orDie)
yield* Effect.forEach(plugins.data, (item) => Effect.logInfo("plugin", item), { discard: true })
}),
Filter the schema values in an Effect pipeline when only active plugins matter.
effect: (ctx) =>
Effect.gen(function* () {
const plugin = ctx.plugin
const active = yield* plugin.list().pipe(
Effect.orDie,
Effect.map((result) => result.data.filter((item) => item.status === "active")),
)
yield* Effect.logInfo("active plugin count", { count: active.length })
}),
Schemas: Plugin.Info, Plugin.Source.
interface PluginApi<E = never> {
readonly list: PluginListOperation<E>
}
interface Context {
readonly plugin: PluginApi<unknown>
}
References
Read references available at the current location.
effect: (ctx) =>
Effect.gen(function* () {
const reference = ctx.reference
const references = yield* reference.list().pipe(Effect.orDie)
yield* Effect.logInfo("references", { count: references.data.length })
}),
Add or remove local and Git references, then reload after external state changes. get(name) returns the current configured
source, or undefined when the name is absent.
effect: (ctx) =>
Effect.gen(function* () {
const reference = ctx.reference
yield* reference.transform((editor) => {
editor.add("handbook", { type: "local", path: "/workspace/docs/handbook" })
editor.add("standards", { type: "git", repository: "https://github.com/acme/standards", branch: "main" })
const handbook = editor.get("handbook")
editor.remove("legacy")
})
yield* reference.reload()
}),
Schemas: Reference.Info, Reference.LocalSource,
Reference.GitSource.
interface ReferenceEditor {
add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void
remove(name: string): void
list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[]
get(name: string): ReferenceLocalSource | ReferenceGitSource | undefined
}
interface ReferenceDomain extends ReferenceApi<unknown> {
readonly transform: Transform<ReferenceEditor>
readonly reload: () => Effect.Effect<void>
}
Sessions
Create or read a session, then select the agent and model used by later work.
effect: (ctx) =>
Effect.gen(function* () {
const session = ctx.session
const created = yield* session.create({ title: "Review" }).pipe(Effect.orDie)
const current = yield* session.get({ sessionID: created.id }).pipe(Effect.orDie)
yield* session.switchAgent({ sessionID: current.id, agent: Agent.ID.make("build") }).pipe(Effect.orDie)
yield* session
.switchModel({
sessionID: current.id,
model: { providerID: Provider.ID.make("anthropic"), id: Model.ID.make("claude-sonnet-4-5") },
})
.pipe(Effect.orDie)
}),
Send prompts, transient generation requests, commands, or synthetic messages.
effect: (ctx) =>
Effect.gen(function* () {
const session = ctx.session
const created = yield* session.create({ title: "Automation" }).pipe(Effect.orDie)
yield* session.prompt({ sessionID: created.id, text: "Review the current changes" }).pipe(Effect.orDie)
const summary = yield* session
.generate({ sessionID: created.id, prompt: "Summarize this project" })
.pipe(Effect.orDie)
yield* session.command({ sessionID: created.id, command: "review", arguments: "--staged" }).pipe(Effect.orDie)
yield* session
.synthetic({ sessionID: created.id, text: `Summary generated: ${summary.text}`, resume: false })
.pipe(Effect.orDie)
}),
Schemas: Session.Info, Model.Ref,
Session.Inbox.User,
Session.Inbox.Synthetic.
type SessionDomain = Pick<
SessionApi<unknown>,
| "create"
| "get"
| "switchAgent"
| "switchModel"
| "prompt"
| "generate"
| "command"
| "synthetic"
| "interrupt"
| "rename"
| "wait"
>
Skills
Read skills at the current location.
effect: (ctx) =>
Effect.gen(function* () {
const skill = ctx.skill
const skills = yield* skill.list()
yield* Effect.logInfo("skills", { ids: skills.data.map((item) => item.id) })
}),
Transform and reload skills. Use the re-exported Effect schema constructors for branded values. get(id) returns the
current editor entry, or undefined when the skill is absent.
effect: (ctx) =>
Effect.gen(function* () {
const skill = ctx.skill
yield* skill.transform((editor) => {
editor.add(Skill.Info.make({
id: Skill.ID.make("review"),
name: Skill.Name.make("Review"),
description: "Review the current changes",
location: "/workspace/.opencode/skills/review.md",
content: "Review the current changes for correctness and missing tests.",
}))
const review = editor.get("review")
editor.update("review", (item) => (item.autoinvoke = true))
editor.remove("legacy")
})
yield* skill.reload()
}),
Schema: Skill.Info.
interface SkillEditor {
list(): readonly Types.DeepMutable<Skill.Info>[]
get(id: string): Types.DeepMutable<Skill.Info> | undefined
add(skill: Skill.Info): void
update(id: string, update: (skill: Types.DeepMutable<Skill.Info>) => void): void
remove(id: string): void
}
interface SkillDomain extends SkillApi<unknown> {
readonly transform: Transform<SkillEditor>
readonly reload: () => Effect.Effect<void>
}
Storage
Store, read, and remove durable JSON values scoped to the plugin ID.
effect: (ctx) =>
Effect.gen(function* () {
const storage = ctx.storage
yield* storage.set("settings", { strict: true })
const settings = yield* storage.get("settings")
yield* Effect.logInfo("settings", { settings })
yield* storage.remove("settings")
}),
Scan keys by prefix with cursor pagination.
effect: (ctx) =>
Effect.gen(function* () {
const storage = ctx.storage
const first = yield* storage.scan({ prefix: "cache/", limit: 100 })
const second = first.next
? yield* storage.scan({ prefix: "cache/", after: first.next, limit: 100 })
: { entries: [] }
yield* Effect.logInfo("cache entries", { count: first.entries.length + second.entries.length })
}),
Storage accepts Effect’s JSON type and returns Effects directly.
interface StorageDomain {
readonly get: (key: string) => Effect.Effect<Schema.Json | undefined>
readonly set: (key: string, value: Schema.Json) => Effect.Effect<void>
readonly remove: (key: string) => Effect.Effect<void>
readonly scan: (options: StorageScanOptions) => Effect.Effect<StorageScanResult>
}
Tools
Register typed tools with Effect Schema. The executor receives decoded input and returns an Effect containing typed
output, display content, or metadata.
The editor supports add, update, and remove. The transform callback is synchronous: it must not return an Effect or Promise. Load
external data before registering or reloading. OpenCode replays active transforms in registration order on a fresh
editor; for the same effective tool name, a later valid registration overrides an earlier one.
effect: (ctx) =>
Effect.gen(function* () {
const tool = ctx.tool
yield* 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}!` } }
}),
})
})
}),
Call yield* ctx.tool.reload() after changing source data captured by the callback. Reload replays active transforms
without changing their order; it does not rerun the plugin effect.
Tools have an id containing their effective name, and get() returns undefined when that ID is not present. Use
update and remove with the effective tool name, including its namespace (acme_greeting above). Dots in
namespaces and unsupported characters in tool names become _. Missing names are ignored; creating a tool requires
add with a complete definition. Updates preserve the name and namespace. Assign new schemas or options to replace
them rather than mutating nested values. Invalid updates are logged and leave the previous definition intact.
yield *
ctx.tool.transform((editor) => {
editor.update("acme_greeting", (tool) => {
tool.description = "Greet the user by name"
})
editor.remove("acme_obsolete")
})
Updates and removals replay in order with additions, including after MCP catalog refreshes.
transform returns a scoped registration. Run yield* registration.dispose to remove its transform and rebuild
from the remaining transforms, revealing any earlier definition it overrode. Disposal is idempotent, and closing
the plugin scope also disposes its registrations.
Each model request captures a tool snapshot. Reload and disposal affect future snapshots, not the definitions or executors already captured by an existing request. Executors that close over mutable plugin data still observe that data; capture a value inside the transform when it must remain tied to that definition.
Schemas: Tool.Content, Tool.TextContent,
Tool.FileContent.
interface ToolEditor {
list(): readonly (Tool.Info & { readonly id: string })[]
get(id: string): (Tool.Info & { readonly id: string }) | undefined
namespace(namespace: { name: string; description: string }): void
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
tool: Tool.Info<Input, Output>,
): void
update(id: string, update: (tool: Types.Mutable<Tool.Info>) => void): void
remove(id: string): void
}
interface ToolDomain {
readonly list: () => Effect.Effect<readonly (Tool.Info & { readonly id: string })[]>
readonly transform: Transform<ToolEditor>
readonly reload: () => Effect.Effect<void>
}
VCS
Read repository information, working-copy status, or file diffs.
effect: (ctx) =>
Effect.gen(function* () {
const vcs = ctx.vcs
const info = yield* vcs.get().pipe(Effect.orDie)
const branches = yield* vcs.branches({ search: "feature", limit: 10 }).pipe(Effect.orDie)
const changes = yield* vcs.status().pipe(Effect.orDie)
const diff = yield* vcs.diff({ mode: "working", context: 3 }).pipe(Effect.orDie)
yield* Effect.logInfo("vcs", { branch: info.data.branch.current, files: changes.data.length })
}),
Register a location-scoped provider through a scoped transform. Providers matching the detected repository type are
selected automatically; use editor.default.set to select a different provider.
effect: (ctx) =>
Effect.gen(function* () {
const vcs = ctx.vcs
yield* vcs.transform((editor) => {
editor.add({
id: "custom",
name: "Custom VCS",
info: () => Effect.succeed({ branch: { current: "feature", default: "main" } }),
branches: (input) => readBranches(input),
status: (scope) => readStatus(scope.worktree),
diff: (input) => readDiff(input),
})
editor.default.set("custom")
})
}),
Provider callbacks receive the current location, working-copy root, canonical project root, and optional repository store. Diff callbacks also receive the selected mode, requested context, and output byte budget. Repository discovery continues to use OpenCode’s built-in Git and Mercurial detectors.
Schemas: Vcs.Info, Vcs.FileStatus,
FileDiff.Info.
interface VcsEditor {
add(definition: VcsDefinition): void
readonly default: {
get(): string | undefined
set(selection: string): void
}
}
interface VcsDomain extends VcsApi<unknown> {
readonly transform: Transform<VcsEditor>
readonly reload: () => Effect.Effect<void>
}
Websearch
List providers or run a query through the selected provider.
effect: (ctx) =>
Effect.gen(function* () {
const websearch = ctx.websearch
const providers = yield* websearch.providers().pipe(Effect.orDie)
const results = yield* websearch
.query({ query: "OpenCode plugins", providerID: WebSearch.ID.make("internal") })
.pipe(Effect.orDie)
yield* Effect.logInfo("websearch", { providers: providers.data.length, results: results.data.results.length })
}),
Register an Effect executor and select the default provider. Set the default to false to disable websearch.
effect: (ctx) =>
Effect.gen(function* () {
const websearch = ctx.websearch
yield* websearch.transform((editor) => {
editor.add({
id: "internal",
name: "Internal search",
execute: ({ query }) => searchInternal(query),
})
editor.default.set("internal")
})
yield* websearch.reload()
}),
Schemas: WebSearch.Provider,
WebSearch.Result.
interface WebSearchDefinition {
readonly id: string
readonly name: string
readonly execute: (input: WebSearch.ProviderInput) => Effect.Effect<readonly WebSearch.Result[], unknown>
}
interface WebSearchEditor {
add(definition: WebSearchDefinition): void
readonly default: {
get(): string | false | undefined
set(selection: string | false): void
}
}
interface WebSearchDomain extends WebsearchApi<unknown> {
readonly transform: Transform<WebSearchEditor>
readonly reload: () => Effect.Effect<void>
}
Events
The public server event subscription is an Effect Stream. Fork its consumer in the plugin scope for automatic
interruption.
effect: (ctx) =>
Effect.gen(function* () {
const event = ctx.event
yield* event.subscribe().pipe(
Stream.tap((item) => Effect.logDebug("OpenCode event", { type: item.type })),
Stream.runDrain,
Effect.forkScoped,
)
}),
Use Stream operators to select and process event types.
effect: (ctx) =>
Effect.gen(function* () {
const event = ctx.event
yield* event.subscribe().pipe(
Stream.filter((item) => item.type === "config.updated"),
Stream.runForEach(() => Effect.logInfo("configuration changed")),
Effect.forkScoped,
)
}),
Schema: V2EventEncoded.
type EventSubscribeOutput = OpenCodeEvent
type EventSubscribeOperation<E = never> = () => Stream.Stream<EventSubscribeOutput, E>
interface EventApi<E = never> {
readonly subscribe: EventSubscribeOperation<E>
}
interface EventDomain extends Pick<EventApi<unknown>, "subscribe"> {}
Hooks
Hooks intercept live operations. Multiple plugins can register the same hook; OpenCode runs them in plugin order so later hooks see earlier changes. Registrations remain active for the plugin scope.
effect: (ctx) =>
Effect.gen(function* () {
const session = ctx.session
yield* session.hook("context", () => Effect.void)
}),
Sessions
Modify assembled system instructions, messages, or tools immediately before model dispatch. context runs for the
agent loop; compaction, generate, and title run for those auxiliary requests. compaction and title accept a
result that skips the model call.
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 }
}),
)
}),
Modify model request settings and optionally scope the hook to one provider. The event carries the same kind as
the HTTP hooks below.
effect: (ctx) =>
Effect.gen(function* () {
const session = ctx.session
yield* session.hook(
"model.request",
(event) => Effect.sync(() => (event.headers["x-plugin"] = "review")),
{ providerID: "anthropic" },
)
}),
Modify native provider requests or responses. Their bodies are one-shot streams; clone or replace a body before reading
it. Both hooks run for every request a session issues; event.kind is "primary", "compaction", "title", or
"generate" depending on which flow issued it.
effect: (ctx) =>
Effect.gen(function* () {
const session = ctx.session
yield* session.hook("http.request", (event) =>
Effect.sync(() => {
event.request.headers.set("x-session-id", event.sessionID)
if (event.kind === "title") event.request.headers.set("x-priority", "background")
}),
)
yield* session.hook("http.response", (event) =>
Effect.sync(() => {
event.response = new Response(event.response.body, {
status: event.response.status,
headers: { ...Object.fromEntries(event.response.headers), "x-plugin": "review" },
})
}),
)
}),
Providers that stream over a WebSocket reuse one connection per session, so the HTTP hooks never see that traffic. The
experimental experimental.ws.handshake hook runs once per model call before the connection is selected and exposes the
connection url and headers; changing either reopens the socket. HTTP hooks still run for any request a WebSocket
route falls back to.
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.session.hook(
"experimental.ws.handshake",
(event) =>
Effect.gen(function* () {
event.headers.authorization = `Bearer ${yield* mintToken(event.url)}`
delete event.headers["api-key"]
}),
{ providerID: "azure" },
)
}),
experimental.ws.send and experimental.ws.receive expose the frames themselves: send runs after the provider
driver builds an outbound frame, receive runs on each inbound frame before the driver observes it. Whatever frame holds when the hook returns is what crosses the wire or reaches the driver;
OpenCode does not validate it.
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.session.hook(
"experimental.ws.send",
(event) =>
Effect.sync(() => {
const body = JSON.parse(event.frame)
if (body.type === "response.create") body.metadata = { ...body.metadata, session: event.sessionID }
event.frame = JSON.stringify(body)
}),
{ providerID: "openai" },
)
}),
Override the retry decision for a provider failure or replace its delay in milliseconds. The hook runs after OpenCode classifies the failure and proposes its policy, but before any retry is scheduled. It does not expose how OpenCode internally performs the next attempt.
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.session.hook("retry", (event) =>
Effect.sync(() => {
if (event.error.status === 429) {
event.decision = { retry: true, delay: 10_000 }
return
}
if (event.error.type === "provider.invalid-request" && event.attempt === 2) {
event.decision = { retry: true, delay: 0 }
return
}
if (event.attempt >= 3) event.decision = { retry: false }
}),
)
}),
The initial decision is OpenCode’s policy, so hooks may make a normally terminal provider failure retryable or veto a
proposed retry. Multiple hooks run in registration order and later hooks see the current decision. The built-in maximum
attempt count remains a hard limit. attempt is the physical attempt being proposed; the initial request is attempt 1,
so the first retry is attempt 2. Invalid delays (NaN, infinity, or negative values) fall back to the computed delay.
Context-overflow recovery remains separate because it compacts the conversation instead of retrying the same request.
Reference
interface SessionHooks {
readonly context: SessionContext
readonly compaction: SessionCompaction
readonly generate: SessionGenerate
readonly title: SessionTitle
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
readonly "experimental.ws.handshake": SessionWebSocketHandshake
readonly "experimental.ws.send": SessionWebSocketSend
readonly "experimental.ws.receive": SessionWebSocketReceive
readonly retry: SessionRetry
}
type RetryDecision = { retry: false } | { retry: true; delay: number }
interface SessionRetry {
readonly sessionID: string
readonly agent: string
readonly model: { providerID: string; id: string; variant?: string }
readonly error: { type: string; message: string; status?: number }
readonly attempt: number
decision: RetryDecision
}
interface SessionHookDomain {
readonly hook: ModelHooks<SessionHooks>
}
Shell
Modify shell commands, working directories, timeouts, executables, or environment variables before execution.
effect: (ctx) =>
Effect.gen(function* () {
const shell = ctx.shell
yield* shell.hook("create.before", (event) =>
Effect.sync(() => {
if (event.command === "npm") event.command = "bun"
event.timeout = Math.min(event.timeout, 60_000)
event.env.COMPANY_ENV = "development"
}),
)
}),
Reference
interface ShellCreateBefore {
command: string
cwd: string
timeout: number
shell: string
env: Record<string, string | undefined>
}
interface ShellHooks {
readonly "create.before": ShellCreateBefore
}
interface ShellHookDomain {
readonly hook: Hooks<ShellHooks>
}
Tools
Before hooks may replace input or fail with Tool.Error.
effect: (ctx) =>
Effect.gen(function* () {
const tool = ctx.tool
yield* 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 }),
)
}),
After hooks may inspect or replace successful results and failures.
effect: (ctx) =>
Effect.gen(function* () {
const tool = ctx.tool
yield* tool.hook("execute.after", (event) => {
if (event.status === "error") return Effect.logWarning("tool failed", { message: event.error.message })
return Effect.sync(() => {
event.result = { ...event.result, metadata: { observed: true } }
})
})
}),
Reference
interface ToolHooks {
readonly "execute.before": ToolExecuteBefore
readonly "execute.after": ToolExecuteAfter
}
interface ToolFailures extends Record<keyof ToolHooks, unknown> {
readonly "execute.before": Tool.Error
readonly "execute.after": never
}
interface ToolHookDomain {
readonly hook: Hooks<ToolHooks, ToolFailures>
}
The shared registration types show which operations require the plugin scope.
interface Registration {
readonly dispose: Effect.Effect<void>
}
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>
Publish
A package plugin uses the same default export as a local Effect plugin. Export the Effect implementation from the main entrypoint and declare both runtime dependencies.
{
"name": "opencode-acme-effect-plugin",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@opencode/plugin": "latest",
"effect": "4.0.0-rc.111"
}
}The package entrypoint exports Plugin.define with an effect function.
import { Plugin } from "@opencode/plugin/effect"
import { Effect } from "effect"
export default Plugin.define({
id: "acme.published",
effect: (ctx) =>
Effect.gen(function* () {
const storage = ctx.storage
yield* storage.set("installed", true)
}),
})Use versions compatible with the OpenCode release you target and test the installed package rather than only a workspace-linked copy.
bun pm pack
bun add ./opencode-acme-effect-plugin-1.0.0.tgz