Skip to content

Gate moves with plugin hooks

Two things keep biting you on feedmill. First: the agent edits a parser, reports done, and you only find out at the next go vet 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/sync/state.go holds the cursor the cron sync server reads at 3am to know where each feed left off; an edit there doesn’t break a test, it silently re-downloads or skips a week of items in production. You’ve told the agent in AGENTS.md 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 came from Claude Code or Codex you’ll reach for a hooks block in a settings file - a declarative list of “on this event, run this command.” OpenCode doesn’t have one. Its 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 sync cursor. Four constraints, three homes - an AGENTS.md rule, a per-tool permission, a plugin hook - and a 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 after the line compacted out.” 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. The line compacts out at hour three, and 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 and what the loader treats as the primary path. The singular .opencode/plugin/ you’ll see in older gists and bug reports (e.g. #10574, “Plugin loader fails to resolve npm deps from .opencode/plugin/ subdirectory”) still loads too, but only as a backwards-compatibility fallback - the loader’s Bun.Glob scan matches both singular and plural forms, the same way it does for agents//agent/ and skills//skill/. Prefer the plural; treat the singular as legacy. Either way, the other path is the top-level plugin array in opencode.json, 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. That’s the whole payload: in this release 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 the instant it’s written; go vet runs right after and its output is available to the loop, so a formatting or vet problem surfaces in the same exchange that caused it instead of three turns later. 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.

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.

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/sync/state.go to be impossible, not discouraged. The hook for that 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/sync/state.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
const path = output.args.filePath ?? output.args.path
if ((input.tool === "write" || input.tool === "edit") && path?.includes(PROTECTED)) {
throw new Error(`${PROTECTED} is protected - sync cursor, 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. Throwing from inside the handler aborts the tool call before it touches disk. From the TUI it looks like this - the agent reaches for the file the way it always would, and the wall is just there:

> the cursor logic looks off - fix the resume offset in internal/sync/state.go
⏺ read internal/sync/state.go
| Edit internal/sync/state.go
error: internal/sync/state.go is protected - sync cursor, do not edit from the agent
I can't edit internal/sync/state.go - a plugin hook blocks writes to it.
The offset math looks wrong in resumeFrom(); want me to write the fix as
a patch you apply by hand, or move the logic into a file I can touch?

The model didn’t decide to respect the rule - it couldn’t violate it. That’s the difference between AGENTS.md and a hook. AGENTS.md is an instruction the model usually follows; tool.execute.before is a gate the model runs into. For “leave this file alone” you want the gate, because the cost of the one time the instruction slips is a corrupted production sync.

One caveat worth knowing before you trust this as a security boundary rather than a guardrail: historical reports #5894 and #11324 described gaps involving subagent tool hooks and task-target permissions. 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 is the same: treat the hook as a guardrail and use the per-agent permission model for anything a subagent could reach - the hook and a permission.edit deny, not the hook alone.

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.

You’ve now seen all three extension surfaces close on feedmill, and 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, wall off a path, expose a project-specific verb - that’s a plugin, and only a plugin.

That’s the chapter: reach, intelligence, and a gate that can’t be argued with, all wired into the running project rather than bolted on as demos. Next we leave the repo behind entirely - turning a feedmill debugging session into a link a teammate can read, then driving the same fix headless against its own sync server. Next: sharing & headless.