Files
crewAI/docs/edge/en/learn/llm-hooks.mdx
Lucas Gomide 6be349f2e7 docs: prefix hook cross-links with /en so they resolve in edge
The new edge-only hooks pages linked each other with versionless paths
like `/learn/step-hooks`, which mintlify resolves against the frozen
default version where those pages do not exist, breaking the CI link
check. Uses the `/en/learn/...` form the other edge pages already use.
2026-07-14 17:48:53 -03:00

283 lines
9.1 KiB
Plaintext

---
title: LLM Call Hooks
description: Learn how to use LLM call hooks to intercept, modify, and control language model interactions in CrewAI
mode: "wide"
---
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.
## Overview
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](/en/learn/execution-hooks). The
[legacy `@before_llm_call` / `@after_llm_call` decorators](#legacy-decorators)
keep working unchanged — both styles register on the same engine and run in one
ordered chain.
## Hook Signature
```python
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](/en/learn/execution-hooks#telemetry).
## LLM Hook Context
The `LLMCallHookContext` object provides comprehensive access to execution state:
```python
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.
### Modifying Messages
**Important:** Always modify messages in-place:
```python
# ✅ 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"}]
```
## Registration Methods
### 1. Global Hooks
Apply to all LLM calls across all crews. Use the `agents=` filter to scope a
hook to specific agent roles:
```python
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)}")
```
### 2. Crew-Scoped Hooks
Apply the same decorator to a method inside a `@CrewBase` class to scope the
hook to that crew only:
```python
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)
```
## Common Use Cases
### 1. Iteration Limiting
```python
@on(InterceptionPoint.PRE_MODEL_CALL)
def limit_iterations(ctx: LLMCallHookContext) -> None:
if ctx.iterations > 15:
raise HookAborted(reason="exceeded 15 iterations", source="loop-guard")
```
### 2. Human Approval Gate
```python
@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")
```
### 3. Adding System Context
```python
@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."
})
```
### 4. Response Sanitization
```python
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)
```
### 5. Debug Logging
```python
@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]}...")
```
## Hook Management
```python
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.
## Legacy Decorators
The original per-point decorators keep working unchanged and run in the same
registration-order chain as `@on` hooks:
```python
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`:
- **Blocking** is `return False` from a before hook — equivalent to raising
`HookAborted`, but without a custom reason or source for telemetry.
- **Signatures** are point-specific: before hooks return `bool | None`, after
hooks return `str | None`. The context object is the same
`LLMCallHookContext`.
- **Filters and crew-scoping** work the same way: `@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.
## Best Practices
1. **Keep hooks focused and fast** — they run on every LLM call
2. **Modify in-place** — always mutate `ctx.messages`, never replace the list
3. **Use type hints** — annotate with `LLMCallHookContext` for IDE support
4. **Abort loudly** — raise `HookAborted` with a meaningful reason and source;
any other exception is swallowed (fail-open)
5. **Clear hooks in tests** — call `clear_all_hooks()` between test runs
## Troubleshooting
### Hook Not Executing
- Verify the hook is registered before crew execution
- Check whether an earlier hook aborted (subsequent hooks don't run)
### Message Modifications Not Persisting
- Use in-place modifications: `ctx.messages.append(...)`
- Don't replace the list: `ctx.messages = []`
### Response Modifications Not Working
- Return the modified string from a `POST_MODEL_CALL` hook
- Returning `None` keeps the original response
## Related Documentation
- [Execution Hooks Overview →](/en/learn/execution-hooks)
- [Tool Call Hooks →](/en/learn/tool-hooks)
- [Execution Boundary Hooks →](/en/learn/execution-boundary-hooks)
- [Step Hooks →](/en/learn/step-hooks)