docs/edge/en/learn/llm-hooks.mdx
LLM Call Hooks provide fine-grained control over language model interactions during agent execution. These hooks allow you to intercept LLM calls, modify prompts, transform responses, implement approval gates, and add custom logging or monitoring.
LLM hooks are executed at two interception points:
| Point | When | Hook receives |
|---|---|---|
PRE_MODEL_CALL | Before every LLM call | LLMCallHookContext |
POST_MODEL_CALL | After every LLM call | LLMCallHookContext (with response set) |
Write them with the @on decorator. The
legacy @before_llm_call / @after_llm_call decorators
keep working unchanged — both styles register on the same engine and run in one
ordered chain.
from crewai.hooks import on, HookAborted, InterceptionPoint, LLMCallHookContext
@on(InterceptionPoint.PRE_MODEL_CALL)
def before_hook(ctx: LLMCallHookContext) -> None:
# Mutate ctx.messages in place, or
# raise HookAborted(reason, source) to block the call
...
@on(InterceptionPoint.POST_MODEL_CALL)
def after_hook(ctx: LLMCallHookContext) -> str | None:
# Return a string to replace ctx.response
# Return None to keep the original response
...
Unlike the boundary and step points, the model-call points pass the rich
LLMCallHookContext directly as the hook argument (there is no separate
ctx.payload): mutate ctx.messages in place before the call, and return a
string to replace the response after it.
Blocking a call raises ValueError("LLM call blocked by before_llm_call hook")
inside the executor; the HookAborted reason and source are recorded in
telemetry.
The LLMCallHookContext object provides comprehensive access to execution state:
class LLMCallHookContext:
executor: CrewAgentExecutor | LiteAgent | None # Executor (None for direct LLM calls)
messages: list # Mutable message list
agent: Agent | None # Current agent (None for direct LLM calls)
task: Task | None # Current task (None for direct calls or LiteAgent)
crew: Crew | None # Crew instance (None for direct calls or LiteAgent)
llm: BaseLLM | None # LLM instance
iterations: int # Current iteration count (0 for direct calls)
response: str | None # LLM response (POST_MODEL_CALL only)
The context also exposes request_human_input(prompt, default_message), which
pauses live console updates and collects input from the terminal — useful for
approval gates.
Important: Always modify messages in-place:
# ✅ Correct - modify in-place
@on(InterceptionPoint.PRE_MODEL_CALL)
def add_context(ctx: LLMCallHookContext) -> None:
ctx.messages.append({"role": "system", "content": "Be concise"})
# ❌ Wrong - replaces list reference and breaks the executor
@on(InterceptionPoint.PRE_MODEL_CALL)
def wrong_approach(ctx: LLMCallHookContext) -> None:
ctx.messages = [{"role": "system", "content": "Be concise"}]
Apply to all LLM calls across all crews. Use the agents= filter to scope a
hook to specific agent roles:
from crewai.hooks import on, InterceptionPoint
@on(InterceptionPoint.PRE_MODEL_CALL)
def log_llm_call(ctx):
print(f"LLM call by {ctx.agent.role} at iteration {ctx.iterations}")
@on(InterceptionPoint.POST_MODEL_CALL, agents=["Researcher"])
def log_researcher_responses(ctx):
print(f"Response length: {len(ctx.response)}")
Apply the same decorator to a method inside a @CrewBase class to scope the
hook to that crew only:
from crewai.hooks import on, InterceptionPoint
@CrewBase
class MyProjCrew:
@on(InterceptionPoint.PRE_MODEL_CALL)
def validate_inputs(self, ctx):
# Only applies to this crew
if ctx.iterations == 0:
print(f"Starting task: {ctx.task.description}")
@crew
def crew(self) -> Crew:
return Crew(agents=self.agents, tasks=self.tasks, process=Process.sequential)
@on(InterceptionPoint.PRE_MODEL_CALL)
def limit_iterations(ctx: LLMCallHookContext) -> None:
if ctx.iterations > 15:
raise HookAborted(reason="exceeded 15 iterations", source="loop-guard")
@on(InterceptionPoint.PRE_MODEL_CALL)
def require_approval(ctx: LLMCallHookContext) -> None:
if ctx.iterations > 5:
response = ctx.request_human_input(
prompt=f"Iteration {ctx.iterations}: Approve LLM call?",
default_message="Press Enter to approve, or type 'no' to block:",
)
if response.lower() == "no":
raise HookAborted(reason="blocked by user", source="approval-gate")
@on(InterceptionPoint.PRE_MODEL_CALL)
def add_guardrails(ctx: LLMCallHookContext) -> None:
ctx.messages.append({
"role": "system",
"content": "Ensure responses are factual and cite sources when possible."
})
import re
@on(InterceptionPoint.POST_MODEL_CALL)
def sanitize_sensitive_data(ctx: LLMCallHookContext) -> str | None:
if not ctx.response:
return None
sanitized = re.sub(r'\b\d{3}-\d{2}-\d{4}\b', '[SSN-REDACTED]', ctx.response)
return re.sub(r'\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b', '[CARD-REDACTED]', sanitized)
@on(InterceptionPoint.PRE_MODEL_CALL)
def debug_request(ctx: LLMCallHookContext) -> None:
print(f"Agent: {ctx.agent.role}, iteration {ctx.iterations}, "
f"{len(ctx.messages)} messages")
@on(InterceptionPoint.POST_MODEL_CALL)
def debug_response(ctx: LLMCallHookContext) -> None:
if ctx.response:
print(f"Response preview: {ctx.response[:100]}...")
from crewai.hooks import (
InterceptionPoint,
clear_all_hooks,
clear_hooks,
get_hooks,
unregister_hook,
)
# Unregister a specific hook
unregister_hook(InterceptionPoint.PRE_MODEL_CALL, my_hook)
# Clear one point, or everything (e.g. between tests)
clear_hooks(InterceptionPoint.POST_MODEL_CALL)
clear_all_hooks()
# Inspect what's registered
print(len(get_hooks(InterceptionPoint.PRE_MODEL_CALL)))
The legacy management API (register_before_llm_call_hook,
unregister_before_llm_call_hook, clear_before_llm_call_hooks,
clear_all_llm_call_hooks, get_before_llm_call_hooks, and their after_
counterparts) operates on the same underlying registries, so either API can
manage hooks registered by the other.
The original per-point decorators keep working unchanged and run in the same
registration-order chain as @on hooks:
from crewai.hooks import before_llm_call, after_llm_call
@before_llm_call
def validate_iteration_count(context):
if context.iterations > 10:
return False # Block execution
return None
@after_llm_call(agents=["Researcher"])
def sanitize_response(context):
if context.response and "API_KEY" in context.response:
return context.response.replace("API_KEY", "[REDACTED]")
return None
Differences from @on:
return False from a before hook — equivalent to raising
HookAborted, but without a custom reason or source for telemetry.bool | None, after
hooks return str | None. The context object is the same
LLMCallHookContext.@before_llm_call(agents=[...]),
and applying the decorator to a @CrewBase method scopes it to that crew.Prefer @on for new code; keep the legacy style where it is already in use —
there is no behavioral penalty.
ctx.messages, never replace the listLLMCallHookContext for IDE supportHookAborted with a meaningful reason and source;
any other exception is swallowed (fail-open)clear_all_hooks() between test runsctx.messages.append(...)ctx.messages = []POST_MODEL_CALL hookNone keeps the original response