Skip to content

Hooks

Hooks let capabilities register automated scripts that run at specific points in the agent lifecycle. When you run omnidev sync, OmniDev reads hooks/hooks.toml, composes a provider-specific view, and writes the appropriate output files for each enabled provider.

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 shared top-level section plus optional provider-specific sections:

hooks/hooks.toml
# Shared hooks
[[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
# Provider-specific override for Codex only
[[codex.PreToolUse]]
matcher = "Bash"
[[codex.PreToolUse.hooks]]
type = "command"
command = "${OMNIDEV_CAPABILITY_ROOT}/hooks/validate-bash.sh"
statusMessage = "Checking Bash command"

Provider composition rules:

  • Top-level events are shared.
  • [claude] and [codex] are optional provider-specific escape hatches.
  • If a provider section defines an event, that event replaces the shared event for that provider only.
  • Hooks that are not usable in the active provider are skipped with warnings instead of failing sync.
ProviderOutput filesNotes
Claude Code.claude/settings.jsonShared hooks plus [claude] overrides
Codex.codex/hooks.json, .codex/config.tomlShared hooks plus [codex] overrides; features.hooks = true is written automatically

Top-level hooks should be the portable/common subset you want available on both providers.

Use provider sections when a hook only makes sense on one provider:

[[claude.PermissionRequest]]
matcher = "Bash"
[[claude.PermissionRequest.hooks]]
type = "prompt"
prompt = "Review this permission request."
[[codex.PreToolUse]]
matcher = "Bash"
[[codex.PreToolUse.hooks]]
type = "command"
command = "${OMNIDEV_CAPABILITY_ROOT}/hooks/pre-tool.sh"
statusMessage = "Checking Bash command"

The [claude] section accepts Claude-native events in addition to the shared top-level subset. This is where provider-specific events such as WorktreeCreate, WorktreeRemove, PermissionDenied, PostToolUseFailure, PostCompact, FileChanged, ConfigChange, and related Claude lifecycle hooks belong.

OmniDev’s shared top-level format still supports the existing OmniDev hook events, but providers use only what they can materialize. Claude gets the full shared set plus [claude] overrides. Codex uses its current documented subset and warns when shared hooks cannot be used there.

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

Use the [claude] section for Claude lifecycle hooks that are not part of the shared top-level subset:

  • PermissionDenied
  • PostToolUseFailure
  • SubagentStart
  • TaskCreated
  • TaskCompleted
  • StopFailure
  • TeammateIdle
  • InstructionsLoaded
  • ConfigChange
  • CwdChanged
  • FileChanged
  • WorktreeCreate
  • WorktreeRemove
  • PostCompact
  • Elicitation
  • ElicitationResult

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 shared top-level hooks, UserPromptSubmit, Stop, and SubagentStop ignore matcher. Several Claude-only events in [claude] also ignore matchers, including TaskCreated, TaskCompleted, TeammateIdle, CwdChanged, WorktreeCreate, and WorktreeRemove.

[[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. Supported for the shared Claude-compatible subset: PreToolUse, PostToolUse, PermissionRequest, UserPromptSubmit, Stop, and SubagentStop. Some Claude-only events in [claude] also support prompts, such as PostToolUseFailure, TaskCreated, and TaskCompleted.

[[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 throughout hooks.toml, including provider-specific sections:

In hooks.tomlProvider outputDescription
${OMNIDEV_CAPABILITY_ROOT}Resolved provider command pathCapability root directory
${OMNIDEV_PROJECT_DIR}Resolved provider command pathProject root

Always use OMNIDEV_ prefixed variables in your authored hooks.toml. OmniDev resolves them before writing provider output.

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. Separates shared hooks from [claude] and [codex] sections
  3. Resolves OMNIDEV_ variables to provider-ready command paths
  4. Builds a provider-specific view of the hooks
  5. Warns about hooks that are not usable in the active provider
  6. Writes .claude/settings.json and/or .codex/hooks.json
  7. Enables features.hooks in .codex/config.toml when Codex hooks are present

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