# Effect Plugins **Docs: ** — 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. ```sh bun add --peer @opencode/plugin effect ``` ```json title="package.json" { "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 ```ts 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. ```ts effect: (ctx) => Effect.gen(function* () { yield* Effect.addFinalizer(() => Effect.logInfo("unloaded")) }) ``` Background work uses scoped fibers; the scope interrupts them at cleanup. ```ts 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` and `Transform` 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: ```ts type Hooks = Record> = ( name: Name, callback: (input: Spec[Name]) => Effect.Effect, ) => Effect.Effect type Transform = (callback: (input: Input) => void) => Effect.Effect ``` 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 }`, `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. ```ts // 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`) and `integration.connection.resolve` (a `Credential.Value | undefined`), and for `ToolDomain.list` (`Effect`); 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 ```ts 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. ```ts 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. ```ts 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`: ```ts yield* ctx.tool.hook("execute.before", (event) => event.tool === "write" ? Effect.fail(new Tool.Error({ message: "Writes are disabled" })) : Effect.logDebug("tool input", { tool: event.tool, input: event.input }), ) ``` Hook names, event fields, ordering, and override precedence are the same **where the Effect page documents them** — but the two flavors do not have the same hook set: - The Effect `SessionHooks` type lists `context`, `compaction`, `generate`, `title`, `model.request`, `http.request`, `http.response`, the three `experimental.ws.*`, and `retry`. **It has no `prompt` hook.** Do not carry the Promise `prompt` example over without confirming it on this page. - Provider scoping (`{ providerID: "anthropic" }` as a third argument) is documented here for `model.request`, `experimental.ws.handshake`, and `experimental.ws.send`. The simplified `Hooks` 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. ```ts 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: ```ts 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. ```ts 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. ```ts yield* integration.transform((editor) => { editor.method.update({ integrationID: "acme", method: { id: "device", type: "oauth", label: "Sign in with Acme" }, authorize: () => Effect.succeed({ mode: "code", url: "https://acme.example/device", instructions: "Enter the displayed code", callback: (code) => exchangeCode(code), }), }) }) ``` `authorize` returns `mode: "auto"` (callback is the Effect that performs the flow) or `mode: "code"` (callback takes the code). The Effect's `R` may include `Scope.Scope`, so it can hold scoped resources for the attempt's duration. ## Context shape `Context` declares `app`, `location`, `options`, and these domains: `agent`, `aisdk`, `command`, `event`, `experimental`, `integration`, `mcp`, `model`, `generate`, `permission`, `plugin`, `provider`, `reference`, `rpc`, `session`, `shell`, `skill`, `storage`, `tool`, `vcs`, `websearch`, `worktree`. Three narrowed members worth noting: - `experimental` is only `{ terminal: Pick["persistentPty"], "read"> }`. The narrowed member is `experimental.terminal`, not `experimental`. - `plugin` is only `Pick, "list">`; `list` reports active, failed, and resolved plugins, so filter in a pipeline when you need only active ones. The page's example filters on `item.status === "active"`, but the shipped `Plugin.Info` nests that as `state.status` — trust the type. - `vcs` omits `init` as a client method, but a registered VCS **definition** may still implement it. This is the **Effect** `Context`. Do not assume it matches the Promise context member for member — this page does not state that they are identical, and `aisdk` appears here with no Promise-page counterpart. The context exposes the Effect client plus plugin-only transforms, hooks, storage, reloads, and options. It does **not** expose OpenCode's private Core services. ## Plugin type ```ts interface Plugin { readonly id: string readonly effect: (context: Context) => Effect.Effect } ``` The `R` defaults to `Scope.Scope`, so registrations and scoped fibers are released when the plugin scope closes. Two runtime caveats: - Only **public** `effect` subpaths are provided. Importing an `internal` module fails to load with an error naming the path. - The compiled OpenCode binary omits the Scalar/Swagger UI assets used by Effect's HTTP API doc pages; serving those from a plugin shows a notice. ## Publish Declare `effect` as a `peerDependency` and export the Effect implementation from the main entrypoint. ```json title="package.json" { "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: ```sh bun pm pack bun add ./opencode-acme-effect-plugin-1.0.0.tgz ```