Claude Agent SDKclaude-agent-sdk 0.2.157 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
46 small wins to finish your pathNext lesson →

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.

Where each hook event fires in one turn of the agent loop. The prompt arrives and UserPromptSubmit fires before the model reads it. The model asks for a tool and PreToolUse fires, where a deny ends the call. If the tool runs, PostToolUse fires with the result; if it failed or was denied, PostToolUseFailure fires instead. The model then either asks for another tool or answers, and Stop fires once it has answered, before the result message.

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.

EventFires whenIts own input fields
UserPromptSubmita prompt is submitted, including a turn Claude Code starts itselfprompt
PreToolUsea tool call is requested, before anything runs ittool_name, tool_input, tool_use_id
PostToolUsea tool returned a resulttool_response, and the three above
PostToolUseFailurea tool call failed or was refusederror, is_interrupt
PermissionRequesta call needs a permission decisiontool_name, tool_input, permission_suggestions
Stopthe agent stopsstop_hook_active
SubagentStarta nested agent is started for a taskagent_id, agent_type
SubagentStopthat nested agent finishesagent_id, agent_type, agent_transcript_path
PreCompactthe conversation is about to be summarised to fittrigger, custom_instructions
NotificationClaude Code has something to reportmessage, 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.

python
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.

python
    async def turn(self, prompt):
        # UserPromptSubmit fires before the model is given the prompt
        await self.hooks("UserPromptSubmit", {"prompt": prompt})
        await self.start(prompt)
python
        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"])
python
        # 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})
python
            # 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
python
                answer = await self.ask({"subtype": "hook_callback",
                                         "callback_id": callback,
                                         "tool_use_id": data.get("tool_use_id"),
                                         "input": self.hook_input(event, data)})
python
    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)}"})
Project files used on this pageThis lesson builds on a project from earlier lessons. The code below imports these files. Click a file to see its code, or follow the link to the lesson that wrote it. To run the code yourself, keep them in the same folder.
View the code here
watch.py
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))
shop_desk.py
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"]
shop_tools.py
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.

python
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 {}
python
options = ClaudeAgentOptions(
    mcp_servers={"shop": shop},
    allowed_tools=["mcp__shop__lookup_order"],
    hooks={event: [HookMatcher(hooks=[note])] for event in EVENTS})
Example
async with ClaudeSDKClient(options, transport=ShopDesk(options)) as client:
    await client.query("Where is my order A17?")
    async for message in client.receive_response():
        pass
  • 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.

Example
# 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():
        pass

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.

Example
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 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 additionalContext before 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_active so a hook that restarts the agent can tell it is already inside one.
Watch out. A timeout is not a quiet failure. The hooks page says a PreToolUse callback that misses its deadline blocks the call and the model is told the hook did not answer, while a PostToolUse one that times out leaves the result alone. A slow network call inside a guard hook therefore stops work; set timeout on the matcher or return {"async_": True} and do the slow part in the background.
Try it yourself
  • Add "PermissionRequest" to EVENTS and run the refused refund again. Does the desk fire it?
  • Give the note matcher the pattern "mcp__shop__.*" and watch which of the five events stop printing.
  • Turn include_hook_events on for all five events and count the messages one turn produces.

This is what real progress feels like.