mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-21 23:05:10 +00:00
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 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.
282 lines
9.6 KiB
Plaintext
282 lines
9.6 KiB
Plaintext
---
|
|
title: Execution Hooks
|
|
description: Intercept, modify, and control CrewAI's runtime with the @on decorator - one contract covering every interception point
|
|
mode: "wide"
|
|
---
|
|
|
|
Execution hooks provide fine-grained control over the runtime behavior of your
|
|
CrewAI agents. Unlike kickoff hooks that run before and after crew execution,
|
|
execution hooks intercept specific operations during execution — from the moment
|
|
a run starts, through every model call, tool call, and task or flow-method step,
|
|
down to the final output.
|
|
|
|
Hooks are written with the `@on` decorator: one registration API and one
|
|
contract cover every interception point in the framework.
|
|
|
|
```python
|
|
from crewai.hooks import on, HookAborted, InterceptionPoint
|
|
|
|
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["delete_file"])
|
|
def guard_deletes(ctx):
|
|
raise HookAborted(reason="file deletion is not allowed", source="policy")
|
|
```
|
|
|
|
<Note>
|
|
The point-specific decorators (`@before_llm_call`, `@after_tool_call`, ...) keep
|
|
working unchanged — they are adapters over the same engine. See
|
|
[Point-specific decorators (legacy)](#point-specific-decorators-legacy) at the
|
|
end of this page.
|
|
</Note>
|
|
|
|
## The contract
|
|
|
|
Every hook is a **synchronous** callable that receives a single typed context:
|
|
|
|
```python
|
|
from crewai.hooks import on, HookAborted, InterceptionPoint
|
|
|
|
@on(InterceptionPoint.INPUT)
|
|
def add_defaults(ctx):
|
|
# 1. Observe: read anything off the context.
|
|
# 2. Mutate in place: change ctx.payload or nested fields directly.
|
|
ctx.payload.setdefault("locale", "en-US")
|
|
# 3. Or replace: return a new value to swap ctx.payload.
|
|
# 4. Or abort: raise HookAborted(reason, source) to stop the operation.
|
|
return None
|
|
```
|
|
|
|
A hook may do any of four things:
|
|
|
|
| Action | How | Effect |
|
|
|--------|-----|--------|
|
|
| **Proceed** | `return None` (or nothing) | Operation continues unchanged |
|
|
| **Mutate** | Change `ctx.payload` / fields in place | Change is visible downstream |
|
|
| **Replace** | `return new_payload` | A non-`None` return replaces `ctx.payload` |
|
|
| **Abort** | `raise HookAborted(reason, source)` | Operation is stopped; the reason propagates |
|
|
|
|
## Registering hooks
|
|
|
|
Use `@on` for global hooks. It accepts `agents=` / `tools=` filters to scope a
|
|
hook to specific agent roles or tool names:
|
|
|
|
```python
|
|
from crewai.hooks import on, InterceptionPoint
|
|
|
|
@on(InterceptionPoint.POST_TOOL_CALL, agents=["researcher"], tools=["web_search"])
|
|
def log_search_results(ctx):
|
|
print(f"search returned: {(ctx.tool_result or '')[:80]}")
|
|
```
|
|
|
|
Applied to a method inside a `@CrewBase` class, `@on` registers a
|
|
**crew-scoped** hook, active only while that crew runs:
|
|
|
|
```python
|
|
from crewai import CrewBase
|
|
from crewai.hooks import on, InterceptionPoint
|
|
|
|
@CrewBase
|
|
class MyProjCrew:
|
|
@on(InterceptionPoint.PRE_MODEL_CALL)
|
|
def validate_inputs(self, ctx):
|
|
# Only applies to this crew
|
|
return None
|
|
```
|
|
|
|
## Interception point catalog
|
|
|
|
Each family has a detailed guide covering its context schema, payload
|
|
semantics, and examples.
|
|
|
|
### [Execution boundaries](/edge/en/learn/execution-boundary-hooks)
|
|
|
|
| Point | When | `ctx.payload` |
|
|
|-------|------|---------------|
|
|
| `EXECUTION_START` | A crew or flow is about to begin | inputs `dict` |
|
|
| `INPUT` | Resolved inputs for the execution | inputs `dict` |
|
|
| `OUTPUT` | Final result is ready | the output object |
|
|
| `EXECUTION_END` | A crew or flow has finished | the output object |
|
|
|
|
### [Model boundaries](/edge/en/learn/llm-hooks) & [tool boundaries](/edge/en/learn/tool-hooks)
|
|
|
|
| Point | When | Hook receives |
|
|
|-------|------|---------------|
|
|
| `PRE_MODEL_CALL` | Before an LLM call | `LLMCallHookContext` |
|
|
| `POST_MODEL_CALL` | After an LLM call | `LLMCallHookContext` (with `response` set) |
|
|
| `PRE_TOOL_CALL` | Before a tool runs | `ToolCallHookContext` |
|
|
| `POST_TOOL_CALL` | After a tool runs | `ToolCallHookContext` (with results set) |
|
|
|
|
At these four points the hook receives the rich legacy context **directly** as
|
|
its argument — there is no separate `ctx.payload`. Mutate `ctx.messages` /
|
|
`ctx.tool_input` in place, and return a string from a post hook to replace the
|
|
response / tool result.
|
|
|
|
### [Step points](/edge/en/learn/step-hooks)
|
|
|
|
| Point | When | `ctx.payload` |
|
|
|-------|------|---------------|
|
|
| `PRE_STEP` | Before a task or flow-method step | step input |
|
|
| `POST_STEP` | After a task or flow-method step | step output |
|
|
|
|
`PRE_STEP` / `POST_STEP` carry `ctx.kind` (`"task"` or `"flow_method"`) and
|
|
`ctx.step_name`.
|
|
|
|
## Aborting an operation
|
|
|
|
`HookAborted` carries a `reason` and an optional `source`. The `source` defaults
|
|
to the aborting hook when omitted, which is useful for telemetry and failure
|
|
messages:
|
|
|
|
```python
|
|
@on(InterceptionPoint.EXECUTION_START)
|
|
def enforce_policy(ctx):
|
|
if not ctx.payload.get("authorized"):
|
|
raise HookAborted(reason="unauthorized execution", source="access-control")
|
|
```
|
|
|
|
## Composition, ordering, and fail-open
|
|
|
|
- Multiple hooks on the same point run in **registration order**, global hooks
|
|
first, then execution-scoped hooks. Legacy hooks registered for the same point
|
|
participate in the same chain.
|
|
- The (possibly mutated) payload flows from one hook to the next.
|
|
- `HookAborted` **propagates by design** and stops the chain.
|
|
- Any *other* exception raised by a hook is **swallowed** (fail-open) so a single
|
|
buggy hook can't crash a run.
|
|
- When no hook is registered for a point, dispatch is a single dict lookup
|
|
(no-op fast path), so unused points cost effectively nothing.
|
|
|
|
## Common patterns
|
|
|
|
### Safety guardrails
|
|
|
|
```python
|
|
@on(InterceptionPoint.PRE_TOOL_CALL)
|
|
def block_dangerous_tools(ctx):
|
|
dangerous = {"delete_file", "drop_table", "system_shutdown"}
|
|
if ctx.tool_name in dangerous:
|
|
raise HookAborted(reason=f"{ctx.tool_name} is blocked", source="safety-policy")
|
|
|
|
@on(InterceptionPoint.PRE_MODEL_CALL)
|
|
def iteration_limit(ctx):
|
|
if ctx.iterations > 15:
|
|
raise HookAborted(reason="maximum iterations exceeded", source="loop-guard")
|
|
```
|
|
|
|
### Human-in-the-loop approval
|
|
|
|
```python
|
|
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["send_email", "make_payment"])
|
|
def require_approval(ctx):
|
|
response = ctx.request_human_input(
|
|
prompt=f"Approve {ctx.tool_name}?",
|
|
default_message="Type 'yes' to approve:",
|
|
)
|
|
if response.lower() != "yes":
|
|
raise HookAborted(reason="rejected by operator", source="approval-gate")
|
|
```
|
|
|
|
### Sanitizing outputs
|
|
|
|
A non-`None` return value replaces the interceptable value, so transformations
|
|
are plain return statements:
|
|
|
|
```python
|
|
import re
|
|
|
|
@on(InterceptionPoint.POST_MODEL_CALL)
|
|
def redact_keys(ctx):
|
|
return re.sub(
|
|
r'(api[_-]?key)["\']?\s*[:=]\s*["\']?[\w-]+',
|
|
r"\1: [REDACTED]",
|
|
ctx.response,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
```
|
|
|
|
### Observing steps
|
|
|
|
```python
|
|
@on(InterceptionPoint.POST_STEP)
|
|
def trace_steps(ctx):
|
|
print(f"{ctx.kind} '{ctx.step_name}' finished")
|
|
```
|
|
|
|
## Telemetry
|
|
|
|
Whenever a point actually dispatches to at least one hook, CrewAI emits a
|
|
`HookDispatchedEvent` on the event bus with the point, the outcome
|
|
(`proceeded` / `modified` / `aborted`), the hook count, the duration, and — for
|
|
aborts — the reason and source. The no-op fast path emits nothing.
|
|
|
|
## Managing hooks in tests
|
|
|
|
Global hooks persist for the lifetime of the process. Reset them between tests:
|
|
|
|
```python
|
|
import pytest
|
|
from crewai.hooks import clear_all_hooks
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_hooks():
|
|
clear_all_hooks()
|
|
yield
|
|
clear_all_hooks()
|
|
```
|
|
|
|
## Best practices
|
|
|
|
1. **Keep hooks focused** — one clear responsibility per hook; register several
|
|
small hooks rather than one that does everything.
|
|
2. **Keep hooks fast** — hooks run on every dispatch of their point; avoid heavy
|
|
computation and lazy-import heavy dependencies.
|
|
3. **Prefer scoping** — use `agents=` / `tools=` filters and crew-scoped
|
|
registration instead of unconditional global hooks.
|
|
4. **Abort loudly** — raise `HookAborted` with a meaningful `reason` and
|
|
`source`; that context surfaces in error messages and telemetry. Remember
|
|
that any other exception is swallowed (fail-open), so don't rely on raising
|
|
`ValueError` to stop a run.
|
|
|
|
## Point-specific decorators (legacy)
|
|
|
|
Before `@on`, LLM and tool calls were hooked with dedicated decorator pairs.
|
|
These keep working unchanged — they are adapters over the same dispatcher, so
|
|
they compose with `@on` hooks in the same registration-order chain:
|
|
|
|
```python
|
|
from crewai.hooks import before_llm_call, after_llm_call, before_tool_call, after_tool_call
|
|
|
|
@before_llm_call
|
|
def limit_iterations(context):
|
|
if context.iterations > 10:
|
|
return False # Block execution
|
|
|
|
@after_tool_call
|
|
def log_tool_result(context):
|
|
print(f"Tool {context.tool_name} completed")
|
|
```
|
|
|
|
Differences from `@on`:
|
|
|
|
- They cover **only** the four model/tool points — no execution boundaries, no
|
|
steps.
|
|
- Blocking is `return False`, with no abort reason or source attached.
|
|
- They receive the same rich contexts — `LLMCallHookContext` (with full
|
|
executor access) and `ToolCallHookContext` — that `@on` hooks receive at the
|
|
model/tool points.
|
|
- Crew-scoping works the same way: apply the decorator to a method inside a
|
|
`@CrewBase` class.
|
|
- They support the same `agents=` / `tools=` filters.
|
|
|
|
You might still prefer them for existing codebases that already use
|
|
`return False` semantics, or when you want the point-specific typed signatures.
|
|
For the detailed guides — context attributes, patterns, and management APIs
|
|
(`register_*` / `unregister_*` / `clear_*`) — see:
|
|
|
|
- [LLM Call Hooks →](/edge/en/learn/llm-hooks)
|
|
- [Tool Call Hooks →](/edge/en/learn/tool-hooks)
|
|
|
|
## Related documentation
|
|
|
|
- [Before and After Kickoff Hooks →](/edge/en/learn/before-and-after-kickoff-hooks)
|
|
- [Human-in-the-Loop →](/edge/en/learn/human-in-the-loop)
|