The hook events
A hook event is the named point in the agent loop at which Claude Code calls your callbacks. The Python SDK accepts ten of them, and the key you use in hooks is the event's name.
Last updated: 20 Oct, 2026 · Claude Agent SDK
PreToolUse and PostToolUse are the two most used, but they are two of ten. The rest cover the prompt arriving, the turn ending, a tool failing, and a few moments this course meets later.
The ten events Python accepts
These are the values the installed 0.2.157 allows as keys of hooks. The hooks page lists many more, and its Python column marks the ones TypeScript has and Python does not.
| Event | Fires when | Its own input fields |
|---|---|---|
| UserPromptSubmit | a prompt is submitted, including a turn Claude Code starts itself | prompt |
| PreToolUse | a tool call is requested, before anything runs it | tool_name, tool_input, tool_use_id |
| PostToolUse | a tool returned a result | tool_response, and the three above |
| PostToolUseFailure | a tool call failed or was refused | error, is_interrupt |
| PermissionRequest | a call needs a permission decision | tool_name, tool_input, permission_suggestions |
| Stop | the agent stops | stop_hook_active |
| SubagentStart | a nested agent is started for a task | agent_id, agent_type |
| SubagentStop | that nested agent finishes | agent_id, agent_type, agent_transcript_path |
| PreCompact | the conversation is about to be summarised to fit | trigger, custom_instructions |
| Notification | Claude Code has something to report | message, title, notification_type |
Two gaps are worth knowing. SessionStart and SessionEnd are not in the Python SDK's event list, although a SessionStartHookSpecificOutput type exists beside it; the hooks page says to register those two as shell command hooks in a settings file instead. The events marked TypeScript-only there, such as PostToolBatch and MessageDisplay, raise nothing in Python; they are accepted as dictionary keys and never fire.
The three arguments and the two return shapes
Every callback has the same signature, whatever the event.
from claude_agent_sdk import HookContext, HookInput, HookJSONOutput
async def any_hook(input_data: HookInput, tool_use_id: str | None,
context: HookContext) -> HookJSONOutput:
# hook_event_name is on every input, so one callback can serve many events
if input_data["hook_event_name"] == "Stop":
return {"systemMessage": "the turn ended"}
return {}HookInput is a union of ten typed dictionaries picked apart by hook_event_name, and HookContext carries one field, signal, which the Python SDK keeps for later and leaves as None. HookJSONOutput is either the synchronous shape you have been returning, or {"async_": True}, which tells the agent not to wait: the hooks page says an async output cannot block, change or add to the operation, because the agent has already moved on.
Firing the other events from the desk
shop_desk.py grows four small changes: turn fires UserPromptSubmit and Stop, use fires PostToolUseFailure, the matcher test stops assuming there is a tool name, and a new lifecycle method sends the frames include_hook_events asks for.
async def turn(self, prompt):
# UserPromptSubmit fires before the model is given the prompt
await self.hooks("UserPromptSubmit", {"prompt": prompt})
await self.start(prompt) await self.say(step["text"])
# Stop fires once the model has answered and wants no more tools
await self.hooks("Stop", {"stop_hook_active": False})
await self.finish("success", turns + 1, step["text"]) # a denied or raising call gets its own event, so a failure is never silent
if failed:
await self.hooks("PostToolUseFailure", {"tool_name": name, "error": text,
"tool_input": args, "tool_use_id": call}) # an event with no tool name tests the matcher against an empty string
if group["matcher"] and not re.fullmatch(group["matcher"],
data.get("tool_name", "")):
continue answer = await self.ask({"subtype": "hook_callback",
"callback_id": callback,
"tool_use_id": data.get("tool_use_id"),
"input": self.hook_input(event, data)}) async def lifecycle(self, event, phase):
# include_hook_events asks for these two frames around every callback
if self.options.include_hook_events:
await self.send({"type": "system", "subtype": phase,
"hook_event": event, "session_id": self.session_id,
"uuid": f"ev_{next(self.ids)}"})View the code here
from claude_agent_sdk import (AssistantMessage, ResultMessage, SystemMessage,
TextBlock, ToolResultBlock, ToolUseBlock, UserMessage, query)
from shop_desk import ShopDesk
def describe(message):
# the two messages with no content blocks are read from their own fields
if isinstance(message, SystemMessage):
return f"system {message.subtype}"
if isinstance(message, ResultMessage):
return f"result {message.subtype}: {message.result or message.errors}"
# a type this course does not use prints its name rather than failing
if not isinstance(message, (AssistantMessage, UserMessage)):
return type(message).__name__
return describe_block(message.content[0])
def describe_block(block):
if isinstance(block, TextBlock):
return f"assistant {block.text}"
if isinstance(block, ToolUseBlock):
return f"assistant calls {block.name} {block.input}"
# a tool result sits on the user's side, and its error flag is worth seeing
if isinstance(block, ToolResultBlock):
flag = " (error)" if block.is_error else ""
return f"user result{flag}: {block.content}"
return type(block).__name__
async def watch(prompt, options, model=None):
async for message in query(prompt=prompt, options=options,
transport=ShopDesk(options, model)):
print(describe(message))
import asyncio
import itertools
import json
import re
from claude_agent_sdk import ClaudeAgentOptions, ProcessError, Transport
ORDER = re.compile(r"\b[A-Z]\d+\b")
SESSIONS = itertools.count(1)
class ShopModel:
"""Stands in for Claude: reads the conversation, picks the next step."""
def __init__(self, script=None):
self.script = list(script or [])
def reply(self, said, results, tools):
# a scripted reply lets a lesson force a step the stand-in would not pick
if self.script:
return self.script.pop(0)
if results:
return {"text": " ".join(results)}
# the newest message first, then the last id named earlier in the chat
orders = ORDER.findall(said[-1]) or ORDER.findall(" ".join(said))[-1:]
wanted = "refund_order" if "refund" in said[-1].lower() else "lookup_order"
tool = next((name for name in tools if name.endswith(wanted)), None)
if orders and tool:
return {"calls": [(tool, {"order_id": order}) for order in orders]}
return {"text": f"I cannot help with order {orders[0]} yet." if orders
else "Which order is this about?"}
class ShopDesk(Transport):
"""Stands in for the Claude Code binary: it runs the loop over JSON lines."""
def __init__(self, options=None, model=None):
self.options = options or ClaudeAgentOptions()
self.model = model or ShopModel()
# a counted id, not a UUID, so the printed output is the same every run
self.session_id = f"session-{next(SESSIONS)}"
self.inbox, self.outbox = asyncio.Queue(), asyncio.Queue()
self.prompts, self.said = asyncio.Queue(), []
self.ids, self.setup, self.waiting = itertools.count(1), {}, {}
self.tools, self.failed, self.ready = {}, None, False
async def connect(self):
self.ready = True
def is_ready(self):
return self.ready
async def write(self, data):
# one JSON line at a time, which is what the SDK writes to a real process
await self.inbox.put(json.loads(data))
async def end_input(self):
# for a real process this would close its standard input
await self.inbox.put(None)
async def close(self):
self.ready = False
async def send(self, message):
await self.outbox.put(message)
async def read_messages(self):
# the listener and the worker run alongside the SDK's own read loop
asyncio.create_task(self.listen())
self.worker = asyncio.create_task(self.work())
while (message := await self.outbox.get()) is not None:
if isinstance(message, Exception):
raise message
yield message
async def listen(self):
while (message := await self.inbox.get()) is not None:
# a control request is the SDK asking a question; initialize is first
if message["type"] == "control_request":
if message["request"]["subtype"] == "initialize":
self.setup = message["request"]
await self.respond(message["request_id"])
# a control response answers a question the desk itself asked
elif message["type"] == "control_response":
answer = message["response"]
self.waiting.pop(answer["request_id"]).set_result(answer)
elif message["type"] == "user":
await self.prompts.put(message["message"]["content"])
# the input has ended: finish the work in hand, then pass on any failure
await self.prompts.put(None)
await self.worker
await self.send(self.failed)
async def respond(self, request_id):
# the stand-in says yes to every request the SDK makes of it
await self.send({"type": "control_response", "response": {
"subtype": "success", "request_id": request_id, "response": {}}})
async def ask(self, request):
# a future per request, so the answer can be awaited where it was asked
request_id = f"desk_{next(self.ids)}"
self.waiting[request_id] = asyncio.get_running_loop().create_future()
await self.send({"type": "control_request", "request_id": request_id,
"request": request})
return await self.waiting[request_id]
async def work(self):
while (prompt := await self.prompts.get()) is not None:
await self.turn(prompt)
async def start(self, prompt):
self.said.append(prompt)
if not self.tools:
self.tools = await self.find_tools()
await self.send({"type": "system", "subtype": "init", "model": "shop-desk",
"session_id": self.session_id, "tools": list(self.tools)})
async def turn(self, prompt):
# UserPromptSubmit fires before the model is given the prompt
await self.hooks("UserPromptSubmit", {"prompt": prompt})
await self.start(prompt)
results, turns, limit = [], 0, self.options.max_turns
while "calls" in (step := self.model.reply(self.said, results, self.tools)):
turns += 1
# max_turns is the desk's own stop, not something the model respects
if limit and turns > limit:
message = f"Reached maximum number of turns ({limit})"
return await self.finish("error_max_turns", turns, message)
results = [await self.use(name, args) for name, args in step["calls"]]
await self.say(step["text"])
# Stop fires once the model has answered and wants no more tools
await self.hooks("Stop", {"stop_hook_active": False})
await self.finish("success", turns + 1, step["text"])
async def say(self, text):
# with include_partial_messages on, the text goes out a word at a time
if self.options.include_partial_messages:
for word in text.split(" "):
delta = {"type": "text_delta", "text": word + " "}
await self.send({"type": "stream_event", "uuid": f"ev_{next(self.ids)}",
"session_id": self.session_id, "event": {
"type": "content_block_delta", "index": 0,
"delta": delta}})
# the whole message still follows, so code written earlier keeps working
await self.send(self.message("assistant", [{"type": "text", "text": text}]))
async def finish(self, subtype, turns, text):
ok = subtype == "success"
result = {"type": "result", "subtype": subtype, "is_error": not ok,
"duration_ms": 0, "duration_api_ms": 0, "num_turns": turns,
"session_id": self.session_id, "total_cost_usd": 0.0,
"usage": {"input_tokens": len(" ".join(self.said).split()),
"output_tokens": len(text.split())}}
# cost is zero and usage counts words, because no model was called
result.update({"result": text} if ok else {"errors": [text]})
await self.send(result)
self.failed = ProcessError("Command failed", exit_code=1) if not ok else None
def message(self, kind, content, parent=None):
body = {"role": kind, "content": content}
if kind == "assistant":
body.update(model="shop-desk", id=f"msg_{next(self.ids)}")
return {"type": kind, "message": body, "parent_tool_use_id": parent,
"session_id": self.session_id}
async def mcp(self, server, method, params):
message = {"jsonrpc": "2.0", "id": next(self.ids), "method": method,
"params": params}
answer = await self.ask({"subtype": "mcp_message", "server_name": server,
"message": message})
return answer["response"]["mcp_response"]["result"]
async def find_tools(self):
tools = {}
for server in self.options.mcp_servers:
await self.mcp(server, "initialize", {"protocolVersion": "2025-06-18",
"capabilities": {}, "clientInfo": {"name": "desk", "version": "1"}})
for tool in (await self.mcp(server, "tools/list", {}))["tools"]:
name = f"mcp__{server}__{tool['name']}"
# a tool left out here never reaches the model at all
if name not in self.options.disallowed_tools:
tools[name] = (server, tool["name"])
return tools
async def use(self, name, args):
call = f"toolu_{next(self.ids)}"
block = {"type": "tool_use", "id": call, "name": name, "input": args}
await self.send(self.message("assistant", [block]))
allowed, args = await self.permit(name, args, call)
text, failed = (await self.run(name, args, call)) if allowed else (args, True)
# a denied or raising call gets its own event, so a failure is never silent
if failed:
await self.hooks("PostToolUseFailure", {"tool_name": name, "error": text,
"tool_input": args, "tool_use_id": call})
result = {"type": "tool_result", "tool_use_id": call, "content": text,
"is_error": failed}
await self.send(self.message("user", [result]))
return text
async def run(self, name, args, call):
# the full name carries the server it came from, so the call goes back there
server, tool = self.tools[name]
result = await self.mcp(server, "tools/call", {"name": tool, "arguments": args})
# the hooks see the answer before the model does, with the same call id
text = result["content"][0]["text"]
for output in await self.hooks("PostToolUse", {"tool_name": name, "tool_input": args,
"tool_response": text, "tool_use_id": call}):
# updatedToolOutput replaces what the model is about to read
decision = output.get("hookSpecificOutput", {})
text = decision.get("updatedToolOutput", text)
return text, result.get("isError", False)
def hook_input(self, event, data):
# the fields a hook input carries whatever the event is, plus the
# event's own fields on top
return {"hook_event_name": event, "session_id": self.session_id,
"cwd": ".", "transcript_path": "", **data}
async def hooks(self, event, data):
outputs = []
for group in (self.setup.get("hooks") or {}).get(event, []):
# a matcher of None means the group runs for every tool
# an event with no tool name tests the matcher against an empty string
if group["matcher"] and not re.fullmatch(group["matcher"],
data.get("tool_name", "")):
continue
# the SDK sent ids, not functions, so the call goes back by id
for callback in group["hookCallbackIds"]:
await self.lifecycle(event, "hook_started")
answer = await self.ask({"subtype": "hook_callback",
"callback_id": callback,
"tool_use_id": data.get("tool_use_id"),
"input": self.hook_input(event, data)})
outputs.append(answer["response"])
await self.lifecycle(event, "hook_response")
return outputs
async def lifecycle(self, event, phase):
# include_hook_events asks for these two frames around every callback
if self.options.include_hook_events:
await self.send({"type": "system", "subtype": phase,
"hook_event": event, "session_id": self.session_id,
"uuid": f"ev_{next(self.ids)}"})
async def pre_hooks(self, name, args, call):
verdict = None
for output in await self.hooks("PreToolUse", {"tool_name": name,
"tool_input": args, "tool_use_id": call}):
decision = output.get("hookSpecificOutput", {})
# one deny ends it, and the other hooks' opinions no longer matter
if decision.get("permissionDecision") == "deny":
return "deny", decision.get("permissionDecisionReason", "Blocked by a hook")
verdict = decision.get("permissionDecision", verdict)
args = decision.get("updatedInput", args)
return verdict, args
async def permit(self, name, args, call):
# hooks are the first step, so nothing below here can skip them
verdict, args = await self.pre_hooks(name, args, call)
if verdict in ("allow", "deny"):
return verdict == "allow", args
o = self.options
if o.permission_mode == "bypassPermissions" or name in o.allowed_tools:
return True, args
# nothing approved the call and there is nobody left to ask
if o.can_use_tool is None or o.permission_mode == "dontAsk":
return False, f"Permission to use {name} was not granted."
return await self.ask_permission(name, args, call)
async def ask_permission(self, name, args, call):
# the SDK runs your callback and writes its answer back on this channel
decision = (await self.ask({"subtype": "can_use_tool", "tool_name": name,
"input": args, "tool_use_id": call,
"permission_suggestions": None,
"blocked_path": None}))["response"]
# an allow may hand back different arguments to run with
if decision["behavior"] == "allow":
return True, decision["updatedInput"]
return False, decision["message"]
from claude_agent_sdk import create_sdk_mcp_server, tool
ORDERS = {"A17": "Order A17 shipped on 3 March."}
# the description is what Claude reads to decide this is the right tool
@tool("lookup_order", "Look up an order's shipping status by its id, such as A17.",
{"order_id": str})
async def lookup_order(args):
order = args["order_id"]
# an unknown id is answered in words, not raised, so Claude can pass it on
status = ORDERS.get(order, f"No order {order} was found.")
return {"content": [{"type": "text", "text": status}]}
REFUNDED = []
@tool("refund_order", "Refund an order by its id. The money goes back to the customer.",
{"order_id": str})
async def refund_order(args):
REFUNDED.append(args["order_id"])
text = f"Order {args['order_id']} has been refunded."
return {"content": [{"type": "text", "text": text}]}
shop = create_sdk_mcp_server("shop", tools=[lookup_order, refund_order])
One callback on five events
Registering the same function everywhere turns the printed order into the loop's order.
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, HookMatcher
from shop_desk import ShopDesk
from shop_tools import shop
EVENTS = ["UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", "Stop"]
async def note(input_data, tool_use_id, context):
# one callback on every event, so the printed order is the loop's order
print("hook:", input_data["hook_event_name"], tool_use_id)
return {}options = ClaudeAgentOptions(
mcp_servers={"shop": shop},
allowed_tools=["mcp__shop__lookup_order"],
hooks={event: [HookMatcher(hooks=[note])] for event in EVENTS})async with ClaudeSDKClient(options, transport=ShopDesk(options)) as client:
await client.query("Where is my order A17?")
async for message in client.receive_response():
passhook: UserPromptSubmit None hook: PreToolUse toolu_6 hook: PostToolUse toolu_6 hook: Stop None
- UserPromptSubmit came first, with no tool use id, because no tool is involved yet.
- PreToolUse and PostToolUse share a tool use id, which is how you pair a request with its answer.
- PostToolUseFailure did not fire, because the lookup was allowed and succeeded.
- Stop came last, after the model had answered and before the result message.
The same turn with the call refused
Drop the lookup out of allowed_tools and ask for a refund instead. Nothing allows the call and there is no callback to ask, so it is refused and a different event fires.
# the same five hooks, and no tool allowed this time
refused = ClaudeAgentOptions(mcp_servers={"shop": shop}, hooks=options.hooks)
async with ClaudeSDKClient(refused, transport=ShopDesk(refused)) as client:
await client.query("Please refund my order A17")
async for message in client.receive_response():
passhook: UserPromptSubmit None hook: PreToolUse toolu_6 hook: PostToolUseFailure toolu_6 hook: Stop None
PostToolUseFailure took PostToolUse's place. A hook on that event is where you count refusals, or tell the model to stop asking for a tool it is never going to get.
Watching the hooks themselves with include_hook_events
Set include_hook_events=True and Claude Code puts two more messages in the stream for every callback it runs. The SDK parses them into HookEventMessage, which is a SystemMessage with the event's name on it.
from claude_agent_sdk import HookEventMessage
# one hook this time, with the lifecycle frames turned on
watched = ClaudeAgentOptions(mcp_servers={"shop": shop}, include_hook_events=True,
allowed_tools=["mcp__shop__lookup_order"],
hooks={"PreToolUse": [HookMatcher(hooks=[note])]})
async with ClaudeSDKClient(watched, transport=ShopDesk(watched)) as client:
await client.query("Where is my order A17?")
async for message in client.receive_response():
if isinstance(message, HookEventMessage):
print(message.subtype, message.hook_event_name)hook_started PreToolUse hook: PreToolUse toolu_5 hook_response PreToolUse
hook_started arrives before your function runs and hook_response after it, with the raw frame kept in data. From the real Claude Code the response frame also carries output, exit_code and outcome, which is how you see a hook that timed out or crashed without instrumenting the hook itself.
Picking the event for the job
- Add context to a question. UserPromptSubmit, which can return
additionalContextbefore the model has read anything. - Guard an action. PreToolUse, the only event that can deny.
- Clean or record a result. PostToolUse for a success, PostToolUseFailure for the rest.
- Save state at the end of a turn. Stop, which also sees
stop_hook_activeso a hook that restarts the agent can tell it is already inside one.
timeout on the matcher or return {"async_": True} and do the slow part in the background.Related
- Previous: Hooks that change a call
- Next: A conversation with ClaudeSDKClient
- Reference: Intercept and control agent behavior with hooks
- Add
"PermissionRequest"toEVENTSand run the refused refund again. Does the desk fire it? - Give the
notematcher the pattern"mcp__shop__.*"and watch which of the five events stop printing. - Turn
include_hook_eventson for all five events and count the messages one turn produces.
This is what real progress feels like.