Intercept and control agent behavior with hooks changedagent-sdk/hooks
Nearest release: v2.1.302, published 8 hours before upstream edited the page. Shown because the two are within 24 hours of each other. Nothing here says the release caused the edit.
Upstream edited this page at 17 Oct 2026 01:27 UTC, give or take a minute or two: the time comes from Anthropic’s own sitemap rather than from a commit. This site recorded the change at 20 Oct 2026 18:37 UTC.
Upstream edited
Recorded here
Lines+570added
Lines−596removed
From line
12
where the diff opens
First seen
14 Aug 2026
this site's first read of the page
Recorded edits18to this page, all time
The whole hunk
from line 12, old and new numbered
/
The two sides of this change are more than 400 edits apart, too far apart to line up, so this is the differ's own diff of it and the words inside a line are not marked.
from line 12
1212
1313## How hooks work
1414
15<Steps>
16 <Step title="An event fires">
17 Something happens during agent execution and the SDK fires an event: a tool is about to be called (`PreToolUse`), a tool returned a result (`PostToolUse`), a subagent started or stopped, the agent is idle, or execution finished. See the [full list of events](#available-hooks).
18 </Step>
19
20 <Step title="The SDK collects registered hooks">
21 The SDK checks for hooks registered for that event type. This includes callback hooks you pass in `options.hooks` and shell command hooks from settings files when the corresponding [`settingSources`](/docs/en/agent-sdk/typescript#settingsource) or [`setting_sources`](/docs/en/agent-sdk/python#settingsource) entry is enabled, which it is for default `query()` options.
22 </Step>
23
24 <Step title="Matchers filter which hooks run">
25 If a hook has a [`matcher`](#matchers) pattern (like `"Write|Edit"`), the SDK tests it against the event's target (for example, the tool name). Hooks without a matcher run for every event of that type.
26 </Step>
27
28 <Step title="Callback functions execute">
29 Each matching hook's [callback function](#callback-functions) receives input about what's happening: the tool name, its arguments, the session ID, and other event-specific details.
30 </Step>
31
32 <Step title="Your callback returns a decision">
33 After performing any operations (logging, API calls, validation), your callback returns an [output object](#outputs) that tells the agent what to do: allow the operation, block it, modify the input, or inject context into the conversation.
34 </Step>
35</Steps>
15**1. An event fires**
16
17Something happens during agent execution and the SDK fires an event: a tool is about to be called (`PreToolUse`), a tool returned a result (`PostToolUse`), a subagent started or stopped, the agent is idle, or execution finished. See the [full list of events](#available-hooks).
18
19**2. The SDK collects registered hooks**
20
21The SDK checks for hooks registered for that event type. This includes callback hooks you pass in `options.hooks` and shell command hooks from settings files when the corresponding [`settingSources`](/docs/en/agent-sdk/typescript#settingsource) or [`setting_sources`](/docs/en/agent-sdk/python#settingsource) entry is enabled, which it is for default `query()` options.
22
23**3. Matchers filter which hooks run**
24
25If a hook has a [`matcher`](#matchers) pattern (like `"Write|Edit"`), the SDK tests it against the event's target (for example, the tool name). Hooks without a matcher run for every event of that type.
26
27**4. Callback functions execute**
28
29Each matching hook's [callback function](#callback-functions) receives input about what's happening: the tool name, its arguments, the session ID, and other event-specific details.
30
31**5. Your callback returns a decision**
32
33After performing any operations (logging, API calls, validation), your callback returns an [output object](#outputs) that tells the agent what to do: allow the operation, block it, modify the input, or inject context into the conversation.
3634
3735This example registers a `PreToolUse` hook (step 1) with a `"Write|Edit"` matcher (step 3) so the callback fires for the Write and Edit tools. When triggered, the callback receives the tool's input (step 4), checks if the file path targets a `.env` file, and returns `permissionDecision: "deny"` to block the operation (step 5):
3836
39<CodeGroup>
40 ```python Python theme={null}
41 import asyncio
42 from claude_agent_sdk import (
43 AssistantMessage,
44 ClaudeSDKClient,
45 ClaudeAgentOptions,
46 HookMatcher,
47 ResultMessage,
48 )
49
50
51 # Define a hook callback that receives tool call details
52 async def protect_env_files(input_data, tool_use_id, context):
53 # Extract the file path from the tool's input arguments
54 file_path = input_data["tool_input"].get("file_path", "")
55 file_name = file_path.split("/")[-1]
56
57 # Block the operation if targeting a .env file
58 if file_name.lower() == ".env":
59 return {
60 "hookSpecificOutput": {
61 "hookEventName": input_data["hook_event_name"],
62 "permissionDecision": "deny",
63 "permissionDecisionReason": "Cannot modify .env files",
64 }
65 }
66
67 # Return empty object to allow the operation
68 return {}
69
70
71 async def main():
72 options = ClaudeAgentOptions(
73 hooks={
74 # Register the hook for PreToolUse events
75 # The matcher filters to only Write and Edit tool calls
76 "PreToolUse": [HookMatcher(matcher="Write|Edit", hooks=[protect_env_files])]
77 }
78 )
79
80 async with ClaudeSDKClient(options=options) as client:
81 await client.query("Create a .env file with the standard local development database configuration")
82 async for message in client.receive_response():
83 # Filter for assistant and result messages
84 if isinstance(message, (AssistantMessage, ResultMessage)):
85 print(message)
86
87
88 asyncio.run(main())
89 ```
90
91 ```typescript TypeScript theme={null}
92 import { query, HookCallback, PreToolUseHookInput } from "@anthropic-ai/claude-agent-sdk";
93
94 // Define a hook callback with the HookCallback type
95 const protectEnvFiles: HookCallback = async (input, toolUseID, { signal }) => {
96 // Cast input to the specific hook type for type safety
97 const preInput = input as PreToolUseHookInput;
98
99 // Cast tool_input to access its properties (typed as unknown in the SDK)
100 const toolInput = preInput.tool_input as Record<string, unknown>;
101 const filePath = toolInput?.file_path as string;
102 const fileName = filePath?.split("/").pop();
103
104 // Block the operation if targeting a .env file
105 if (fileName?.toLowerCase() === ".env") {
106 return {
107 hookSpecificOutput: {
108 hookEventName: preInput.hook_event_name,
109 permissionDecision: "deny",
110 permissionDecisionReason: "Cannot modify .env files"
37```python Python theme={null}
38import asyncio
39from claude_agent_sdk import (
40 AssistantMessage,
41 ClaudeSDKClient,
42 ClaudeAgentOptions,
43 HookMatcher,
44 ResultMessage,
45)
46
47
48# Define a hook callback that receives tool call details
49async def protect_env_files(input_data, tool_use_id, context):
50 # Extract the file path from the tool's input arguments
51 file_path = input_data["tool_input"].get("file_path", "")
52 file_name = file_path.split("/")[-1]
53
54 # Block the operation if targeting a .env file
55 if file_name.lower() == ".env":
56 return {
57 "hookSpecificOutput": {
58 "hookEventName": input_data["hook_event_name"],
59 "permissionDecision": "deny",
60 "permissionDecisionReason": "Cannot modify .env files",
61 }
11162 }
112 };
63
64 # Return empty object to allow the operation
65 return {}
66
67
68async def main():
69 options = ClaudeAgentOptions(
70 hooks={
71 # Register the hook for PreToolUse events
72 # The matcher filters to only Write and Edit tool calls
73 "PreToolUse": [HookMatcher(matcher="Write|Edit", hooks=[protect_env_files])]
74 }
75 )
76
77 async with ClaudeSDKClient(options=options) as client:
78 await client.query("Create a .env file with the standard local development database configuration")
79 async for message in client.receive_response():
80 # Filter for assistant and result messages
81 if isinstance(message, (AssistantMessage, ResultMessage)):
82 print(message)
83
84
85asyncio.run(main())
86```
87
88```typescript TypeScript theme={null}
89import { query, HookCallback, PreToolUseHookInput } from "@anthropic-ai/claude-agent-sdk";
90
91// Define a hook callback with the HookCallback type
92const protectEnvFiles: HookCallback = async (input, toolUseID, { signal }) => {
93 // Cast input to the specific hook type for type safety
94 const preInput = input as PreToolUseHookInput;
95
96 // Cast tool_input to access its properties (typed as unknown in the SDK)
97 const toolInput = preInput.tool_input as Record<string, unknown>;
98 const filePath = toolInput?.file_path as string;
99 const fileName = filePath?.split("/").pop();
100
101 // Block the operation if targeting a .env file
102 if (fileName?.toLowerCase() === ".env") {
103 return {
104 hookSpecificOutput: {
105 hookEventName: preInput.hook_event_name,
106 permissionDecision: "deny",
107 permissionDecisionReason: "Cannot modify .env files"
108 }
109 };
110 }
111
112 // Return empty object to allow the operation
113 return {};
114};
115
116for await (const message of query({
117 prompt: "Create a .env file with the standard local development database configuration",
118 options: {
119 hooks: {
120 // Register the hook for PreToolUse events
121 // The matcher filters to only Write and Edit tool calls
122 PreToolUse: [{ matcher: "Write|Edit", hooks: [protectEnvFiles] }]
113123 }
114
115 // Return empty object to allow the operation
116 return {};
117 };
118
119 for await (const message of query({
120 prompt: "Create a .env file with the standard local development database configuration",
121 options: {
122 hooks: {
123 // Register the hook for PreToolUse events
124 // The matcher filters to only Write and Edit tool calls
125 PreToolUse: [{ matcher: "Write|Edit", hooks: [protectEnvFiles] }]
126 }
127 }
128 })) {
129 // Filter for assistant and result messages
130 if (message.type === "assistant" || message.type === "result") {
131 console.log(message);
132 }
133 }
134 ```
135</CodeGroup>
124 }
125})) {
126 // Filter for assistant and result messages
127 if (message.type === "assistant" || message.type === "result") {
128 console.log(message);
129 }
130}
131```
136132
137133The hook denies a Write or Edit call whose `file_path` ends in `/.env`, in any letter case. On Windows, `file_path` uses backslashes, such as `C:\project\.env`, and this hook allows a Write or Edit call to `C:\project\.env`.
138134
139<Warning>
140 The `Write|Edit` matcher limits this hook to the Write and Edit tools. If Claude writes `.env` with a Bash command, the hook doesn't run. To block Bash writes to a file where [the sandbox runs](/docs/en/sandboxing), see the sandbox configuration for [Python](/docs/en/agent-sdk/python#sandbox-configuration) or [TypeScript](/docs/en/agent-sdk/typescript#sandbox-configuration).
141</Warning>
135> [!WARNING]
136>
137> The `Write|Edit` matcher limits this hook to the Write and Edit tools. If Claude writes `.env` with a Bash command, the hook doesn't run. To block Bash writes to a file where [the sandbox runs](/docs/en/sandboxing), see the sandbox configuration for [Python](/docs/en/agent-sdk/python#sandbox-configuration) or [TypeScript](/docs/en/agent-sdk/typescript#sandbox-configuration).
142138
143139## Available hooks
144140
from line 180
184180
185181To configure a hook, pass it in the `hooks` field of your agent options (`ClaudeAgentOptions` in Python, the `options` object in TypeScript). This snippet assumes you have already defined a hook callback, like `protect_env_files` in Python or `protectEnvFiles` in TypeScript from the example above:
186182
187<CodeGroup>
188 ```python Python theme={null}
189 options = ClaudeAgentOptions(
190 hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[my_callback])]}
191 )
192
193 async with ClaudeSDKClient(options=options) as client:
194 await client.query("Your prompt")
195 async for message in client.receive_response():
196 print(message)
197 ```
198
199 ```typescript TypeScript theme={null}
200 for await (const message of query({
201 prompt: "Your prompt",
202 options: {
203 hooks: {
204 PreToolUse: [{ matcher: "Bash", hooks: [myCallback] }]
205 }
183```python Python theme={null}
184options = ClaudeAgentOptions(
185 hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[my_callback])]}
186)
187
188async with ClaudeSDKClient(options=options) as client:
189 await client.query("Your prompt")
190 async for message in client.receive_response():
191 print(message)
192```
193
194```typescript TypeScript theme={null}
195for await (const message of query({
196 prompt: "Your prompt",
197 options: {
198 hooks: {
199 PreToolUse: [{ matcher: "Bash", hooks: [myCallback] }]
206200 }
207 })) {
208 console.log(message);
209 }
210 ```
211</CodeGroup>
201 }
202})) {
203 console.log(message);
204}
205```
212206
213207The `hooks` option is a dictionary in Python or an object in TypeScript, where:
214208
from line 247
253247
254248Return `{}` to allow the operation without changes. SDK callback hooks use the same JSON output format as [Claude Code shell command hooks](/docs/en/hooks#json-output), which documents every field and event-specific option. For the SDK type definitions, see the [TypeScript](/docs/en/agent-sdk/typescript#synchookjsonoutput) and [Python](/docs/en/agent-sdk/python#synchookjsonoutput) SDK references.
255249
256<Note>
257 When multiple `PreToolUse` hooks return different [decisions](/docs/en/hooks#pretooluse-decision-control), `deny` takes priority over `defer`, which takes priority over `ask`, which takes priority over `allow`.
258</Note>
250> [!NOTE]
251>
252> When multiple `PreToolUse` hooks return different [decisions](/docs/en/hooks#pretooluse-decision-control), `deny` takes priority over `defer`, which takes priority over `ask`, which takes priority over `allow`.
259253
260254#### Asynchronous output
261255
262256By default, the agent waits for your hook to return before proceeding. If your hook performs a side effect, such as logging or sending a webhook, and doesn't need to influence the agent's behavior, you can return an async output instead. This tells the agent to continue immediately without waiting for the hook to finish. In this snippet, `send_to_logging_service` in Python and `sendToLoggingService` in TypeScript stand in for any logging function you define:
263257
264<CodeGroup>
265 ```python Python theme={null}
266 async def async_hook(input_data, tool_use_id, context):
267 # Start a background task, then return immediately
268 asyncio.create_task(send_to_logging_service(input_data))
269 return {"async_": True, "asyncTimeout": 30000}
270 ```
271
272 ```typescript TypeScript theme={null}
273 const asyncHook: HookCallback = async (input, toolUseID, { signal }) => {
274 // Start a background task, then return immediately
275 sendToLoggingService(input).catch(console.error);
276 return { async: true, asyncTimeout: 30000 };
277 };
278 ```
279</CodeGroup>
258```python Python theme={null}
259async def async_hook(input_data, tool_use_id, context):
260 # Start a background task, then return immediately
261 asyncio.create_task(send_to_logging_service(input_data))
262 return {"async_": True, "asyncTimeout": 30000}
263```
264
265```typescript TypeScript theme={null}
266const asyncHook: HookCallback = async (input, toolUseID, { signal }) => {
267 // Start a background task, then return immediately
268 sendToLoggingService(input).catch(console.error);
269 return { async: true, asyncTimeout: 30000 };
270};
271```
280272
281273| Field | Type | Description |
282274| - | - | - |
283275| `async` | `true` | Signals async mode. The agent proceeds without waiting. In Python, use `async_` to avoid the reserved keyword. |
284276| `asyncTimeout` | `number` | Optional timeout in milliseconds for the background operation |
285277
286<Note>
287 Async outputs can't block, modify, or inject context into the operation since the agent has already moved on. Use them only for side effects like logging, metrics, or notifications.
288</Note>
278> [!NOTE]
279>
280> Async outputs can't block, modify, or inject context into the operation since the agent has already moved on. Use them only for side effects like logging, metrics, or notifications.
289281
290282## Examples
291283
from line 287
295287
296288This example intercepts Write tool calls and rewrites the `file_path` argument to prepend `/sandbox`. The callback returns `updatedInput` with the modified path and `permissionDecision: 'allow'` to auto-approve the rewritten operation:
297289
298<CodeGroup>
299 ```python Python theme={null}
300 async def redirect_to_sandbox(input_data, tool_use_id, context):
301 if input_data["hook_event_name"] != "PreToolUse":
302 return {}
303
304 if input_data["tool_name"] == "Write":
305 original_path = input_data["tool_input"].get("file_path", "")
306 return {
307 "hookSpecificOutput": {
308 "hookEventName": input_data["hook_event_name"],
309 "permissionDecision": "allow",
310 "updatedInput": {
311 **input_data["tool_input"],
312 "file_path": f"/sandbox{original_path}",
313 },
314 }
315 }
316 return {}
317 ```
318
319 ```typescript TypeScript theme={null}
320 const redirectToSandbox: HookCallback = async (input, toolUseID, { signal }) => {
321 if (input.hook_event_name !== "PreToolUse") return {};
322
323 const preInput = input as PreToolUseHookInput;
324 const toolInput = preInput.tool_input as Record<string, unknown>;
325 if (preInput.tool_name === "Write") {
326 const originalPath = toolInput.file_path as string;
327 return {
328 hookSpecificOutput: {
329 hookEventName: preInput.hook_event_name,
330 permissionDecision: "allow",
331 updatedInput: {
332 ...toolInput,
333 file_path: `/sandbox${originalPath}`
334 }
290```python Python theme={null}
291async def redirect_to_sandbox(input_data, tool_use_id, context):
292 if input_data["hook_event_name"] != "PreToolUse":
293 return {}
294
295 if input_data["tool_name"] == "Write":
296 original_path = input_data["tool_input"].get("file_path", "")
297 return {
298 "hookSpecificOutput": {
299 "hookEventName": input_data["hook_event_name"],
300 "permissionDecision": "allow",
301 "updatedInput": {
302 **input_data["tool_input"],
303 "file_path": f"/sandbox{original_path}",
304 },
305 }
335306 }
336 };
337 }
338 return {};
339 };
340 ```
341</CodeGroup>
342
343<Note>
344 Pair `updatedInput` with `permissionDecision: 'allow'` to auto-approve the modified input, or `permissionDecision: 'ask'` to show it to the user. If you omit `permissionDecision`, the modified input still applies and flows through the normal permission evaluation. With `'defer'`, `updatedInput` is ignored. Always return a new object rather than mutating the original `tool_input`.
345</Note>
307 return {}
308```
309
310```typescript TypeScript theme={null}
311const redirectToSandbox: HookCallback = async (input, toolUseID, { signal }) => {
312 if (input.hook_event_name !== "PreToolUse") return {};
313
314 const preInput = input as PreToolUseHookInput;
315 const toolInput = preInput.tool_input as Record<string, unknown>;
316 if (preInput.tool_name === "Write") {
317 const originalPath = toolInput.file_path as string;
318 return {
319 hookSpecificOutput: {
320 hookEventName: preInput.hook_event_name,
321 permissionDecision: "allow",
322 updatedInput: {
323 ...toolInput,
324 file_path: `/sandbox${originalPath}`
325 }
326 }
327 };
328 }
329 return {};
330};
331```
332
333> [!NOTE]
334>
335> Pair `updatedInput` with `permissionDecision: 'allow'` to auto-approve the modified input, or `permissionDecision: 'ask'` to show it to the user. If you omit `permissionDecision`, the modified input still applies and flows through the normal permission evaluation. With `'defer'`, `updatedInput` is ignored. Always return a new object rather than mutating the original `tool_input`.
346336
347337To confirm the redirect, set the prefix to a path you can write to, such as `./sandbox` or `/tmp/sandbox` (macOS doesn't allow creating a root-level `/sandbox` directory), then ask the agent to write a file: the Write tool's result in the message stream names the path with your sandbox prefix rather than the one Claude requested.
348338
from line 344
354344* `permissionDecisionReason` tells the model why.
355345* `systemMessage` shows the user what happened.
356346
357<CodeGroup>
358 ```python Python theme={null}
359 async def block_etc_writes(input_data, tool_use_id, context):
360 file_path = input_data["tool_input"].get("file_path", "")
361
362 if file_path.startswith("/etc"):
363 return {
364 # Top-level field: message shown to the user
365 "systemMessage": "Remember: system directories like /etc are protected.",
366 # hookSpecificOutput: block the operation
367 "hookSpecificOutput": {
368 "hookEventName": input_data["hook_event_name"],
369 "permissionDecision": "deny",
370 "permissionDecisionReason": "Writing to /etc is not allowed",
371 },
372 }
373 return {}
374 ```
375
376 ```typescript TypeScript theme={null}
377 const blockEtcWrites: HookCallback = async (input, toolUseID, { signal }) => {
378 const preInput = input as PreToolUseHookInput;
379 const toolInput = preInput.tool_input as Record<string, unknown>;
380 const filePath = toolInput?.file_path as string;
381
382 if (filePath?.startsWith("/etc")) {
383 return {
384 // Top-level field: message shown to the user
385 systemMessage: "Remember: system directories like /etc are protected.",
386 // hookSpecificOutput: block the operation
387 hookSpecificOutput: {
388 hookEventName: preInput.hook_event_name,
389 permissionDecision: "deny",
390 permissionDecisionReason: "Writing to /etc is not allowed"
347```python Python theme={null}
348async def block_etc_writes(input_data, tool_use_id, context):
349 file_path = input_data["tool_input"].get("file_path", "")
350
351 if file_path.startswith("/etc"):
352 return {
353 # Top-level field: message shown to the user
354 "systemMessage": "Remember: system directories like /etc are protected.",
355 # hookSpecificOutput: block the operation
356 "hookSpecificOutput": {
357 "hookEventName": input_data["hook_event_name"],
358 "permissionDecision": "deny",
359 "permissionDecisionReason": "Writing to /etc is not allowed",
360 },
391361 }
392 };
362 return {}
363```
364
365```typescript TypeScript theme={null}
366const blockEtcWrites: HookCallback = async (input, toolUseID, { signal }) => {
367 const preInput = input as PreToolUseHookInput;
368 const toolInput = preInput.tool_input as Record<string, unknown>;
369 const filePath = toolInput?.file_path as string;
370
371 if (filePath?.startsWith("/etc")) {
372 return {
373 // Top-level field: message shown to the user
374 systemMessage: "Remember: system directories like /etc are protected.",
375 // hookSpecificOutput: block the operation
376 hookSpecificOutput: {
377 hookEventName: preInput.hook_event_name,
378 permissionDecision: "deny",
379 permissionDecisionReason: "Writing to /etc is not allowed"
380 }
381 };
382 }
383 return {};
384};
385```
386
387To confirm the block, register the callback under `PreToolUse` with a `Write|Edit` matcher and ask the agent to create a file under `/etc`: the Write tool's result in the message stream contains `Writing to /etc is not allowed`, and no file is created.
388
389### Auto-approve specific tools
390
391By default, the agent may prompt for permission before using certain tools. This example auto-approves read-only filesystem tools (Read, Glob, Grep) by returning `permissionDecision: 'allow'`, letting them run without user confirmation, apart from reads from [network paths](/docs/en/permissions#network-paths), while leaving all other tools subject to normal permission checks:
392
393```python Python theme={null}
394async def auto_approve_read_only(input_data, tool_use_id, context):
395 if input_data["hook_event_name"] != "PreToolUse":
396 return {}
397
398 read_only_tools = ["Read", "Glob", "Grep"]
399 if input_data["tool_name"] in read_only_tools:
400 return {
401 "hookSpecificOutput": {
402 "hookEventName": input_data["hook_event_name"],
403 "permissionDecision": "allow",
404 "permissionDecisionReason": "Read-only tool auto-approved",
405 }
406 }
407 return {}
408```
409
410```typescript TypeScript theme={null}
411const autoApproveReadOnly: HookCallback = async (input, toolUseID, { signal }) => {
412 if (input.hook_event_name !== "PreToolUse") return {};
413
414 const preInput = input as PreToolUseHookInput;
415 const readOnlyTools = ["Read", "Glob", "Grep"];
416 if (readOnlyTools.includes(preInput.tool_name)) {
417 return {
418 hookSpecificOutput: {
419 hookEventName: preInput.hook_event_name,
420 permissionDecision: "allow",
421 permissionDecisionReason: "Read-only tool auto-approved"
422 }
423 };
424 }
425 return {};
426};
427```
428
429### Register multiple hooks
430
431When an event fires, the matching hooks run in parallel. For permission decisions, the most restrictive result applies. One hook's `deny` blocks the tool call. Because completion order is non-deterministic, write each hook to act independently rather than relying on another hook having run first.
432
433The example below registers three independent checks, each with an [omitted matcher](/docs/en/hooks#matcher-patterns). The hook names in it, such as `audit_logger` in Python or `auditLogger` in TypeScript, stand in for callbacks you define:
434
435```python Python theme={null}
436options = ClaudeAgentOptions(
437 hooks={
438 "PreToolUse": [
439 HookMatcher(hooks=[authorization_check]),
440 HookMatcher(hooks=[input_validator]),
441 HookMatcher(hooks=[audit_logger]),
442 ]
393443 }
394 return {};
395 };
396 ```
397</CodeGroup>
398
399To confirm the block, register the callback under `PreToolUse` with a `Write|Edit` matcher and ask the agent to create a file under `/etc`: the Write tool's result in the message stream contains `Writing to /etc is not allowed`, and no file is created.
400
401### Auto-approve specific tools
402
403By default, the agent may prompt for permission before using certain tools. This example auto-approves read-only filesystem tools (Read, Glob, Grep) by returning `permissionDecision: 'allow'`, letting them run without user confirmation, apart from reads from [network paths](/docs/en/permissions#network-paths), while leaving all other tools subject to normal permission checks:
404
405<CodeGroup>
406 ```python Python theme={null}
407 async def auto_approve_read_only(input_data, tool_use_id, context):
408 if input_data["hook_event_name"] != "PreToolUse":
409 return {}
410
411 read_only_tools = ["Read", "Glob", "Grep"]
412 if input_data["tool_name"] in read_only_tools:
413 return {
414 "hookSpecificOutput": {
415 "hookEventName": input_data["hook_event_name"],
416 "permissionDecision": "allow",
417 "permissionDecisionReason": "Read-only tool auto-approved",
418 }
419 }
420 return {}
421 ```
422
423 ```typescript TypeScript theme={null}
424 const autoApproveReadOnly: HookCallback = async (input, toolUseID, { signal }) => {
425 if (input.hook_event_name !== "PreToolUse") return {};
426
427 const preInput = input as PreToolUseHookInput;
428 const readOnlyTools = ["Read", "Glob", "Grep"];
429 if (readOnlyTools.includes(preInput.tool_name)) {
430 return {
431 hookSpecificOutput: {
432 hookEventName: preInput.hook_event_name,
433 permissionDecision: "allow",
434 permissionDecisionReason: "Read-only tool auto-approved"
435 }
436 };
437 }
438 return {};
439 };
440 ```
441</CodeGroup>
442
443### Register multiple hooks
444
445When an event fires, the matching hooks run in parallel. For permission decisions, the most restrictive result applies. One hook's `deny` blocks the tool call. Because completion order is non-deterministic, write each hook to act independently rather than relying on another hook having run first.
446
447The example below registers three independent checks, each with an [omitted matcher](/docs/en/hooks#matcher-patterns). The hook names in it, such as `audit_logger` in Python or `auditLogger` in TypeScript, stand in for callbacks you define:
448
449<CodeGroup>
450 ```python Python theme={null}
451 options = ClaudeAgentOptions(
452 hooks={
453 "PreToolUse": [
454 HookMatcher(hooks=[authorization_check]),
455 HookMatcher(hooks=[input_validator]),
456 HookMatcher(hooks=[audit_logger]),
457 ]
458 }
459 )
460 ```
461
462 ```typescript TypeScript theme={null}
463 const options = {
464 hooks: {
465 PreToolUse: [
466 { hooks: [authorizationCheck] },
467 { hooks: [inputValidator] },
468 { hooks: [auditLogger] }
469 ]
470 }
471 };
472 ```
473</CodeGroup>
444)
445```
446
447```typescript TypeScript theme={null}
448const options = {
449 hooks: {
450 PreToolUse: [
451 { hooks: [authorizationCheck] },
452 { hooks: [inputValidator] },
453 { hooks: [auditLogger] }
454 ]
455 }
456};
457```
474458
475459### Filter with multi-tool matchers
476460
from line 464
480464* A regex (`^mcp__`) triggers `mcp_audit_hook` for any MCP tool whose name starts with `mcp__`.
481465* An omitted matcher triggers `global_logger` without filtering by tool name.
482466
483<CodeGroup>
484 ```python Python theme={null}
485 options = ClaudeAgentOptions(
486 hooks={
487 "PreToolUse": [
488 # Match file modification tools
489 HookMatcher(matcher="Write|Edit|NotebookEdit", hooks=[file_security_hook]),
490 # Match all MCP tools
491 HookMatcher(matcher="^mcp__", hooks=[mcp_audit_hook]),
492 # Match everything (no matcher)
493 HookMatcher(hooks=[global_logger]),
494 ]
495 }
496 )
497 ```
498
499 ```typescript TypeScript theme={null}
500 const options = {
467```python Python theme={null}
468options = ClaudeAgentOptions(
469 hooks={
470 "PreToolUse": [
471 # Match file modification tools
472 HookMatcher(matcher="Write|Edit|NotebookEdit", hooks=[file_security_hook]),
473 # Match all MCP tools
474 HookMatcher(matcher="^mcp__", hooks=[mcp_audit_hook]),
475 # Match everything (no matcher)
476 HookMatcher(hooks=[global_logger]),
477 ]
478 }
479)
480```
481
482```typescript TypeScript theme={null}
483const options = {
484 hooks: {
485 PreToolUse: [
486 // Match file modification tools
487 { matcher: "Write|Edit|NotebookEdit", hooks: [fileSecurityHook] },
488
489 // Match all MCP tools
490 { matcher: "^mcp__", hooks: [mcpAuditHook] },
491
492 // Match everything (no matcher)
493 { hooks: [globalLogger] }
494 ]
495 }
496};
497```
498
499### Track subagent activity
500
501Use `SubagentStop` hooks to monitor when subagents finish their work. See the full input type in the [TypeScript](/docs/en/agent-sdk/typescript#hookinput) and [Python](/docs/en/agent-sdk/python#hookinput) SDK references. This example logs a summary each time a subagent completes:
502
503```python Python theme={null}
504async def subagent_tracker(input_data, tool_use_id, context):
505 # Log subagent details when it finishes
506 print(f"[SUBAGENT] Completed: {input_data['agent_id']}")
507 print(f" Transcript: {input_data['agent_transcript_path']}")
508 print(f" Tool use ID: {tool_use_id}")
509 print(f" Stop hook active: {input_data.get('stop_hook_active')}")
510 return {}
511
512
513options = ClaudeAgentOptions(
514 hooks={"SubagentStop": [HookMatcher(hooks=[subagent_tracker])]}
515)
516```
517
518```typescript TypeScript theme={null}
519import { HookCallback, SubagentStopHookInput } from "@anthropic-ai/claude-agent-sdk";
520
521const subagentTracker: HookCallback = async (input, toolUseID, { signal }) => {
522 // Cast to SubagentStopHookInput to access subagent-specific fields
523 const subInput = input as SubagentStopHookInput;
524
525 // Log subagent details when it finishes
526 console.log(`[SUBAGENT] Completed: ${subInput.agent_id}`);
527 console.log(` Transcript: ${subInput.agent_transcript_path}`);
528 console.log(` Tool use ID: ${toolUseID}`);
529 console.log(` Stop hook active: ${subInput.stop_hook_active}`);
530 return {};
531};
532
533const options = {
534 hooks: {
535 SubagentStop: [{ hooks: [subagentTracker] }]
536 }
537};
538```
539
540To confirm the hook fires, register the callback and ask the agent to delegate a small task to a subagent, such as listing the files in the current directory: when the subagent finishes, the callback prints the `[SUBAGENT] Completed:` lines with the subagent's ID and transcript path.
541
542### Make HTTP requests from hooks
543
544Hooks can perform asynchronous operations like HTTP requests. Catch errors inside your hook instead of letting them propagate.
545
546This example sends a webhook after each tool completes, logging which tool ran and when. The hook catches errors from a failed webhook:
547
548```python Python theme={null}
549import asyncio
550import json
551import urllib.request
552from datetime import datetime
553
554
555def _send_webhook(tool_name):
556 """Synchronous helper that POSTs tool usage data to an external webhook."""
557 data = json.dumps(
558 {
559 "tool": tool_name,
560 "timestamp": datetime.now().isoformat(),
561 }
562 ).encode()
563 req = urllib.request.Request(
564 "https://api.example.com/webhook",
565 data=data,
566 headers={"Content-Type": "application/json"},
567 method="POST",
568 )
569 urllib.request.urlopen(req)
570
571
572async def webhook_notifier(input_data, tool_use_id, context):
573 # Only fire after a tool completes (PostToolUse), not before
574 if input_data["hook_event_name"] != "PostToolUse":
575 return {}
576
577 try:
578 # Run the blocking HTTP call in a thread to avoid blocking the event loop
579 await asyncio.to_thread(_send_webhook, input_data["tool_name"])
580 except Exception as e:
581 # Log the error but don't raise
582 print(f"Webhook request failed: {e}")
583
584 return {}
585```
586
587```typescript TypeScript theme={null}
588import { query, HookCallback, PostToolUseHookInput } from "@anthropic-ai/claude-agent-sdk";
589
590const webhookNotifier: HookCallback = async (input, toolUseID, { signal }) => {
591 // Only fire after a tool completes (PostToolUse), not before
592 if (input.hook_event_name !== "PostToolUse") return {};
593
594 try {
595 await fetch("https://api.example.com/webhook", {
596 method: "POST",
597 headers: { "Content-Type": "application/json" },
598 body: JSON.stringify({
599 tool: (input as PostToolUseHookInput).tool_name,
600 timestamp: new Date().toISOString()
601 }),
602 // Pass signal so the request cancels if the hook times out
603 signal
604 });
605 } catch (error) {
606 // Handle cancellation separately from other errors
607 if (error instanceof Error && error.name === "AbortError") {
608 console.log("Webhook request cancelled");
609 }
610 // Don't re-throw
611 }
612
613 return {};
614};
615
616// Register as a PostToolUse hook
617for await (const message of query({
618 prompt: "Refactor the auth module",
619 options: {
501620 hooks: {
502 PreToolUse: [
503 // Match file modification tools
504 { matcher: "Write|Edit|NotebookEdit", hooks: [fileSecurityHook] },
505
506 // Match all MCP tools
507 { matcher: "^mcp__", hooks: [mcpAuditHook] },
508
509 // Match everything (no matcher)
510 { hooks: [globalLogger] }
511 ]
621 PostToolUse: [{ hooks: [webhookNotifier] }]
512622 }
513 };
514 ```
515</CodeGroup>
516
517### Track subagent activity
518
519Use `SubagentStop` hooks to monitor when subagents finish their work. See the full input type in the [TypeScript](/docs/en/agent-sdk/typescript#hookinput) and [Python](/docs/en/agent-sdk/python#hookinput) SDK references. This example logs a summary each time a subagent completes:
520
521<CodeGroup>
522 ```python Python theme={null}
523 async def subagent_tracker(input_data, tool_use_id, context):
524 # Log subagent details when it finishes
525 print(f"[SUBAGENT] Completed: {input_data['agent_id']}")
526 print(f" Transcript: {input_data['agent_transcript_path']}")
527 print(f" Tool use ID: {tool_use_id}")
528 print(f" Stop hook active: {input_data.get('stop_hook_active')}")
529 return {}
530
531
532 options = ClaudeAgentOptions(
533 hooks={"SubagentStop": [HookMatcher(hooks=[subagent_tracker])]}
534 )
535 ```
536
537 ```typescript TypeScript theme={null}
538 import { HookCallback, SubagentStopHookInput } from "@anthropic-ai/claude-agent-sdk";
539
540 const subagentTracker: HookCallback = async (input, toolUseID, { signal }) => {
541 // Cast to SubagentStopHookInput to access subagent-specific fields
542 const subInput = input as SubagentStopHookInput;
543
544 // Log subagent details when it finishes
545 console.log(`[SUBAGENT] Completed: ${subInput.agent_id}`);
546 console.log(` Transcript: ${subInput.agent_transcript_path}`);
547 console.log(` Tool use ID: ${toolUseID}`);
548 console.log(` Stop hook active: ${subInput.stop_hook_active}`);
549 return {};
550 };
551
552 const options = {
553 hooks: {
554 SubagentStop: [{ hooks: [subagentTracker] }]
555 }
556 };
557 ```
558</CodeGroup>
559
560To confirm the hook fires, register the callback and ask the agent to delegate a small task to a subagent, such as listing the files in the current directory: when the subagent finishes, the callback prints the `[SUBAGENT] Completed:` lines with the subagent's ID and transcript path.
561
562### Make HTTP requests from hooks
563
564Hooks can perform asynchronous operations like HTTP requests. Catch errors inside your hook instead of letting them propagate.
565
566This example sends a webhook after each tool completes, logging which tool ran and when. The hook catches errors from a failed webhook:
567
568<CodeGroup>
569 ```python Python theme={null}
570 import asyncio
571 import json
572 import urllib.request
573 from datetime import datetime
574
575
576 def _send_webhook(tool_name):
577 """Synchronous helper that POSTs tool usage data to an external webhook."""
578 data = json.dumps(
579 {
580 "tool": tool_name,
581 "timestamp": datetime.now().isoformat(),
582 }
583 ).encode()
584 req = urllib.request.Request(
585 "https://api.example.com/webhook",
586 data=data,
587 headers={"Content-Type": "application/json"},
588 method="POST",
589 )
590 urllib.request.urlopen(req)
591
592
593 async def webhook_notifier(input_data, tool_use_id, context):
594 # Only fire after a tool completes (PostToolUse), not before
595 if input_data["hook_event_name"] != "PostToolUse":
596 return {}
597
598 try:
599 # Run the blocking HTTP call in a thread to avoid blocking the event loop
600 await asyncio.to_thread(_send_webhook, input_data["tool_name"])
601 except Exception as e:
602 # Log the error but don't raise
603 print(f"Webhook request failed: {e}")
604
605 return {}
606 ```
607
608 ```typescript TypeScript theme={null}
609 import { query, HookCallback, PostToolUseHookInput } from "@anthropic-ai/claude-agent-sdk";
610
611 const webhookNotifier: HookCallback = async (input, toolUseID, { signal }) => {
612 // Only fire after a tool completes (PostToolUse), not before
613 if (input.hook_event_name !== "PostToolUse") return {};
614
615 try {
616 await fetch("https://api.example.com/webhook", {
617 method: "POST",
618 headers: { "Content-Type": "application/json" },
619 body: JSON.stringify({
620 tool: (input as PostToolUseHookInput).tool_name,
621 timestamp: new Date().toISOString()
622 }),
623 // Pass signal so the request cancels if the hook times out
624 signal
625 });
626 } catch (error) {
627 // Handle cancellation separately from other errors
628 if (error instanceof Error && error.name === "AbortError") {
629 console.log("Webhook request cancelled");
630 }
631 // Don't re-throw
632 }
633
634 return {};
635 };
636
637 // Register as a PostToolUse hook
638 for await (const message of query({
639 prompt: "Refactor the auth module",
640 options: {
641 hooks: {
642 PostToolUse: [{ hooks: [webhookNotifier] }]
643 }
644 }
645 })) {
646 console.log(message);
647 }
648 ```
649</CodeGroup>
623 }
624})) {
625 console.log(message);
626}
627```
650628
651629To confirm the hook fires, point the webhook URL at an endpoint you can watch and send a prompt that uses a tool: the hook sends a POST with the tool name and timestamp after each tool completes.
652630
from line 641
663641
664642This example forwards every notification to a Slack channel. It requires a [Slack incoming webhook URL](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/), which you create by adding an app to your Slack workspace and enabling incoming webhooks:
665643
666<CodeGroup>
667 ```python Python theme={null}
668 import asyncio
669 import json
670 import urllib.request
671
672 from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions, HookMatcher
673
674
675 def _send_slack_notification(message):
676 """Synchronous helper that sends a message to Slack via incoming webhook."""
677 data = json.dumps({"text": f"Agent status: {message}"}).encode()
678 req = urllib.request.Request(
679 "https://hooks.slack.com/services/YOUR/WEBHOOK/URL",
680 data=data,
681 headers={"Content-Type": "application/json"},
682 method="POST",
683 )
684 urllib.request.urlopen(req)
685
686
687 async def notification_handler(input_data, tool_use_id, context):
688 try:
689 # Run the blocking HTTP call in a thread to avoid blocking the event loop
690 await asyncio.to_thread(_send_slack_notification, input_data.get("message", ""))
691 except Exception as e:
692 print(f"Failed to send notification: {e}")
693
694 # Return empty object. Notification hooks don't modify agent behavior
695 return {}
696
697
698 async def main():
699 options = ClaudeAgentOptions(
700 hooks={
701 # Register the hook for Notification events (no matcher needed)
702 "Notification": [HookMatcher(hooks=[notification_handler])],
703 },
704 )
705
706 async with ClaudeSDKClient(options=options) as client:
707 await client.query("Analyze this codebase")
708 async for message in client.receive_response():
709 print(message)
710
711
712 asyncio.run(main())
713 ```
714
715 ```typescript TypeScript theme={null}
716 import { query, HookCallback, NotificationHookInput } from "@anthropic-ai/claude-agent-sdk";
717
718 // Define a hook callback that sends notifications to Slack
719 const notificationHandler: HookCallback = async (input, toolUseID, { signal }) => {
720 // Cast to NotificationHookInput to access the message field
721 const notification = input as NotificationHookInput;
722
723 try {
724 // POST the notification message to a Slack incoming webhook
725 await fetch("https://hooks.slack.com/services/YOUR/WEBHOOK/URL", {
726 method: "POST",
727 headers: { "Content-Type": "application/json" },
728 body: JSON.stringify({
729 text: `Agent status: ${notification.message}`
730 }),
731 // Pass signal so the request cancels if the hook times out
732 signal
733 });
734 } catch (error) {
735 if (error instanceof Error && error.name === "AbortError") {
736 console.log("Notification cancelled");
737 } else {
738 console.error("Failed to send notification:", error);
739 }
644```python Python theme={null}
645import asyncio
646import json
647import urllib.request
648
649from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions, HookMatcher
650
651
652def _send_slack_notification(message):
653 """Synchronous helper that sends a message to Slack via incoming webhook."""
654 data = json.dumps({"text": f"Agent status: {message}"}).encode()
655 req = urllib.request.Request(
656 "https://hooks.slack.com/services/YOUR/WEBHOOK/URL",
657 data=data,
658 headers={"Content-Type": "application/json"},
659 method="POST",
660 )
661 urllib.request.urlopen(req)
662
663
664async def notification_handler(input_data, tool_use_id, context):
665 try:
666 # Run the blocking HTTP call in a thread to avoid blocking the event loop
667 await asyncio.to_thread(_send_slack_notification, input_data.get("message", ""))
668 except Exception as e:
669 print(f"Failed to send notification: {e}")
670
671 # Return empty object. Notification hooks don't modify agent behavior
672 return {}
673
674
675async def main():
676 options = ClaudeAgentOptions(
677 hooks={
678 # Register the hook for Notification events (no matcher needed)
679 "Notification": [HookMatcher(hooks=[notification_handler])],
680 },
681 )
682
683 async with ClaudeSDKClient(options=options) as client:
684 await client.query("Analyze this codebase")
685 async for message in client.receive_response():
686 print(message)
687
688
689asyncio.run(main())
690```
691
692```typescript TypeScript theme={null}
693import { query, HookCallback, NotificationHookInput } from "@anthropic-ai/claude-agent-sdk";
694
695// Define a hook callback that sends notifications to Slack
696const notificationHandler: HookCallback = async (input, toolUseID, { signal }) => {
697 // Cast to NotificationHookInput to access the message field
698 const notification = input as NotificationHookInput;
699
700 try {
701 // POST the notification message to a Slack incoming webhook
702 await fetch("https://hooks.slack.com/services/YOUR/WEBHOOK/URL", {
703 method: "POST",
704 headers: { "Content-Type": "application/json" },
705 body: JSON.stringify({
706 text: `Agent status: ${notification.message}`
707 }),
708 // Pass signal so the request cancels if the hook times out
709 signal
710 });
711 } catch (error) {
712 if (error instanceof Error && error.name === "AbortError") {
713 console.log("Notification cancelled");
714 } else {
715 console.error("Failed to send notification:", error);
740716 }
741
742 // Return empty object. Notification hooks don't modify agent behavior
743 return {};
744 };
745
746 // Register the hook for Notification events (no matcher needed)
747 for await (const message of query({
748 prompt: "Analyze this codebase",
749 options: {
750 hooks: {
751 Notification: [{ hooks: [notificationHandler] }]
752 }
717 }
718
719 // Return empty object. Notification hooks don't modify agent behavior
720 return {};
721};
722
723// Register the hook for Notification events (no matcher needed)
724for await (const message of query({
725 prompt: "Analyze this codebase",
726 options: {
727 hooks: {
728 Notification: [{ hooks: [notificationHandler] }]
753729 }
754 })) {
755 console.log(message);
756 }
757 ```
758</CodeGroup>
730 }
731})) {
732 console.log(message);
733}
734```
759735
760736When a `Notification` event fires, the hook posts the notification's `message`, prefixed with `Agent status:`, to the channel your webhook targets.
761737
from line 808
832808
833809`SessionStart` and `SessionEnd` can be registered as SDK callback hooks in TypeScript, but aren't available in the Python SDK because its `HookEvent` type omits them. In Python, they are only available as [shell command hooks](/docs/en/hooks#hook-events) defined in settings files such as `.claude/settings.json`. Which settings files your SDK application loads depends on [`setting_sources`](/docs/en/agent-sdk/python#settingsource) or [`settingSources`](/docs/en/agent-sdk/typescript#settingsource). If you set that option, include the source that holds the hooks:
834810
835<CodeGroup>
836 ```python Python theme={null}
837 options = ClaudeAgentOptions(
838 setting_sources=["project"], # Loads .claude/settings.json including hooks
839 )
840 ```
841
842 ```typescript TypeScript theme={null}
843 const options = {
844 settingSources: ["project"] // Loads .claude/settings.json including hooks
845 };
846 ```
847</CodeGroup>
811```python Python theme={null}
812options = ClaudeAgentOptions(
813 setting_sources=["project"], # Loads .claude/settings.json including hooks
814)
815```
816
817```typescript TypeScript theme={null}
818const options = {
819 settingSources: ["project"] // Loads .claude/settings.json including hooks
820};
821```
848822
849823To run initialization logic as a Python SDK callback instead, use the first message from `client.receive_response()` as your trigger.
850824
851825
No line in this hunk matches that.