docs/edge/en/learn/execution-hooks.mdx
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.
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")
Every hook is a synchronous callable that receives a single typed context:
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 |
Use @on for global hooks. It accepts agents= / tools= filters to scope a
hook to specific agent roles or tool names:
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:
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
Each family has a detailed guide covering its context schema, payload semantics, and examples.
| 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 |
| 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.
| 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.
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:
@on(InterceptionPoint.EXECUTION_START)
def enforce_policy(ctx):
if not ctx.payload.get("authorized"):
raise HookAborted(reason="unauthorized execution", source="access-control")
HookAborted propagates by design and stops the chain.@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")
@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")
A non-None return value replaces the interceptable value, so transformations
are plain return statements:
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,
)
@on(InterceptionPoint.POST_STEP)
def trace_steps(ctx):
print(f"{ctx.kind} '{ctx.step_name}' finished")
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.
Global hooks persist for the lifetime of the process. Reset them between tests:
import pytest
from crewai.hooks import clear_all_hooks
@pytest.fixture(autouse=True)
def reset_hooks():
clear_all_hooks()
yield
clear_all_hooks()
agents= / tools= filters and crew-scoped
registration instead of unconditional global hooks.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.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:
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:
return False, with no abort reason or source attached.LLMCallHookContext (with full
executor access) and ToolCallHookContext — that @on hooks receive at the
model/tool points.@CrewBase class.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: