Skip to content

Hooks

Hooks let capabilities register automated scripts that run at specific points in the Claude Code agent lifecycle. When you run omnidev sync, hooks from all enabled capabilities are merged and written to .claude/settings.json.

This enables powerful automation: validating commands before execution, running linters after file edits, injecting context at session start, and more.

Create a hooks/hooks.toml file in your capability:

# Validate bash commands before execution
[[PreToolUse]]
matcher = "Bash"
[[PreToolUse.hooks]]
type = "command"
command = "${OMNIDEV_CAPABILITY_ROOT}/hooks/validate-bash.sh"
timeout = 30

Then add your validation script at hooks/validate-bash.sh:

#!/bin/bash
# Read JSON input from stdin
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
# Block dangerous patterns
if echo "$COMMAND" | grep -qE 'rm -rf /|dd if='; then
echo "Dangerous command blocked" >&2
exit 2 # Exit code 2 blocks the tool
fi
exit 0 # Allow the command

Make it executable:

Terminal window
chmod +x hooks/validate-bash.sh

Place hooks configuration and scripts in the hooks/ directory:

my-capability/
├── omni.toml
├── hooks/
│ ├── hooks.toml # Hook configuration
│ ├── validate-bash.sh # PreToolUse script
│ └── run-linter.sh # PostToolUse script
└── ...

Hooks are defined in TOML with a simple structure:

hooks/hooks.toml
# Each event is a TOML array of tables
[[PreToolUse]]
matcher = "Bash" # Regex pattern for tool names
[[PreToolUse.hooks]] # Array of hooks to run
type = "command"
command = "${OMNIDEV_CAPABILITY_ROOT}/hooks/validate-bash.sh"
timeout = 30 # Optional, in seconds
# Multiple matchers per event
[[PostToolUse]]
matcher = "Write|Edit" # Match Write OR Edit tools
[[PostToolUse.hooks]]
type = "command"
command = "${OMNIDEV_PROJECT_DIR}/.omni/hooks/lint.sh"

OmniDev supports all 10 Claude Code hook events:

EventWhen it runsSupports MatcherSupports Prompt
PreToolUseBefore a tool executesYesYes
PostToolUseAfter a tool completesYesNo
PermissionRequestBefore permission prompt shownYesYes
EventWhen it runsSupports MatcherSupports Prompt
UserPromptSubmitBefore user prompt processedNoYes
StopWhen main agent finishesNoYes
SubagentStopWhen subagent finishesNoYes
NotificationWhen notifications sentYesNo
EventWhen it runsSupports MatcherSupports Prompt
SessionStartWhen session starts/resumesYesNo
SessionEndWhen session endsNoNo
PreCompactBefore context compactionYesNo

Matchers filter which tools or events trigger a hook. They use regex patterns.

[[PreToolUse]]
matcher = "Bash" # Exact match
[[PreToolUse]]
matcher = "Edit|Write" # Match Edit OR Write
[[PreToolUse]]
matcher = "mcp__.*" # All MCP tools
[[PreToolUse]]
matcher = ".*" # All tools (or omit matcher)

Common tool names: Bash, Read, Write, Edit, Glob, Grep, Task, WebFetch, WebSearch, NotebookEdit, LSP, TodoWrite, AskUserQuestion

Notification event:

  • permission_prompt - Permission prompts
  • idle_prompt - Idle prompts
  • auth_success - Auth success notifications
  • elicitation_dialog - Elicitation dialogs
[[Notification]]
matcher = "permission_prompt"
[[Notification.hooks]]
type = "command"
command = "./notify.sh"

SessionStart event:

  • startup - Initial session start
  • resume - Session resumed
  • clear - Session cleared
  • compact - After compaction
[[SessionStart]]
matcher = "startup|resume"
[[SessionStart.hooks]]
type = "command"
command = "./init-session.sh"

PreCompact event:

  • manual - Manual compaction
  • auto - Auto compaction

For UserPromptSubmit, Stop, SubagentStop, and SessionEnd, the matcher field is ignored:

[[Stop]]
[[Stop.hooks]]
type = "command"
command = "./cleanup.sh"

Execute a shell command. Available for all events.

[[PreToolUse.hooks]]
type = "command"
command = "${OMNIDEV_CAPABILITY_ROOT}/hooks/validate.sh"
timeout = 60 # Default: 60 seconds

Use LLM evaluation. Only available for: PreToolUse, PermissionRequest, UserPromptSubmit, Stop, SubagentStop.

[[PermissionRequest.hooks]]
type = "prompt"
prompt = "Review this permission request. Is it safe? Respond with JSON: {\"ok\": true} or {\"ok\": false, \"reason\": \"explanation\"}"
timeout = 30 # Default: 30 seconds

OmniDev uses its own variable naming that gets transformed during sync:

In hooks.tomlIn settings.jsonDescription
${OMNIDEV_CAPABILITY_ROOT}${CLAUDE_PLUGIN_ROOT}Capability’s root directory
${OMNIDEV_PROJECT_DIR}${CLAUDE_PROJECT_DIR}Project root

