Skip to content

Commit d754e5c

Browse files
ashwin-antclaude
andauthored
feat: add strongly-typed hook inputs with TypedDict (#240)
Add typed hook input structures (PreToolUseHookInput, PostToolUseHookInput, etc.) to provide better IDE autocomplete and type safety for hook callbacks. Also convert HookContext from dataclass to TypedDict to match runtime behavior. Changes: - Add BaseHookInput, PreToolUseHookInput, PostToolUseHookInput, UserPromptSubmitHookInput, StopHookInput, SubagentStopHookInput, and PreCompactHookInput TypedDict classes - Update HookCallback signature to use HookInput union type - Convert HookContext from dataclass to TypedDict (fixes type mismatch) - Export all new hook input types from __init__.py - Update all examples and tests to use typed hook inputs Benefits: - Zero breaking changes (TypedDict is dict-compatible at runtime) - Full type safety and IDE autocomplete for hook callbacks - Matches TypeScript SDK structure exactly - Self-documenting hook input fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <[email protected]>
1 parent 48b62a0 commit d754e5c

File tree

5 files changed

+111
-23
lines changed

5 files changed

+111
-23
lines changed

e2e-tests/test_hooks.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
ClaudeAgentOptions,
77
ClaudeSDKClient,
88
HookContext,
9+
HookInput,
910
HookJSONOutput,
1011
HookMatcher,
1112
)
@@ -18,7 +19,7 @@ async def test_hook_with_permission_decision_and_reason():
1819
hook_invocations = []
1920

2021
async def test_hook(
21-
input_data: dict, tool_use_id: str | None, context: HookContext
22+
input_data: HookInput, tool_use_id: str | None, context: HookContext
2223
) -> HookJSONOutput:
2324
"""Hook that uses permissionDecision and reason fields."""
2425
tool_name = input_data.get("tool_name", "")
@@ -73,7 +74,7 @@ async def test_hook_with_continue_and_stop_reason():
7374
hook_invocations = []
7475

7576
async def post_tool_hook(
76-
input_data: dict, tool_use_id: str | None, context: HookContext
77+
input_data: HookInput, tool_use_id: str | None, context: HookContext
7778
) -> HookJSONOutput:
7879
"""PostToolUse hook that stops execution with stopReason."""
7980
tool_name = input_data.get("tool_name", "")
@@ -114,7 +115,7 @@ async def test_hook_with_additional_context():
114115
hook_invocations = []
115116

116117
async def context_hook(
117-
input_data: dict, tool_use_id: str | None, context: HookContext
118+
input_data: HookInput, tool_use_id: str | None, context: HookContext
118119
) -> HookJSONOutput:
119120
"""Hook that provides additional context."""
120121
hook_invocations.append("context_added")

