Editing the skill, or a plugin under plugins/, is a change to the ~/.config/opencode repository. That had no rule attached to it, so work landed locally and the remote silently drifted. Add "Push every change" near the top of SKILL.md, where it is read before the reference material rather than buried at the end, plus a fifth item in "Verifying before you answer" so the push is part of the final check rather than a separate task that gets skipped. The section records the three constraints that are not guessable: - Push over SSH. origin is git@git.xsinfo.vip:xiaoshuai/oc.git; an HTTPS push prompts for a password and there is no TTY, so it fails outright. - Never git init inside skills/ or plugins/. OpenCode discovers every immediate subdirectory of plugins/ as a plugin package, so a .git there is loaded as plugins/.git/index and fails on each startup. The repository belongs at the config root, one level above both scan roots. - Do not git add -f past .gitignore. opencode.jsonc, cli.json, service.json, package.json and node_modules/ are machine-local by design.
33 KiB
name, description, metadata
| name | description | metadata | ||
|---|---|---|---|---|
| OpenCode V2 Plugins | Use when creating, loading, configuring, or publishing OpenCode plugins — server plugins via @opencode/plugin, Effect-native plugins, custom RPC methods, or TUI/CLI plugins. Also use for plugin hooks, transforms, custom tools, provider/model registration, and plugin troubleshooting. |
|
OpenCode V2 Plugins
OpenCode plugins extend the service and the terminal. Pick the flavor first,
copy the matching file from templates/, then read the matching reference.
V2 only. V1 is out of scope: V1 plugins, server()-returned hook maps, the
opencode.ai/docs/ site, and the V1→V2 migration material are all out of scope
here. See "Version scope" below.
Anything you write under skills/ or plugins/ gets committed and pushed in
the same turn. See "Push every change" below.
Push every change
~/.config/opencode is itself the git repository, and .gitignore admits only
skills/, plugins/, and itself. After you create, edit, or delete a file
under either tree, commit and push it before you report back. Do not leave the
change uncommitted, and do not ask whether to push.
cd ~/.config/opencode
git status --porcelain # confirm the change landed in skills/ or plugins/
git add -A # .gitignore limits this to skills/ and plugins/
git commit -m "<what changed and why>"
git push # origin is git@git.xsinfo.vip:xiaoshuai/oc.git
git status -sb # expect "## main...origin/main" with no file lines
- Push over SSH.
originis already the SSH URL. An HTTPS push prompts for a password and there is no TTY, so it fails. - Do not add machine-local files.
opencode.jsonc,cli.json,service.json,package.json,node_modules/and any*.bak*stay local by design;git add -Aalready excludes them, so do not reach forgit add -f. - Never
git initinsideskills/orplugins/. A.gitdirectory in either one is discovered as a plugin and fails to load on every startup. The repository belongs at the config root, one level above both scan roots. - Write a real commit message. "update files" is not a history; name the change and, when it corrects a mistake, say what the mistake was.
- If a push fails, say so and stop. Report the error text. The commit is
local and recoverable, so do not retry silently or run
git reset.
Source docs
Fetch these. Do not work from this skill alone — it is a summary, and it can drift from the pages.
| Page | URL | Covers |
|---|---|---|
| Plugins (Promise) | https://opencode.ai/v2/docs/build/plugins/ | Server API, transforms, hooks, tools, publish |
| Effect plugins | https://opencode.ai/v2/docs/build/plugins/effect/ | @opencode/plugin/effect |
| RPC (Promise) | https://opencode.ai/v2/docs/build/plugins/rpc/ | Rpc.define, registration, calls, events |
| RPC (Effect) | https://opencode.ai/v2/docs/build/plugins/effect/rpc/ | Effect-native RPC — same shape, Effect Schema |
| CLI / TUI | https://opencode.ai/v2/docs/build/plugins/cli/ | @opencode/plugin/tui |
| Configure plugins | https://opencode.ai/v2/docs/plugins | plugins in config, discovery, disabling, CLI |
| Terminal plugins | https://opencode.ai/v2/docs/cli/plugins | cli.json, CLI-only plugins, discovery |
| CLI settings | https://opencode.ai/v2/docs/cli/config | cli.json path, no project-local CLI file |
| Client | https://opencode.ai/v2/docs/build/client | client.rpc(Acme) over HTTP |
| Client (Effect) | https://opencode.ai/v2/docs/build/client/effect | Effect client used by Effect RPC |
| API schemas | https://opencode.ai/v2/docs/api | Every Info/schema linked from Reference blocks |
| Doc index | https://opencode.ai/v2/llms.txt | Machine-readable list of every V2 page |
The running server serves an OpenAPI document at /openapi.json
(opencode api get /openapi.json). It self-describes as an experimental
HttpApi surface for selected instance routes, so it covers the HTTP contract,
not the plugin API — transforms, hooks, and Plugin.define are not routes. Use
the /api reference page for schemas.
Check the installed version first
Plugin APIs and package names change between releases, so confirm what the user actually has before writing anything.
opencode --version # e.g. v2.0.24
opencode api get /api/info # version + pid + urls + capabilities
opencode plugin list # what is loaded right now
opencode service status # the background service URL
Install the package that matches your release
The docs' import specifiers are correct. The package is not bundled with the CLI — you install it yourself, at the version matching your OpenCode release:
opencode --version # e.g. v2.0.24
cd ~/.config/opencode
npm install @opencode/plugin@$(opencode --version | sed 's/^v//')
opencode service restart # required: the running
# service caches resolution
A missing package fails loudly per-plugin rather than breaking the whole instance:
failed to load plugin … Cannot find package '@opencode/plugin' imported from
/home/you/.config/opencode/plugins/<name>/index.ts
A stale @opencode-ai/plugin (the V1-era package name) sitting in
~/.config/opencode/node_modules is a red herring. It is a leftover user-side
dependency; it does not reflect what the host provides. Do not read its
export list and conclude that @opencode/plugin "does not exist" — check npm
(npm view @opencode/plugin version) instead. The real @opencode/plugin
ships a Plugin namespace with Plugin.define, plus Model, Provider,
Rpc, WebSearch, Vcs, and Worktree, and its ./* subpath resolves
@opencode/plugin/rpc.
TUI plugins also need the peers from that package (solid-js,
@opentui/core, @opentui/solid, @opencode/theme).
Verify the default export
The host requires a default export carrying id and either setup or
effect. Without one:
failed to load plugin … Plugin must export a default definition with an id and
an effect or setup function.
If a plugin loads but does nothing, check the log at
~/.local/share/opencode/log/opencode.log, filtering on the plugin directory
name.
To confirm the runtime context on an unfamiliar machine, write a read-only probe
under ~/.config/opencode/plugins/ that dumps Object.keys(ctx) to a file from
setup — do not register hooks, transforms, or tools.
Version scope
Answer for the V2 plugin API. The V1 plugin shape — a server() function
returning a map of string keys like "tool.execute.before" — is not V2 and must
not be produced. A V2 plugin uses Plugin.define with typed hooks.
Never cite https://opencode.ai/docs/. It documents V1. If a question only makes sense for V1, say so rather than answering from memory.
Follow the docs and the templates
- Start from
templates/. Copy the file and fill in the blanks rather than writing a plugin from scratch; the templates are transcribed from the pages' own examples. - Cite the page whenever a field name, hook name, or option is uncertain.
- Do not invent identifiers. A plausible-sounding hook name that is not in the docs silently never fires, and an invented config field can fail to parse.
- Do not "improve" on the documented shape. If you need behavior the docs do not describe, say so and propose the closest supported approach.
- Prefer the page's own example over any type signature quoted here. Several reference types in these docs are simplified and omit real parameters.
Entrypoints
Four entrypoints. The RPC contract has a Promise and an Effect variant, so it appears as two rows:
| Entrypoint | Import | Runs in | Reference | Template |
|---|---|---|---|---|
| Server (Promise) | @opencode/plugin |
background service | this file | templates/promise-plugin.ts |
| Server (Effect) | @opencode/plugin/effect |
background service | references/effect.md |
templates/effect-plugin.ts |
| RPC contract | @opencode/plugin/rpc |
contract only, no code | references/rpc.md |
templates/rpc.ts |
| RPC (Effect contract) | @opencode/plugin/rpc |
contract only, no code | references/rpc.md |
templates/rpc-effect.ts |
| TUI / CLI | @opencode/plugin/tui |
terminal frontend | references/cli.md |
templates/tui-plugin.tsx |
Supporting files: templates/rpc-implementation.ts (Promise registration),
templates/rpc-implementation-effect.ts (Effect registration),
templates/opencode-plugin-config.jsonc (config reference),
templates/package.json + templates/package-json.md (publishing).
Default to the Promise server flavor. Use Effect only when the project already uses Effect. A TUI plugin is a separate export from a server plugin.
Shape
import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "example",
async setup(ctx) {
await ctx.storage.set("loaded", true)
},
})
setup runs at load and may return a cleanup function that runs at unload.
Pick an id that will not collide — the page does not give naming guidance.
setup(ctx) {
const timer = setInterval(() => void refresh(), 60_000)
return () => clearInterval(timer)
}
Options arrive on the untyped ctx.options — narrow before use:
const strict = ctx.options.strict === true.
Loading
Plugins under a discovered .opencode/plugins/ directory load automatically.
Packages and directories elsewhere must be listed in plugins in
opencode.json(c):
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
"opencode-acme-plugin",
"opencode-acme-plugin@1.2.0",
"@acme/opencode-plugin",
"./plugins/local",
"../shared/plugin.ts",
"/absolute/path/plugin.ts",
"file:///home/me/plugins/local",
{ "package": "./plugins/company", "options": { "strict": true } }
]
}
Relative paths resolve from the config file containing the entry, not from the working directory. Plugin arrays from applicable config files apply lowest→highest precedence rather than replacing one another.
This is the opposite of the skills array, which
https://opencode.ai/v2/docs/skills documents as resolving a relative path "from
the active OpenCode working directory, not the config file". Do not transfer one
rule to the other.
Full reference: templates/opencode-plugin-config.jsonc. It also covers
discovery paths, the - / * / .* disable syntax, the two built-ins that
ignore removals, the opencode plugin subcommands (including
list --builtin and scoping check/update to one package), and reload/pinning
rules.
Reload
Watched config directories reload automatically. Startup loads cached package
plugins immediately, installs missing packages in the background, and checks
unpinned npm and Git plugins for updates without changing the installed package.
Exact versions and full commit hashes stay pinned. Changes to unwatched local
dependencies may still need opencode service restart.
Context
ctx is an OpenCode server client plus plugin-only methods. ctx.location is
where this plugin instance loaded (directory, workspaceID, project), not
every session it can reach.
The plugins page does not enumerate the Promise context as a single interface, so this is the set that page documents, not a verified full type list.
Domains with transform + reload (synchronous edit, then re-read):
agent · provider · model · command · integration · mcp · reference
· skill · tool · vcs · websearch · worktree
Also documented: app · location · options · session · storage ·
event · permission · plugin · generate · shell · rpc
(ctx.rpc.register / ctx.rpc are on the RPC page).
Hooks live on four domains: ctx.session.hook, ctx.permission.hook,
ctx.shell.hook, ctx.tool.hook.
The Effect page lists a different, more explicit Context that also includes
aisdk and narrows experimental and plugin. Do not assume the two flavors
expose the same members — see references/effect.md.
Transforms
The core pattern: a synchronous callback that edits domain state through an editor. The Effect page states it outright — "The transform callback is synchronous: it must not return an Effect or Promise."
await ctx.model.transform((editor) => {
editor
.list()
.filter((m) => m.cost.some((tier) => tier.output > 20))
.forEach((m) => editor.remove(m.providerID, m.id))
})
Rules that cause bugs if forgotten:
- Any registration, removal, or
reload()marks the registry changed. The next read rebuilds by replaying every active transform in order onto a fresh value. Keep transforms cheap and repeatable. - A value you already read is never mutated by a later rebuild.
- Captured inputs are not watched. Load external data before the sync
callback, then call
reload()when it changes. reload()replays transforms. It does not rerunsetup.
const source = { providers: await loadFromSource() }
await ctx.provider.transform((editor) => {
source.providers.forEach((p) => editor.add(p))
})
const refresh = async () => {
source.providers = await loadFromSource()
await ctx.provider.reload()
}
const timer = setInterval(() => void refresh().catch(console.error), 60_000)
return () => clearInterval(timer)
Ordering: provider transforms contribute providers and immutable model definitions first. After availability resolves, OpenCode materializes active providers' models, then replays every model transform, so a model transform sees the whole candidate collection.
Model-transform details: disabled candidates stay editable until all transforms
finish, and update can add a model only under an available provider. Callbacks
edit raw model overrides; provider defaults merge once when OpenCode commits the
result. editor.provider exposes immutable source definitions (including
inactive templates), not model-transform output — use it to clamp against the
real limit. ModelEditor.get returns DeepMutable<Model.Info>, and
ProviderEditor.update hands you a mutable draft, so in-place edits inside an
update callback are expected.
transform resolves to a Registration; await registration.dispose() removes
that transform and rebuilds from the rest. Idempotent; plugin unload disposes
automatically. Each model request captures a stable tool snapshot, so later
transforms, reloads, and disposal affect future snapshots only.
Tools
const registration = await ctx.tool.transform((editor) => {
editor.namespace({ name: "acme", description: "Customer account tools" })
editor.add({
name: "greeting",
description: "Create a greeting",
input: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
additionalProperties: false,
},
options: { namespace: "acme", codemode: true },
execute: async (input, context) => {
await context.progress({ status: "greeting" })
return { content: `Hello ${(input as { name: string }).name}!` }
},
})
})
- Names are effective names including namespace:
acme_greeting. Dots in namespaces and unsupported characters become_.update/removeuse that effective name; onlyaddcreates a tool. A missing name is ignored. updatepreserves the name and namespace. Assign a new schema or options to replace one rather than mutating nested values. Invalid updates are logged and leave the previous definition intact.- A later valid registration overrides the same effective name; disposing it reveals the earlier one.
- Pass
context.signalto cancellable work so stopping the session stops it:
execute: async ({ url }, context) => {
const response = await fetch(url, { signal: context.signal })
return { content: await response.text() }
}
Hooks
Hooks intercept live operations and use the same registration model as transforms. Multiple plugins may register the same hook; they run in plugin order, so later hooks see earlier changes.
For model-request hooks the page states that changes "affect only the outgoing model call, not persisted history or configuration". The prompt hook is the exception — its edits become the persisted user input.
Session hooks
| Hook | When | Notes |
|---|---|---|
prompt |
User prompt admission | draft at event.prompt; event.metadata and event.delivery are top level |
context |
Agent loop, incl. tool continuations | system / messages / tools / options |
compaction |
Checkpoint summaries | set result to skip the model call |
generate |
ctx.session.generate calls |
|
title |
Title generation | no agent/tools; set result to supply the title |
model.request |
Model request settings | every model request; scope with { providerID } |
http.request |
Native provider HTTP request | bodies are one-shot streams |
http.response |
Native provider HTTP response | can replace the response |
experimental.ws.handshake |
WebSocket connect, once per model call | can change url / headers |
experimental.ws.send |
Outbound WS frame | can rewrite frame |
experimental.ws.receive |
Inbound WS frame | can rewrite frame |
retry |
Override the retry decision | { retry: false } or { retry: true, delay } |
await ctx.session.hook("context", (event) => {
event.system.push({ type: "text", text: "Keep the review focused on correctness." })
delete event.tools.write
event.options.temperature = 0.2
})
await ctx.session.hook("compaction", async (event) => {
event.result = { summary: await summarize(event.messages) }
})
await ctx.session.hook("retry", (event) => {
if (event.error.status === 429) event.decision = { retry: true, delay: 10_000 }
if (event.attempt >= 3) event.decision = { retry: false }
})
await ctx.session.hook(
"model.request",
(event) => {
event.headers["x-plugin"] = "review"
},
{ providerID: "anthropic" },
)
// The page's "Provider options" section shows `options` on the `context` hook.
// `model.request` edits headers instead.
await ctx.session.hook(
"context",
(event) => {
event.options.reasoningEffort = "high"
},
{ providerID: "openai" },
)
Details that matter:
optionsstarts empty each call; it does not contain resolved model settings. Request overrides beat model defaults, which beat route defaults.deletean override or setundefinedto fall back to configured defaults.- Provider options use the protocol's semantic names, not raw HTTP body
fields (
reasoningEffort,maxTokens;topKexists on Gemini but not OpenAI Responses). event.kindis"primary" | "compaction" | "title" | "generate", and it exists on themodel.request,http.request,http.response, andexperimental.ws.*hooks. It is not oncontext,compaction,generate,title,prompt, orretry— useagentand the hook itself to tell auxiliary requests apart on those.attemptis the physical attempt; the initial request is1, so the first retry is2. The built-in max attempt count is a hard limit. Invalid delays fall back to the computed delay.- Context-overflow recovery is separate: it compacts the conversation rather than retrying the same request.
- A hook that must apply to every request registers for each kind.
WebSocket caveat: providers streaming over a WebSocket open one connection per
session and reuse it, so http.request and http.response never see that
traffic. Changing experimental.ws.handshake's url or headers changes which
socket is used, so rotating a token reopens the connection. The HTTP hooks still
run for any request a WebSocket route falls back to. OpenCode does not validate
rewritten frames.
Prompt hook
Intercepts user prompts before attachment/skill resolution and durable inbox admission.
await ctx.session.hook("prompt", (event) => {
event.prompt.text = event.prompt.text.replaceAll("company-secret", "[redacted]")
event.delivery = "queue"
})
event.promptholdstext,files, agent mentions inagents, and selectedskills.metadataanddeliveryare separate top-level fields.- Runs once at admission, not before every model call. Session and message IDs are readonly; a hook cannot redirect admission.
- Runs in registration order after the session's location plugins are ready.
Commands submitted through
session.promptrun it. Synthetic messages, shell messages, compaction controls, and move controls do not. - No provider scoping — model resolution happens after admission.
- Not an exactly-once side-effect boundary. Retrying an ID already pending or delivered returns the original admission without rerunning hooks, and concurrent submissions can run hooks more than once — but only the first successful admission wins.
- Edits become the canonical persisted user input.
- Files and skills added by a hook follow the normal resolution and validation
path. When rewriting text, fix or drop attachment
mentionoffsets that no longer match. Agent mentions do not switch the active agent. - Prompt hooks transform input only — there is no typed rejection API.
Other domains
await ctx.permission.hook("evaluate", async (event) => {
if (event.action === "read") return
event.effect = "allow" // or "ask" | "deny"
event.message = "reason shown on escalation"
})
await ctx.shell.hook("create.before", (event) => {
if (event.command === "npm") event.command = "bun"
event.timeout = Math.min(event.timeout, 60_000)
event.env.COMPANY_ENV = "development" // also has cwd and shell
})
await ctx.tool.hook("execute.before", (event) => console.log(event.tool, event.input))
await ctx.tool.hook("execute.after", (event) => {
if (event.status === "error") console.error(event.error.message)
})
Permission hooks run for allow and ask decisions. An explicit configured
deny is final and never invokes the hook.
Storage
Durable JSON scoped to the plugin. Unlike model/provider reads, storage returns values directly — see the per-operation note under Effect reads.
await ctx.storage.set("settings", { strict: true })
const settings = await ctx.storage.get("settings")
const page = await ctx.storage.scan({ prefix: "cache/", limit: 100 })
// page.entries, page.next for cursor pagination
await ctx.storage.remove("settings")
Events
const controller = new AbortController()
void (async () => {
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
console.log(event.type)
}
})()
return () => controller.abort() // must abort during cleanup
Sessions
const created = await ctx.session.create({ title: "Review" })
await ctx.session.prompt({ sessionID: created.id, text: "Review the current changes" })
const summary = await ctx.session.generate({ sessionID: created.id, prompt: "Summarize this project" })
const command = await ctx.session.command({ sessionID: created.id, command: "review", arguments: "--staged" })
const synthetic = await ctx.session.synthetic({ sessionID: created.id, text: "Deployment completed" })
await ctx.session.compact({ sessionID: created.id })
await ctx.session.remove({ sessionID: created.id }) // stops work, deletes children
Also available: get, context, switchAgent, switchModel, update,
interrupt, wait, move.
compact returns the admitted inbox item, not a finished summary;
compaction runs at the next step boundary. Pass delivery: "queue" to preserve
queue order.
generate.text runs a model with no session, no tools, no history — handy for a
second opinion inside a hook:
const review = await ctx.generate.text({
model: { providerID: "anthropic", id: "claude-sonnet-4-6" },
prompt: "Review this proposed action.",
})
Commands
The command transform is add-only.
await ctx.command.transform((editor) => {
editor.add({
name: "security-review",
description: "Review changes for security issues",
execute: async ({ sessionID, prompt, delivery }) => {
await ctx.session.prompt({
...prompt,
sessionID,
text: `Review these changes for security issues.\n\n${prompt.text}`,
delivery,
})
},
})
})
MCP
Plugins manage MCP servers only through transforms.
await ctx.mcp.transform((editor) => {
editor.set("docs", { type: "remote", url: "https://mcp.example.com" })
editor.update("docs", (server) => {
server.disabled = false
})
editor.remove("legacy")
})
Setting disabled: true disconnects; OpenCode reconciles server lifecycle from
the transformed config. Call reload() after external changes.
Prefer opencode mcp add … --global for ordinary MCP setup; a transform is for
programmatic or dynamic control.
Worktrees
Every operation needs a projectID, available as ctx.location.project.id.
list reads only saved inventory — it does not load config, start plugins, or
discover filesystem changes.
const projectID = ctx.location.project.id
const created = await ctx.worktree.create({ projectID, name: "task" })
const inventory = await ctx.worktree.list({ projectID })
await ctx.worktree.refresh({ projectID })
await ctx.worktree.remove({ projectID, directory: created.directory, force: false })
- Adding a strategy via
ctx.worktree.transformselects it automatically — the last active registration wins. Disposing or unloading restores the previous implementation, ultimately the bundled Git strategy. createloads config and plugins from the project's savedcanonicaldirectory and waits for those plugins to activate. Afromoverride must belong to that project; choosing a source does not change the config root. Exception: calls made during that checkout's plugin setup see the registrations made so far and do not wait for their own activation.removeuses the worktree's recorded strategy, not the current default. A missing owner fails rather than falling back to Git. A strategy can request force confirmation withnew Worktree.OperationError({ message, forceRequired: true }), importingWorktreefrom@opencode/plugin.refreshcombines all available strategies, preserves recorded ownership, and prunes missing directories. Observeworktree.updatedand calllistagain.- Creation failures do not retry through Git. Strategies manage local directories only — remote provisioning is not part of this interface.
Publish
templates/package.json is strict JSON; templates/package-json.md carries the
guidance, the peer dependencies, and the publishing steps. Entrypoint summary:
| Export | Needed for | Documented on |
|---|---|---|
. |
a server plugin | build/plugins |
./rpc |
publishing a shared RPC contract; described there as optional | build/plugins |
./tui |
the TUI plugin; a package listed in opencode.json(c) that exposes it is loaded automatically by the CLI |
build/plugins/cli, cli/plugins |
Do not add a package that exports ./tui to cli.json as well — the CLI gets
the active plugin list from the server, so it already works remotely.
The $schema caveat
The V2 docs' own examples include "$schema": "https://opencode.ai/config.json",
but that file does not match the V2 config shape. Verified against the live file:
its Config has additionalProperties: false, has no plugins key (only a
singular plugin), types skills as an object rather than the V2 array, and has
no worktree key. https://opencode.ai/v2/config.json returns 404.
So keeping that line makes some editors flag the documented V2 plugins array as
an invalid field, and there is no published V2 config schema to replace it with.
Keep the line to match the docs, or drop it for a clean editor — either way,
never infer V2 field names or shapes from that file. For cli.json the
schema is https://opencode.ai/v2/cli.json.
Type-checking locally: expect noise
tsc --strict against the installed @opencode/plugin surfaces friction the
docs do not mention. Verified, with the fix used in the templates:
- Branded values (Promise flavor).
Model.ID,Provider.ID, andModel.cost[].input/.outputplus the cache fields are branded types. The docs writeeditor.remove(model.providerID, model.id)andtier.output > 20; against the real types you needString(...)on bothremovearguments andNumber(...)on the money fields. The Effect flavor'sDeepMutableunwraps the brands, so the docs' form compiles there. - Two copies of
effect. The host redirects plugin imports ofeffectto its own instance, so a localeffectinnode_modulesis a different copy. Effect templates then fail withProperty '[NodeInspectSymbol]' is missingand similar type-identity errors, and EffectSchema.Structstops satisfyingValueSchema(Codec | StandardSchemaV1 | JsonSchema). These are local typecheck artifacts, not plugin bugs — the Effect page requires the host's instance, which is exactly what you get at runtime. Installeffectat the version@opencode/plugindepends on if you want a clean typecheck. - JSX. A TUI template reports
Cannot use JSX unless the '--jsx' flag is providedandJSX.IntrinsicElementsis missing until you pass--jsxand install the Solid/OpenTUI peers.
Do not "fix" these by loosening the code or by abandoning a documented form such as Effect Schema. Use the narrowing casts the templates show, and treat the remaining Effect and JSX errors as local-environment noise.
Troubleshooting
- Plugin not loading: it must be under a discovered
.opencode/plugins/directory or listed inplugins. Aplugins/directory beside a project-rootopencode.json(c)is not auto-discovered..opencode/skillsis for skills, not plugins. Confirm withopencode plugin list. - Plugin loads but nothing changes: a
*entry plus a-iddisable elsewhere, a wrong relative path (config-file-relative, not working-directory-relative), or a change to an unwatched local dependency. - Overridden tool: a later registration wins.
dispose()the later one to reveal the earlier definition. - Hook not firing: check the hook name exists on the page for your flavor —
the Effect
SessionHooksset differs from the Promise one. Also confirmcontextcovers only the agent loop. - Type errors in a transform callback: the callback is synchronous.
- Nothing in the model list: a provider transform registered a definition but no connection, so it is not available. A registered definition does not mean its resource work finished.
- Environment:
~/.local/share/opencode/log/opencode.log, filterrole=server.--print-logsalone prints client logs; server logs (which is where plugin loading shows up) needopencode --standalone --print-logs. For a debug run useopencode --log-level debug. - Stuck service:
opencode service restart, thenopencode service status. Compare withopencode --standaloneto isolate shared-service issues.
Verifying before you answer
- Confirm the installed version (
opencode --version,opencode api get /api/info). - Check the doc page for that area for the exact identifier, and cite it.
- For schema shapes, use
/openapi.jsonor https://opencode.ai/v2/docs/api — theReferenceblock at the end of each API section points at the right schema. - If a page is silent, or a claim cannot be traced to a page, say so and mark it as inference. Do not guess a field name or present an inference as documented.
- If you touched anything under
skills/orplugins/, confirm it is pushed before you answer:git -C ~/.config/opencode status -sbmust show## main...origin/mainand no file lines.
Rules the pages state
- A
transformcallback is typed(editor) => voidin both flavors. - Tool
updatepreserves name and namespace; assign new schemas or options rather than mutating nested values. reload()replays transforms. It does not rerunsetup(Promise) or the plugineffect(Effect).- Use the effective tool name including its namespace for
update/remove. - Tear down event subscriptions:
AbortControllerin Promise,forkScopedin Effect. - Prefer a page's example over any simplified type signature quoted in a
reference — the docs' shared
Hooks<Spec, Failures>type, for instance, omits the provider-scoping argument their own examples pass.
References
templates/README.md— picking and using the starter filesreferences/effect.md— Effect plugins (https://opencode.ai/v2/docs/build/plugins/effect/)references/rpc.md— RPC, Promise and Effect flavors (https://opencode.ai/v2/docs/build/plugins/rpc/ and https://opencode.ai/v2/docs/build/plugins/effect/rpc/)references/cli.md— TUI/CLI plugins (https://opencode.ai/v2/docs/build/plugins/cli/)templates/opencode-plugin-config.jsonc— full config reference (https://opencode.ai/v2/docs/plugins)