Creating Custom Hooks
This tutorial walks you through creating custom hooks for LLxprt Code, from simple scripts to advanced use cases.
Prerequisites
- LLxprt Code installed and configured
- Basic knowledge of bash or Python scripting
- Understanding of JSON
Tutorial 1: Your First Hook - Security Policy
Let's create a hook that blocks writes to sensitive directories.
Step 1: Create the Hook Directory
mkdir -p ~/.llxprt/hooks
Step 2: Write the Hook Script
Create ~/.llxprt/hooks/block-sensitive-writes.sh:
#!/bin/bash
#
# BeforeTool hook: Block writes to sensitive directories
#
# Input: JSON with tool_name, tool_input, session_id, etc.
# Output: JSON with decision and optional reason
#
# Read all input from stdin
INPUT=$(cat)
# Parse the tool name
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
# Only check write operations
if [[ "$TOOL_NAME" != "write_file" && "$TOOL_NAME" != "edit" ]]; then
echo '{"decision": "allow"}'
exit 0
fi
# Get the target path
if [[ "$TOOL_NAME" == "write_file" ]]; then
TARGET_PATH=$(echo "$INPUT" | jq -r '.tool_input.path // empty')
else
TARGET_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
fi
# Define sensitive directories
SENSITIVE_DIRS=("/etc" "/var" "/usr" "/System" "/Library")
# Check if path is in a sensitive directory
for DIR in "${SENSITIVE_DIRS[@]}"; do
if [[ "$TARGET_PATH" == "$DIR"* ]]; then
echo "{\"decision\": \"deny\", \"reason\": \"Writing to $DIR is prohibited by security policy\"}"
exit 2
fi
done
# Allow all other writes
echo '{"decision": "allow"}'
exit 0
Make it executable:
chmod +x ~/.llxprt/hooks/block-sensitive-writes.sh
Step 3: Configure the Hook
Add to ~/.llxprt/settings.json:
{
"hooks": {
"BeforeTool": [
{
"hooks": [
{
"type": "command",
"command": "~/.llxprt/hooks/block-sensitive-writes.sh",
"timeout": 5000
}
]
}
]
}
}
Step 4: Test It
Run LLxprt Code and try to write to a sensitive directory:
You: Write "test" to /etc/test.txt
LLxprt: I cannot complete this request. The security policy blocks writes to /etc.
Tutorial 2: Audit Logging Hook
Create a hook that logs all tool executions.
Create the Script
~/.llxprt/hooks/audit-log.sh:
#!/bin/bash
#
# AfterTool hook: Log all tool executions
#
INPUT=$(cat)
# Extract relevant fields
TIMESTAMP=$(echo "$INPUT" | jq -r '.timestamp')
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id')
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input')
TOOL_RESPONSE=$(echo "$INPUT" | jq -c '.tool_response')
# Log to file
LOG_FILE="${HOME}/.llxprt/audit.log"
echo "{\"timestamp\": \"$TIMESTAMP\", \"session\": \"$SESSION_ID\", \"tool\": \"$TOOL_NAME\", \"input\": $TOOL_INPUT, \"response\": $TOOL_RESPONSE}" >> "$LOG_FILE"
# Always allow - this is just logging
echo '{"decision": "allow"}'
exit 0
Configure It
{
"hooks": {
"AfterTool": [
{
"hooks": [
{
"type": "command",
"command": "~/.llxprt/hooks/audit-log.sh"
}
]
}
]
}
}
Tutorial 3: Tool Input Modification
Create a hook that automatically adds safety flags to shell commands.
Create the Script
~/.llxprt/hooks/safe-shell.sh:
#!/bin/bash
#
# BeforeTool hook: Add safety flags to dangerous commands
#
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
# Only modify shell commands
if [[ "$TOOL_NAME" != "run_shell_command" ]]; then
echo '{"decision": "allow"}'
exit 0
fi
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
# Add safety flags for rm commands
if [[ "$COMMAND" == rm\ * ]]; then
# Add -i (interactive) flag if not present
if [[ "$COMMAND" != *"-i"* && "$COMMAND" != *"--interactive"* ]]; then
SAFE_COMMAND=$(echo "$COMMAND" | sed 's/^rm /rm -i /')
echo "{\"decision\": \"allow\", \"hookSpecificOutput\": {\"tool_input\": {\"command\": \"$SAFE_COMMAND\"}}}"
exit 0
fi
fi
# Add --dry-run for rsync if not present
if [[ "$COMMAND" == rsync\ * && "$COMMAND" != *"--dry-run"* ]]; then
SAFE_COMMAND=$(echo "$COMMAND" | sed 's/^rsync /rsync --dry-run /')
echo "{\"decision\": \"allow\", \"hookSpecificOutput\": {\"tool_input\": {\"command\": \"$SAFE_COMMAND\"}}}"
exit 0
fi
echo '{"decision": "allow"}'
exit 0
Tutorial 4: Python Hook - Rate Limiting
Create a Python hook that implements rate limiting for API calls.
Create the Script
~/.llxprt/hooks/rate-limit.py:
#!/usr/bin/env python3
"""
BeforeModel hook: Rate limit LLM calls
"""
import json
import sys
import os
import time
from pathlib import Path
# Rate limit config
MAX_CALLS_PER_MINUTE = 10
STATE_FILE = Path.home() / '.llxprt' / '.rate-limit-state.json'
def load_state():
"""Load call history from state file."""
if STATE_FILE.exists():
try:
with open(STATE_FILE) as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
pass
return {'calls': []}
def save_state(state):
"""Save call history to state file."""
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(STATE_FILE, 'w') as f:
json.dump(state, f)
def main():
# Read input
input_data = json.load(sys.stdin)
# Load state
state = load_state()
now = time.time()
# Remove calls older than 1 minute
state['calls'] = [t for t in state['calls'] if now - t < 60]
# Check rate limit
if len(state['calls']) >= MAX_CALLS_PER_MINUTE:
wait_time = int(60 - (now - state['calls'][0]))
print(json.dumps({
'continue': False,
'reason': f'Rate limit exceeded. Please wait {wait_time} seconds.'
}))
sys.exit(2)
# Record this call
state['calls'].append(now)
save_state(state)
# Allow
print(json.dumps({'continue': True}))
sys.exit(0)
if __name__ == '__main__':
main()
Make it executable:
chmod +x ~/.llxprt/hooks/rate-limit.py
Configure It
{
"hooks": {
"BeforeModel": [
{
"hooks": [
{
"type": "command",
"command": "~/.llxprt/hooks/rate-limit.py",
"timeout": 2000
}
]
}
]
}
}
Tutorial 5: Tool Selection Restriction
Limit which tools are available based on context.
Create the Script
~/.llxprt/hooks/restrict-tools.sh:
#!/bin/bash
#
# BeforeToolSelection hook: Restrict tools based on directory
#
INPUT=$(cat)
CWD=$(echo "$INPUT" | jq -r '.cwd')
# In production directories, only allow read operations
if [[ "$CWD" == */production/* || "$CWD" == */prod/* ]]; then
echo '{
"hookSpecificOutput": {
"toolConfig": {
"mode": "AUTO",
"allowedFunctionNames": ["read_file", "read_many_files", "glob", "search_file_content", "list_directory"]
}
}
}'
exit 0
fi
# In test directories, allow everything
echo '{"decision": "allow"}'
exit 0
Configure It
{
"hooks": {
"BeforeToolSelection": [
{
"hooks": [
{
"type": "command",
"command": "~/.llxprt/hooks/restrict-tools.sh"
}
]
}
]
}
}
Tutorial 6: Multiple Hooks with Matchers
Configure different hooks for different tools.
{
"hooks": {
"BeforeTool": [
{
"matcher": "write_*",
"hooks": [
{
"type": "command",
"command": "~/.llxprt/hooks/validate-writes.sh"
}
]
},
{
"matcher": "run_shell_command",
"hooks": [
{
"type": "command",
"command": "~/.llxprt/hooks/validate-shell.sh"
}
]
},
{
"hooks": [
{
"type": "command",
"command": "~/.llxprt/hooks/audit-all.sh"
}
]
}
]
}
}
In this configuration:
validate-writes.shruns only forwrite_file,write_many_files, etc.validate-shell.shruns only for shell commandsaudit-all.shruns for ALL tools (no matcher)
Best Practices
1. Keep Hooks Fast
Hooks run synchronously. Slow hooks delay the entire operation.
# BAD: Slow network call
curl https://api.example.com/validate ...
# GOOD: Local check with timeout
timeout 1 ./quick-check.sh
2. Exit Early
Don't do unnecessary work:
# Check if we care about this tool first
if [[ "$TOOL_NAME" != "write_file" ]]; then
echo '{"decision": "allow"}'
exit 0 # Early exit
fi
# Now do the expensive check
3. Handle Errors Gracefully
# Validate input
if ! echo "$INPUT" | jq empty 2>/dev/null; then
echo '{"decision": "allow"}' # Default to allow on parse error
exit 0
fi
4. Use Structured Logging
import logging
import sys
import os
logging.basicConfig(
filename=os.path.expanduser('~/.llxprt/hooks/debug.log'),
level=logging.DEBUG
)
# Log to file, not stdout (stdout is for hook output)
logging.info(f"Processing {input_data.get('tool_name')}")
5. Test Your Hooks
Create a test script:
#!/bin/bash
# test-hook.sh
echo '{"tool_name": "write_file", "tool_input": {"path": "/etc/test"}}' | \
~/.llxprt/hooks/block-sensitive-writes.sh
echo "Exit code: $?"
Common Patterns
Allow by Default, Deny Specific
# Deny list approach
case "$TOOL_NAME" in
run_shell_command)
# Extra scrutiny for shell
if is_dangerous_command "$COMMAND"; then
exit 2
fi
;;
esac
echo '{"decision": "allow"}'
exit 0
Deny by Default, Allow Specific
# Allow list approach
case "$TOOL_NAME" in
read_file|list_directory|glob)
echo '{"decision": "allow"}'
exit 0
;;
esac
echo '{"decision": "deny", "reason": "Tool not in allowlist"}'
exit 2
Conditional Based on Session
import os
# Check if running in CI
if os.environ.get('CI'):
# More permissive in CI
print('{"decision": "allow"}')
else:
# Stricter locally
# ... validation logic
Troubleshooting
Hook Not Running
- Check the hook is executable:
ls -la ~/.llxprt/hooks/ - Check settings.json syntax:
jq . ~/.llxprt/settings.json - Enable debug:
DEBUG=llxprt:core:hooks:* llxprt
Hook Output Not Parsed
- Ensure output is valid JSON:
echo '{"test": 1}' | jq . - Don't mix stdout logging with JSON output
- Check for stderr output that might confuse parsing
Hook Times Out
- Check timeout setting in config
- Profile your hook to find slow parts
- Consider async patterns for slow operations operations