Skip to content

Course · OpenCode · Extending opencode

Gate moves with plugin hooks

Two things keep biting you on feedmill, the course’s illustrative Go feed aggregator. First: the agent edits a parser, reports done, and you only find out at review that it left the package un-gofmt’d - the lint should have run the moment the file changed, not whenever you remembered to ask. Second, and sharper: there’s a path you never want the agent to touch. internal/feed/store.go holds the schema of the SQLite reading store that every sync writes to; an edit there doesn’t break a test, it silently changes the table under the stored reading queue. You’ve told the agent in AGENTS.md - the standing-instructions file OpenCode loads into every session - to leave it alone, and mostly it does. But “mostly” is the wrong guarantee for the one file that quietly corrupts your reading queue.

Both wants are the same shape: something deterministic should happen on a lifecycle event, regardless of what the model decided in the moment. If you’re used to tools whose hooks are a declarative block in a settings file - a list of “on this event, run this command” - that’s the habit to drop here. OpenCode’s hook model is plugin-based: you write a JS/TS module that subscribes to events in code, and the same module can register custom tools. It’s more setup than a config line, and more power - the gate is real code with the full shell in hand, not a string template.

Before the plugin mechanics, feel why “I told it in AGENTS.md” is the wrong tier for the store schema. The widget below runs four constraints you might want to hold - a preference, a never, a condition on content, an every-time - through three homes: a rule (the model is informed), a permission (the harness forbids a named class of action), and a hook (your code enforces a condition on the real action, every time). In OpenCode terms those homes are an AGENTS.md line, the per-tool permission map, and a plugin like the one this page writes. Re-home each constraint, then move the clock, because “mostly” only shows its cost when nobody’s watching:

Four constraints you want to hold, three homes each: a rule (the model is told), a permission (the harness forbids a class of action), a hook (your code runs on the rail, every time). All four start in the rules file. Re-home them - then move the clock, because what holds while you watch isn’t what holds at 2am.

situation
  • the fragile modulea preference · low stakes

    The auth module is fragile - prefer minimal, surgical diffs there.

    held - you’re the gate

    “Minimal and surgical” is a judgment call, not a checkable condition. You want the model informed and weighing it - and your diff review is the backstop for the times it doesn’t.

  • the secrets walla never · high stakes

    The agent must never read secrets/.

    held - you’re the gate

    An instruction the model weighs - so "never" actually means "unless a debugging trail leads there and, this turn, outweighs the line." Walls that matter don’t get to be suggestions.

  • the tested-commit gatea condition on content · high stakes

    No commit that touches money code unless the money tests pass.

    held - you’re the gate

    It works all morning, which is what makes it dangerous. By hour three it is one line among hundreds and loses to an eager streak - the untested commit lands at 2am with nobody to catch it.

  • the every-time formatteran every-time · low stakes

    Every file the agent writes gets formatted - every time, not most times.

    held - you’re the gate

    The model formats when it remembers, and “every time” done by memory is “most times.” No single miss hurts; the drift and the diff noise pile up.

holding4 of 4shaky0silently broken0

All four look fine - and that’s the trap state. While you watch, an instruction is indistinguishable from a guarantee, because you’re the enforcement. The file didn’t hold the line; you did. Move the clock.

Each tool wears its own names - permissions may be an approvals-and-sandbox dial, a hook a plugin on lifecycle events - but the ladder is the same: guarantee strength is set by what sits in the loop - the model’s memory, a harness wall, or your code.

A plugin is a JS/TS module exporting a function. OpenCode calls it once at startup with a context object and keeps whatever it returns. The context is the useful part:

.opencode/plugins/feedmill-guard.ts
import type { Plugin } from "@opencode-ai/plugin"
export const FeedmillGuard: Plugin = async ({ project, client, $, directory, worktree }) => {
// project - the current project
// client - the opencode SDK client (talk back to the session)
// $ - Bun's shell, so you can run commands from inside a hook
// directory - the working directory
// worktree - the git worktree path
// (the context is richer than this - it also carries serverUrl and an
// experimental_workspace field - but these five are what you'll reach for)
return {
// hooks and tools go here
}
}

The $ is the one to notice - it’s Bun’s shell API, injected so your hook can shell out without importing anything. That’s how a hook runs your linter: await $\gofmt -w …`` from inside the handler. Plugins are live code, closer to a VS Code extension than to a static config block - which is exactly why they can do things a declarative list can’t.

