15a1bbcee0
Repository sits at the OpenCode config root rather than inside skills/ or
plugins/. OpenCode scans exactly those two subdirectories, and a .git
directory inside either one is picked up as a plugin and fails to load:
loading plugin id=.../plugins/.git entrypoint=.../plugins/.git/index
failed to load plugin ... AggregateError: 2 errors building .../plugins/.git/index
The watcher.ignore config option does not help: it only suppresses filesystem
update events, not plugin discovery, and the "-" disable prefix only applies to
plugins listed in opencode.json(c), not auto-discovered ones.
.gitignore admits only skills/ and plugins/ (plus itself). Personal config
(opencode.jsonc, cli.json, service.json) and node_modules stay local.
skills/opencode-plugins V2-only plugin authoring reference and 7 templates,
transcribed from the V2 docs with the URL on every file
plugins/searxng-search searxng_search tool over a self-hosted SearXNG JSON API
404 lines
13 KiB
Markdown
404 lines
13 KiB
Markdown
# Effect Plugins
|
|
|
|
**Docs: <https://opencode.ai/v2/docs/build/plugins/effect/>** — fetch this page
|
|
before writing an Effect plugin. This file is a summary of it.
|
|
**Template: `../templates/effect-plugin.ts`**
|
|
|
|
`@opencode/plugin/effect` is the Effect-native flavor of the server plugin API.
|
|
Context operations return Effects or Streams, callbacks return Effects, and
|
|
lifetime is a `Scope`. Use it when the project already uses Effect. Otherwise
|
|
use the Promise flavor in `SKILL.md`.
|
|
|
|
## Install
|
|
|
|
OpenCode provides `effect`, `effect/*`, and `@opencode/plugin` at runtime. Do
|
|
**not** bundle them. The page says to install `effect` and `@opencode/plugin` as
|
|
peer/dev dependencies for local type-checking and testing.
|
|
|
|
```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<Spec, Failures>` and `Transform<Input>` are the shared registration types.
|
|
Both return an Effect that requires the plugin scope, which is why
|
|
`transform`/`hook`/`dispose` are all yieldable and scoped:
|
|
|
|
```ts
|
|
type Hooks<Spec, Failures extends Record<keyof Spec, unknown> = Record<keyof Spec, never>> = <Name extends keyof Spec>(
|
|
name: Name,
|
|
callback: (input: Spec[Name]) => Effect.Effect<void, Failures[Name]>,
|
|
) => Effect.Effect<Registration, never, Scope.Scope>
|
|
|
|
type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Registration, never, Scope.Scope>
|
|
```
|
|
|
|
These are the page's simplified shared types. They are **not** the complete
|
|
signature for the session domain: the page's session hook type is
|
|
`SessionHookDomain { readonly hook: ModelHooks<SessionHooks> }`, `ModelHooks` is
|
|
not defined on the page, and the page's own examples pass a third
|
|
`{ providerID }` argument that the `Hooks` type above does not accept. Trust the
|
|
page's examples over the simplified type.
|
|
|
|
The transform callback is still synchronous in both flavors. It must not return
|
|
an Effect or a Promise.
|
|
|
|
Result shape is **per operation, not per flavor**. Some reads are enveloped and
|
|
some are not, in both flavors — check the doc example for the call you are using.
|
|
|
|
```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<ConnectionInfo | undefined>`) and
|
|
`integration.connection.resolve` (a `Credential.Value | undefined`), and for
|
|
`ToolDomain.list`
|
|
(`Effect<readonly (Tool.Info & { id: string })[]>`); editor `list()` calls also
|
|
return plain arrays. For everything else, mirror the page's own example rather
|
|
than assuming either shape.
|
|
|
|
Many examples in the page pipe reads through `Effect.orDie` to drop the error
|
|
channel, then read `.data` — copy that shape when the example uses it.
|
|
|
|
## Transforms
|
|
|
|
```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<Spec, Failures>` type quoted
|
|
earlier in this file does not show that third parameter, so do not treat it as
|
|
the complete signature.
|
|
|
|
For the request-hook rules that both flavors share, see `SKILL.md`.
|
|
|
|
Effect callbacks use Effect interruption instead of `signal`; the other
|
|
`experimental.ws.*` and `retry` hooks behave as documented there.
|
|
|
|
## Events
|
|
|
|
`ctx.event.subscribe()` is a `Stream`. Fork it in scope for automatic
|
|
interruption — no `AbortController` needed.
|
|
|
|
```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<ExperimentalApi<unknown>["persistentPty"], "read"> }`. The
|
|
narrowed member is `experimental.terminal`, not `experimental`.
|
|
- `plugin` is only `Pick<PluginApi<unknown>, "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<R = Scope.Scope> {
|
|
readonly id: string
|
|
readonly effect: (context: Context) => Effect.Effect<void, never, R>
|
|
}
|
|
```
|
|
|
|
The `R` defaults to `Scope.Scope`, so registrations and scoped fibers are
|
|
released when the plugin scope closes.
|
|
|
|
Two runtime caveats:
|
|
|
|
- Only **public** `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
|
|
```
|