examples/hooks.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from claude_agent_sdk.types import (
2020
AssistantMessage,
2121
HookContext,
22+
HookInput,
2223
HookJSONOutput,
2324
HookMatcher,
2425
Message,
@@ -43,7 +44,7 @@ def display_message(msg: Message) -> None:
4344

4445
##### Hook callback functions
4546
async def check_bash_command(
46-
input_data: dict[str, Any], tool_use_id: str | None, context: HookContext
47+
input_data: HookInput, tool_use_id: str | None, context: HookContext
4748
) -> HookJSONOutput:
4849
"""Prevent certain bash commands from being executed."""
4950
tool_name = input_data["tool_name"]
@@ -70,7 +71,7 @@ async def check_bash_command(
7071

7172

7273
async def add_custom_instructions(
73-
input_data: dict[str, Any], tool_use_id: str | None, context: HookContext
74+
input_data: HookInput, tool_use_id: str | None, context: HookContext
7475
) -> HookJSONOutput:
7576
"""Add custom instructions when a session starts."""
7677
return {
@@ -82,7 +83,7 @@ async def add_custom_instructions(
8283

8384

8485
async def review_tool_output(
85-
input_data: dict[str, Any], tool_use_id: str | None, context: HookContext
86+
input_data: HookInput, tool_use_id: str | None, context: HookContext
8687
) -> HookJSONOutput:
8788
"""Review tool output and provide additional context or warnings."""
8889
tool_response = input_data.get("tool_response", "")
@@ -102,7 +103,7 @@ async def review_tool_output(
102103

103104

104105
async def strict_approval_hook(
105-
input_data: dict[str, Any], tool_use_id: str | None, context: HookContext
106+
input_data: HookInput, tool_use_id: str | None, context: HookContext
106107
) -> HookJSONOutput:
107108
"""Demonstrates using permissionDecision to control tool execution."""
108109
tool_name = input_data.get("tool_name")
@@ -135,7 +136,7 @@ async def strict_approval_hook(
135136

136137

137138
async def stop_on_error_hook(
138-
input_data: dict[str, Any], tool_use_id: str | None, context: HookContext
139+
input_data: HookInput, tool_use_id: str | None, context: HookContext
139140
) -> HookJSONOutput:
140141
"""Demonstrates using continue=False to stop execution on certain conditions."""
141142
tool_response = input_data.get("tool_response", "")

src/claude_agent_sdk/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@
1818
from .types import (
1919
AgentDefinition,
2020
AssistantMessage,
21+
BaseHookInput,
2122
CanUseTool,
2223
ClaudeAgentOptions,
2324
ContentBlock,
2425
HookCallback,
2526
HookContext,
27+
HookInput,
2628
HookJSONOutput,
2729
HookMatcher,
2830
McpSdkServerConfig,
@@ -33,15 +35,21 @@
3335
PermissionResultAllow,
3436
PermissionResultDeny,
3537
PermissionUpdate,
38+
PostToolUseHookInput,
39+
PreCompactHookInput,
40+
PreToolUseHookInput,
3641
ResultMessage,
3742
SettingSource,
43+
StopHookInput,
44+
SubagentStopHookInput,
3845
SystemMessage,
3946
TextBlock,
4047
ThinkingBlock,
4148
ToolPermissionContext,
4249
ToolResultBlock,
4350
ToolUseBlock,
4451
UserMessage,
52+
UserPromptSubmitHookInput,
4553
)
4654

4755
# MCP Server Support
@@ -307,8 +315,17 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
307315
"PermissionResultAllow",
308316
"PermissionResultDeny",
309317
"PermissionUpdate",
318+
# Hook support
310319
"HookCallback",
311320
"HookContext",
321+
"HookInput",
322+
"BaseHookInput",
323+
"PreToolUseHookInput",
324+
"PostToolUseHookInput",
325+
"UserPromptSubmitHookInput",
326+
"StopHookInput",
327+
"SubagentStopHookInput",
328+
"PreCompactHookInput",
312329
"HookJSONOutput",
313330
"HookMatcher",
314331
# Agent support

src/claude_agent_sdk/types.py

Lines changed: 78 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,73 @@ class PermissionResultDeny:
157157
)
158158

159159

160+
# Hook input types - strongly typed for each hook event
161+
class BaseHookInput(TypedDict):
162+
"""Base hook input fields present across many hook events."""
163+
164+
session_id: str
165+
transcript_path: str
166+
cwd: str
167+
permission_mode: NotRequired[str]
168+
169+
170+
class PreToolUseHookInput(BaseHookInput):
171+
"""Input data for PreToolUse hook events."""
172+
173+
hook_event_name: Literal["PreToolUse"]
174+
tool_name: str
175+
tool_input: dict[str, Any]
176+
177+
178+
class PostToolUseHookInput(BaseHookInput):
179+
"""Input data for PostToolUse hook events."""
180+
181+
hook_event_name: Literal["PostToolUse"]
182+
tool_name: str
183+
tool_input: dict[str, Any]
184+
tool_response: Any
185+
186+
187+
class UserPromptSubmitHookInput(BaseHookInput):
188+
"""Input data for UserPromptSubmit hook events."""
189+
190+
hook_event_name: Literal["UserPromptSubmit"]
191+
prompt: str
192+
193+
194+
class StopHookInput(BaseHookInput):
195+
"""Input data for Stop hook events."""
196+
197+
hook_event_name: Literal["Stop"]
198+
stop_hook_active: bool
199+
200+
201+
class SubagentStopHookInput(BaseHookInput):
202+
"""Input data for SubagentStop hook events."""
203+
204+
hook_event_name: Literal["SubagentStop"]
205+
stop_hook_active: bool
206+
207+
208+
class PreCompactHookInput(BaseHookInput):
209+
"""Input data for PreCompact hook events."""
210+
211+
hook_event_name: Literal["PreCompact"]
212+
trigger: Literal["manual", "auto"]
213+
custom_instructions: str | None
214+
215+
216+
# Union type for all hook inputs
217+
HookInput = (
218+
PreToolUseHookInput
219+
| PostToolUseHookInput
220+
| UserPromptSubmitHookInput
221+
| StopHookInput
222+
| SubagentStopHookInput
223+
| PreCompactHookInput
224+
)
225+
226+
160227
# Hook-specific output types
161228
class PreToolUseHookSpecificOutput(TypedDict):
162229
"""Hook-specific output for PreToolUse events."""
@@ -265,21 +332,22 @@ class SyncHookJSONOutput(TypedDict):
265332
HookJSONOutput = AsyncHookJSONOutput | SyncHookJSONOutput
266333

267334

268-
@dataclass
269-
class HookContext:
270-
"""Context information for hook callbacks."""
335+
class HookContext(TypedDict):
336+
"""Context information for hook callbacks.
271337
272-
signal: Any | None = None # Future: abort signal support
338+
Fields:
339+
signal: Reserved for future abort signal support. Currently always None.
340+
"""
341+
342+
signal: Any | None # Future: abort signal support
273343

274344

275345
HookCallback = Callable[
276346
# HookCallback input parameters:
277-
# - input
278-
# See https://docs.anthropic.com/en/docs/claude-code/hooks#hook-input for
279-
# the type of 'input', the first value.
280-
# - tool_use_id
281-
# - context
282-
[dict[str, Any], str | None, HookContext],
347+
# - input: Strongly-typed hook input with discriminated unions based on hook_event_name
348+
# - tool_use_id: Optional tool use identifier
349+
# - context: Hook context with abort signal support (currently placeholder)
350+
[HookInput, str | None, HookContext],
283351
Awaitable[HookJSONOutput],
284352
]
285353

tests/test_tool_callbacks.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from claude_agent_sdk import (
88
ClaudeAgentOptions,
99
HookContext,
10+
HookInput,
1011
HookJSONOutput,
1112
HookMatcher,
1213
PermissionResultAllow,
@@ -216,7 +217,7 @@ async def test_hook_execution(self):
216217
hook_calls = []
217218

218219
async def test_hook(
219-
input_data: dict, tool_use_id: str | None, context: HookContext
220+
input_data: HookInput, tool_use_id: str | None, context: HookContext
220221
) -> dict:
221222
hook_calls.append({"input": input_data, "tool_use_id": tool_use_id})
222223
return {"processed": True}
@@ -266,7 +267,7 @@ async def test_hook_output_fields(self):
266267

267268
# Test all SyncHookJSONOutput fields together
268269
async def comprehensive_hook(
269-
input_data: dict, tool_use_id: str | None, context: HookContext
270+
input_data: HookInput, tool_use_id: str | None, context: HookContext
270271
) -> HookJSONOutput:
271272
return {
272273
# Control fields
@@ -349,7 +350,7 @@ async def test_async_hook_output(self):
349350
"""Test AsyncHookJSONOutput type with proper async fields."""
350351

351352
async def async_hook(
352-
input_data: dict, tool_use_id: str | None, context: HookContext
353+
input_data: HookInput, tool_use_id: str | None, context: HookContext
353354
) -> HookJSONOutput:
354355
# Test that async hooks properly use async_ and asyncTimeout fields
355356
return {
@@ -399,7 +400,7 @@ async def test_field_name_conversion(self):
399400
"""Test that Python-safe field names (async_, continue_) are converted to CLI format (async, continue)."""
400401

401402
async def conversion_test_hook(
402-
input_data: dict, tool_use_id: str | None, context: HookContext
403+
input_data: HookInput, tool_use_id: str | None, context: HookContext
403404
) -> HookJSONOutput:
404405
# Return both async_ and continue_ to test conversion
405406
return {
@@ -468,7 +469,7 @@ async def my_callback(
468469
return PermissionResultAllow()
469470

470471
async def my_hook(
471-
input_data: dict, tool_use_id: str | None, context: HookContext
472+
input_data: HookInput, tool_use_id: str | None, context: HookContext
472473
) -> dict:
473474
return {}
474475

0 commit comments

Comments
 (0)