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 →

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.

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
tools.py
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."
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?"

A writer with a card number in its reply

python
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

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

Example
print(crew.kickoff().raw)

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

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

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

Example
try:
    crew.kickoff()
except Exception as error:
    print(type(error).__name__, "|", error)
finally:
    clear_all_hooks()

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

HookRunsCan
POST_MODEL_CALLafter each replyreplace the reply text
PRE_MODEL_CALLbefore each callstop 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.
Watch out
A PRE_MODEL_CALL abort ends the whole run with HookAborted, unlike the max_iter lesson's cap, which ends the loop with an answer. Call clear_all_hooks() when you are done, or the hook affects the next crew in the program.
Try it yourself
  • Change the scripted reply to contain two card numbers.
  • Make at_most_two allow five calls and count the lookups.
  • Print ctx.agent.role in hide_cards.

Slow is fine. Stopping is the only problem.