Use hooks.yaml to run automation on OpenCode session and tool lifecycle events.
If you only need one rule, start with file.changed. It is the cleanest hook for file-oriented workflows like linting, formatting, test selection, indexing, and atomic commits.
The runtime discovers hooks in this order:
~/.config/opencode/hook/hooks.yaml%APPDATA%/opencode/hook/hooks.yamlon Windows, but only when the preferred global file does not exist<project>/.opencode/hook/hooks.yaml
Global hooks load first. Project hooks load second.
hooks:
- id: <optional-hook-id>
event: <hook-event>
action: <stop>
scope: <all|main|child>
runIn: <current|main>
async: <boolean>
conditions:
- matchesCodeFiles
- matchesAnyPath: src/**/*.ts
- matchesAllPaths:
- package.json
- apps/*/package.json
actions:
- command: <string>Rules:
hooksis requiredhooksmust be an array- each item in
hooksmust be an object - normal hooks need a non-empty
actionsarray - each action must define exactly one of
command,tool, orbash action, when present, must bestopand is only valid ontool.before.*andtool.before.<name>hooksidis optional, but you need it if a later file should override or disable the hook- override entries also live inside
hooks
Optional for normal hooks.
Use id when you want a later config file to replace or disable a hook from an earlier file.
Rules:
- must be a non-empty string when present
- must be unique within the same
hooks.yamlfile - hooks without an
idcannot be targeted by overrides
Required for normal hooks and replacement overrides.
Supported values:
session.createdsession.deletedsession.idlefile.changedtool.before.*tool.before.<name>tool.after.*tool.after.<name>
Optional. Default: all.
scope controls which session can trigger the hook.
| Value | Meaning |
|---|---|
all |
Main and child sessions can trigger the hook |
main |
Only the root session can trigger the hook |
child |
Only child sessions can trigger the hook |
Optional.
The only supported value is stop.
Use action: stop on a blocking pre-tool hook when you want the runtime to make a best-effort attempt to abort the active session after the hook blocks execution.
Rules:
- only supported on
tool.before.*andtool.before.<name> - only meaningful when a
bashaction exits with code2 - ignored for non-blocking hooks because only pre-tool bash hooks can block
Optional. Default: current.
runIn controls where command and tool actions execute.
| Value | Meaning |
|---|---|
current |
Run the action in the session that triggered the hook |
main |
Run the action in the root session for that session tree |
Notes:
runInaffectscommandandtoolactions onlybashactions run in the plugin runtime process, not in another OpenCode session
Optional. Default: synchronous.
When async: true, the hook returns immediately and its actions run in the background.
Rules:
- must be a boolean when present
- cannot be
trueontool.before.*ortool.before.<name>hooks - cannot be
trueonsession.idle - async hooks must use only
bashactions - actions inside one async hook still run sequentially
- async work is serialized per event and source session, so rapid-fire triggers queue up instead of overlapping
- async failures are logged, not thrown
- async execution is best-effort, so work can be lost if the host process exits early
Optional.
Supported values:
matchesCodeFilesmatchesAnyPath: <string|string[]>matchesAllPaths: <string|string[]>
All configured conditions must pass.
Rules:
matchesCodeFileschecks whether at least one tracked changed file has a supported code extensionmatchesAnyPathpasses when at least one final changed file path matches at least one supplied glob patternmatchesAllPathspasses when every final changed file path matches at least one supplied glob patternmatchesAnyPathandmatchesAllPathsonly work onfile.changedandsession.idle- path conditions accept either a non-empty string or a non-empty string array
- empty strings, empty arrays, non-string entries, and unknown condition keys are rejected
- path conditions fail when there are no changed files to evaluate
Example:
hooks:
- id: lint-src-on-change
event: file.changed
conditions:
- matchesAnyPath: src/**/*.ts
actions:
- bash: "npm run lint -- --fix"
- id: verify-package-edits-when-idle
event: session.idle
scope: main
conditions:
- matchesAllPaths:
- package.json
- apps/*/package.json
actions:
- bash: "npm test"Invalid example:
hooks:
- event: tool.after.write
conditions:
- matchesAnyPath: src/**/*.ts
actions:
- bash: "echo invalid"The example above is rejected because path conditions are only supported on file.changed and session.idle.
Required for normal hooks and replacement overrides.
Rules:
- must be an array
- must be non-empty
- each action must define exactly one of
command,tool, orbash
Optional. Only use this on override entries.
override must be a non-empty string containing the target hook id.
Supported modes:
- replacement override:
override: <target-id>plus a full replacement hook - disable override:
override: <target-id>plusdisable: true
Replacement overrides must still define a valid hook, including event and a non-empty actions array.
Optional. Only meaningful together with override.
disable: trueremoves the targeted earlier hook- omitted or
falsemeans the override entry is treated as a replacement hook
The loader rejects invalid entries and keeps the last valid config state active.
Common validation rules:
- invalid or unreadable YAML is rejected
- missing
hooksis rejected - non-array
hooksis rejected - unsupported
event,scope,runIn,action, or condition values are rejected - invalid action shapes are rejected
- duplicate
idvalues inside one file are rejected - an override targeting an unknown id is rejected
String form:
actions:
- command: simplify-changesObject form:
actions:
- command:
name: review-pr
args: "main feature"Behavior:
- runs an OpenCode command
- runs in the current session by default
- uses the root session when
runIn: main - failures are logged and later actions still run
actions:
- tool:
name: bash
args:
command: "echo done"Behavior:
- prompts the target session to use the named tool with the provided args
- runs in the current session by default
- uses the root session when
runIn: main - failures are logged and later actions still run
String form:
actions:
- bash: "npm run lint"Object form:
actions:
- bash:
command: "npm run lint -- --fix"
timeout: 30000Behavior:
- runs directly, without another LLM step
- receives JSON context on stdin
- inherits the current process environment plus OpenCode-specific variables
- uses a default timeout of
60000milliseconds whentimeoutis omitted
Fires when OpenCode creates a session.
Good uses:
- bootstrap commands
- logging session startup
- root-session setup with
scope: main
Example:
hooks:
- id: main-session-started
event: session.created
scope: main
actions:
- bash: 'echo "main session started: $OPENCODE_SESSION_ID"'Fires when OpenCode deletes a session.
Good uses:
- cleanup notifications
- end-of-session logging
Fires when a session becomes idle.
Behavior:
- receives the accumulated tracked file changes for the current session
- clears tracked changes only after successful dispatch
- preserves tracked changes if dispatch fails
Good uses:
- batch checks after a burst of edits
- deferred follow-up work
Do not use async: true here. Idle dispatch needs to finish before tracked changes are consumed.
Fires after a supported mutation tool reports file changes.
This is the recommended public API for file-oriented automation.
Supported mutation tools:
writeeditmultieditpatchapply_patch
Good uses:
- linting and formatting
- test selection
- indexing
- atomic commit workflows
Example:
hooks:
- id: lint-on-change
event: file.changed
conditions:
- matchesCodeFiles
- matchesAnyPath: src/**/*.ts
actions:
- bash:
command: "npm run lint -- --fix"
timeout: 30000Fires before every tool execution.
Good uses:
- policy checks
- auditing
- blocking invalid operations with a bash exit code of
2
Fires before one specific tool.
Use this when you need a targeted policy check.
Example:
hooks:
- id: block-sensitive-writes
event: tool.before.write
actions:
- bash: |
file=$(cat | jq -r '.tool_args.filePath // .tool_args.file_path // .tool_args.path')
if echo "$file" | grep -qE '\.(env|pem|key)$'; then
echo "Cannot modify sensitive files: $file" >&2
exit 2
fiFires after every tool execution.
This is an advanced hook. Prefer file.changed when your workflow depends on changed files.
Good uses:
- observability
- non-file tool auditing
Fires after one specific tool.
This is also advanced. Use it for tool-specific post-processing that does not map cleanly to file.changed.
For a tool named write, hooks run in this order:
tool.before.*tool.before.write- tool executes
file.changed, if tracked changes were detectedtool.after.*tool.after.write
Overrides are resolved while config files are loaded in discovery order.
What that means in practice:
- earlier files load first
- overrides in a later file can target hooks that were already loaded
- the runtime resolves overrides before it appends normal hooks from the current file
- a replacement override swaps the earlier hook in place
- a disable override removes the earlier hook entirely
- targeting an unknown id produces an
override_target_not_foundvalidation error - same-file overrides do not work
- project hooks can override global hooks
- global hooks cannot override project hooks
Replacement example:
Global file:
hooks:
- id: format-on-change
event: file.changed
conditions: [matchesCodeFiles]
actions:
- bash: "npm run lint -- --fix"Project file:
hooks:
- override: format-on-change
event: file.changed
scope: main
conditions: [matchesCodeFiles]
actions:
- bash:
command: "pnpm lint --fix"
timeout: 30000Disable example:
hooks:
- override: format-on-change
disable: trueEvery bash action receives JSON on stdin.
Common fields:
{
"session_id": "abc123",
"event": "session.idle",
"cwd": "/path/to/project"
}Possible additional fields:
fileschangestool_nametool_args
{
"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"
}
}Change operations currently emitted:
createmodifydeleterename
{
"session_id": "abc123",
"event": "tool.before.write",
"cwd": "/path/to/project",
"tool_name": "write",
"tool_args": {
"filePath": "src/index.ts"
}
}bash actions inherit the current process environment and also receive:
OPENCODE_PROJECT_DIROPENCODE_SESSION_IDOPENCODE_GIT_COMMON_DIRwhen available
Only bash actions on tool.before.* and tool.before.<name> can block execution.
| Result | Meaning |
|---|---|
exit code 0 |
success |
exit code 2 |
blocking failure for pre-tool bash hooks |
| any other non-zero exit code | logged, but non-blocking |
| timeout | logged, but non-blocking |
If a blocking pre-tool hook also sets action: stop, the runtime makes a best-effort attempt to abort the active session.
hooks:
- id: atomic-commit-on-change
event: file.changed
scope: main
conditions:
- matchesCodeFiles
- matchesAnyPath:
- src/**/*.{ts,tsx,js,jsx}
- package.json
actions:
- bash: "$HOME/.config/opencode/hook/atomic-commit.sh"hooks:
- id: async-atomic-commit
event: file.changed
async: true
scope: main
conditions: [matchesCodeFiles]
actions:
- bash: "$HOME/.config/opencode/hook/atomic-commit.sh"The agent does not wait for the commit to finish. Rapid-fire edits queue up and run one at a time for the same event and source session.
hooks:
- id: review-pr-on-change
event: file.changed
scope: all
runIn: main
actions:
- command:
name: review-pr
args: "main feature"hooks:
- id: audit-tool-usage
event: tool.after.*
actions:
- bash: |
context=$(cat)
echo "advanced after hook for $(echo "$context" | jq -r '.tool_name')"Use low-level tool hooks when you really need raw tool activity. Otherwise, stick with file.changed.