A quick note on where this file lives, because the docs and the community drift on it: the canonical directory is .opencode/plugins/ (plural) for project plugins and ~/.config/opencode/plugins/ (plural) for global ones - that’s what the official plugins page documents (as of October 2026) and what the loader treats as the primary path. The singular .opencode/plugin/ you’ll see in older gists and bug reports still loads, but only as a backwards-compatibility fallback. Prefer the plural; treat the singular as legacy. Either way, the other path is the top-level plugin array in opencode.json, OpenCode’s main config file, which takes npm package names and installs them with Bun at startup (cached under ~/.cache/opencode/node_modules/):

opencode.json
{
"plugin": ["opencode-helicone-session", "@my-org/feedmill-guard"]
}

Local file for the thing you’re writing today; the array for something you want to share or pull from npm.

One thing to keep straight as you wire these up: OpenCode distinguishes event-bus event names from subscribable hook keys, and they don’t line up one-to-one. file.edited is an event type delivered through a generic event handler, not a top-level hook key you subscribe to directly. The keys you actually return are things like tool.execute.before, tool.execute.after, the event handler (where file.edited and friends arrive), the permission hook (permission.ask), the command hook (command.execute.before), config, shell.env, and a set of experimental.* transforms - plus session and server activity, which arrives as event types through that same event handler rather than as their own keys. The next two sections show both shapes.

The first want is the easy one. file.edited arrives as an event type through the generic event handler - match on event.type, then run gofmt against whatever just changed:

export const FeedmillGuard: Plugin = async ({ $, directory }) => {
return {
event: async ({ event }) => {
if (event.type !== "file.edited") return
const file = event.properties.file
// fire only for Go files under the repo
if (!file.endsWith(".go")) return
await $`gofmt -w ${file}`
await $`go vet ./...`.cwd(directory).nothrow()
},
}
}

The path lives at event.properties.file - not at a top-level event.file - and it is an absolute path. That’s the whole payload: as of October 2026 the file.edited event carries properties: { file: string } and nothing else, so don’t reach for edit ranges or a diff off this event - if you need the changed content, read the file yourself.

Now the lint isn’t a thing you remember - it’s a thing that happens. Edit a parser from any agent, on any turn, and the file is gofmt’d right after it’s written. The hook doesn’t care which agent edited the file or whether the model thought to format - it fires on the event, which is the whole point. The file tools (edit, write, apply_patch) emit file.edited; a file changed by a bash command does not.

One limit: go vet runs, but the agent never sees its output. OpenCode calls the event handler without waiting for it and ignores what it returns (plugin/index.ts), and .nothrow() discards the exit code. To put vet findings in front of the agent, run vet in tool.execute.after instead and append its output to output.output, the tool result the model reads next. And if formatting is all you need, OpenCode’s built-in formatters (gofmt among them) do it without a plugin; they stay off until you set "formatter": true (formatters documentation).

This is genuinely different from asking the model to “remember to run the linter.” That instruction lives in the prompt, competes with everything else in context, and degrades over a long session. The hook lives in code and fires on a system event. One is a hope; the other is a guarantee, for the events the hook matches.

Block the protected path with tool.execute.before

Section titled “Block the protected path with tool.execute.before”

The second want is the one that matters. You want a write to internal/feed/store.go to be impossible, not discouraged. A permission path rule, shown in the next section, is the simpler wall; the plugin’s version is tool.execute.before - it runs before any tool executes, sees the call, and can block it by throwing:

export const FeedmillGuard: Plugin = async ({ $ }) => {
const PROTECTED = "internal/feed/store.go"
return {
"tool.execute.before": async (input, output) => {
// input: { tool, sessionID, callID } - note: no args here
// output: { args } - mutable, so a hook can also rewrite a call
// apply_patch carries its paths inside patchText, not in filePath
const target = input.tool === "apply_patch" ? output.args.patchText : output.args.filePath
if (["edit", "write", "apply_patch"].includes(input.tool) && target?.includes(PROTECTED)) {
throw new Error(`${PROTECTED} is protected - store schema, do not edit from the agent`)
}
},
}
}

The handler receives input (the tool name, the sessionID, and the callID - but not the args) and output (a mutable args, so a hook can rewrite a call instead of just refusing it). The args you inspect live on output.args, never on input. The matcher names three tools because OpenCode edits files through more than one: some models (currently most GPT models) get apply_patch in place of edit and write, and apply_patch puts its file paths in marker lines inside output.args.patchText (as of October 2026; tools documentation). Throwing from inside the handler aborts the tool call before it touches disk. From the TUI (terminal user interface) it looks like this - an illustrative exchange; the agent reaches for the file the way it always would, and the wall is just there:

> the items table needs a published column - add it in internal/feed/store.go
⏺ read internal/feed/store.go
| Edit internal/feed/store.go
error: internal/feed/store.go is protected - store schema, do not edit from the agent
I can't edit internal/feed/store.go - a plugin hook blocks writes to it.
The schema is the CREATE TABLE in OpenStore(); want me to write the
change as a patch you apply by hand, with a migration for feedmill.db?

