mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-22 07:15:10 +00:00
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.
341 lines
11 KiB
Plaintext
341 lines
11 KiB
Plaintext
---
|
|
title: Tool Call Hooks
|
|
description: Learn how to use tool call hooks to intercept, modify, and control tool execution in CrewAI
|
|
mode: "wide"
|
|
---
|
|
|
|
Tool Call Hooks provide fine-grained control over tool execution during agent
|
|
operations. These hooks allow you to intercept tool calls, modify inputs,
|
|
transform outputs, implement safety checks, and add comprehensive logging or
|
|
monitoring.
|
|
|
|
## Overview
|
|
|
|
Tool hooks are executed at two interception points:
|
|
|
|
| Point | When | Hook receives |
|
|
|-------|------|---------------|
|
|
| `PRE_TOOL_CALL` | Before every tool execution | `ToolCallHookContext` |
|
|
| `POST_TOOL_CALL` | After every tool execution | `ToolCallHookContext` (with results set) |
|
|
|
|
Write them with the [`@on` decorator](/learn/execution-hooks). The
|
|
[legacy `@before_tool_call` / `@after_tool_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, ToolCallHookContext
|
|
|
|
@on(InterceptionPoint.PRE_TOOL_CALL)
|
|
def before_hook(ctx: ToolCallHookContext) -> None:
|
|
# Mutate ctx.tool_input in place, or
|
|
# raise HookAborted(reason, source) to block the call
|
|
...
|
|
|
|
@on(InterceptionPoint.POST_TOOL_CALL)
|
|
def after_hook(ctx: ToolCallHookContext) -> str | None:
|
|
# Return a string to replace ctx.tool_result
|
|
# Return None to keep the original result
|
|
...
|
|
```
|
|
|
|
Unlike the boundary and step points, the tool-call points pass the rich
|
|
`ToolCallHookContext` directly as the hook argument (there is no separate
|
|
`ctx.payload`): mutate `ctx.tool_input` in place before the call, and return a
|
|
string to replace the result after it.
|
|
|
|
When a call is blocked, the tool does not run and the agent receives
|
|
`"Tool execution blocked by hook. Tool: <name>"` as the result — the run
|
|
continues. `POST_TOOL_CALL` hooks still fire on blocked calls, so monitoring
|
|
hooks see every attempt.
|
|
|
|
## Tool Hook Context
|
|
|
|
The `ToolCallHookContext` object provides comprehensive access to tool
|
|
execution state:
|
|
|
|
```python
|
|
class ToolCallHookContext:
|
|
tool_name: str # Name of the tool being called
|
|
tool_input: dict[str, Any] # Mutable tool input parameters
|
|
tool: CrewStructuredTool # Tool instance reference
|
|
agent: Agent | BaseAgent | None # Agent executing the tool
|
|
task: Task | None # Current task
|
|
crew: Crew | None # Crew instance
|
|
tool_result: str | None # Agent-facing result string (POST_TOOL_CALL only)
|
|
raw_tool_result: Any | None # Raw Python result (POST_TOOL_CALL only)
|
|
```
|
|
|
|
For typed tool outputs, `tool_result` is the string the agent sees. By default,
|
|
this is JSON. If the tool uses custom formatting, it can be Markdown or another
|
|
string. Use `raw_tool_result` when your hook needs the typed object or
|
|
dictionary; it is not affected by result replacement.
|
|
|
|
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 Tool Inputs
|
|
|
|
**Important:** Always modify tool inputs in-place:
|
|
|
|
```python
|
|
# ✅ Correct - modify in-place
|
|
@on(InterceptionPoint.PRE_TOOL_CALL)
|
|
def sanitize_input(ctx: ToolCallHookContext) -> None:
|
|
ctx.tool_input['query'] = ctx.tool_input['query'].lower()
|
|
|
|
# ❌ Wrong - replaces dict reference; the tool never sees it
|
|
@on(InterceptionPoint.PRE_TOOL_CALL)
|
|
def wrong_approach(ctx: ToolCallHookContext) -> None:
|
|
ctx.tool_input = {'query': 'new query'}
|
|
```
|
|
|
|
## Registration Methods
|
|
|
|
### 1. Global Hooks
|
|
|
|
Apply to all tool calls across all crews. Use `tools=` / `agents=` filters to
|
|
scope a hook:
|
|
|
|
```python
|
|
from crewai.hooks import on, HookAborted, InterceptionPoint
|
|
|
|
@on(InterceptionPoint.PRE_TOOL_CALL)
|
|
def log_tool_call(ctx):
|
|
print(f"Tool: {ctx.tool_name}, input: {ctx.tool_input}")
|
|
|
|
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["delete_file", "drop_table"])
|
|
def block_destructive(ctx):
|
|
raise HookAborted(reason=f"{ctx.tool_name} is not allowed", source="safety-policy")
|
|
|
|
@on(InterceptionPoint.POST_TOOL_CALL, tools=["web_search"], agents=["Researcher"])
|
|
def log_search_results(ctx):
|
|
print(f"search returned {len(ctx.tool_result or '')} chars")
|
|
```
|
|
|
|
### 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_TOOL_CALL)
|
|
def validate_tool_inputs(self, ctx):
|
|
# Only applies to this crew
|
|
if ctx.tool_name == "web_search" and not ctx.tool_input.get("query"):
|
|
raise HookAborted(reason="empty search query", source="input-validation")
|
|
|
|
@crew
|
|
def crew(self) -> Crew:
|
|
return Crew(agents=self.agents, tasks=self.tasks, process=Process.sequential)
|
|
```
|
|
|
|
## Common Use Cases
|
|
|
|
### 1. Safety Guardrails
|
|
|
|
```python
|
|
@on(InterceptionPoint.PRE_TOOL_CALL)
|
|
def safety_check(ctx: ToolCallHookContext) -> None:
|
|
destructive = {'delete_file', 'drop_table', 'remove_user', 'system_shutdown'}
|
|
if ctx.tool_name in destructive:
|
|
raise HookAborted(reason=f"{ctx.tool_name} is destructive", source="safety-policy")
|
|
```
|
|
|
|
### 2. Human Approval Gate
|
|
|
|
```python
|
|
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["send_email", "make_purchase", "delete_file"])
|
|
def require_approval(ctx: ToolCallHookContext) -> None:
|
|
response = ctx.request_human_input(
|
|
prompt=f"Approve {ctx.tool_name}?",
|
|
default_message=f"Input: {ctx.tool_input}\nType 'yes' to approve:",
|
|
)
|
|
if response.lower() != 'yes':
|
|
raise HookAborted(reason="denied by operator", source="approval-gate")
|
|
```
|
|
|
|
### 3. Input Validation and Sanitization
|
|
|
|
```python
|
|
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["web_search"])
|
|
def validate_query(ctx: ToolCallHookContext) -> None:
|
|
query = ctx.tool_input.get('query', '')
|
|
if len(query) < 3:
|
|
raise HookAborted(reason="search query too short", source="input-validation")
|
|
ctx.tool_input['query'] = query.strip().lower()
|
|
|
|
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["read_file"])
|
|
def validate_path(ctx: ToolCallHookContext) -> None:
|
|
path = ctx.tool_input.get('path', '')
|
|
if '..' in path or path.startswith('/'):
|
|
raise HookAborted(reason="invalid file path", source="input-validation")
|
|
```
|
|
|
|
### 4. Result Sanitization
|
|
|
|
```python
|
|
import re
|
|
|
|
@on(InterceptionPoint.POST_TOOL_CALL)
|
|
def sanitize_sensitive_data(ctx: ToolCallHookContext) -> str | None:
|
|
if not ctx.tool_result:
|
|
return None
|
|
result = re.sub(
|
|
r'(api[_-]?key|token)["\']?\s*[:=]\s*["\']?[\w-]+',
|
|
r'\1: [REDACTED]',
|
|
ctx.tool_result,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
return re.sub(
|
|
r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b',
|
|
'[EMAIL-REDACTED]',
|
|
result,
|
|
)
|
|
```
|
|
|
|
### 5. Tool Usage Analytics
|
|
|
|
```python
|
|
import time
|
|
from collections import defaultdict
|
|
|
|
tool_stats = defaultdict(lambda: {'count': 0, 'total_time': 0})
|
|
|
|
@on(InterceptionPoint.PRE_TOOL_CALL)
|
|
def start_timer(ctx: ToolCallHookContext) -> None:
|
|
ctx.tool_input['_start_time'] = time.time()
|
|
|
|
@on(InterceptionPoint.POST_TOOL_CALL)
|
|
def track_tool_usage(ctx: ToolCallHookContext) -> None:
|
|
start_time = ctx.tool_input.pop('_start_time', time.time())
|
|
tool_stats[ctx.tool_name]['count'] += 1
|
|
tool_stats[ctx.tool_name]['total_time'] += time.time() - start_time
|
|
```
|
|
|
|
### 6. Rate Limiting
|
|
|
|
```python
|
|
from collections import defaultdict
|
|
from datetime import datetime, timedelta
|
|
|
|
tool_call_history = defaultdict(list)
|
|
|
|
@on(InterceptionPoint.PRE_TOOL_CALL)
|
|
def rate_limit_tools(ctx: ToolCallHookContext) -> None:
|
|
now = datetime.now()
|
|
history = tool_call_history[ctx.tool_name]
|
|
history[:] = [t for t in history if now - t < timedelta(minutes=1)]
|
|
if len(history) >= 10:
|
|
raise HookAborted(reason=f"rate limit exceeded for {ctx.tool_name}",
|
|
source="rate-limiter")
|
|
history.append(now)
|
|
```
|
|
|
|
## 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_TOOL_CALL, my_hook)
|
|
|
|
# Clear one point, or everything (e.g. between tests)
|
|
clear_hooks(InterceptionPoint.POST_TOOL_CALL)
|
|
clear_all_hooks()
|
|
|
|
# Inspect what's registered
|
|
print(len(get_hooks(InterceptionPoint.PRE_TOOL_CALL)))
|
|
```
|
|
|
|
The legacy management API (`register_before_tool_call_hook`,
|
|
`unregister_before_tool_call_hook`, `clear_before_tool_call_hooks`,
|
|
`clear_all_tool_call_hooks`, `get_before_tool_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_tool_call, after_tool_call
|
|
|
|
@before_tool_call
|
|
def block_dangerous_tools(context):
|
|
if context.tool_name in ('delete_database', 'drop_table'):
|
|
return False # Block execution
|
|
return None
|
|
|
|
@after_tool_call(tools=["web_search"])
|
|
def sanitize_results(context):
|
|
if context.tool_result and "password" in context.tool_result.lower():
|
|
return context.tool_result.replace("password", "[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. The agent
|
|
sees the same `"Tool execution blocked by hook"` message.
|
|
- **Signatures** are point-specific: before hooks return `bool | None`, after
|
|
hooks return `str | None`. The context object is the same
|
|
`ToolCallHookContext`.
|
|
- **Filters and crew-scoping** work the same way:
|
|
`@before_tool_call(tools=[...], 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 tool call
|
|
2. **Modify in-place** — always mutate `ctx.tool_input`, never replace the dict
|
|
3. **Prefer filters over conditionals** — `tools=` / `agents=` keep hook bodies small
|
|
4. **Abort loudly** — raise `HookAborted` with a meaningful reason and source;
|
|
any other exception is swallowed (fail-open)
|
|
5. **Use type hints** — annotate with `ToolCallHookContext` for IDE support
|
|
6. **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 blocked the call (subsequent pre hooks don't run)
|
|
- Check `tools=` / `agents=` filters against the actual tool name and agent role
|
|
|
|
### Input Modifications Not Working
|
|
- Use in-place modifications: `ctx.tool_input['key'] = value`
|
|
- Don't replace the dict: `ctx.tool_input = {}`
|
|
|
|
### Result Modifications Not Working
|
|
- Return the modified string from a `POST_TOOL_CALL` hook
|
|
- Returning `None` keeps the original result
|
|
|
|
### Tool Blocked Unexpectedly
|
|
- Check all pre hooks for `HookAborted` / `return False` conditions
|
|
- The abort reason and source appear on the `HookDispatchedEvent` telemetry
|
|
|
|
## Related Documentation
|
|
|
|
- [Execution Hooks Overview →](/learn/execution-hooks)
|
|
- [LLM Call Hooks →](/learn/llm-hooks)
|
|
- [Execution Boundary Hooks →](/learn/execution-boundary-hooks)
|
|
- [Step Hooks →](/learn/step-hooks)
|