"""OpenHands Agent SDK — Hooks ExampleDemonstrates the OpenHands hooks system.Hooks are shell scripts that run at key lifecycle events:- PreToolUse: Block dangerous commands before execution- PostToolUse: Log tool usage after execution- UserPromptSubmit: Inject context into user messages- Stop: Enforce task completion criteriaThe hook scripts are in the scripts/ directory alongside this file."""import osimport signalimport tempfilefrom pathlib import Pathfrom pydantic import SecretStrfrom openhands.sdk import LLM, Conversationfrom openhands.sdk.hooks import HookConfig, HookDefinition, HookMatcherfrom openhands.tools.preset.default import get_default_agentsignal.signal(signal.SIGINT, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()))SCRIPT_DIR = Path(__file__).parent / "hook_scripts"# Configure LLMapi_key = os.getenv("LLM_API_KEY")assert api_key is not None, "LLM_API_KEY environment variable is not set."model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")base_url = os.getenv("LLM_BASE_URL")llm = LLM( usage_id="agent", model=model, base_url=base_url, api_key=SecretStr(api_key),)# Create temporary workspace with git repowith tempfile.TemporaryDirectory() as tmpdir: workspace = Path(tmpdir) os.system(f"cd {workspace} && git init -q && echo 'test' > file.txt") log_file = workspace / "tool_usage.log" summary_file = workspace / "summary.txt" # Configure hooks using the typed approach (recommended) # This provides better type safety and IDE support hook_config = HookConfig( pre_tool_use=[ HookMatcher( matcher="terminal", hooks=[ HookDefinition( command=str(SCRIPT_DIR / "block_dangerous.sh"), timeout=10, ) ], ) ], post_tool_use=[ HookMatcher( matcher="*", hooks=[ HookDefinition( command=(f"LOG_FILE={log_file} {SCRIPT_DIR / 'log_tools.sh'}"), timeout=5, ) ], ) ], user_prompt_submit=[ HookMatcher( hooks=[ HookDefinition( command=str(SCRIPT_DIR / "inject_git_context.sh"), ) ], ) ], stop=[ HookMatcher( hooks=[ HookDefinition( command=( f"SUMMARY_FILE={summary_file} " f"{SCRIPT_DIR / 'require_summary.sh'}" ), ) ], ) ], ) # Alternative: You can also use .from_dict() for loading from JSON config files # Example with a single hook matcher: # hook_config = HookConfig.from_dict({ # "hooks": { # "PreToolUse": [{ # "matcher": "terminal", # "hooks": [{"command": "path/to/script.sh", "timeout": 10}] # }] # } # }) agent = get_default_agent(llm=llm) conversation = Conversation( agent=agent, workspace=str(workspace), hook_config=hook_config, ) # Demo 1: Safe command (PostToolUse logs it) print("=" * 60) print("Demo 1: Safe command - logged by PostToolUse") print("=" * 60) conversation.send_message("Run: echo 'Hello from hooks!'") conversation.run() if log_file.exists(): print(f"\n[Log: {log_file.read_text().strip()}]") # Demo 2: Dangerous command (PreToolUse blocks it) print("\n" + "=" * 60) print("Demo 2: Dangerous command - blocked by PreToolUse") print("=" * 60) conversation.send_message("Run: rm -rf /tmp/test") conversation.run() # Demo 3: Context injection + Stop hook enforcement print("\n" + "=" * 60) print("Demo 3: Context injection + Stop hook") print("=" * 60) print("UserPromptSubmit injects git status; Stop requires summary.txt\n") conversation.send_message( "Check what files have changes, then create summary.txt describing the repo." ) conversation.run() if summary_file.exists(): print(f"\n[summary.txt: {summary_file.read_text()[:80]}...]") print("\n" + "=" * 60) print("Example Complete!") print("=" * 60) cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost print(f"\nEXAMPLE_COST: {cost}")
You can run the example code as-is.
The model name should follow the LiteLLM convention: provider/model_name (e.g., anthropic/claude-sonnet-4-5-20250929, openai/gpt-4o).
The LLM_API_KEY should be the API key for your chosen provider.
ChatGPT Plus/Pro subscribers: You can use LLM.subscription_login() to authenticate with your ChatGPT account and access Codex models without consuming API credits. See the LLM Subscriptions guide for details.
The example uses external hook scripts in the hook_scripts/ directory:
block_dangerous.sh - PreToolUse hook
#!/bin/bash# PreToolUse hook: Block dangerous rm -rf commands# Uses jq for JSON parsing (needed for nested fields like tool_input.command)input=$(cat)command=$(echo "$input" | jq -r '.tool_input.command // ""')# Block rm -rf commandsif [[ "$command" =~ "rm -rf" ]]; then echo '{"decision": "deny", "reason": "rm -rf commands are blocked for safety"}' exit 2 # Exit code 2 = block the operationfiexit 0 # Exit code 0 = allow the operation
log_tools.sh - PostToolUse hook
#!/bin/bash# PostToolUse hook: Log all tool usage# Uses OPENHANDS_TOOL_NAME env var (no jq/python needed!)# LOG_FILE should be set by the calling scriptLOG_FILE="${LOG_FILE:-/tmp/tool_usage.log}"echo "[$(date)] Tool used: $OPENHANDS_TOOL_NAME" >> "$LOG_FILE"exit 0
inject_git_context.sh - UserPromptSubmit hook
#!/bin/bash# UserPromptSubmit hook: Inject git status when user asks about code changesinput=$(cat)# Check if user is asking about changes, diff, or gitif echo "$input" | grep -qiE "(changes|diff|git|commit|modified)"; then # Get git status if in a git repo if git rev-parse --git-dir > /dev/null 2>&1; then status=$(git status --short 2>/dev/null | head -10) if [ -n "$status" ]; then # Escape for JSON escaped=$(echo "$status" | sed 's/"/\\"/g' | tr '\n' ' ') echo "{\"additionalContext\": \"Current git status: $escaped\"}" fi fifiexit 0
require_summary.sh - Stop hook
#!/bin/bash# Stop hook: Require a summary.txt file before allowing agent to finish# SUMMARY_FILE should be set by the calling scriptSUMMARY_FILE="${SUMMARY_FILE:-./summary.txt}"if [ ! -f "$SUMMARY_FILE" ]; then echo '{"decision": "deny", "additionalContext": "Create summary.txt first."}' exit 2fiexit 0