Model hooks around the call
A model hook is a function CrewAI runs around each model call; a POST_MODEL_CALL hook can replace the reply, a PRE_MODEL_CALL hook can stop the call.
Last updated: 28 Sep, 2026 · CrewAI 1.15
The tool-hooks lesson's hook guarded a tool. The same @on works on each model call. Here the writer's model has a scripted reply that contains a card number, which must never reach a customer.
View the code here
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."
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?"
A writer with a card number in its reply
from crewai import Agent, Crew, Task
from shop_llm import ShopLLM
writer = Agent(
role="Reply writer",
goal="Write replies to customers",
backstory="You write short, clear emails.",
llm=ShopLLM(model="shop", script=["We refunded card 4111 1111 1111 1234."]),
)
task = Task(description="Tell the customer where the refund went.",
expected_output="One sentence.", agent=writer)
crew = Crew(agents=[writer], tasks=[task])script from the tool-calls lesson makes the model return that exact sentence.
Masking a reply with POST_MODEL_CALL
import re
from crewai.hooks import InterceptionPoint, on
@on(InterceptionPoint.POST_MODEL_CALL)
def hide_cards(ctx):
if isinstance(ctx.response, str):
return re.sub(r"\b(\d{4}) ?\d{4} ?\d{4} ?(\d{4})\b", r"\1 **** **** \2", ctx.response)A POST_MODEL_CALL hook runs after the model answers, with the reply in ctx.response. Returning a string replaces the reply; returning nothing keeps it. A tool call is a list, not a string, so the hook leaves it alone.
print(crew.kickoff().raw)We refunded card 4111 **** **** 1234.
The agent, the task and the crew only ever saw the masked number. The guardrail in the guardrails lesson does a similar job on a task's finished answer, with a retry instead of an edit.
Stopping a call with PRE_MODEL_CALL
from crewai.hooks import HookAborted, InterceptionPoint, clear_all_hooks, on
@on(InterceptionPoint.PRE_MODEL_CALL)
def at_most_two(ctx):
print("model call, iteration", ctx.iterations)
if ctx.iterations >= 2:
raise HookAborted(reason="too many model calls")PRE_MODEL_CALL runs before each call. ctx.iterations counts the rounds of the agent loop so far, starting at 0.
from crewai import Agent, Crew, Task
from shop_llm import ShopLLM
from tools import lookup_order
ask = [{"id": "c1", "type": "function",
"function": {"name": "lookup_order", "arguments": '{"order_id": "A17"}'}}]
clerk = Agent(role="Order clerk", goal="Find orders", backstory="You look orders up.",
llm=ShopLLM(model="shop", script=[ask] * 5), tools=[lookup_order])
task = Task(description="Where is my order A17?", expected_output="One sentence.", agent=clerk)
crew = Crew(agents=[clerk], tasks=[task])This clerk's model is scripted to ask for the same lookup five times in a row.
try:
crew.kickoff()
except Exception as error:
print(type(error).__name__, "|", error)
finally:
clear_all_hooks()model call, iteration 0 model call, iteration 1 model call, iteration 2 [CrewAIEventsBus] Warning: Event pairing mismatch. 'task_failed' closed 'agent_execution_started' (expected 'task_started') [CrewAIEventsBus] Warning: Event pairing mismatch. 'crew_kickoff_failed' closed 'task_started' (expected 'crew_kickoff_started') HookAborted | too many model calls
The hook allowed two calls and aborted the third, and HookAborted ended the whole run. clear_all_hooks() in finally removes both hooks, so nothing registered here affects a crew that runs later in the same program. Where a tool hook's abort became a message to the model, a model hook's abort stops the crew. the max_iter lesson's cap ends the loop with an answer; this ends it with an error your code can catch.
Reading the two hooks
- POST_MODEL_CALL saw the reply first: the crew only ever saw the masked number.
- Returning a string replaces the reply; returning nothing keeps it, and a tool call is a list, so it is left alone.
- A model hook's abort stops the crew, where a tool hook's abort became a message to the model.
The two model hooks
| Hook | Runs | Can |
|---|---|---|
| POST_MODEL_CALL | after each reply | replace the reply text |
| PRE_MODEL_CALL | before each call | stop the call with HookAborted |
When to hook the model call
- Redacting anything a reply must not contain, such as a card number.
- Capping the loop with your own count when max_iter is not enough.
- Logging or timing every call in one place.
Related
- Previous: Tool hooks: approving a refund
- Next: Sequential tasks: two agents in a row
- Reference: CrewAI docs
- Change the scripted reply to contain two card numbers.
- Make
at_most_twoallow five calls and count the lookups. - Print
ctx.agent.roleinhide_cards.
Slow is fine. Stopping is the only problem.