Adding Hooks¶
Hooks let you intercept tool calls at specific points in their lifecycle — before execution, after success, on error, and more. This guide shows how to create and register hooks.
Architecture¶
Hooks are managed by HooksRegistry (core/hooks_registry.py), which follows the same decorator-based pattern as ToolsRegistry:
class HooksRegistry:
def register(self, hook_point: str) -> Callable: ...
async def dispatch(self, hook_point: str, context: HookContext) -> None: ...
def unregister(self, hook_point: str, func: Callable) -> bool: ...
def clear_hook_point(self, hook_point: str) -> None: ...
def clear(self) -> None: ...
hooks_registry = HooksRegistry() # global singleton
HookContext¶
Every hook receives a HookContext dataclass with all the information about the tool call:
@dataclass
class HookContext:
hook_point: str # Which hook point fired (e.g., "on_before_tool")
tool_name: str # Name of the tool being called
parameters: dict[str, Any] # Parameters passed to the tool
role: str = "agent" # Calling role (agent, user, system)
result: Any = None # Tool result (only for on_after_tool)
error: str | None = None # Error message (only for on_tool_error)
duration: float = 0.0 # Execution time in seconds (only for on_after_tool)
attempt: int = 1 # Retry attempt number (only for on_tool_error)
workspace: str = "main" # Active workspace
session_id: str | None = None # Current session ID
The 6 interception points¶
| Hook point | When it fires | Context fields available |
|---|---|---|
on_before_tool |
Before tool execution | tool_name, parameters, role, workspace, session_id |
on_after_tool |
After successful execution | + result, duration |
on_tool_error |
After execution fails | + error, attempt |
on_permission_denied |
When user denies tool permission | tool_name, parameters, role |
on_token_budget_exceeded |
When token budget is exceeded mid-workflow | tool_name, parameters |
on_tools_disabled |
When tools are globally disabled | tool_name, role |
Step 1: Create a hook implementation¶
Create core/hooks/my_monitor.py:
"""Custom hook: monitor tool calls and log summary statistics."""
import json
import logging
import time
from core.hooks_registry import HookContext, hooks_registry
logger = logging.getLogger(__name__)
# Track cumulative stats per tool
_stats: dict[str, dict] = {}
def _get_stats(tool_name: str) -> dict:
if tool_name not in _stats:
_stats[tool_name] = {
"calls": 0,
"successes": 0,
"failures": 0,
"total_duration_ms": 0.0,
}
return _stats[tool_name]
@hooks_registry.register("on_before_tool")
def monitor_before_tool(ctx: HookContext) -> None:
"""Count every tool invocation attempt."""
stats = _get_stats(ctx.tool_name)
stats["calls"] += 1
logger.debug(f"▶ {ctx.tool_name} called with {list(ctx.parameters.keys())}")
@hooks_registry.register("on_after_tool")
def monitor_after_tool(ctx: HookContext) -> None:
"""Record successful execution and duration."""
stats = _get_stats(ctx.tool_name)
stats["successes"] += 1
stats["total_duration_ms"] += ctx.duration * 1000
# Log a summary every 10 calls
if stats["calls"] % 10 == 0:
avg = stats["total_duration_ms"] / stats["calls"]
logger.info(
f"📊 {ctx.tool_name}: {stats['calls']} calls, "
f"{stats['successes']} ok / {stats['failures']} fail, "
f"avg {avg:.1f}ms"
)
@hooks_registry.register("on_tool_error")
def monitor_tool_error(ctx: HookContext) -> None:
"""Record failure and log the error."""
stats = _get_stats(ctx.tool_name)
stats["failures"] += 1
logger.warning(
f"⚠ {ctx.tool_name} failed (attempt {ctx.attempt}): {ctx.error}"
)
@hooks_registry.register("on_token_budget_exceeded")
def monitor_budget_exceeded(ctx: HookContext) -> None:
"""Alert when token budget is exhausted."""
logger.warning(
f"💰 Token budget exceeded during {ctx.tool_name} call in workspace '{ctx.workspace}'"
)
Step 2: The decorator pattern¶
Use @hooks_registry.register("hook_point_name") to register. The decorator works like @tools_registry.register():
@hooks_registry.register("on_before_tool")
def my_hook(ctx: HookContext) -> None:
# ctx contains all the context fields shown above
print(f"About to call {ctx.tool_name}")
Hooks can be sync or async — the dispatcher handles both:
# Sync hook
@hooks_registry.register("on_before_tool")
def sync_hook(ctx: HookContext) -> None:
pass
# Async hook
@hooks_registry.register("on_after_tool")
async def async_hook(ctx: HookContext) -> None:
await some_async_operation(ctx.result)
Hook exceptions are caught
If a hook raises an exception, the dispatcher logs a warning and continues. Hooks never break the tool execution pipeline.
Step 3: Load your hook¶
Option A: Global hooks (always active)¶
Place your hook in core/hooks/ and import it in the application entry point or __init__.py. The built-in audit.py hook is loaded this way:
# In run.py or core/__init__.py:
import core.hooks.audit # registers via @hooks_registry.register
import core.hooks.my_monitor # registers via @hooks_registry.register
Option B: Workspace hooks (per-workspace)¶
Place hooks in workspaces/<name>/hooks/*.py. They are loaded and cleared on workspace switch via the same loader mechanism used for tools.
Step 4: Test your hook¶
Create tests/test_hooks_my_monitor.py:
import pytest
from unittest.mock import MagicMock
from core.hooks_registry import HookContext, HooksRegistry
@pytest.mark.asyncio
async def test_monitor_counts_calls():
"""The monitor hook should increment call counts."""
reg = HooksRegistry()
call_count = 0
@reg.register("on_before_tool")
def count_calls(ctx: HookContext) -> None:
nonlocal call_count
call_count += 1
ctx = HookContext(
hook_point="on_before_tool",
tool_name="file_manager",
parameters={"action": "read", "path": "test.py"},
)
await reg.dispatch("on_before_tool", ctx)
await reg.dispatch("on_before_tool", ctx)
assert call_count == 2
@pytest.mark.asyncio
async def test_error_hook_receives_error_field():
"""On tool error, the context should carry error info."""
reg = HooksRegistry()
captured_error = None
@reg.register("on_tool_error")
def capture_error(ctx: HookContext) -> None:
nonlocal captured_error
captured_error = ctx.error
ctx = HookContext(
hook_point="on_tool_error",
tool_name="bash_manager",
parameters={"command": ""},
error="missing_required_param: command",
attempt=1,
)
await reg.dispatch("on_tool_error", ctx)
assert captured_error == "missing_required_param: command"
def test_hook_unregister():
"""Hooks can be removed from specific hook points."""
reg = HooksRegistry()
@reg.register("on_before_tool")
def temp_hook(ctx: HookContext) -> None:
pass
assert len(reg.list_hooks().get("on_before_tool", [])) == 1
reg.unregister("on_before_tool", temp_hook)
assert len(reg.list_hooks().get("on_before_tool", [])) == 0
Complete example: Audit logging hook¶
This is the built-in core/hooks/audit.py — it logs every tool call to the audit trail:
"""Global hook: audit every tool call to the audit log."""
import json
import logging
from agents.audit import log_operation
from core.hooks_registry import HookContext, hooks_registry
logger = logging.getLogger(__name__)
@hooks_registry.register("on_before_tool")
def audit_on_before_tool(ctx: HookContext) -> None:
"""Log tool invocation attempt before execution."""
log_operation(
operation="tool_before",
details=json.dumps({
"tool": ctx.tool_name,
"params": {k: str(v)[:200] for k, v in ctx.parameters.items()},
"role": ctx.role,
"workspace": ctx.workspace,
}),
success=True,
)
@hooks_registry.register("on_after_tool")
def audit_on_after_tool(ctx: HookContext) -> None:
"""Log tool result after successful execution."""
log_operation(
operation="tool_after",
details=json.dumps({
"tool": ctx.tool_name,
"duration": round(ctx.duration, 3),
"role": ctx.role,
"workspace": ctx.workspace,
}),
success=True,
)
@hooks_registry.register("on_tool_error")
def audit_on_tool_error(ctx: HookContext) -> None:
"""Log tool failure with error details."""
log_operation(
operation="tool_error",
details=json.dumps({
"tool": ctx.tool_name,
"error": ctx.error,
"attempt": ctx.attempt,
"role": ctx.role,
"workspace": ctx.workspace,
}),
success=False,
)
Hook dispatch in the tool pipeline¶
Hooks fire from tools/orchestrator.py during execute_tool():
execute_tool(tool_name, params)
│
├─► hooks.dispatch("on_before_tool", ctx)
│
├─► [execute tool]
│ │
│ ├─ success ─► hooks.dispatch("on_after_tool", ctx)
│ │
│ └─ failure ─► hooks.dispatch("on_tool_error", ctx)
│
└─► return result
Checklist¶
- [ ] Hook function uses
@hooks_registry.register("hook_point")decorator - [ ] Function signature accepts
ctx: HookContextparameter - [ ] Sync or async — both supported; exceptions are caught and logged
- [ ] Hook placed in
core/hooks/(global) orworkspaces/<name>/hooks/(workspace-scoped) - [ ] Imported in application entry point for global hooks
- [ ] Tests cover each registered hook point
- [ ] Tests verify context fields are populated correctly