Skip to content

Commit 3cf6b56

Browse files
ashwin-antclaude
andcommitted
Add missing hook output fields to match TypeScript SDK
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 6793e40 commit 3cf6b56

File tree

6 files changed

+509
-10
lines changed

6 files changed

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

examples/hooks.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,69 @@ 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 blocking with decision='block' and providing a reason."""
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+
"decision": "block",
118+
"reason": "Writes to files containing 'important' in the name are not allowed for safety",
119+
"systemMessage": "🚫 Write operation blocked by security policy",
120+
}
121+
122+
# Approve everything else explicitly
123+
return {
124+
"decision": "approve",
125+
"reason": "Tool use approved after security review",
126+
}
127+
128+
129+
async def stop_on_error_hook(
130+
input_data: dict[str, Any], tool_use_id: str | None, context: HookContext
131+
) -> HookJSONOutput:
132+
"""Demonstrates using continue=False to stop execution on certain conditions."""
133+
tool_response = input_data.get("tool_response", "")
134+
135+
# Stop execution if we see a critical error
136+
if "critical" in str(tool_response).lower():
137+
logger.error("Critical error detected - stopping execution")
138+
return {
139+
"continue_": False,
140+
"stopReason": "Critical error detected in tool output - execution halted for safety",
141+
"systemMessage": "🛑 Execution stopped due to critical error",
142+
}
143+
144+
return {"continue_": True}
145+
146+
84147
async def example_pretooluse() -> None:
85148
"""Basic example demonstrating hook protection."""
86149
print("=== PreToolUse Example ===")
@@ -143,11 +206,99 @@ async def example_userpromptsubmit() -> None:
143206
print("\n")
144207

145208

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

153304
if len(sys.argv) < 2:
@@ -157,6 +308,12 @@ async def main() -> None:
157308
print(" all - Run all examples")
158309
for name in examples:
159310
print(f" {name}")
311+
print("\nExample descriptions:")
312+
print(" PreToolUse - Block commands using PreToolUse hook")
313+
print(" UserPromptSubmit - Add context at prompt submission")
314+
print(" PostToolUse - Review tool output with reason and systemMessage")
315+
print(" DecisionFields - Use decision='block'/'approve' with reason")
316+
print(" ContinueControl - Control execution with continue_ and stopReason")
160317
sys.exit(0)
161318

162319
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)