The model didn’t decide to respect the rule: through the three file tools the hook matches, the edit could not happen. The matcher does not cover bash, so a command such as sed -i could still write the file. That is the difference between an instruction and a gate, and the limit of a gate, and both deserve a name of their own.

The block above completes a distinction that runs through everything OpenCode offers for holding a line: guidance versus enforcement. Three layers can carry a constraint, and each enforces a different amount.

AGENTS.md is guidance. It steers behavior; it does not bind it. The model reads the line, weighs it against everything else in the session, and usually complies - and “usually” is the whole problem: an instruction that lives in the context window competes with everything else that lives there, and on a long session an eager streak can outweigh a line it read hours ago. What guidance can do that nothing else can is carry judgment: “be surgical in the auth module” is a preference, not a checkable condition, and a preference is only sayable as an instruction.

The permission map is enforcement by named class - allow, ask, or deny per tool per agent, evaluated by OpenCode itself rather than by the model (the per-tool reference page carries the full grammar). A deny holds at 2am exactly as it holds while you watch: no memory, no mood, no exception argued for mid-turn. A permission sees the tool’s name and one input pattern - the file path for edit and read, the command string for bash - but not the content of the work. For store.go that is enough. The edit key covers edit, write, and apply_patch, so one path rule walls off the file for all three:

opencode.json
{
"permission": {
"edit": {
"*": "ask",
"internal/feed/store.go": "deny"
}
}
}

That rule is the primary wall; the pattern is the path relative to the repo root, and the last matching rule wins, so the specific path goes after the catch-all. Like the hook, it does not cover bash: a shell command that writes the file is a bash call, which the edit key never sees, so gate bash too if that path matters. A bash rule matches the command text, not the files the command touches, so for that agent the reliable settings are ask or deny on bash as a whole; a pattern or a hook that looks for store.go in the command misses a command that builds the path another way. Only an OS boundary, such as a container, makes the write impossible. The hook above is defense in depth, and the hook’s real job starts where a path is not enough. “No commit that touches money code unless the money tests pass” has no tool to deny: committing is allowed, and should be; the condition lives inside that specific commit’s content, which a named class never inspects. The agent commits through bash, so a hook for this gate sees git commit only as text in output.args.command and inherits the text-matching limit above: it narrows the gap but does not close it. And ask is only as strong as the person approving it - a checkpoint you can wave through is not a wall.

A plugin hook enforces a condition on the real action at the moment it happens. tool.execute.before runs with the actual arguments of the actual call and can block it or rewrite it - including a call the permission map allowed. Editing may be allowed for this agent; this edit, failing this check on its content, dies before it touches disk. That is a different axis from the map’s: not “who may use this tool” but “does this specific invocation meet a condition only code can check.”

Each layer is honest about its limits, so stack them instead of treating them as interchangeable. A hook is only as good as its matcher’s coverage: when the constraint is a plain named class or a path, you are writing code to rebuild what a one-line deny already gives you, and a matcher that misses one file tool leaves a gap the deny would not. And a historical pair of reports says hooks are not yet a boundary to lean on alone - #5894 described plugin hooks failing to intercept subagent tool calls, and #11324 described the task tool ignoring per-target deny rules and allowing self-dispatch recursion. Both issues are now closed, but their issue bodies are reports rather than release notes, so do not infer exact fix versions from their status - verify the behavior on the OpenCode version you deploy. The defense-in-depth move stands: the hook and the permission map, not the hook alone.

None of the three layers is the operating-system boundary, either. A container bounds what runs at all; hooks gate what each move does once it is running - a different scope, covered where it belongs.

Matching each constraint to the layer that can deliver its guarantee - informed, walled, or checked in the moment - is the control principle at the granularity where it bites: what holds the line is where the constraint lives, not how firmly it is worded.

The same plugin module can also register custom tools - new capabilities scoped to your project, gated by the same permission system as everything else. That’s a big enough topic to get its own lesson: register a custom tool.

Of the three extension surfaces this chapter covers, the plugin is the one that does what neither MCP nor LSP can: it acts on OpenCode’s own lifecycle. MCP reaches out to systems beyond the repo; LSP reads in from your code’s type system; a plugin sits inside the loop itself, firing on edits and tool calls and registering tools the loop can use. When the need is “something must happen every time the agent does X, no matter what it decided” - lint on edit, check a call’s content before it runs, expose a project-specific verb - that’s a plugin. And because OpenCode itself loads the plugin, the gate travels with the loop wherever it runs, including the headless runs the next chapter wires up.

The same module has a second job: handing the agent a project-specific verb as a tool. Next: register a custom tool.