Refunds need approval: tool hooks
A hook is a function CrewAI calls at a fixed point of every run. A hook before each tool call can block the call, which is how a refund waits for a manager.
The shop's rule from lesson 2: 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.
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."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.
A hook before the tool 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)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.
After a manager says yes
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. Lesson 24 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 lesson 9. Two of them are this lesson's; the other two are the next lesson's.
@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.- 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.