Use simple configs to declaratively define shell command hooks on tool/subagent invocations. With a single line of config, you can inject a hook's output directly into context for your agent to read.
Define hooks in just a couple lines of markdown frontmatter. Putting them here is also really nice because you can see your entire agent's config in one place.
---
description: Analyzes the codebase and implements code changes.
mode: subagent
hooks:
after:
- run: "npm test"
inject: "Test Output:\n{stdout}\n{stderr}"
---This plugin was not built by the OpenCode team nor is it affiliated with them.
- Runs automatically on the configured event
- Executes shell commands (sequentially, if you pass an array)
- Captures output, then reports it up to the configured limit (default 30,000 characters)
- Optionally reports results via
inject(to the session) and/ortoast(to the UI).
When working with a fleet of subagents, automatic validation of the state of your codebase is really useful. By setting up lint/typecheck/test checks or other automation, you can surface errors quickly and reliably.
Doing this by asking your orchestrator agent to use the bash tool (or call a validator subagent) is non-deterministic and can cost a lot of tokens over time. You could always write your own custom plugin to achieve this automatic validation behavior, but I found myself writing the same boilerplate, error handling, output capture, and session injection logic over and over again.
Though this plugin is mostly a wrapper around accessing hooks that OpenCode already exposes, it provides basic plumbing that reduces overhead, giving you a simple, opinionated system for integrating command hooks into your OpenCode workflow. I also just like having hooks/config for my agents all colocated in one place (markdown files) and thought that maybe somebody else would like this too.
hooks:
after:
- run: ["npm run lint", "npm run typecheck", "npm test"]
inject: "Validation Results (exit {exitCode}): \n`{stdout}`\n`{stderr}`"
toast:
message: "Validation Complete"| Option | Type | Description |
|---|---|---|
run |
string | string[] |
Command(s) to execute |
inject |
string |
Message injected into the session |
toast |
object |
Toast notification configuration |
when.toolArgs |
Record<string, string | string[] | matcher> |
Exact argument filters, or { glob: "..." } / { regex: "..." } matchers |
overrideGlobal |
boolean |
When true, suppresses global hooks matching the same event/phase+tool. Must be a JSON boolean (true/false), not a string. |
toast:
title: "Build" # optional
message: "exit {exitCode}"
variant: "info" # optional- one of: info, success, warning, error
duration: 5000 # optional (milliseconds){id}- Hook ID{agent}- Agent name (if available){tool}- Tool name (tool hooks only){cmd}- Executed command{stdout}- Command stdout (truncated){stderr}- Command stderr (truncated){exitCode}- Command exit code{args.<key>}- Direct tool argument value (own properties only; strings, numbers, and booleans as text; arrays and objects as JSON)
---
description: Engineer Agent
mode: subagent
hooks:
before:
- run: "echo 'Engineer starting...'"
toast:
message: "Engineer starting"
variant: "info"
after:
- run: ["npm run typecheck", "npm run lint"]
inject: "Typecheck + lint (exit {exitCode}) ``` {stdout} ```"
---
<your subagent instructions>If inject is set, the command output is posted into the session, so your agents can react to failures.
You can set up tool hooks to only trigger on specific arguments via when.toolArgs.
String and string-array values retain exact matching (and "*" matches any
value). Matcher objects support full-string glob matching and JavaScript regex
search matching. Every configured argument must match, and pattern matchers
only match arguments whose runtime value is a string. Leading ! is not an
implicit negation and leading # is not treated as a comment.
{
"id": "playwright-access-localhost",
"when": {
"phase": "after",
"tool": "playwright_browser_navigate",
"toolArgs": { "url": "http://localhost:3000]" },
},
"run": ["osascript -e 'display notification \"Agent triggered playwright\"'"],
"toast": {
"message": "Agent used the playwright {tool} tool",
},
}Matchers work with arbitrary argument names and custom tools:
when:
phase: before
tool: write_file
toolArgs:
filePath:
glob: "**/*.{ts,js}"
content:
regex: "TODO"Argument values are also available in inject and toast templates through
direct placeholders such as {args.filePath}. Strings, numbers, and booleans
are rendered as text; arrays and objects are rendered as JSON. Missing and
null values render as empty strings. Argument placeholders are not expanded
inside run commands. Instead, every tool-hook command receives the complete
argument object in a private temporary JSON file. Its path is available in the
OPENCODE_HOOK_ARGS_FILE environment variable; read it from the command, for
example with cat "$OPENCODE_HOOK_ARGS_FILE". Tool hooks without arguments receive
{}. The file is unique to the tool-hook execution and removed after all of its
commands finish. Supplying an argument that looks like shell syntax does not
execute it or interpolate it into the command source, and the complete payload
is not placed in the environment.
- Tool hooks (
before/after) and session hooks (start/idle) via JSON/YAML config- Hooks wait for commands; failures do not block tool execution.
- Commands run sequentially, even if earlier ones fail.
- Inject bash output into context with
injectand notify user withtoastinject/toastinterpolate using the last commandβs output ifrunis an array.
- Match by tool name and (optionally) arguments
- Optional session injection and toast notifications
- Automatic output truncation (30,000 by default)
Add to your opencode.json:
{
"plugin": ["opencode-command-hooks"],
}The plugin loads hooks from two locations:
- User global:
~/.config/opencode/command-hooks.jsoncβ hooks that apply to all projects - Project:
.opencode/command-hooks.jsoncβ project-specific hooks (searches upward from the project directory supplied by OpenCode)
Both are merged by default. See Configuration Precedence for details. Relative hook commands execute from that same OpenCode project directory.
{
"truncationLimit": 30000,
"ignoreGlobalConfig": false,
"tool": [
// Tool hooks
],
"session": [
// Session hooks
],
}| Option | Type | Description |
|---|---|---|
truncationLimit |
number |
Maximum characters reported per stdout/stderr after command completion. Defaults to 30,000 (matching OpenCode's bash tool). Must be a positive integer. Project config overrides global when both are set. |
ignoreGlobalConfig |
boolean |
When true, skip loading ~/.config/opencode/command-hooks.jsonc. Defaults to false. Must be a JSON boolean (true/false), not a string. |
tool |
ToolHook[] |
Array of tool execution hooks |
session |
SessionHook[] |
Array of session lifecycle hooks |
Use hooks: in agent markdown for the simplified format:
---
description: Engineer agent
mode: subagent
hooks:
before:
- run: "echo 'Starting engineering work...'"
after:
- run: "npm run lint"
inject: "Lint output:\n{stdout}\n{stderr}"
---Hooks are loaded from two locations and merged:
- User global config:
~/.config/opencode/command-hooks.jsonc - Project config:
.opencode/command-hooks.jsonc(searches upward from the project directory supplied by OpenCode)
Merge behavior:
| Scenario | Result |
|---|---|
| Different hook IDs | Both run (concatenation) |
| Same hook ID | Project replaces global |
overrideGlobal: true on hook |
Suppresses all global hooks for same event/phase+tool |
ignoreGlobalConfig: true in project |
Skips global config entirely |
Both set truncationLimit |
Project value wins |
Example: Override all global hooks for an event
{
"session": [
{
"id": "my-session-idle",
"when": { "event": "session.idle" },
"run": "echo only this runs",
"overrideGlobal": true
}
]
}Example: Ignore global config entirely
{
"ignoreGlobalConfig": true,
"tool": [
// Only these hooks will run
]
}Additional precedence rules:
- Markdown hooks are converted to normal hooks with auto-generated IDs
- If a markdown hook and a config hook share the same
id, the markdown hook wins - Duplicate IDs within the same source are errors
- Tool override matching uses canonical keys, so
"bash"and["bash"]are treated as equivalent - Config files are schema-validated; invalid value types (for example
"false"for a boolean field) make that source invalid
Run validation after certain subagents complete, inject results back into the session, and show a small toast.
{
"tool": [
{
"id": "validate-after-task",
"when": {
"phase": "after",
"tool": "task",
"toolArgs": { "subagent_type": ["engineer", "debugger"] },
},
"run": ["npm run typecheck", "npm run lint", "npm test"],
"inject": "Validation (exit {exitCode})\n\n{stdout}\n{stderr}",
"toast": {
"title": "Validation",
"message": "exit {exitCode}",
"variant": "info",
"duration": 5000,
},
},
],
}{
"tool": [
{
"id": "tests-after-task",
"when": { "phase": "after", "tool": "task" },
"run": ["npm test"],
"inject": "Tests (exit {exitCode})\n\n{stdout}\n{stderr}",
},
],
}Tool-arg matching is exact. This example runs only when the tool arg path equals src/index.ts.
{
"tool": [
{
"id": "lint-src-index",
"when": {
"phase": "after",
"tool": "write",
"toolArgs": { "path": "src/index.ts" },
},
"run": ["npm run lint"],
"inject": "Lint (exit {exitCode})\n\n{stdout}\n{stderr}",
},
],
}{
"tool": [
{
"id": "build-toast",
"when": { "phase": "after", "tool": "write" },
"run": ["npm run build"],
"toast": {
"title": "Build",
"message": "exit {exitCode}",
"variant": "info",
"duration": 3000,
},
},
],
}session.start aliases session.created; session.end and slash-command hooks are not supported.
{
"session": [
{
"id": "session-start",
"when": { "event": "session.start" },
"run": ["echo 'New session started'"],
"toast": { "title": "Session", "message": "started", "variant": "info" },
},
{
"id": "session-idle",
"when": { "event": "session.idle" },
"run": ["notify-user.sh 'Waiting for input'"],
},
],
}session.idle hooks run only for root sessions by default, preventing a completed
subagent's child session from producing a premature idle notification. Set
rootSessionOnly to false to run the hook for root and child sessions:
{
"session": [
{
"id": "every-session-idle",
"when": {
"event": "session.idle",
"rootSessionOnly": false,
},
"run": ["echo 'Any session idle'"],
},
],
}For session.created and its session.start alias, rootSessionOnly defaults to
false and can be set to true explicitly. Root-only idle filtering identifies
child sessions through OpenCode's parentID; it does not guarantee that no
background work remains or that OpenCode is specifically waiting for user input.
If session lookup fails, hooks run without root filtering rather than being
silently dropped.
All inject/toast string templates support these placeholders:
| Placeholder | Description | Example |
|---|---|---|
{id} |
Hook ID | lint-ts |
{agent} |
Calling agent name | orchestrator |
{tool} |
Tool name | write |
{cmd} |
Executed command | npm run lint |
{stdout} |
Command stdout (truncated) | Linting complete |
{stderr} |
Command stderr (truncated) | Error: missing semicolon |
{exitCode} |
Command exit code | 0 or 1 |
It lets you easily set up bash hooks with ~3-5 lines of YAML which are cleanly colocated with your subagent configuration. Conversely, rolling your own looks something like this (for each project and set of hooks you want to set up):
import type { Plugin } from "@opencode-ai/plugin";
export const MyHooks: Plugin = async ({ $, client }) => {
const argsCache = new Map();
return {
"tool.execute.before": async (input, output) => {
if (input.tool === "task") {
argsCache.set(input.callID, output.args);
}
},
"tool.execute.after": async (input, output) => {
if (!output && input.tool === "task") return;
const args = argsCache.get(input.callID);
argsCache.delete(input.callID);
// Filter by tool and subagent type
if (input.tool !== "task") return;
if (!["engineer", "debugger"].includes(args?.subagent_type)) return;
try {
// Run commands sequentially, even if they fail
let lastResult = { exitCode: 0, stdout: "", stderr: "" };
for (const cmd of ["npm run typecheck", "npm run lint"]) {
try {
const result = await $`sh -c ${cmd}`.nothrow().quiet();
const stdout = result.stdout?.toString() || "";
const stderr = result.stderr?.toString() || "";
// After capture, report up to 30k chars to match OpenCode's bash tool
lastResult = {
exitCode: result.exitCode ?? 0,
stdout:
stdout.length > 30000
? stdout.slice(0, 30000) +
"\n[Output truncated: exceeded 30000 character limit]"
: stdout,
stderr:
stderr.length > 30000
? stderr.slice(0, 30000) +
"\n[Output truncated: exceeded 30000 character limit]"
: stderr,
};
} catch (err) {
lastResult = { exitCode: 1, stdout: "", stderr: String(err) };
}
}
// Inject results into session
const message = `Validation (exit ${lastResult.exitCode})\n\n${lastResult.stdout}\n${lastResult.stderr}`;
await client.session.promptAsync({
path: { id: input.sessionID },
body: {
parts: [{ type: "text", text: message }],
},
});
// Show toast notification
await client.tui.showToast({
body: {
title: "Validation",
message: `exit ${lastResult.exitCode}`,
variant: "info",
},
});
} catch (err) {
console.error("Hook failed:", err);
}
},
};
};
{ "tool": [ { "id": "validate-engineer", "when": { "phase": "after", "tool": "task", "toolArgs": { "subagent_type": "engineer" }, }, "run": ["npm run lint", "npm run typecheck", "npm test"], "inject": "Validation Results (exit {exitCode}): \n`{stdout}`\n`{stderr}`", }, ], }