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
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."View the code here
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.
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
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.
result = crew.kickoff(inputs={"question": "Please refund order A17."})
print(result.raw)Tool execution blocked by hook. Tool: refund_order
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
APPROVED.add("A17")
result = crew.kickoff(inputs={"question": "Please refund order A17."})
print(result.raw)Refund for A17 sent.
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.
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
| Point | When it runs |
|---|---|
| PRE_TOOL_CALL | before a tool runs (this lesson) |
| POST_TOOL_CALL | after a tool returns |
| PRE_MODEL_CALL | before each model call (the model-hooks lesson) |
| POST_MODEL_CALL | after 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.
Related
- Previous: max_iter: capping an agent's loop
- Next: Model hooks around the call
- Reference: CrewAI docs
- Ask to refund C40 after approving only A17.
- Remove
tools=["refund_order"]from@onand ask about A17's status. - Print
ctx.agent_roleinside the hook.
You understood something today that you didn't yesterday.