Start typing to search the documentation.

Build navigation

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.

.opencode/plugins/concise/index.ts
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.

opencode.jsonc
{
  "$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.

plugins/local-effect/index.ts
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).

opencode.jsonc
{
  "plugins": [
    {
      "package": "./plugins/company-effect",
      "options": { "strict": true },
    },
  ],
}

Read options from ctx.options. Narrow unknown values before use.

plugins/company-effect/index.ts
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.

plugins/models-effect/index.ts
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.

plugins/model-budget-effect/index.ts
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.

plugins/models-effect/index.ts
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.

package.json
{
  "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.

src/index.ts
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