Overview
Plugins can modify OpenCode’s behavior and add new features. To change the terminal UI, build a CLI plugin.
Migrating an existing OpenCode 1 plugin? Follow the V1 plugin migration guide.
import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "example",
async setup(ctx) {
await ctx.storage.set("loaded", true)
},
})Plugins under .opencode/plugins/ are loaded automatically, like the local example above. To load published packages
or plugin directories from other locations, add them to 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",
"/absolute/path/plugin",
"file:///home/me/plugins/local",
{
"package": "@acme/opencode-plugin",
"options": {
"agent": "reviewer",
"strict": true,
},
},
],
}See Configure plugins for more loading and configuration options.
Lifecycle
setup runs when the plugin loads. It may return a cleanup function that runs when the plugin unloads.
import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "example",
setup(ctx) {
console.log(`loaded in OpenCode ${ctx.app.version}`)
return () => console.log("unloaded")
},
})
Context
The plugin context is essentially an OpenCode server client. Its read and action methods use the same inputs and responses as the client. It adds plugin-only methods for transforms, runtime hooks, reloads, registrations, and plugin options.
setup(ctx) {
console.log(ctx.app.version)
}
ctx.location describes the location where this plugin instance is loaded. It
includes directory, optional workspaceID, and project metadata (id,
directory, and canonical). It is available in both Promise and Effect plugins.
This is the plugin instance’s location, not the location of every session it can
access or event it receives.
setup(ctx) {
console.log(ctx.location.directory)
console.log(ctx.location.project.canonical)
}
Options
Pass plugin options with the object form in opencode.json(c).
{
"plugins": [
{
"package": "./plugins/company",
"options": {
"strict": true,
},
},
],
}Read those values from ctx.options during setup.
import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "company",
setup(ctx) {
const strict = ctx.options.strict === true
},
})Transforms
Transforms are synchronous edits to OpenCode’s domain state, and each builds on earlier registrations in its domain.
Registry reads such as ctx.model.list() reflect every registration so far, including during plugin setup;
startup batching only coalesces update notifications and never delays what a read returns. Resource status APIs still
report the state of running resources: a registered definition does not mean its connection or other resource work has
completed.
Sources contribute providers and immutable model definitions through ctx.provider.transform.
import { Model, Plugin, Provider } from "@opencode/plugin"
export default Plugin.define({
id: "company.models",
async setup(ctx) {
const providerID = Provider.ID.make("acme")
const models = [{ ...Model.Info.default(providerID, Model.ID.make("reasoner")), name: "Acme Reasoner" }]
await 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,
})
})
const available = await ctx.model.list()
},
})Any registration, removal, or reload() marks the registry changed; the next read rebuilds it by replaying every
active transform in registration order onto a fresh value. Keep transforms cheap and repeatable. A value you have
already read is never modified by later rebuilds.
After provider transforms and availability are resolved, OpenCode materializes the active providers’ models and replays every model transform in order. A model policy can inspect the whole candidate collection, including newly refreshed sources.
import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "company.model-budget",
async setup(ctx) {
await ctx.model.transform((editor) => {
editor
.list()
.filter((model) => model.cost.some((tier) => tier.output > 20))
.forEach((model) => {
editor.remove(model.providerID, model.id)
})
})
},
})Captured inputs are not watched automatically. Load external data before the synchronous callback, then call
reload() after those inputs change. Here, loadFromSource() returns
{ info: Provider.Info, models: readonly Model.Info[] } entries.
import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "company.models",
async setup(ctx) {
const source = { providers: await loadFromSource() }
await ctx.provider.transform((editor) => {
source.providers.forEach((provider) => editor.add(provider))
})
const refresh = async () => {
source.providers = await loadFromSource()
await ctx.provider.reload()
}
const timer = setInterval(() => void refresh().catch(console.error), 60_000)
return () => clearInterval(timer)
},
})Provider changes invalidate the whole active model result, so the output-price policy still filters the refreshed models.
Use ctx.model.reload() when only an input captured by a model transform changes.
API
Agent
Read all agents or fetch one by ID.
const agents = await ctx.agent.list()
const build = await ctx.agent.get({ agentID: "build" })
Register a transform to inspect, update, remove, or select the default agent.
await ctx.agent.transform((editor) => {
const agents = editor.list()
const build = editor.get("build")
editor.default("build")
editor.update("build", (agent) => {
agent.description = "Builds features and fixes bugs"
})
editor.remove("legacy")
})
Reload agents after external state used by a transform changes.
await ctx.agent.reload()
Reference
Schema: Agent.Info
interface AgentContext {
list(input?: AgentListInput, requestOptions?: RequestOptions): Promise<AgentListOutput>
get(input: AgentGetInput, requestOptions?: RequestOptions): Promise<AgentGetOutput>
transform(callback: (editor: AgentEditor) => void): Promise<Registration>
reload(): Promise<void>
}
interface AgentEditor {
list(): readonly AgentInfo[]
get(id: string): AgentInfo | undefined
default(id: string | undefined): void
update(id: string, update: (agent: AgentInfo) => void): void
remove(id: string): void
}
interface Registration {
dispose(): Promise<void>
}
Providers
Read available providers or inspect one provider’s metadata by ID.
const providers = await ctx.provider.list()
const provider = await ctx.provider.get({ providerID: "anthropic" })
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.
await 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 provider’s source inventory with models.set, then reload after the captured definitions change.
const source = { models: await loadModels() }
await ctx.provider.transform((editor) => {
editor.models.set("acme", source.models)
})
source.models = await loadModels()
await ctx.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 = await ctx.integration.connection.active("acme")
if (!connection) return
const inventory = await loadInventory(connection)
await ctx.provider.transform((editor) => {
editor.add({ info: inventory.provider, models: inventory.models, sourceConnection: connection })
})
Reference
Schemas: Provider.Info, Model.Info
interface ProviderDomain extends ProviderApi {
transform(callback: (editor: ProviderEditor) => void): Promise<Registration>
reload(): Promise<void>
}
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: 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: DeepMutable<Model.Info>) => void): void
remove(providerID: string, modelID: string): void
}
}
Models
Read available models and the default selection.
const models = await ctx.model.list()
const selected = await ctx.model.default()
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.
await 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 exposes immutable source definitions, including inactive templates, rather than
model-transform output.
await ctx.model.transform((editor) => {
const source = editor.provider.get("anthropic")?.models.get("claude-sonnet-4-5")
if (!source) return
editor.update("anthropic", source.id, (model) => {
model.limit.context = Math.min(model.limit.context, source.limit.context)
})
})
await ctx.model.reload()
Reference
Schema: Model.Info
interface ModelDomain extends ModelApi {
transform(callback: (editor: ModelEditor) => void): Promise<Registration>
reload(): Promise<void>
}
interface ModelEditor {
list(providerID?: string): readonly DeepMutable<Model.Info>[]
get(providerID: string, modelID: string): DeepMutable<Model.Info> | undefined
update(providerID: string, modelID: string, update: (model: 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
}
}
Commands
Read the commands available at a location.
const commands = await ctx.command.list()
Register commands with a transform. The executor receives the session, prompt attachments, and requested delivery mode.
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,
})
},
})
})
Reload commands after external state used by a transform changes.
await ctx.command.reload()
Reference
Schemas: Command.Info,
Session.Inbox.Delivery
interface CommandContext {
list(input?: CommandListInput, requestOptions?: RequestOptions): Promise<CommandListOutput>
transform(callback: (editor: CommandEditor) => void): Promise<Registration>
reload(): Promise<void>
}
interface CommandEditor {
add(definition: CommandDefinition): void
}
interface CommandDefinition {
name: string
description?: string
execute(input: CommandInvocation): Promise<void>
}
interface CommandInvocation {
sessionID: string
prompt: PromptInput
delivery: "steer" | "queue"
}
Integrations
Read integrations and inspect active credentials.
const integrations = await ctx.integration.list()
const github = await ctx.integration.get({ integrationID: "github" })
const connection = await ctx.integration.connection.active("github")
const credential = connection ? await ctx.integration.connection.resolve(connection) : undefined
Connect integrations with an API key.
await ctx.integration.connect.key({
integrationID: "github",
key: process.env.GITHUB_TOKEN!,
})
Start, inspect, complete, or cancel an OAuth connection attempt.
const attempt = await ctx.integration.oauth.connect({ integrationID: "github", methodID: "oauth" })
const status = await ctx.integration.oauth.status({ integrationID: "github", attemptID: attempt.data.attemptID })
await ctx.integration.oauth.complete({ integrationID: "github", attemptID: attempt.data.attemptID, code })
await ctx.integration.oauth.cancel({ integrationID: "github", attemptID: attempt.data.attemptID })
Command-based connections expose the same start, status, and cancel flow.
const attempt = await ctx.integration.command.connect({ integrationID: "acme", methodID: "cli" })
const status = await ctx.integration.command.status({ integrationID: "acme", attemptID: attempt.data.attemptID })
await ctx.integration.command.cancel({ integrationID: "acme", attemptID: attempt.data.attemptID })
Register a transform to inspect integrations and manage their authentication methods.
await ctx.integration.transform((editor) => {
const integrations = editor.list()
const acme = editor.get("acme")
editor.update("acme", (integration) => {
integration.name = "Acme"
})
editor.method.update({
integrationID: "acme",
method: { id: "cli", type: "command", label: "Sign in with Acme CLI", command: ["acme", "login"] },
})
const methods = editor.method.list("acme")
const legacy = methods.find((method) => method.type === "command" && method.id === "legacy")
if (legacy) editor.method.remove("acme", legacy)
editor.remove("legacy")
})
Reload integrations after external state used by a transform changes.
await ctx.integration.reload()
Reference
Schemas: Integration.Info, Integration.Method,
Connection.Info, Form.Answer
interface IntegrationContext {
list(input?: IntegrationListInput, requestOptions?: RequestOptions): Promise<IntegrationListOutput>
get(input: IntegrationGetInput, requestOptions?: RequestOptions): Promise<IntegrationGetOutput>
connect: {
key(input: IntegrationConnectKeyInput, requestOptions?: RequestOptions): Promise<void>
}
oauth: {
connect(
input: IntegrationOauthConnectInput,
requestOptions?: RequestOptions,
): Promise<IntegrationOauthConnectOutput>
status(input: IntegrationOauthStatusInput, requestOptions?: RequestOptions): Promise<IntegrationOauthStatusOutput>
complete(input: IntegrationOauthCompleteInput, requestOptions?: RequestOptions): Promise<void>
cancel(input: IntegrationOauthCancelInput, requestOptions?: RequestOptions): Promise<void>
}
command: {
connect(
input: IntegrationCommandConnectInput,
requestOptions?: RequestOptions,
): Promise<IntegrationCommandConnectOutput>
status(
input: IntegrationCommandStatusInput,
requestOptions?: RequestOptions,
): Promise<IntegrationCommandStatusOutput>
cancel(input: IntegrationCommandCancelInput, requestOptions?: RequestOptions): Promise<void>
}
transform(callback: (editor: IntegrationEditor) => void): Promise<Registration>
reload(): Promise<void>
connection: {
active(integrationID: string): Promise<ConnectionInfo | undefined>
resolve(connection: ConnectionInfo): Promise<CredentialValue | undefined>
}
}
interface IntegrationEditor {
list(): readonly IntegrationRef[]
get(id: string): IntegrationRef | undefined
update(id: string, update: (integration: IntegrationRef) => void): void
remove(id: string): void
method: {
list(integrationID: string): readonly IntegrationMethod[]
update(input: IntegrationMethodRegistration): void
remove(integrationID: string, method: IntegrationMethod): void
}
}
MCP
List MCP servers and their current connection state.
const servers = await ctx.mcp.list()
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.
await ctx.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.
await ctx.mcp.reload()
Reference
Schemas: Mcp.Server, Mcp.LocalConfigEncoded,
Mcp.RemoteConfigEncoded
interface MCPContext {
list(input?: McpListInput, requestOptions?: RequestOptions): Promise<McpListOutput>
transform(callback: (editor: MCPEditor) => void): Promise<Registration>
reload(): Promise<void>
}
interface MCPEditor {
list(): readonly (readonly [string, McpServerConfig])[]
get(name: string): McpServerConfig | undefined
set(name: string, config: McpServerConfig): void
update(name: string, update: (config: McpServerConfig) => void): void
remove(name: string): void
}
Plugins
List the plugins currently active for a location.
const plugins = await ctx.plugin.list()
Reference
Schemas: Plugin.Info, Plugin.Source
interface PluginContext {
list(input?: PluginListInput, requestOptions?: RequestOptions): Promise<PluginListOutput>
}
References
Read the references available at a location.
const references = await ctx.reference.list()
Register a transform to inspect, add, or remove local and Git references. get(name) returns the current configured source,
or undefined when the name is absent.
await ctx.reference.transform((editor) => {
const references = editor.list()
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")
})
Reload references after external state used by a transform changes.
await ctx.reference.reload()
Reference
Schemas: Reference.Info, Reference.LocalSource,
Reference.GitSource
interface ReferenceContext {
list(input?: ReferenceListInput, requestOptions?: RequestOptions): Promise<ReferenceListOutput>
transform(callback: (editor: ReferenceEditor) => void): Promise<Registration>
reload(): Promise<void>
}
interface ReferenceEditor {
list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[]
get(name: string): ReferenceLocalSource | ReferenceGitSource | undefined
add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void
remove(name: string): void
}
Generate
Generate text with a selected model without creating a session, invoking tools, or adding to session history.
const review = await ctx.generate.text({
model: { providerID: "anthropic", id: "claude-sonnet-4-6" },
prompt: "Review this proposed action.",
})
Permissions
Inspect or resolve pending permission requests.
const pending = await ctx.permission.list({ sessionID })
const request = await ctx.permission.get({ sessionID, requestID })
await ctx.permission.reply({ sessionID, requestID, reply: "once" })
Replace the session-scoped permission rules. They are evaluated after the agent’s rules, and the last matching rule wins. Child sessions inherit the rules in effect when they are created.
await ctx.permission.rules({
sessionID,
permissions: [{ action: "edit", resource: "/path/to/original/checkout/**", effect: "deny" }],
})
Sessions
Create or read a session.
const created = await ctx.session.create({ title: "Review" })
const session = await ctx.session.get({ sessionID })
const messages = await ctx.session.context({ sessionID })
Change the agent or model used by subsequent requests.
await ctx.session.switchAgent({ sessionID, agent: "build" })
await ctx.session.switchModel({ sessionID, model: { providerID: "anthropic", id: "claude-sonnet-4-5" } })
Send user prompts, transient generation requests, commands, or synthetic messages.
const prompt = await ctx.session.prompt({ sessionID, text: "Review the current changes" })
const generated = await ctx.session.generate({ sessionID, prompt: "Summarize this project" })
const command = await ctx.session.command({ sessionID, command: "review", arguments: "--staged" })
const synthetic = await ctx.session.synthetic({ sessionID, text: "Deployment completed" })
Rename, interrupt, or wait for a session.
await ctx.session.rename({ sessionID, title: "Review" })
await ctx.session.interrupt({ sessionID, continue: false })
await ctx.session.wait({ sessionID })
Reference
Schemas: Session.Info, Model.Ref,
Session.Inbox.User, Session.Inbox.Synthetic
interface SessionContext {
create(input?: SessionCreateInput, requestOptions?: RequestOptions): Promise<SessionInfo>
get(input: SessionGetInput, requestOptions?: RequestOptions): Promise<SessionInfo>
context(input: SessionContextInput, requestOptions?: RequestOptions): Promise<readonly SessionMessageInfo[]>
switchAgent(input: SessionSwitchAgentInput, requestOptions?: RequestOptions): Promise<void>
switchModel(input: SessionSwitchModelInput, requestOptions?: RequestOptions): Promise<void>
prompt(input: SessionPromptInput, requestOptions?: RequestOptions): Promise<SessionInboxUser>
generate(input: SessionGenerateInput, requestOptions?: RequestOptions): Promise<{ text: string }>
command(input: SessionCommandInput, requestOptions?: RequestOptions): Promise<SessionInboxUser>
synthetic(input: SessionSyntheticInput, requestOptions?: RequestOptions): Promise<SessionInboxSynthetic>
interrupt(input: SessionInterruptInput, requestOptions?: RequestOptions): Promise<void>
rename(input: SessionRenameInput, requestOptions?: RequestOptions): Promise<void>
wait(input: SessionWaitInput, requestOptions?: RequestOptions): Promise<void>
}
Skills
Read the skills available at a location.
const skills = await ctx.skill.list()
Register a transform to inspect, add, update, or remove skills. get(id) returns the current editor entry, or undefined
when the skill is absent.
await ctx.skill.transform((editor) => {
const skills = editor.list()
editor.add({
id: "review",
name: "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", (skill) => {
skill.autoinvoke = true
})
editor.remove("legacy")
})
Reload skills after external state used by a transform changes.
await ctx.skill.reload()
Reference
Schema: Skill.Info
interface SkillContext {
list(input?: SkillListInput, requestOptions?: RequestOptions): Promise<SkillListOutput>
transform(callback: (editor: SkillEditor) => void): Promise<Registration>
reload(): Promise<void>
}
interface SkillEditor {
list(): readonly SkillInfo[]
get(id: string): SkillInfo | undefined
add(skill: SkillInfo): void
update(id: string, update: (skill: SkillInfo) => void): void
remove(id: string): void
}
Storage
Store, read, or remove durable JSON values scoped to the plugin.
await ctx.storage.set("settings", { strict: true })
const settings = await ctx.storage.get("settings")
await ctx.storage.remove("settings")
Scan keys by prefix with optional cursor pagination.
const page = await ctx.storage.scan({ prefix: "cache/", limit: 100 })
const next = page.next
Reference
interface StorageContext {
get(key: string): Promise<Json | undefined>
set(key: string, value: Json): Promise<void>
remove(key: string): Promise<void>
scan(options: StorageScanOptions): Promise<StorageScanResult>
}
interface StorageScanOptions {
prefix: string
after?: string
limit?: number
}
interface StorageScanResult {
entries: readonly { key: string; value: Json }[]
next?: string
}
Tools
Read the tools currently registered, after every transform, outside of a transform callback. Each tool has an id
containing its effective name.
const tools = await ctx.tool.list()
const read = tools.find((tool) => tool.id === "read")
Register, update, and remove tools with a synchronous transform, including in Promise plugins. Load external data before registering or reloading. A later valid registration overrides the same effective tool name.
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}!` }
},
})
})
Promise tool executors receive context.signal. Pass it to cancellable work such as fetch so stopping the Session
also stops the underlying operation.
execute: async ({ url }, context) => {
const response = await fetch(url, { signal: context.signal })
return { content: await response.text() }
}
Call reload() after changing source data captured by the callback. Reload replays the active transforms without
changing their order; it does not rerun plugin setup.
await ctx.tool.reload()
Use list() and get() to inspect tools currently in the editor. 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.
await 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. Disposing their registration removes those changes and rebuilds from the remaining transforms.
Dispose a registration to remove its transform and rebuild from the remaining transforms, revealing any earlier definition it overrode. Disposal is idempotent, and unloading the plugin also disposes its registrations.
await registration.dispose()
Each model request captures a stable, executable tool snapshot. Later transforms, reloads, and disposal affect future snapshots, not the definitions, Code Mode namespace descriptions, or executors already captured. 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.
Reference
Schemas: Tool.Content, Tool.TextContent,
Tool.FileContent
interface ToolContext {
list(): Promise<readonly (ToolInfo & { readonly id: string })[]>
transform(callback: (editor: ToolEditor) => void): Promise<Registration>
reload(): Promise<void>
}
interface ToolEditor {
list(): readonly (ToolInfo & { readonly id: string })[]
get(id: string): (ToolInfo & { readonly id: string }) | undefined
namespace(namespace: { name: string; description: string }): void
add(tool: ToolInfo): void
update(id: string, update: (tool: Types.Mutable<ToolInfo>) => void): void
remove(id: string): void
}
VCS
Read repository information, working-copy status, or file diffs.
const info = await ctx.vcs.get()
const branches = await ctx.vcs.branches({ search: "feature", limit: 10 })
const changes = await ctx.vcs.status()
const diff = await ctx.vcs.diff({ mode: "working", context: 3 })
Register a location-scoped VCS provider with a transform. A provider matching the detected repository type is selected
automatically; use editor.default.set to select a different provider.
await ctx.vcs.transform((editor) => {
editor.add({
id: "custom",
name: "Custom VCS",
info: async () => ({ branch: { current: "feature", default: "main" } }),
branches: async (input, { signal }) => readBranches(input, signal),
status: async (scope, { signal }) => readStatus(scope.worktree, signal),
diff: async (input, { signal }) => readDiff(input, signal),
})
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.
Reference
Schemas: Vcs.Info, Vcs.FileStatus,
FileDiff.Info
interface VcsContext {
get(input?: VcsGetInput, requestOptions?: RequestOptions): Promise<VcsGetOutput>
branches(input?: VcsBranchesInput, requestOptions?: RequestOptions): Promise<VcsBranchesOutput>
status(input?: VcsStatusInput, requestOptions?: RequestOptions): Promise<VcsStatusOutput>
diff(input: VcsDiffInput, requestOptions?: RequestOptions): Promise<VcsDiffOutput>
transform(callback: (editor: VcsEditor) => void): Promise<Registration>
reload(): Promise<void>
}
interface VcsEditor {
add(definition: VcsDefinition): void
default: {
get(): string | undefined
set(providerID: string): void
}
}
Worktrees
Register a local worktree strategy using the normal plugin lifecycle. The implementation module in this example owns
option validation and the backend’s create, remove, and list operations.
import { Plugin } from "@opencode/plugin"
import { makeStrategy } from "./strategy"
export default Plugin.define({
id: "company.worktrees",
async setup(ctx) {
const strategy = makeStrategy(ctx.options)
await ctx.worktree.transform((editor) => {
editor.add(strategy)
})
},
})Load the plugin through plugins and configure the common destination separately:
{
"worktree": { "directory": "../worktrees" },
"plugins": [{ "package": "./plugins/worktrees", "options": {} }],
}- Adding a strategy selects it automatically. The last active registration wins; no strategy-selection config is needed.
- Disposing its registration or unloading the plugin restores the previous implementation, ultimately the bundled Git strategy.
reload()replays transforms after captured inputs change; it does not rerun plugin setup.- Creation failures do not retry through Git. Existing worktrees retain their recorded owner even when the default changes.
- Strategies manage local directories. Remote workspace provisioning is not part of this interface.
Operations
The plugin context exposes the same project-based worktree operations as the client. Every operation requires a
projectID. list reads only saved inventory, including known checkouts of the same project; it does not load
configuration, 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 })
Create loads configuration and plugins from the project’s saved canonical directory and uses their selected strategy.
It accepts an optional parent directory, source from, starting branch, and name.
The source defaults to the canonical checkout, and a from override must belong to that project. Choosing a source
does not change the configuration root.
A supplied starting ref must be supported by the selected strategy;
Rift’s native snapshot operation, for example, has no ref-selection option.
Create and refresh wait for the canonical checkout’s plugins to activate. Calls made during that checkout’s plugin setup see registrations made so far and do not wait for their own activation.
Remove loads the canonical configuration and activates its plugins, then uses the worktree’s recorded strategy rather than the currently selected default. An unavailable recorded owner produces an error. The worktree’s destination can be elsewhere:
await ctx.worktree.remove({
directory: "/worktrees/task",
projectID,
force: false,
})
Missing owners fail removal rather than falling back to Git. A strategy can throw new Worktree.OperationError({ message: "Uncommitted changes", forceRequired: true }), importing Worktree from @opencode/plugin, to request
force confirmation without depending on Core or Git errors.
Refresh loads canonical configuration and asks all available strategies to find worktrees across known checkout roots.
It combines results, preserves recorded ownership, and prunes missing directories. A failed discovery source is logged
while other sources continue. Existing directories remain saved even if no available strategy reports them. Observe
worktree.updated and call list again to read changed inventory.
await ctx.worktree.refresh({ projectID })
Reference
Implementations receive a suggested destination after naming and collision handling. Return the actual directory from
create; it may differ when a backend requires its own layout. OpenCode resolves the returned path and uses it for
inventory, startup commands, and the API result. The returned directory must exist.
Strategies choosing another destination handle naming collisions there. OpenCode still creates the suggested parent
directory before calling the strategy. list must report only directories the strategy owns, plus any repository roots.
interface WorktreeDefinition {
readonly id: string
create(
input: { sourceDirectory: string; directory: string; branch?: string },
context: { signal: AbortSignal },
): Promise<{ directory: string }>
remove(input: { directory: string; force: boolean }, context: { signal: AbortSignal }): Promise<void>
list(
sourceDirectory: string,
context: { signal: AbortSignal },
): Promise<readonly { directory: string; type: "root" | "worktree" }[]>
}
interface WorktreeEditor {
add(definition: WorktreeDefinition): void
}
interface WorktreeDomain extends WorktreeApi {
transform(callback: (editor: WorktreeEditor) => void): Promise<Registration>
reload(): Promise<void>
}
Promise callbacks must cooperate with signal cancellation. Effect callbacks return Effects and use Effect interruption
instead. Registrations remain scoped to the plugin runtime; project operations use the canonical checkout’s registry.
Core configuration adapters feed directory settings into that registry.
Websearch
List websearch providers or run a query through the selected provider.
const providers = await ctx.websearch.providers()
const results = await ctx.websearch.query({ query: "OpenCode plugins", providerID: "internal" })
Register a provider and select the default provider with a transform.
await ctx.websearch.transform((editor) => {
editor.add({
id: "internal",
name: "Internal search",
execute: async ({ query }, { signal }) => {
const response = await fetch(`https://search.example.com?q=${encodeURIComponent(query)}`, { signal })
const result = await response.json()
return [{ url: result.url, title: result.title, content: result.content, time: {} }]
},
})
editor.default.set("internal")
})
Disable websearch by selecting false, or reload providers after external state changes.
await ctx.websearch.transform((editor) => editor.default.set(false))
await ctx.websearch.reload()
Reference
Schemas: WebSearch.Provider, WebSearch.Result
interface WebSearchContext {
providers(input?: WebsearchProvidersInput, requestOptions?: RequestOptions): Promise<WebsearchProvidersOutput>
query(input: WebsearchQueryInput, requestOptions?: RequestOptions): Promise<WebsearchQueryOutput>
transform(callback: (editor: WebSearchEditor) => void): Promise<Registration>
reload(): Promise<void>
}
interface WebSearchEditor {
add(provider: WebSearchDefinition): void
default: {
get(): string | false | undefined
set(providerID: string | false): void
}
}
Events
Subscribe to the connected server’s public event stream. Abort the stream during plugin cleanup.
const controller = new AbortController()
void (async () => {
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
console.log(event.type)
}
})()
return () => controller.abort()
Reference
Schema: V2EventEncoded
interface EventContext {
subscribe(options?: { signal?: AbortSignal }): AsyncIterable<OpenCodeEvent>
}
Hooks
Hooks intercept live operations. Register a hook on its domain and dispose the returned registration when it is no longer needed.
const registration = await ctx.session.hook("context", () => {})
await registration.dispose()
Multiple plugins can register the same hook. OpenCode runs them in plugin order, so later hooks see changes made by earlier hooks.
Sessions
Session hooks intercept prompt admission, model calls, native HTTP, and retries.
Prompt admission
Intercept incoming user prompts before attachment and skill resolution and durable inbox admission:
await ctx.session.hook("prompt", (event) => {
event.prompt.text = event.prompt.text.replaceAll("company-secret", "[redacted]")
event.prompt.files ??= []
event.prompt.files.push({ uri: "file:///project/policy.md" })
event.metadata = { ...event.metadata, source: "company-policy" }
event.delivery = "queue"
})
The hook receives an owned, mutable draft:
promptcontainstext,files, agent mentions inagents, and selectedskills.metadatacontains application-defined admission metadata.deliveryis"steer"by default and can be changed to"queue".- Session and message IDs are readonly; a hook cannot redirect admission.
Files and skills added by a hook follow the normal resolution and validation
path. When rewriting text, update or remove attachment mention offsets that no
longer match. Agent mentions do not switch the session’s active agent.
Prompt hooks run under these conditions:
- Hooks run in registration order after the session’s location plugins are ready.
- Commands that submit through
session.promptrun the hook. - Synthetic messages, shell messages, compaction controls, and move controls do not.
- The hook runs once during admission, not before every model call.
- Provider scoping is unavailable because model resolution happens after admission.
Edits become the canonical persisted user input. If preparation fails or is interrupted, OpenCode does not admit the prompt.
Keep prompt hooks retry-safe. They are not an exactly-once side-effect boundary:
- Retrying an ID already pending or delivered returns the original admission without rerunning hooks.
- Concurrent submissions can run hooks more than once, but only the first successful admission wins.
- Prompt hooks transform input and do not expose a typed rejection API.
Model requests
Modify assembled system instructions, messages, tools, or request options immediately before model dispatch. Each kind of request a session issues has its own hook, so a plugin can treat the agent loop and auxiliary requests differently:
contextruns for the agent loop, including tool-driven continuations.compactionruns for checkpoint summaries.messagesis the transcript being summarized; OpenCode appends its summary prompt after hooks run. Setresultto record the compaction yourself and skip the model call; it takes the same fields as a completed compaction message.generateruns for transientctx.session.generatecalls.titleruns for title generation. It has noagentortools. Setresultto supply the title yourself.
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
event.options.maxTokens = 8_000
})
await ctx.session.hook("compaction", async (event) => {
event.result = { summary: await summarize(event.messages) }
})
Changes affect only the outgoing model call, not persisted history or configuration. A hook that should apply to every request must register for each kind.
Request overrides follow these rules:
optionsstarts empty for each model call; it does not contain resolved model settings.- Typed keys are generation settings; any other key is passed to the selected protocol as a provider option.
- Hooks run in registration order and see overrides made by earlier hooks.
- Request overrides take precedence over model defaults, which take precedence over route defaults.
- Provider option objects merge recursively; arrays and scalar values replace earlier values.
- Deleting an override or setting it to
undefinedfalls back to configured defaults rather than removing them. - Raw HTTP body overlays apply after protocol lowering and can override the resulting fields.
Provider options
Provider options use the selected protocol’s semantic option names, not raw HTTP body fields. Scope provider-specific
settings to the matching provider. For example, OpenAI Responses uses reasoningEffort:
await ctx.session.hook(
"context",
(event) => {
event.options.reasoningEffort = "high"
},
{ providerID: "openai" },
)
Generation options depend on the selected protocol and model:
maxTokensis the semantic output-token limit.- Gemini supports
topK; OpenAI Responses does not expose it.
Model request
Modify model request settings and optionally scope the hook to one provider. The event carries the same kind
as the HTTP hooks below.
await ctx.session.hook(
"model.request",
(event) => {
event.headers["x-plugin"] = "review"
},
{ providerID: "anthropic" },
)
Native HTTP
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 says which flow issued it: "primary" for the agent
loop, "compaction" for checkpoint summaries, "title" for title generation, and "generate" for transient
ctx.session.generate calls. Use it instead of the agent ID to tell auxiliary requests apart.
await ctx.session.hook("http.request", (event) => {
event.request.headers.set("x-session-id", event.sessionID)
if (event.kind === "title") event.request.headers.set("x-priority", "background")
})
await ctx.session.hook("http.response", (event) => {
event.response = new Response(event.response.body, {
status: event.response.status,
headers: { ...Object.fromEntries(event.response.headers), "x-plugin": "review" },
})
})
Native WebSocket (experimental)
Providers that stream over a WebSocket open one connection per session and reuse it across requests, so
http.request and http.response never see that traffic. The experimental.ws.handshake hook runs once per model
call before the connection is selected. It carries the same sessionID, agent, model, and kind as the HTTP
hooks, plus the connection url and headers. Changing url or headers changes which socket the request uses, so a
rotated bearer token reopens the connection.
HTTP hooks still run for every HTTP request, including the request a WebSocket route falls back to.
await ctx.session.hook(
"experimental.ws.handshake",
async (event) => {
event.headers.authorization = `Bearer ${await mintToken(event.url)}`
delete event.headers["api-key"]
},
{ providerID: "azure" },
)
experimental.ws.send and experimental.ws.receive expose the frames themselves, the WebSocket counterpart of
editing an HTTP request or response body. send runs after the provider driver builds an outbound frame and before it
is written; receive runs on each inbound frame before the driver observes it. Both carry the frame as a string and
send whatever frame holds when the hook returns.
OpenCode does not validate rewritten frames. The driver tracks state from the provider’s replies, so a rewrite that changes protocol meaning is the plugin’s responsibility, just as a rewritten HTTP body is.
await ctx.session.hook(
"experimental.ws.send",
(event) => {
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" },
)
await ctx.session.hook("experimental.ws.receive", (event) => {
if (event.frame.includes('"type":"error"')) console.error(event.frame)
})
These hooks are experimental and their names or shapes may change.
Retry policy
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.
await ctx.session.hook("retry", (event) => {
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 }
})
Retry hooks can change OpenCode’s initial decision by making a terminal failure
retryable or vetoing a proposed retry.
Retry decisions follow these rules:
- Multiple hooks run in registration order, and later hooks see the current decision.
- The built-in maximum attempt count remains a hard limit.
attemptis the physical attempt under consideration. The initial request is1, so the first retry is2.- Invalid delays such as
NaN, infinity, or negative values fall back to the computed delay. - Context-overflow recovery is separate because it compacts the conversation instead of retrying the same request.
Session reference
import type { SessionPrompt } from "@opencode/plugin/promise/session"
interface SessionHooks {
prompt: SessionPrompt
context: SessionContextHook
compaction: SessionContextHook & { result?: SessionCompactionResult }
generate: SessionContextHook
title: SessionRequestHook & { result?: string }
"model.request": SessionModelRequestHook
"http.request": SessionHttpRequestHook
"http.response": SessionHttpResponseHook
"experimental.ws.handshake": SessionWebSocketHandshakeHook
"experimental.ws.send": SessionWebSocketSendHook
"experimental.ws.receive": SessionWebSocketReceiveHook
retry: SessionRetryHook
}
interface SessionWebSocketHandshakeHook {
readonly sessionID: string
readonly agent: string
readonly model: { providerID: string; id: string; variant?: string }
readonly kind: "primary" | "compaction" | "title" | "generate"
url: string
headers: Record<string, string>
}
interface SessionWebSocketSendHook {
readonly sessionID: string
readonly agent: string
readonly model: { providerID: string; id: string; variant?: string }
readonly kind: "primary" | "compaction" | "title" | "generate"
frame: string
}
interface SessionWebSocketReceiveHook {
readonly sessionID: string
readonly agent: string
readonly model: { providerID: string; id: string; variant?: string }
readonly kind: "primary" | "compaction" | "title" | "generate"
frame: string
}
type RetryDecision = { retry: false } | { retry: true; delay: number }
interface SessionRetryHook {
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 SessionRequestHook {
readonly sessionID: string
readonly model: { providerID: string; id: string; variant?: string }
system: SystemPart[]
messages: Message[]
options: {
maxTokens?: number
temperature?: number
topP?: number
topK?: number
frequencyPenalty?: number
presencePenalty?: number
seed?: number
stop?: string[]
} & Record<string, unknown>
}
interface SessionContextHook extends SessionRequestHook {
readonly agent: string
tools: Record<string, { description: string; input: JsonSchema }>
}
interface SessionCompactionResult {
summary: string
providerState?: Record<string, unknown>
metadata?: Record<string, unknown>
tokens?: TokenUsage
}
interface SessionHookContext {
hook<Name extends keyof SessionHooks>(
name: Name,
callback: (event: SessionHooks[Name]) => Promise<void> | void,
options?: Name extends "prompt" ? never : { providerID?: string },
): Promise<Registration>
}
Permissions
Review permission decisions after configured rules are evaluated and before an action runs or a permission prompt is published.
await ctx.permission.hook("evaluate", async (event) => {
if (event.action === "read") return
const messages = await ctx.session.context({ sessionID: event.sessionID })
const review = await ctx.generate.text({
model: { providerID: "anthropic", id: "claude-sonnet-4-6" },
prompt: buildSafetyPrompt({ messages, action: event.action, resources: event.resources }),
})
const decision = parseDecision(review.text)
event.effect = decision.effect
event.message = decision.reason
})
Permission hooks follow these rules:
- Hooks run for
allowandaskdecisions. - An explicit configured
denyis final and does not invoke the hook. - A hook may change
effecttoallow,ask, ordeny. messageappears in an escalated permission request or becomes the denial reason.
Reference
interface PermissionEvaluation {
readonly sessionID: string
readonly agent?: string
readonly action: string
readonly resources: readonly string[]
readonly metadata?: Record<string, unknown>
readonly source?: { type: "tool"; messageID: string; id: string }
effect: "allow" | "ask" | "deny"
message?: string
}
Shell
Modify shell commands, working directories, timeouts, executables, or environment variables before execution.
await ctx.shell.hook("create.before", (event) => {
event.timeout = Math.min(event.timeout, 60_000)
event.env.COMPANY_ENV = "development"
})
Reference
interface ShellHookContext {
hook(name: "create.before", callback: (event: ShellCreateBefore) => Promise<void> | void): Promise<Registration>
}
interface ShellCreateBefore {
command: string
cwd: string
timeout: number
shell: string
env: Record<string, string | undefined>
}
Tools
Inspect or replace tool input before execution.
await ctx.tool.hook("execute.before", (event) => {
if (event.tool === "read") console.log(event.input)
})
Inspect successful results or failures after execution.
await ctx.tool.hook("execute.after", (event) => {
if (event.status === "completed") event.result = { ...event.result, metadata: { observed: true } }
if (event.status === "error") console.error(event.error.message)
})
Reference
interface ToolHooks {
"execute.before": ToolExecuteBefore
"execute.after": ToolExecuteCompleted | ToolExecuteFailed
}
interface ToolHookContext {
hook<Name extends keyof ToolHooks>(
name: Name,
callback: (event: ToolHooks[Name]) => Promise<void> | void,
): Promise<Registration>
}
Publish
A package plugin uses the same default export as a local plugin. A minimal manifest is:
{
"name": "opencode-acme-plugin",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./rpc": "./src/rpc.ts"
},
"dependencies": {
"@opencode/plugin": "latest"
}
}The ./rpc export is optional; include it when publishing a shared
RPC contract for other plugins and clients to import
without loading your implementation.
Use versions compatible with the OpenCode release you target and test the installed package, not only a workspace-linked copy. Publish a compatible plugin update when you adopt a newer API contract.
Support V1
A plugin can support V1 and V2 from the same package entrypoint. Default export
one object with a V1 server() function and a V2 setup() function:
import { Plugin } from "@opencode/plugin"
export default {
...Plugin.define({
id: "example",
async setup(ctx) {
await ctx.tool.hook("execute.before", () => {
console.log("A tool is about to run")
})
},
}),
async server() {
return {
"tool.execute.before": async () => {
console.log("A tool is about to run")
},
}
},
}- V1 calls
server()and uses the returned hooks. - V2 reads the default export’s
idandsetup()(oreffect()for Effect plugins), ignoringserver(). - Keep each implementation on its own API; sharing an export does not translate V1 hooks into V2 hooks.
- Spread
Plugin.define(...)into the exported object so it type-checks the V2 definition separately fromserver().
The V1 object form is supported in OpenCode 1.18.29. Older V1 releases may
expect function exports instead; test the installed package with the oldest V1
release you intend to support and with V2.