CrewAICrewAI 1.15 · Python 3.10 to 3.13
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
35 small wins to finish your pathNext lesson →

Tool hooks: approving a refund

A tool hook is a function CrewAI runs before a tool; raising HookAborted blocks the call, which is how a refund waits for a manager.

Last updated: 28 Sep, 2026 · CrewAI 1.15

The shop's rule from the plain-Python lesson: a refund needs a manager's approval. The clerk gets a second tool, and the rule goes in a hook, outside the model, where no prompt can talk its way round it.

A refund tool and the clerk

python
from crewai.tools import tool

ORDERS = {"A17": "shipped on 3 March", "C40": "waiting for stock"}


@tool
def lookup_order(order_id: str) -> str:
    """Look up an order's shipping status by its id, such as A17."""
    status = ORDERS.get(order_id)
    return f"{order_id} {status}." if status else f"{order_id} is not an order we have."


@tool
def refund_order(order_id: str) -> str:
    """Refund an order in full. Every refund needs a manager's approval."""
    return f"Refund for {order_id} sent."
Project files used on this pageThis lesson builds on a project from earlier lessons. The code below imports this file. Click a file to see its code, or follow the link to the lesson that wrote it. To run the code yourself, keep it in the same folder.
View the code here
shop_llm.py
import json
import os
import re

from crewai import BaseLLM

os.environ["CREWAI_DISABLE_TELEMETRY"] = "true"
os.environ["CREWAI_TRACING_ENABLED"] = "false"
os.environ["CREWAI_DISABLE_VERSION_CHECK"] = "true"


class ShopLLM(BaseLLM):
    script: list = []

    def supports_function_calling(self):
        return True

    def call(self, messages, tools=None, **kwargs):
        if isinstance(messages, str):
            messages = [{"role": "user", "content": messages}]
        if self.script:
            return self.script.pop(0)
        names = [t["function"]["name"] for t in tools or []]
        return self.decide(messages, names)

    def decide(self, messages, tools):
        last = messages[-1]
        if last["role"] == "tool":
            return last["content"]
        text = last["content"]
        orders = re.findall(r"\b[A-Z]\d+\b", text)
        wanted = "refund_order" if "refund" in text.lower() else "lookup_order"
        matches = [name for name in tools if name.endswith(wanted)]
        if orders and matches:
            args = json.dumps({"order_id": orders[0]})
            return [{"id": f"call_{orders[0]}", "type": "function",
                     "function": {"name": matches[0], "arguments": args}}]
        if orders:
            return f"I have no way to look up {orders[0]} yet."
        return "Hello. Which order is this about?"

refund_order would send the money. Its docstring mentions approval, but a model is free to ignore a docstring.

python
from crewai import Agent, Crew, Task
from shop_llm import ShopLLM
from tools import lookup_order, refund_order

clerk = Agent(
    role="Order clerk",
    goal="Find the status of customers' orders",
    backstory="You can look up any order in the shop's system.",
    llm=ShopLLM(model="shop"),
    tools=[lookup_order, refund_order],
)
task = Task(description="Answer the customer: {question}",
            expected_output="The order's status in one sentence.", agent=clerk)
crew = Crew(agents=[clerk], tasks=[task])

The clerk can now look an order up or refund it, which is exactly the pair worth stopping before it runs.

Blocking a tool before it runs

python
from crewai.hooks import HookAborted, InterceptionPoint, on

APPROVED = set()


@on(InterceptionPoint.PRE_TOOL_CALL, tools=["refund_order"])
def needs_approval(ctx):
    order_id = ctx.tool_input["order_id"]
    if order_id not in APPROVED:
        raise HookAborted(reason=f"refund for {order_id} needs approval")

@on registers a function for an interception point, here PRE_TOOL_CALL, before a tool runs. tools= limits it to refund_order. The hook receives a context with the tool's name and arguments. Raising HookAborted stops the call; returning normally lets it through.

Example
result = crew.kickoff(inputs={"question": "Please refund order A17."})
print(result.raw)

The model asked for refund_order, the hook refused, and the tool never ran. What the model got back as the tool's result is Tool execution blocked by hook and the tool's name. The reason you gave is not in it, although the hooks page says the reason propagates; in 1.15.22 the model is not told why.

Letting it through after approval

Example
APPROVED.add("A17")
result = crew.kickoff(inputs={"question": "Please refund order A17."})
print(result.raw)

The same crew, the same question, and this time the refund ran. The rule lives in your code, so a manager's approval is a change to APPROVED, not to a prompt. The human-feedback lesson shows a flow that pauses until a person answers.

Where a hook can step in
allowedblockedThe agent loopmodel and toolsPRE_MODEL_CALLbefore each callThe modeldecidesPOST_MODEL_CALLafter each callPRE_TOOL_CALLapproval gaterefund_ordersends the money
Hover or tap a piece to see what it is and which lesson built it.
Trace a refund

Pick one to watch it run, step by step.

The four points a hook can sit at, around the loop from the tool-calls lesson. Two of them are this lesson's; the other two are the next lesson's.

Reading the two runs

  • The hook raised, so the tool never ran; the model got "Tool execution blocked by hook" as the result.
  • The reason you passed is not sent to the model in this version, though the hooks page says it propagates.
  • Approval is a change to your set, not to a prompt, so the model cannot argue its way past it.

The four interception points

PointWhen it runs
PRE_TOOL_CALLbefore a tool runs (this lesson)
POST_TOOL_CALLafter a tool returns
PRE_MODEL_CALLbefore each model call (the model-hooks lesson)
POST_MODEL_CALLafter each model reply (the model-hooks lesson)

When to guard a tool with a hook

  • The tool moves money or sends something a customer receives.
  • The tool deletes or changes data that is hard to undo.
  • A person has to sign off before the action, as with a refund here.
Watch out
A hook registered with @on stays registered for every crew in the process, as a listener does. Running the cell that defines one a second time registers it again, and it then runs twice per call. In a notebook, run clear_all_hooks() from crewai.hooks first.
Try it yourself
  • Ask to refund C40 after approving only A17.
  • Remove tools=["refund_order"] from @on and ask about A17's status.
  • Print ctx.agent_role inside the hook.

You understood something today that you didn't yesterday.