Files
crewAI/docs/edge/en/learn/llm-hooks.mdx
Lucas Gomide da9902da4f
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
docs: group execution hooks and document all hook contexts (#6548)
* docs: group execution hooks and document all hook contexts

The hooks pages sat flat in the learn nav and only documented the LLM
and tool call contexts, with examples built on the legacy decorators.
Groups them under a collapsible "Execution Hooks" section, adds
`step-hooks` and `execution-boundary-hooks` pages covering every hook
context from source, reworks the LLM and tool pages to lead with `@on`
while keeping the decorators, and links the orphaned
`before-and-after-kickoff-hooks` page into the group.

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

* docs: use /edge prefix for links to edge-only hooks pages

The `/en/learn/...` form still resolves against the default frozen
version, where `step-hooks` and `execution-boundary-hooks` do not exist
yet, so the link checker kept failing. Links now use the explicit
`/edge/en/learn/...` form, matching how `consuming-streams.mdx` linked
to edge-only streaming pages before they were frozen.
2026-07-15 08:17:52 -04: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](/edge/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](/edge/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 →](/edge/en/learn/execution-hooks)
- [Tool Call Hooks →](/edge/en/learn/tool-hooks)
- [Execution Boundary Hooks →](/edge/en/learn/execution-boundary-hooks)
- [Step Hooks →](/edge/en/learn/step-hooks)