Always use OMNIDEV_ prefixed variables in your hooks.toml. They’re automatically transformed when written to .claude/settings.json.

Additional variables available at runtime:

  • CLAUDE_ENV_FILE - (SessionStart only) File path for persisting environment variables

All hooks receive JSON via stdin:

{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/project/path",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test",
"description": "Run tests"
}
}
  • Exit 0: Success, tool continues
  • Exit 2: Blocking error, tool is prevented
  • Other codes: Non-blocking error, logged but tool continues
#!/bin/bash
# Read JSON input
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
# Block dangerous commands
if echo "$COMMAND" | grep -qE 'rm -rf /'; then
echo "Dangerous command blocked" >&2
exit 2
fi
exit 0

For fine-grained control, output JSON to stdout with exit code 0:

{
"continue": true,
"systemMessage": "Warning: this command modifies production data",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Auto-approved safe operation"
}
}

Permission decisions (PreToolUse only):

  • "allow" - Auto-approve without prompting
  • "deny" - Block the operation
  • "ask" - Show normal permission prompt
#!/usr/bin/env python3
import json
import sys
# Read input
input_data = json.load(sys.stdin)
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
# Auto-approve reading documentation files
if tool_name == "Read":
file_path = tool_input.get("file_path", "")
if file_path.endswith((".md", ".txt", ".json")):
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Documentation file"
}
}
print(json.dumps(output))
sys.exit(0)
sys.exit(0) # Default: continue normally

When you run omnidev sync:

  1. OmniDev loads hooks/hooks.toml from each enabled capability
  2. Validates TOML structure, types, and regex patterns
  3. Transforms OMNIDEV_ variables to CLAUDE_ variables
  4. Merges hooks from all capabilities
  5. Writes to .claude/settings.json

If two capabilities define hooks, they’re combined:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/validate-bash.sh"
}]
},
{
"matcher": "Write",
"hooks": [{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/lint-on-write.sh"
}]
}
]
}
}

Block dangerous bash commands:

[[PreToolUse]]
matcher = "Bash"
[[PreToolUse.hooks]]
type = "command"
command = "${OMNIDEV_CAPABILITY_ROOT}/hooks/validate-bash.sh"
timeout = 30

Run formatter after file edits:

[[PostToolUse]]
matcher = "Write|Edit"
[[PostToolUse.hooks]]
type = "command"
command = "${OMNIDEV_PROJECT_DIR}/node_modules/.bin/prettier --write"

Load project context when session begins:

[[SessionStart]]
matcher = "startup|resume"
[[SessionStart.hooks]]
type = "command"
command = "${OMNIDEV_CAPABILITY_ROOT}/hooks/load-context.sh"

Context script example:

#!/bin/bash
# Output is added as context to Claude
echo "Project: $(basename $PWD)"
echo "Branch: $(git branch --show-current 2>/dev/null || echo 'not a git repo')"
echo "Node: $(node --version 2>/dev/null || echo 'not installed')"
exit 0

Set environment variables for the session:

#!/bin/bash
# SessionStart hook
if [ -n "$CLAUDE_ENV_FILE" ]; then
echo 'export NODE_ENV=development' >> "$CLAUDE_ENV_FILE"
echo 'export DEBUG=true' >> "$CLAUDE_ENV_FILE"
fi
exit 0

Block prompts containing secrets:

[[UserPromptSubmit]]
[[UserPromptSubmit.hooks]]
type = "command"
command = "${OMNIDEV_CAPABILITY_ROOT}/hooks/check-secrets.sh"
#!/bin/bash
INPUT=$(cat)
PROMPT=$(echo "$INPUT" | jq -r '.prompt')
if echo "$PROMPT" | grep -qiE 'password=|api_key=|secret='; then
echo '{"decision": "block", "reason": "Prompt may contain secrets"}'
exit 0
fi
exit 0

OmniDev validates hooks during capability loading:

  • Unknown events are rejected
  • Invalid hook types are rejected
  • Prompt hooks on unsupported events are rejected
  • Invalid regex patterns are rejected
  • CLAUDE_ variables are transformed to OMNIDEV_ (with warning)
  • Missing command/prompt fields are flagged

Run omnidev doctor to check for hook validation issues.

Best practices:

  1. Validate and sanitize inputs - Never trust input blindly
  2. Quote shell variables - Use "$VAR" not $VAR
  3. Check for path traversal - Block .. in file paths
  4. Use absolute paths - Specify full paths for scripts
  5. Skip sensitive files - Avoid processing .env, .git/, keys
  6. Test scripts independently - Ensure they work before integrating

Enable Claude Code debug mode to see hook execution:

Terminal window
claude --debug

Debug output shows:

[DEBUG] Executing hooks for PreToolUse:Bash
[DEBUG] Found 1 hook matchers
[DEBUG] Matched 1 hooks
[DEBUG] Hook command completed with status 0