Skip to content

Repository files navigation

🪝 OpenCode Command Hooks 🪝

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.

Markdown Frontmatter Hooks

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: subagenthooks:
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.

Table of Contents

How It Works

  1. Runs automatically on the configured event
  2. Executes shell commands (sequentially, if you pass an array)
  3. Captures output (truncated to configured limit, default 30,000 characters)
  4. Optionally reports results via inject (to the session) and/or toast (to the UI).

Why?

When working with a fleet of subagents, automatic validation of the state of your codebase is really useful. By setting up quality gates (lint/typecheck/test/etc.) or other automation, you can catch and prevent 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.


JSON Config

{
"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}`",
},
],
}

Markdown Frontmatter Config

hooks:
after:
- run: ["npm run lint", "npm run typecheck", "npm test"]inject: "Validation Results (exit {exitCode}): \n`{stdout}`\n`{stderr}`"toast:
message: "Validation Complete"

Hook Configuration Options

OptionTypeDescription
runstring | string[]Command(s) to execute
injectstringMessage injected into the session
toastobjectToast notification configuration
overrideGlobalbooleanWhen true, suppresses global hooks matching the same event/phase+tool. Must be a JSON boolean (true/false), not a string.

Toast Configuration

toast:
title: "Build"# optionalmessage: "exit {exitCode}"variant: "info"# optional- one of: info, success, warning, errorduration: 5000# optional (milliseconds)

Inject String Template Variables

  • {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

Complete Example

---description: Engineer Agentmode: subagenthooks:
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} ```"---
<yoursubagentinstructions>

Automatic Context Injection

If inject is set, the command output is posted into the session, so your agents can react to failures.

Filter by Tool Arguments

You can set up tool hooks to only trigger on specific arguments via when.toolArgs.

{
"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",
},
}

Features

  • Tool hooks (before/after) and session hooks (start/idle) via simple JSON/YAML frontmatter config
    • Hooks are non-blocking: failures don’t crash the session/tool.
    • Commands run sequentially, even if earlier ones fail.
  • Inject bash output into context with inject and notify user with toast
    • inject/toast interpolate using the last command’s output if run is an array.
  • Match by tool name and (optionally) arguments
  • Optional session injection and toast notifications
  • Automatic output truncation (30,000 by default)

Installation

Add to your opencode.json:

{
"plugin": ["opencode-command-hooks"],
}

Configuration

Config Locations

The plugin loads hooks from two locations:

  1. User global: ~/.config/opencode/command-hooks.jsonc — hooks that apply to all projects
  2. Project: .opencode/command-hooks.jsonc — project-specific hooks (searches upward from cwd)

Both are merged by default. See Configuration Precedence for details.

JSON Config

{
"truncationLimit": 30000,
"ignoreGlobalConfig": false,
"tool": [
// Tool hooks
],
"session": [
// Session hooks
],
}

JSON Config Options

OptionTypeDescription
truncationLimitnumberMaximum characters to capture from command output. Defaults to 30,000 (matching OpenCode's bash tool). Must be a positive integer. Project config overrides global when both are set.
ignoreGlobalConfigbooleanWhen true, skip loading ~/.config/opencode/command-hooks.jsonc. Defaults to false. Must be a JSON boolean (true/false), not a string.
toolToolHook[]Array of tool execution hooks
sessionSessionHook[]Array of session lifecycle hooks

Markdown Frontmatter

Use hooks: in agent markdown for the simplified format:

---description: Engineer agentmode: subagenthooks:
before:
- run: "echo 'Starting engineering work...'"after:
- run: "npm run lint"inject: "Lint output:\n{stdout}\n{stderr}"---

Configuration Precedence

Hooks are loaded from two locations and merged:

  1. User global config: ~/.config/opencode/command-hooks.jsonc
  2. Project config: .opencode/command-hooks.jsonc (searches upward from cwd)

Merge behavior:

ScenarioResult
Different hook IDsBoth run (concatenation)
Same hook IDProject replaces global
overrideGlobal: true on hookSuppresses all global hooks for same event/phase+tool
ignoreGlobalConfig: true in projectSkips global config entirely
Both set truncationLimitProject 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

Examples

Automatically run typecheck, lint, and test (after task)

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,
},
},
],
}

Run Tests After Any task (subagent creation toolcall)

{
"tool": [
{
"id": "tests-after-task",
"when": { "phase": "after", "tool": "task" },
"run": ["npm test"],
"inject": "Tests (exit {exitCode})\n\n{stdout}\n{stderr}",
},
],
}

Enforce Linting After a Specific write

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}",
},
],
}

Toast Notifications for Build Status

{
"tool": [
{
"id": "build-toast",
"when": { "phase": "after", "tool": "write" },
"run": ["npm run build"],
"toast": {
"title": "Build",
"message": "exit {exitCode}",
"variant": "info",
"duration": 3000,
},
},
],
}

Session Lifecycle Hooks

{
"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": ["echo 'Session idle'"],
},
],
}

Template Placeholders

All inject/toast string templates support these placeholders:

PlaceholderDescriptionExample
{id}Hook IDlint-ts
{agent}Calling agent nameorchestrator
{tool}Tool namewrite
{cmd}Executed commandnpm run lint
{stdout}Command stdout (truncated)Linting complete
{stderr}Command stderr (truncated)Error: missing semicolon
{exitCode}Command exit code0 or 1

Why Use This Plugin?

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):

importtype{Plugin}from"@opencode-ai/plugin";exportconstMyHooks: Plugin=async({ $, client })=>{constargsCache=newMap();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;constargs=argsCache.get(input.callID);argsCache.delete(input.callID);// Filter by tool and subagent typeif(input.tool!=="task")return;if(!["engineer","debugger"].includes(args?.subagent_type))return;try{// Run commands sequentially, even if they failletlastResult={exitCode: 0,stdout: "",stderr: ""};for(constcmdof["npm run typecheck","npm run lint"]){try{constresult=await$`sh -c ${cmd}`.nothrow().quiet();conststdout=result.stdout?.toString()||"";conststderr=result.stderr?.toString()||"";// Truncate to 30k chars to match OpenCode's bash toollastResult={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 sessionconstmessage=`Validation (exit ${lastResult.exitCode})\n\n${lastResult.stdout}\n${lastResult.stderr}`;awaitclient.session.promptAsync({path: {id: input.sessionID},body: {parts: [{type: "text",text: message}],},});// Show toast notificationawaitclient.tui.showToast({body: {title: "Validation",message: `exit ${lastResult.exitCode}`,variant: "info",},});}catch(err){console.error("Hook failed:",err);}},};};

About

🪝 A clean way to use OpenCode's event hooks declaratively 🪝

Topics

Resources

Stars

61 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages