opencode-yaml-hooks is an OpenCode plugin that loads hook definitions from hooks.yaml and runs command, tool, or bash actions on session and tool lifecycle events.
Use it to run tests after edits, lint changed files, block risky commands before they run, or trigger local automation without another LLM step.
Install from npm with Bun:
bun add opencode-yaml-hooksOr install directly from this repo:
bun add "https://github.com/KristjanPikhof/OpenCode-Hooks.git"Then register it in your opencode.json:
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-yaml-hooks"]
}OpenCode resolves the package by name, so the plugin entry stays opencode-yaml-hooks even when you install it from GitHub.
Create one of:
~/.config/opencode/hook/hooks.yaml<project>/.opencode/hook/hooks.yaml
Then add a minimal hook:
hooks:
- event: file.changed
conditions: [matchesCodeFiles]
actions:
- bash: "npm test"This runs npm test after a supported file mutation tool changes at least one tracked code file.
Hooks are merged from global and project locations.
| Platform | Global config | Project config |
|---|---|---|
| macOS / Linux | ~/.config/opencode/hook/hooks.yaml |
<project>/.opencode/hook/hooks.yaml |
| Windows | ~/.config/opencode/hook/hooks.yaml preferred, otherwise %APPDATA%/opencode/hook/hooks.yaml |
<project>/.opencode/hook/hooks.yaml |
Unless you need something more specific:
- prefer
file.changedfor file-oriented automation - leave
scopeunset unless you needmainorchild - leave
runInunset unless you need actions to execute in the root session - treat
tool.after.*andtool.after.<name>as advanced hooks for observability or non-file workflows
Explicit defaults in the current runtime:
scopedefaults toallrunIndefaults tocurrentconditionsare optional- bash
timeoutdefaults to60000milliseconds
hooks:
- event: <hook-event>
action: <stop> # optional, only for tool.before.* hooks
scope: <all|main|child> # optional, defaults to all
runIn: <current|main> # optional, defaults to current
async: <boolean> # optional, fire-and-forget execution
conditions: # optional
- matchesCodeFiles
- matchesAnyPath: src/**/*.ts
- matchesAllPaths:
- package.json
- apps/*/package.json
actions: # required, non-empty
- command: <string>
- command:
name: <string>
args: <string>
- tool:
name: <string>
args: <object>
- bash: <string>
- bash:
command: <string>
timeout: <positive integer milliseconds>Validation rules enforced by the runtime:
hooksmust exist and be an array- each hook must be an object with a supported
event scope, if present, must beall,main, orchildrunIn, if present, must becurrentormainaction, if present, must bestopand is only supported ontool.before.*andtool.before.<name>hooksasync, if present, must be a boolean; cannot betrueontool.beforeorsession.idleevents; async hooks must use onlybashactionsconditions, if present, must be an array of supported condition entriesactionsmust be a non-empty array- each action must define exactly one of
command,tool, orbash
| Event | When it fires |
|---|---|
session.created |
When OpenCode creates a session |
session.deleted |
When OpenCode deletes a session |
session.idle |
When a session becomes idle |
file.changed |
After a supported mutation tool reports file changes |
| Event | When it fires |
|---|---|
tool.before.* |
Before every tool execution |
tool.before.<name> |
Before a specific tool, such as tool.before.write |
tool.after.* |
Advanced: after every tool execution |
tool.after.<name> |
Advanced: after a specific tool, such as tool.after.edit |
Tool hook order for a tool named write:
tool.before.*tool.before.write- tool executes
file.changedif the tool changed tracked filestool.after.*tool.after.write
Use file.changed when your automation depends on changed files.
Why it is preferred:
- it only fires for supported mutation tools:
write,edit,multiedit,patch, andapply_patch - it includes
filesand structuredchangesmetadata - it avoids catch-all after-hook ambiguity
- it is the recommended path for linting, formatting, indexing, and atomic commit workflows
Keep using low-level tool hooks only when you need:
- observability for every tool call, including non-file tools
- tool-specific post-processing unrelated to changed files
- compatibility with workflows that truly depend on raw tool arguments instead of normalized file changes
All configured conditions must pass for a hook to run.
| Condition | Meaning |
|---|---|
matchesCodeFiles |
Run only when tracked modified files include at least one supported code extension |
matchesAnyPath |
Run only when at least one final changed file path matches one or more glob patterns |
matchesAllPaths |
Run only when every final changed file path matches at least one glob pattern |
matchesCodeFiles is extension-based. Extensionless files such as Dockerfile do not currently count as code changes.
matchesAnyPath and matchesAllPaths only work on file.changed and session.idle. Both accept either a single string or a string array, and both fail when there are no changed files to evaluate.
Examples:
hooks:
- event: file.changed
conditions:
- matchesAnyPath: src/**/*.ts
actions:
- bash: "npm run lint -- --fix"
- event: session.idle
scope: main
conditions:
- matchesAllPaths:
- package.json
- apps/*/package.json
actions:
- bash: "npm test"Invalid usage example:
hooks:
- event: tool.after.write
conditions:
- matchesAnyPath: src/**/*.ts # invalid: path conditions are file.changed/session.idle only
actions:
- bash: "echo nope"Runs an OpenCode command in the same session, unless runIn: main redirects it to the root session.
actions:
- command: simplify-changes
- command:
name: review-pr
args: "main feature"Prompts the session to use a tool with specific arguments.
actions:
- tool:
name: bash
args:
command: "echo done"Runs a bash command directly without another LLM step.
actions:
- bash: "npm run lint"
- bash:
command: "$OPENCODE_PROJECT_DIR/.opencode/hooks/init.sh"
timeout: 30000If timeout is omitted, bash actions use the runtime default of 60000 milliseconds.
OPENCODE_PROJECT_DIR remains the action cwd / project directory that triggered the hook.
Every bash action receives:
- inherited
process.env OPENCODE_PROJECT_DIRfor the action cwd / project directoryOPENCODE_SESSION_IDOPENCODE_GIT_COMMON_DIRwhen available- JSON over stdin
Example file.changed payload:
{
"session_id": "abc123",
"event": "file.changed",
"cwd": "/path/to/project",
"files": ["src/index.ts", "src/renamed.ts"],
"changes": [
{ "operation": "modify", "path": "src/index.ts" },
{ "operation": "rename", "fromPath": "src/old.ts", "toPath": "src/renamed.ts" }
],
"tool_name": "apply_patch",
"tool_args": {
"patchText": "*** Begin Patch\\n...\\n*** End Patch"
}
}Only tool.before.* and tool.before.<name> hooks can block execution.
- a bash action that exits with
2blocks the tool action: stopescalates a blocking pre-tool hook into a best-effortsession.abort(...)for the active sessiontool.after.*,tool.after.<name>,file.changed, and session hooks do not block execution- non-blocking failures are logged and later actions continue
Example:
hooks:
- event: tool.before.bash
action: stop
actions:
- bash: |
payload=$(cat)
cmd=$(printf '%s' "$payload" | jq -r '.tool_args.command // empty')
case "$cmd" in
"git push"|git\ push\ *)
echo "Blocked and stopping session: git push is not allowed." >&2
exit 2
;;
esac- hooks for the same event run in declaration order
- global hooks load before project hooks
- the runtime reloads discovered
hooks.yamlfiles at each hook entrypoint - invalid reloads are rejected and the last known good config stays active
session.idleclears tracked changes only after successful dispatch- if idle dispatch fails, tracked changes are preserved for retry
- reentrant
file.changedandtool.after.*dispatches are queued and replayed after the active dispatch finishes async: truehooks return immediately without blocking the tool pipeline; their actions run in the background as best-effort work- async actions for the same event and source session are serialized to prevent overlapping executions; note that serialization is per source session, not per target —
runIn: mainhooks from different child sessions are not serialized against each other
See examples/hooks.yaml for:
- main-session only examples
- child-to-main
runIn: mainrouting - recommended
file.changedautomation - advanced
tool.after.*observability - conservative atomic commit wiring
docs/hooks-v2-reference.mdfor the current public config shapedocs/comparison-with-claude-code-hooks.mdfor how this compares to Claude Code's hook system
- file tracking is limited to supported OpenCode mutation tools, not arbitrary filesystem changes
matchesCodeFilesis extension-based and ignores extensionless code-like files- tool hooks depend on actual emitted OpenCode tool names
- Windows discovery is supported, but bash actions still require a working shell runtime
async: trueis not allowed ontool.before.*orsession.idleevents; async hooks cannot block tool execution or idle dispatch- async hooks must use only
bashactions;commandandtoolactions have no timeout and can stall the queue - async hook failures are logged but not retried; async execution is best-effort and not guaranteed to complete if the host process exits
This package does not currently try to:
- define custom hook events beyond session, file, and tool lifecycle events
- provide config inheritance or override priority beyond global-then-project merging
- provide retries, scheduling, or concurrency controls per hook
- track arbitrary filesystem changes outside OpenCode mutation tools
- make command or tool actions blocking
npm install
npm run build
npm test