> ## Documentation Index
> Fetch the complete documentation index at: https://docs.xpander.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# @on_tool_before / @on_tool_after / @on_tool_error

> Lifecycle hooks around every tool invocation.

`@on_tool_before`, `@on_tool_after`, and `@on_tool_error` register hooks that fire around tool invocations: useful for logging, validation, caching, alerting, or analytics. The hooks run during every `Tool.ainvoke` (which means around every framework-driven tool call too).

```python theme={"dark"}
from xpander_sdk import on_tool_before, on_tool_after, on_tool_error

@on_tool_before
async def log_invoke(tool, payload, payload_extension, tool_call_id, agent_version):
    print(f"→ {tool.name} {payload}")

@on_tool_after
async def log_success(tool, payload, payload_extension, tool_call_id, agent_version, result):
    print(f"✓ {tool.name} → {result}")

@on_tool_error
async def log_error(tool, payload, payload_extension, tool_call_id, agent_version, error):
    print(f"✗ {tool.name} failed: {error}")
```

You can register multiple hooks of each type: they fire in registration order. Sync and async functions are both supported.

## Required signatures

| Decorator         | Parameters                                                                        | Notes                                                                 |
| ----------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `@on_tool_before` | `tool`, `payload`, `payload_extension`, `tool_call_id`, `agent_version`           | Fires before the tool runs.                                           |
| `@on_tool_after`  | `tool`, `payload`, `payload_extension`, `tool_call_id`, `agent_version`, `result` | Fires after a successful invocation. `result` is the tool's response. |
| `@on_tool_error`  | `tool`, `payload`, `payload_extension`, `tool_call_id`, `agent_version`, `error`  | Fires when the invocation raises. `error` is the exception.           |

| Parameter               | Type           | Description                                             |
| ----------------------- | -------------- | ------------------------------------------------------- |
| `tool`                  | `Tool`         | The tool being invoked.                                 |
| `payload`               | `Any`          | The input payload after schema validation.              |
| `payload_extension`     | `dict \| None` | Extra fields that will be deep-merged into the payload. |
| `tool_call_id`          | `str \| None`  | Correlation id matching the LLM tool-call.              |
| `agent_version`         | `str \| None`  | Agent version pinned for the call.                      |
| `result` *(after only)* | `Any`          | The tool's response.                                    |
| `error` *(error only)*  | `Exception`    | The raised exception.                                   |

## Decorator forms

```python theme={"dark"}
@on_tool_before
def fn(...): ...

@on_tool_before(configuration=config)
def fn(...): ...
```

The same form applies to `@on_tool_after` and `@on_tool_error`. The optional `configuration` parameter is reserved for future use; hooks don't currently read it.

## Examples

### Log every tool call

```python theme={"dark"}
import time
import contextvars

start_time = contextvars.ContextVar("start_time")

@on_tool_before
def begin(tool, payload, *_):
    start_time.set(time.perf_counter())
    print(f"[{tool.name}] start")

@on_tool_after
def done(tool, payload, *_, result=None):
    elapsed = time.perf_counter() - start_time.get()
    print(f"[{tool.name}] done in {elapsed*1000:.0f}ms")

@on_tool_error
def failed(tool, payload, *_, error=None):
    elapsed = time.perf_counter() - start_time.get()
    print(f"[{tool.name}] failed in {elapsed*1000:.0f}ms: {error}")
```

### Block invocations matching a payload pattern

Hooks can't cancel invocations directly (the runtime catches their exceptions and continues), but you can mutate state or short-circuit by raising a guard before the call:

```python theme={"dark"}
@on_tool_before
def block_test_payloads(tool, payload, *_):
    if isinstance(payload, dict) and payload.get("test_mode"):
        raise RuntimeError("test_mode payloads are blocked")
```

Hooks that raise are caught by the runtime: the exception is logged but doesn't propagate to the caller. To genuinely block a tool, validate at the framework layer or in `@register_tool` function bodies.

### Cache successful results

```python theme={"dark"}
cache: dict = {}

@on_tool_after
def cache_result(tool, payload, _ext, _id, _ver, result):
    if hasattr(payload, "items"):
        key = (tool.id, frozenset(payload.items()))
        cache[key] = result
```

### Alert on failures

```python theme={"dark"}
@on_tool_error
async def alert(tool, payload, _ext, _id, _ver, error):
    await pagerduty.fire(f"tool {tool.id} failed: {error}")
```

## Notes

* Hooks fire **once per tool invocation**, regardless of whether the tool is local or remote.
* Exceptions in hooks are caught and logged via `loguru`: they never crash the invocation pipeline.
* Hooks are class-level on `ToolHooksRegistry` (process-wide singleton). There's no scoping to a specific repository.
* The auto-emitted `ToolCallRequest` / `ToolCallResult` events on `task.aevents()` are independent of these hooks. The hooks fire even when `report_activity=False` in `Tool.ainvoke`.
