Skip to content

Commit e8d7e71

Browse files
ashwin-antclaude
andauthored
Add missing hook output fields to match TypeScript SDK (#226)
Closes the gap between Python and TypeScript SDK hook output types by adding: - `reason` field for explaining decisions - `continue_` field for controlling execution flow - `suppressOutput` field for hiding stdout - `stopReason` field for stop explanations - `decision` now supports both "approve" and "block" (not just "block") - `AsyncHookJSONOutput` type for deferred hook execution - Proper typing for `hookSpecificOutput` with discriminated unions Also adds comprehensive examples and tests: - New examples in hooks.py demonstrating all new fields - Unit tests in test_tool_callbacks.py for new output types - E2E tests in e2e-tests/test_hooks.py with real API calls - CI integration in .github/workflows/test.yml 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <[email protected]>
1 parent 5bea2dc commit e8d7e71

File tree

6 files changed

+526
-9
lines changed

6 files changed

+526
-9
lines changed

.github/workflows/test.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ jobs:
125125
run: |
126126
python examples/quick_start.py
127127
timeout 120 python examples/streaming_mode.py all
128+
timeout 120 python examples/hooks.py PreToolUse
129+
timeout 120 python examples/hooks.py DecisionFields
128130
129131
- name: Run example scripts (Windows)
130132
if: runner.os == 'Windows'
@@ -136,4 +138,14 @@ jobs:
136138
Wait-Job $job -Timeout 120 | Out-Null
137139
Stop-Job $job
138140
Receive-Job $job
141+
142+
$job = Start-Job { python examples/hooks.py PreToolUse }
143+
Wait-Job $job -Timeout 120 | Out-Null
144+
Stop-Job $job
145+
Receive-Job $job
146+
147+
$job = Start-Job { python examples/hooks.py DecisionFields }
148+
Wait-Job $job -Timeout 120 | Out-Null
149+
Stop-Job $job
150+
Receive-Job $job
139151
shell: pwsh

e2e-tests/test_hooks.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""End-to-end tests for hook callbacks with real Claude API calls."""
2+
3+
import pytest
4+
5+
from claude_agent_sdk import (
6+
ClaudeAgentOptions,
7+
ClaudeSDKClient,
8+
HookContext,
9+
HookJSONOutput,
10+
HookMatcher,
11+
)
12+
13+
14+
@pytest.mark.e2e
15+
@pytest.mark.asyncio
16+
async def test_hook_with_permission_decision_and_reason():
17+
"""Test that hooks with permissionDecision and reason fields work end-to-end."""
18+
hook_invocations = []
19+
20+
async def test_hook(
21+
input_data: dict, tool_use_id: str | None, context: HookContext
22+
) -> HookJSONOutput:
23+
"""Hook that uses permissionDecision and reason fields."""
24+
tool_name = input_data.get("tool_name", "")
25+
print(f"Hook called for tool: {tool_name}")
26+
hook_invocations.append(tool_name)
27+
28+
# Block Bash commands for this test
29+
if tool_name == "Bash":
30+
return {
31+
"reason": "Bash commands are blocked in this test for safety",
32+
"systemMessage": "⚠️ Command blocked by hook",
33+
"hookSpecificOutput": {
34+
"hookEventName": "PreToolUse",
35+
"permissionDecision": "deny",
36+
"permissionDecisionReason": "Security policy: Bash blocked",
37+
},
38+
}
39+
40+
return {
41+
"reason": "Tool approved by security review",
42+
"hookSpecificOutput": {
43+
"hookEventName": "PreToolUse",
44+
"permissionDecision": "allow",
45+
"permissionDecisionReason": "Tool passed security checks",
46+
},
47+
}
48+
49+
options = ClaudeAgentOptions(
50+
allowed_tools=["Bash", "Write"],
51+
hooks={
52+
"PreToolUse": [
53+
HookMatcher(matcher="Bash", hooks=[test_hook]),
54+
],
55+
},
56+
)
57+
58+
async with ClaudeSDKClient(options=options) as client:
59+
await client.query("Run this bash command: echo 'hello'")
60+
61+
async for message in client.receive_response():
62+
print(f"Got message: {message}")
63+
64+
print(f"Hook invocations: {hook_invocations}")
65+
# Verify hook was called
66+
assert "Bash" in hook_invocations, f"Hook should have been invoked for Bash tool, got: {hook_invocations}"
67+
68+
69+
@pytest.mark.e2e
70+
@pytest.mark.asyncio
71+
async def test_hook_with_continue_and_stop_reason():
72+
"""Test that hooks with continue_=False and stopReason fields work end-to-end."""
73+
hook_invocations = []
74+
75+
async def post_tool_hook(
76+
input_data: dict, tool_use_id: str | None, context: HookContext
77+
) -> HookJSONOutput:
78+
"""PostToolUse hook that stops execution with stopReason."""
79+
tool_name = input_data.get("tool_name", "")
80+
hook_invocations.append(tool_name)
81+
82+
# Actually test continue_=False and stopReason fields
83+
return {
84+
"continue_": False,
85+
"stopReason": "Execution halted by test hook for validation",
86+
"reason": "Testing continue and stopReason fields",
87+
"systemMessage": "🛑 Test hook stopped execution",
88+
}
89+
90+
options = ClaudeAgentOptions(
91+
allowed_tools=["Bash"],
92+
hooks={
93+
"PostToolUse": [
94+
HookMatcher(matcher="Bash", hooks=[post_tool_hook]),
95+
],
96+
},
97+
)
98+
99+
async with ClaudeSDKClient(options=options) as client:
100+
await client.query("Run: echo 'test message'")
101+
102+
async for message in client.receive_response():
103+
print(f"Got message: {message}")
104+
105+
print(f"Hook invocations: {hook_invocations}")
106+
# Verify hook was called
107+
assert "Bash" in hook_invocations, f"PostToolUse hook should have been invoked, got: {hook_invocations}"
108+
109+
110+
@pytest.mark.e2e
111+
@pytest.mark.asyncio
112+
async def test_hook_with_additional_context():
113+
"""Test that hooks with hookSpecificOutput work end-to-end."""
114+
hook_invocations = []
115+
116+
async def context_hook(
117+
input_data: dict, tool_use_id: str | None, context: HookContext
118+
) -> HookJSONOutput:
119+
"""Hook that provides additional context."""
120+
hook_invocations.append("context_added")
121+
122+
return {
123+
"systemMessage": "Additional context provided by hook",
124+
"reason": "Hook providing monitoring feedback",
125+
"suppressOutput": False,
126+
"hookSpecificOutput": {
127+
"hookEventName": "PostToolUse",
128+
"additionalContext": "The command executed successfully with hook monitoring",
129+
},
130+
}
131+
132+
options = ClaudeAgentOptions(
133+
allowed_tools=["Bash"],
134+
hooks={
135+
"PostToolUse": [
136+
HookMatcher(matcher="Bash", hooks=[context_hook]),
137+
],
138+
},
139+
)
140+
141+
async with ClaudeSDKClient(options=options) as client:
142+
await client.query("Run: echo 'testing hooks'")
143+
144+
async for message in client.receive_response():
145+
print(f"Got message: {message}")
146+
147+
print(f"Hook invocations: {hook_invocations}")
148+
# Verify hook was called
149+
assert "context_added" in hook_invocations, "Hook with hookSpecificOutput should have been invoked"

examples/hooks.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,77 @@ async def add_custom_instructions(
8181
}
8282

8383

84+
async def review_tool_output(
85+
input_data: dict[str, Any], tool_use_id: str | None, context: HookContext
86+
) -> HookJSONOutput:
87+
"""Review tool output and provide additional context or warnings."""
88+
tool_response = input_data.get("tool_response", "")
89+
90+
# If the tool produced an error, add helpful context
91+
if "error" in str(tool_response).lower():
92+
return {
93+
"systemMessage": "⚠️ The command produced an error",
94+
"reason": "Tool execution failed - consider checking the command syntax",
95+
"hookSpecificOutput": {
96+
"hookEventName": "PostToolUse",
97+
"additionalContext": "The command encountered an error. You may want to try a different approach.",
98+
}
99+
}
100+
101+
return {}
102+
103+
104+
async def strict_approval_hook(
105+
input_data: dict[str, Any], tool_use_id: str | None, context: HookContext
106+
) -> HookJSONOutput:
107+
"""Demonstrates using permissionDecision to control tool execution."""
108+
tool_name = input_data.get("tool_name")
109+
tool_input = input_data.get("tool_input", {})
110+
111+
# Block any Write operations to specific files
112+
if tool_name == "Write":
113+
file_path = tool_input.get("file_path", "")
114+
if "important" in file_path.lower():
115+
logger.warning(f"Blocked Write to: {file_path}")
116+
return {
117+
"reason": "Writes to files containing 'important' in the name are not allowed for safety",
118+
"systemMessage": "🚫 Write operation blocked by security policy",
119+
"hookSpecificOutput": {
120+
"hookEventName": "PreToolUse",
121+
"permissionDecision": "deny",
122+
"permissionDecisionReason": "Security policy blocks writes to important files",
123+
},
124+
}
125+
126+
# Allow everything else explicitly
127+
return {
128+
"reason": "Tool use approved after security review",
129+
"hookSpecificOutput": {
130+
"hookEventName": "PreToolUse",
131+
"permissionDecision": "allow",
132+
"permissionDecisionReason": "Tool passed security checks",
133+
},
134+
}
135+
136+
137+
async def stop_on_error_hook(
138+
input_data: dict[str, Any], tool_use_id: str | None, context: HookContext
139+
) -> HookJSONOutput:
140+
"""Demonstrates using continue=False to stop execution on certain conditions."""
141+
tool_response = input_data.get("tool_response", "")
142+
143+
# Stop execution if we see a critical error
144+
if "critical" in str(tool_response).lower():
145+
logger.error("Critical error detected - stopping execution")
146+
return {
147+
"continue_": False,
148+
"stopReason": "Critical error detected in tool output - execution halted for safety",
149+
"systemMessage": "🛑 Execution stopped due to critical error",
150+
}
151+
152+
return {"continue_": True}
153+
154+
84155
async def example_pretooluse() -> None:
85156
"""Basic example demonstrating hook protection."""
86157
print("=== PreToolUse Example ===")
@@ -143,11 +214,99 @@ async def example_userpromptsubmit() -> None:
143214
print("\n")
144215

145216

217+
async def example_posttooluse() -> None:
218+
"""Demonstrate PostToolUse hook with reason and systemMessage fields."""
219+
print("=== PostToolUse Example ===")
220+
print("This example shows how PostToolUse can provide feedback with reason and systemMessage.\n")
221+
222+
options = ClaudeAgentOptions(
223+
allowed_tools=["Bash"],
224+
hooks={
225+
"PostToolUse": [
226+
HookMatcher(matcher="Bash", hooks=[review_tool_output]),
227+
],
228+
}
229+
)
230+
231+
async with ClaudeSDKClient(options=options) as client:
232+
print("User: Run a command that will produce an error: ls /nonexistent_directory")
233+
await client.query("Run this command: ls /nonexistent_directory")
234+
235+
async for msg in client.receive_response():
236+
display_message(msg)
237+
238+
print("\n")
239+
240+
241+
async def example_decision_fields() -> None:
242+
"""Demonstrate permissionDecision, reason, and systemMessage fields."""
243+
print("=== Permission Decision Example ===")
244+
print("This example shows how to use permissionDecision='allow'/'deny' with reason and systemMessage.\n")
245+
246+
options = ClaudeAgentOptions(
247+
allowed_tools=["Write", "Bash"],
248+
model="claude-sonnet-4-5-20250929",
249+
hooks={
250+
"PreToolUse": [
251+
HookMatcher(matcher="Write", hooks=[strict_approval_hook]),
252+
],
253+
}
254+
)
255+
256+
async with ClaudeSDKClient(options=options) as client:
257+
# Test 1: Try to write to a file with "important" in the name (should be blocked)
258+
print("Test 1: Trying to write to important_config.txt (should be blocked)...")
259+
print("User: Write 'test' to important_config.txt")
260+
await client.query("Write the text 'test data' to a file called important_config.txt")
261+
262+
async for msg in client.receive_response():
263+
display_message(msg)
264+
265+
print("\n" + "=" * 50 + "\n")
266+
267+
# Test 2: Write to a regular file (should be approved)
268+
print("Test 2: Trying to write to regular_file.txt (should be approved)...")
269+
print("User: Write 'test' to regular_file.txt")
270+
await client.query("Write the text 'test data' to a file called regular_file.txt")
271+
272+
async for msg in client.receive_response():
273+
display_message(msg)
274+
275+
print("\n")
276+
277+
278+
async def example_continue_control() -> None:
279+
"""Demonstrate continue and stopReason fields for execution control."""
280+
print("=== Continue/Stop Control Example ===")
281+
print("This example shows how to use continue_=False with stopReason to halt execution.\n")
282+
283+
options = ClaudeAgentOptions(
284+
allowed_tools=["Bash"],
285+
hooks={
286+
"PostToolUse": [
287+
HookMatcher(matcher="Bash", hooks=[stop_on_error_hook]),
288+
],
289+
}
290+
)
291+
292+
async with ClaudeSDKClient(options=options) as client:
293+
print("User: Run a command that outputs 'CRITICAL ERROR'")
294+
await client.query("Run this bash command: echo 'CRITICAL ERROR: system failure'")
295+
296+
async for msg in client.receive_response():
297+
display_message(msg)
298+
299+
print("\n")
300+
301+
146302
async def main() -> None:
147303
"""Run all examples or a specific example based on command line argument."""
148304
examples = {
149305
"PreToolUse": example_pretooluse,
150306
"UserPromptSubmit": example_userpromptsubmit,
307+
"PostToolUse": example_posttooluse,
308+
"DecisionFields": example_decision_fields,
309+
"ContinueControl": example_continue_control,
151310
}
152311

153312
if len(sys.argv) < 2:
@@ -157,6 +316,12 @@ async def main() -> None:
157316
print(" all - Run all examples")
158317
for name in examples:
159318
print(f" {name}")
319+
print("\nExample descriptions:")
320+
print(" PreToolUse - Block commands using PreToolUse hook")
321+
print(" UserPromptSubmit - Add context at prompt submission")
322+
print(" PostToolUse - Review tool output with reason and systemMessage")
323+
print(" DecisionFields - Use permissionDecision='allow'/'deny' with reason")
324+
print(" ContinueControl - Control execution with continue_ and stopReason")
160325
sys.exit(0)
161326

162327
example_name = sys.argv[1]

src/claude_agent_sdk/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
ContentBlock,
2424
HookCallback,
2525
HookContext,
26+
HookJSONOutput,
2627
HookMatcher,
2728
McpSdkServerConfig,
2829
McpServerConfig,
@@ -308,6 +309,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
308309
"PermissionUpdate",
309310
"HookCallback",
310311
"HookContext",
312+
"HookJSONOutput",
311313
"HookMatcher",
312314
# Agent support
313315
"AgentDefinition",

0 commit comments

Comments
 (0)