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

# Hooks

> Intercept, log, or block agent actions at any lifecycle point — before tools run, after LLM responds, on error

Hooks intercept agent actions at lifecycle points so you can log, modify, or block them without changing agent code.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent
from praisonaiagents.hooks import add_hook

@add_hook("before_tool")
def log_tools(event_data):
    print(f"Running tool: {event_data.tool_name}")

agent = Agent(name="SecureAssistant", instructions="You are a helpful assistant.")
agent.start("Help me organise my files")
```

The user sends a request; hooks intercept tools and lifecycle steps without changing agent code.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    subgraph "Agent Lifecycle"
        INPUT["📥 Input"] --> BTD["🪝 BEFORE_TOOL_DEFINITIONS\nhook"]
        BTD --> LLM["🧠 LLM Call"]
        LLM --> BEFORE["🪝 BEFORE_TOOL\nhook"]
        BEFORE --> TOOL["⚙️ Tool\nExecution"]
        TOOL --> AFTER["🪝 AFTER_TOOL\nhook"]
        AFTER --> OUTPUT["📤 Output"]
    end

    classDef agent fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef tool fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef hook fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef success fill:#10B981,stroke:#7C90A0,color:#fff

    class INPUT,OUTPUT agent
    class TOOL,LLM tool
    class BTD,BEFORE,AFTER hook
```

<Note>
  **Not the same as `memory.HooksManager`.** This page covers the live `praisonaiagents.hooks` package, which fires automatically around an Agent's tool/LLM calls. The standalone `memory.HooksManager` only runs when you call `.execute()` yourself.

  | System                       | Import                                                     | Auto-fires around agent calls? |
  | ---------------------------- | ---------------------------------------------------------- | ------------------------------ |
  | Live agent hooks (this page) | `from praisonaiagents.hooks import add_hook, HookRegistry` | ✅ Yes                          |
  | Standalone utility           | `from praisonaiagents.memory import HooksManager`          | ❌ No                           |

  See [Memory HooksManager](/docs/features/memory-hooks-manager) and [Hooks CLI](/docs/cli/hooks).
</Note>

<Note>
  **Subscribe with `add_hook`, emit with `fire_hook`.** `add_hook`, `remove_hook`, `has_hook`, and `get_default_registry` manage subscribers; `fire_hook` is the emission counterpart a runtime component calls at a real state transition so those subscribers run. `fire_hook()` targets the process-wide default registry by default — pass `registry=` for a scoped one. See [fire\_hook](/docs/features/fire-hook).
</Note>

## Quick Start

<Steps>
  <Step title="Simple Usage">
    Register a hook with `add_hook` and any agent picks it up automatically:

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents import Agent
    from praisonaiagents.hooks import add_hook

    @add_hook('before_tool')
    def log_tools(event_data):
        print(f"Running tool: {event_data.tool_name}")

    @add_hook('before_tool')
    def block_delete(event_data):
        if "delete" in event_data.tool_name.lower():
            return "Delete operations are blocked"

    agent = Agent(
        name="SecureAssistant",
        instructions="You are a helpful assistant."
    )

    agent.start("Help me organize my files")
    ```

    Hook return values:

    * `None` or no return → Allow
    * `False` → Deny
    * `"reason"` → Deny with custom message
  </Step>

  <Step title="With HooksConfig">
    Attach a `HooksConfig` to a specific agent for scoped hooks:

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents import Agent, HooksConfig

    def log_step(event_data):
        print(f"Step: {event_data}")

    def on_tool(event_data):
        print(f"Tool called: {event_data.tool_name}")

    agent = Agent(
        name="MonitoredAgent",
        instructions="You are a helpful assistant.",
        hooks=HooksConfig(
            on_step=log_step,
            on_tool_call=on_tool,
        )
    )

    agent.start("Write a haiku about Python")
    ```
  </Step>
</Steps>

***

## API Surface

The simplified API covers registering, inspecting, and emitting hooks — all import from `praisonaiagents.hooks`.

| Function                    | Purpose                                                              |
| --------------------------- | -------------------------------------------------------------------- |
| `add_hook(event, callback)` | Register a callback for an event                                     |
| `remove_hook(hook_id)`      | Remove a hook by its id                                              |
| `has_hook(event)`           | Check whether any hook is registered for an event                    |
| `fire_hook(event, data)`    | **Emit** an event so subscribers run — the counterpart to `add_hook` |
| `get_default_registry()`    | Get the process-wide default registry                                |

Runtime components call `fire_hook()` at real state transitions, so subscribing with `add_hook` is all a plugin author needs. See [fire\_hook](/docs/features/fire-hook) for the emission API.

***

## Which Hook Point Should I Use?

Pick the lifecycle event that matches what you want to observe or block.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Start([What do you want to do?]) --> Q1{At which point?}
    Q1 -->|Inspect / block a tool| A[before_tool<br/>gate tool calls]
    Q1 -->|Rewrite / redact a tool result| B[after_tool<br/>rewrite or block output]
    Q1 -->|Shape tools sent to LLM| C[before_tool_definitions<br/>filter the tool list]
    Q1 -->|Inspect the prompt| D[before_llm<br/>modify request]
    Q1 -->|React to a response| E[after_llm<br/>post-process reply]
    Q1 -->|Handle failures| F[on_error<br/>catch and recover]

    classDef start fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef question fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef answer fill:#10B981,stroke:#7C90A0,color:#fff

    class Start start
    class Q1 question
    class A,B,C,D,E,F answer
```

***

## How It Works

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant User
    participant Agent
    participant Hook
    participant Tool

    User->>Agent: start("task")
    Agent->>Hook: BEFORE_TOOL(event_data)
    alt Allow
        Hook-->>Agent: None (allow)
        Agent->>Tool: execute()
        Tool-->>Agent: result
        Agent->>Hook: AFTER_TOOL(result)
        Hook-->>Agent: None (allow)
        Agent-->>User: response
    else Deny
        Hook-->>Agent: "reason" (deny)
        Agent-->>User: blocked — "reason"
    end
```

A hook that **raises**, **times out**, has **no callable** (a `FunctionHook` whose `func is None`), or — for **command hooks** — exits with an **unexpected exit code** (anything other than `0` = allow or `2` = blocking; e.g. `127` command-not-found, `126` not-executable) now counts as `Deny`, not `Allow`. This applies even when the command already printed `{"decision": "allow"}` on stdout — a gating hook that crashes after emitting allow-JSON has not actually approved the call, so the runner denies it. Every hook lifecycle path returns `HookResult(decision="deny", reason=...)` on error — a broken `BEFORE_TOOL` gate no longer lets the tool call through by accident.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    A[Hook fires] --> B{Function hook<br/>with func=None?}
    B -->|Yes| D[🚫 deny — no callable]
    B -->|No| C{Raised or<br/>timed out?}
    C -->|Yes| E[🚫 deny — error/timeout]
    C -->|No| F{Command hook<br/>exit code}
    F -->|0| G[✅ allow<br/>parse JSON]
    F -->|2| H[🚫 deny — blocking]
    F -->|other<br/>127, 126, ...| I[🚫 deny — unexpected exit]
    G --> J{JSON says allow<br/>+ unexpected exit?}
    J -->|Yes| K[🚫 deny — allow-JSON<br/>then unexpected exit]
    J -->|No| L[✅ decision from JSON]

    classDef question fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef deny fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef allow fill:#10B981,stroke:#7C90A0,color:#fff

    class A,B,C,F,J question
    class D,E,H,I,K deny
    class G,L allow
```

### Command Hook Exit Codes

A shell-command hook communicates its verdict through **both** its exit code and its stdout JSON — the runner requires them to agree on `allow`.

| Exit code                                        | Decision | Notes                                                                                                                                                      |
| ------------------------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0`                                              | `allow`  | JSON on stdout (if any) is honoured — `decision`, `reason`, `modified_input`, `additional_context`. Absence of JSON is treated as allow.                   |
| `2`                                              | `deny`   | Blocking sentinel. `stderr` (or `stdout`) becomes `reason`. Explicit deny-JSON is still honoured.                                                          |
| Any other non-zero (`127`, `126`, `1`, `137`, …) | `deny`   | Hook did not render a trustworthy verdict. `reason` names the exit code and the captured stderr/stdout. `allow`-JSON on stdout does **not** override this. |
| Timed out (killed)                               | `deny`   | Same reason format; matches the exception/timeout path.                                                                                                    |

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
# Fail closed on a missing shell script
from praisonaiagents.hooks import HookRunner, HookRegistry

runner = HookRunner(HookRegistry(), cwd="/tmp")

# What happens when a command hook script is not on PATH
result = runner._parse_command_output(stdout="", stderr="not found", exit_code=127)
assert result.decision == "deny"
assert "127" in result.reason  # "Hook exited with unexpected code 127: not found"
```

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
# Fail closed on allow-JSON + unexpected exit — the allow is discarded
result = runner._parse_command_output(
    stdout='{"decision": "allow"}', stderr="", exit_code=127
)
assert result.decision == "deny"
assert "127" in result.reason
```

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
# Explicit deny-JSON still preserved even on unexpected exit
result = runner._parse_command_output(
    stdout='{"decision": "deny", "reason": "policy"}', stderr="", exit_code=127
)
assert result.decision == "deny"
assert result.reason == "policy"  # reason from JSON is respected
```

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
# FunctionHook with func=None fails closed
from praisonaiagents.hooks import FunctionHook, HookEvent

hook = FunctionHook(id="broken", name="broken", event=HookEvent.BEFORE_TOOL, func=None)
# When executed, the runner now returns:
#   output=HookResult(decision="deny", reason="Hook 'broken' has no callable")
# so is_blocked() returns True and the tool is refused.
```

### Sequential vs Parallel Hooks

A hook's execution mode decides whether it can rewrite the payload.

**Sequential hooks** run one after another and their `modified_input` is applied back to the payload. **Parallel hooks** run concurrently and cannot mutate the payload — use them for read-only observers (metrics, logging, tracing). If your hook needs to rewrite the request (redact secrets, inject headers, edit messages), register it with `sequential=True`.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Start([Does your hook need to mutate input?])
    Start --> Q1{Rewrites messages / redacts / edits payload?}
    Q1 -->|Yes| A[Sequential — sequential=True]
    Q1 -->|No, read-only| B[Parallel — the default]

    classDef start fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef question fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef answer fill:#10B981,stroke:#7C90A0,color:#fff

    class Start start
    class Q1 question
    class A,B answer
```

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.hooks import HookRegistry, HookEvent

registry = HookRegistry()

# Read-only observer — parallel (default)
registry.register_function(
    event=HookEvent.BEFORE_LLM,
    func=log_prompt,
    name="my.logger",
)

# Rewriter — MUST be sequential to apply modified_input
registry.register_function(
    event=HookEvent.BEFORE_LLM,
    func=redact_pii,
    name="my.pii_redactor",
    sequential=True,   # <-- required for modified_input to take effect
)
```

<Warning>
  Registering a mutating hook without `sequential=True` looks correct but silently no-ops — the runner runs it in parallel with sibling hooks, so its `modified_input` is discarded. As of PraisonAI [PR #4034](https://github.com/MervinPraison/PraisonAI/pull/4034) the runtime **logs a warning** when a parallel hook returns a non-empty `modified_input`: `"Parallel hook '<name>' returned modified_input which is discarded; register it with sequential=True to apply it."` Watch your logs when adopting a new mutating hook.
</Warning>

### Thread Safety

`HookRegistry` guards its per-event hook lists with a re-entrant lock, so `register` / `unregister` / `clear` / `enable_hook` / `disable_hook` and `get_hooks` are safe under concurrent access from multiple threads. `get_hooks` snapshots the list under the lock before filtering, so a concurrent `unregister` / `clear` on another thread can no longer raise `RuntimeError: list changed size during iteration` or skip / duplicate a hook mid-iteration. The lock is an `RLock`, so a hook callback that registers or unregisters other hooks (a legitimate re-entrant pattern) still works.

This matters most for the process-wide default registry (the one you get via `get_default_registry()` or the module-level `add_hook`-style helpers) — it is shared across every agent in the process, so plugins registering hooks at import time can race an agent iterating hooks on another thread. As of [PraisonAI PR #4634](https://github.com/MervinPraison/PraisonAI/pull/4634) that race is fixed. `fire_hook()` targets that default registry too; pass `registry=` for a scoped one.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.hooks import HookRegistry, HookEvent
import threading

registry = HookRegistry()

# Safe to register / unregister from any thread while another thread reads:
def worker():
    hook_id = registry.register_function(
        event=HookEvent.BEFORE_TOOL,
        func=lambda data: data,
        name="my.hook",
    )                                              # under RLock
    registry.get_hooks(HookEvent.BEFORE_TOOL)      # snapshots under RLock
    registry.unregister(hook_id)                   # under RLock

threading.Thread(target=worker).start()
```

### Available Hook Events

The events most agents will ever need are the agent / tool / LLM / error / session ones — start here.

| Event                     | When it fires                                                             |
| ------------------------- | ------------------------------------------------------------------------- |
| `before_agent`            | Before the agent starts a turn                                            |
| `after_agent`             | After the agent completes a turn                                          |
| `before_tool`             | Before any tool executes                                                  |
| `after_tool`              | After a tool returns a result                                             |
| `before_tool_definitions` | After tool list assembled, before sent to LLM — shape what the model sees |
| `before_llm`              | Before an LLM API call (sync + async)                                     |
| `after_llm`               | After an LLM API response (sync + async)                                  |
| `on_error`                | When any error occurs                                                     |
| `on_retry`                | Before a retry attempt (sync + async LLM paths)                           |
| `session_start`           | When a session begins                                                     |
| `session_end`             | When a session ends                                                       |

<Note>
  This is the **core lifecycle** subset. The SDK ships \~40 events in total — including LLM lifecycle hooks (`before_llm`, `after_llm`, `model_fallback`), plugin lifecycle hooks (`on_init`, `on_shutdown` — now emitted by `PluginManager.register/unregister`), message-level bot hooks (`message_received`, `message_sending`, `message_sent`, `message_undelivered`; `before_message` / `after_message` / `tool_result_persist` are aliases of live events), gateway hooks (`gateway_start`, `gateway_stop`), compaction hooks (`before_compaction`, `after_compaction`), permission/config/auth hooks (`on_permission_ask`, `on_config`, `on_auth`), schedule hooks (`schedule_add`, `schedule_remove`, `schedule_trigger`), background-job hooks (`job_completed`, `subagent_stop`), and kanban task hooks.

  See [Hook Events](/docs/features/hook-events) for the complete reference with input dataclasses and examples for each, and [fire\_hook](/docs/features/fire-hook) for the sibling emitter that delivers these events to subscribers.
</Note>

`on_retry` is emitted once per retryable error, just before the back-off sleep. It runs whether the LLM call is `agent.chat(...)` (sync) or `await agent.achat(...)` (async). Sync-registered callbacks on the async path are run in a thread executor — they cannot block the event loop.

<Note>
  Since PraisonAI PR [#3908](https://github.com/MervinPraison/PraisonAI/pull/3908), `before_llm` and `after_llm` fire on both `chat()` and `achat()`. See [Hook Events → LLM Events](/docs/features/hook-events#llm-events) for the parity note and blocking semantics.
</Note>

<Note>
  **Two lookalike fields.** Function-style hooks return `HookResult` and set `modified_input` to rewrite the payload. Shell-command hooks parse into `HookOutput` and use `modified_data` for the same purpose. When you write a hook in Python, always use `HookResult(decision="allow", modified_input={...})` — the internal Agent code reads `.modified_input` on hook results.
</Note>

***

## Configuration Options

<Card title="HooksConfig SDK Reference" icon="code" href="/docs/sdk/reference/praisonaiagents/modules/feature_configs">
  Full parameter reference for HooksConfig
</Card>

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent, HooksConfig

agent = Agent(
    instructions="...",
    hooks=HooksConfig(
        on_step=my_step_callback,
        on_tool_call=my_tool_callback,
        middleware=[my_middleware],
    )
)
```

| Option         | Type               | Default | Description                                |
| -------------- | ------------------ | ------- | ------------------------------------------ |
| `on_step`      | `Callable \| None` | `None`  | Observer called once per model call        |
| `on_tool_call` | `Callable \| None` | `None`  | Observer called before each tool execution |
| `middleware`   | `List`             | `[]`    | Middleware list applied to all events      |

<Note>
  `on_step` and `on_tool_call` are **observers**, wired into the same middleware chain that powers `middleware=[...]`:

  * `on_step` maps to the [`after_model`](/docs/features/hook-events) slot and receives the `ModelResponse` for that step — **once per model call**, not per token.
  * `on_tool_call` maps to the [`before_tool`](/docs/features/hook-events) slot and receives a `ToolRequest` (`.tool_name`, `.arguments`) before every tool the agent runs.
  * **Return values are ignored** — the `ModelResponse` / `ToolRequest` always passes through unchanged, so an observer can never corrupt the run. To short-circuit or rewrite, use `middleware=[...]` (function-style hooks that return `HookResult`) instead.
  * **Async callbacks are safe** — an `async def` callback is awaited automatically. No manual wrapping needed.
  * **`middleware` runs before `on_tool_call`.** Any `middleware=[...]` entry fires first, then the `on_tool_call` observer sees the (possibly rewritten) request.
</Note>

***

## Common Patterns

### Security Filtering

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent
from praisonaiagents.hooks import add_hook

BLOCKED_TOOLS = {"delete_file", "execute_command", "drop_table"}

@add_hook('before_tool')
def security_filter(event_data):
    if event_data.tool_name in BLOCKED_TOOLS:
        return f"Tool '{event_data.tool_name}' is not allowed in this environment"

agent = Agent(
    name="SafeAgent",
    instructions="Help users manage their documents safely."
)

agent.start("Clean up the old log files")
```

### Audit Logging

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
import json
from datetime import datetime
from praisonaiagents import Agent
from praisonaiagents.hooks import add_hook

@add_hook('before_tool')
def audit_before(event_data):
    print(json.dumps({
        "ts": datetime.utcnow().isoformat(),
        "event": "before_tool",
        "tool": event_data.tool_name,
    }))

@add_hook('after_tool')
def audit_after(event_data):
    print(json.dumps({
        "ts": datetime.utcnow().isoformat(),
        "event": "after_tool",
        "tool": event_data.tool_name,
    }))

agent = Agent(
    name="AuditAgent",
    instructions="Process user requests with full audit trail."
)

agent.start("Search for the latest AI research papers")
```

### Redact Secrets from Tool Output

Rewrite the tool result the model sees — scrub API keys before they ever reach the LLM. Returning a value from an `after_tool` hook **replaces** `event_data.tool_output`.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
import re
from praisonaiagents import Agent
from praisonaiagents.hooks import add_hook

SECRET_RE = re.compile(r"sk-[A-Za-z0-9]{20,}")

@add_hook('after_tool')
def redact_secrets(event_data):
    """Rewrite the tool result the model sees — never leak API keys."""
    result = event_data.tool_output
    if isinstance(result, str) and SECRET_RE.search(result):
        return SECRET_RE.sub("[REDACTED-SECRET]", result)
    return None  # unchanged

agent = Agent(name="Assistant", instructions="Help with logs.")
agent.start("Read /var/log/api.log and summarise the errors")
```

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant Agent
    participant Tool
    participant AfterTool as after_tool hook
    participant LLM

    Agent->>Tool: execute
    Tool-->>Agent: raw result (may contain secret)
    Agent->>AfterTool: AFTER_TOOL(tool_output=raw)
    AfterTool-->>Agent: rewritten output (secret redacted)
    Agent->>LLM: send rewritten output only
```

### Block a Tool Result via GuardrailBlocked

Raise `GuardrailBlocked` inside `after_tool` to stop a result reaching the model — mirrors the block path already available on `before_tool`.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent
from praisonaiagents.hooks import add_hook
from praisonaiagents.plugins import GuardrailBlocked

@add_hook('after_tool')
def block_pii(event_data):
    if "ssn=" in str(event_data.tool_output).lower():
        raise GuardrailBlocked("Tool result contained PII; blocked before reaching model.")

agent = Agent(name="Assistant", instructions="Help with customer records.")
agent.start("Look up the account details for order 4821")
```

<Note>
  `after_tool` returns are honoured as of PraisonAI PR [#3969](https://github.com/MervinPraison/PraisonAI/pull/3969) — before that release the return value was silently discarded, so the hook could observe but never rewrite or block. Both the sync (`agent.chat(...)`) and async (`await agent.achat(...)`) tool-execution paths now read back `event_data.tool_output` and honour `GuardrailBlocked`.
</Note>

### Tool Matching with HookRegistry

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent
from praisonaiagents.hooks import HookRegistry, HookEvent, HookResult

registry = HookRegistry()

@registry.on(HookEvent.BEFORE_TOOL)
def file_guard(event_data) -> HookResult:
    if event_data.tool_name.startswith("write_"):
        print(f"Write operation: {event_data.tool_name}")
    return HookResult.allow()

agent = Agent(
    name="GuardedAgent",
    instructions="You are a helpful assistant.",
    hooks=registry
)

agent.start("Create a summary and save it to output.txt")
```

### Redact a Tool Result

Rewrite `event_data.tool_output` in place to scrub a secret, or return `HookResult.block(reason)` to suppress the result entirely.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent
from praisonaiagents.hooks import HookRegistry, HookEvent, HookResult

registry = HookRegistry()

@registry.on(HookEvent.AFTER_TOOL)
def redact_secrets(event_data):
    scrubbed = str(event_data.tool_output).replace("sk-SECRET123", "[REDACTED]")
    event_data.tool_output = scrubbed        # rewrite in place
    return HookResult.allow()

@registry.on(HookEvent.AFTER_TOOL)
def block_on_pii(event_data):
    if "ssn=" in str(event_data.tool_output):
        return HookResult.block("PII detected in tool output")
    return HookResult.allow()

agent = Agent(
    name="SafeAgent",
    instructions="Answer using the fetch tool.",
    tools=["fetch"],
    hooks=registry,
)
agent.start("Fetch the user profile")
```

See [Redact / Block Tool Output](/docs/features/redact-tool-output) for the plugin-style equivalent.

<Note>
  `HookEvent.BEFORE_TOOL` and `HookEvent.AFTER_TOOL` now fire on **both** the sync (`agent.chat(...)`) and async (`await agent.achat(...)`) tool-execution paths. When a `BEFORE_TOOL` hook blocks a call, the tool returns `"Execution of {tool_name} was blocked by security policy."` on either path. `AFTER_TOOL` results aggregate context onto the tool output (string concat, or the `_additional_context` key on a dict result). The check costs nothing when no hooks are registered.

  **`AFTER_TOOL` can also rewrite or block the result** (PraisonAI PRs [#3968](https://github.com/MervinPraison/PraisonAI/issues/3968) / [#3969](https://github.com/MervinPraison/PraisonAI/pull/3969)). Mutate `event_data.tool_output` in place to redact the value the model sees, or return `HookResult.block(reason)` to suppress it. See [Redact / Block Tool Output](/docs/features/redact-tool-output).
</Note>

***

## Best Practices

<AccordionGroup>
  <Accordion title="Use on_step / on_tool_call for logging, middleware for control">
    `on_step` and `on_tool_call` are **observers** — their return value is ignored, so use them for logging, metrics, and tracing. When you need to **control** the run (rewrite a request, block a tool, retry a model call), use `middleware=[...]` with function-style hooks that return `HookResult`.

    Ordering rule: `middleware` always runs **before** `on_tool_call`, so the observer sees whatever the middleware chain produced.

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents import Agent, HooksConfig
    from praisonaiagents.hooks import before_tool, HookResult

    @before_tool
    def block_delete(request):
        if request.tool_name == "delete_file":
            return HookResult.block("delete_file is not allowed")
        return request

    def log_tool(request):            # observer — return ignored
        print(f"tool: {request.tool_name} args={request.arguments}")

    agent = Agent(
        name="Assistant",
        instructions="Manage files safely.",
        hooks=HooksConfig(
            on_tool_call=log_tool,    # logging
            middleware=[block_delete] # control (runs first)
        ),
    )
    agent.start("Tidy up the workspace")
    ```
  </Accordion>

  <Accordion title="Keep hooks lightweight">
    Hooks run synchronously before/after each operation. Avoid network calls or heavy computation inside hook functions — use async queues for heavy processing.
  </Accordion>

  <Accordion title="Return None to allow, string to deny">
    The simplest hook contract: return nothing (or `None`) to allow, return a string with a reason to block. This keeps hooks readable.
  </Accordion>

  <Accordion title="Use add_hook for global rules, HooksConfig for per-agent rules">
    `add_hook` registers hooks globally — all agents in the process obey them. Use `HooksConfig` when you need different rules per agent.
  </Accordion>

  <Accordion title="Sequential for rewriters, parallel for observers">
    Parallel is the default and is faster. Only mark a hook `sequential=True` when it needs to mutate the payload via `modified_input`.
  </Accordion>

  <Accordion title="Hooks fail closed on error, timeout, missing callable, or unexpected exit">
    A `BEFORE_TOOL` (or any) hook fails closed — returns `HookResult(decision="deny", reason=...)` and the tool does **not** execute — on any of four triggers:

    * **Raises** an exception.
    * **Times out**.
    * Has **no callable** — a `FunctionHook` whose `func is None` denies with `"Hook '<name>' has no callable"`.
    * For **command hooks**, exits with an **unexpected exit code** — anything other than `0` (allow) or `2` (blocking), e.g. `127` command-not-found or `126` not-executable. `allow`-JSON printed on stdout before the crash is discarded; explicit `deny`-JSON keeps its `reason`.

    This matches `GuardrailChain` and closes a silent pass-through where a buggy security hook used to allow the call through. Whenever any trigger fires on `BEFORE_TOOL`, the viewer sees `"Execution of {tool_name} was blocked by security policy."` Set `agent._strict_hooks = True` in tests to also surface the underlying exception, not just the deny.
  </Accordion>

  <Accordion title="Tool errors don't fail closed — hooks do">
    Tool failures are tolerated: a tool that raises or returns a non-JSON result is fed back to the model as `{"error": ...}` / `{"result": ...}` and the run continues. Hooks are the opposite — a raising or timed-out hook denies the call. Keep security gates in hooks, not in tool bodies.
  </Accordion>

  <Accordion title="Use on_step / on_tool_call for logging, middleware for control">
    `on_step` and `on_tool_call` are **observers** — their return values are ignored, so they can only watch, never change or stop a run. Use them for logging, metrics, and tracing.

    `middleware=[...]` are **controllers** — function-style hooks that return `HookResult` to rewrite payloads or short-circuit the call. Use middleware when you need to block, retry, or mutate.

    When both are present on the same tool, **middleware runs first**, then the `on_tool_call` observer.

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents import Agent, HooksConfig

    async def log_tool(request):          # observer — awaited, return ignored
        print(f"→ {request.tool_name}({request.arguments})")

    agent = Agent(
        instructions="Help with tasks.",
        hooks=HooksConfig(
            on_tool_call=log_tool,        # logging
            middleware=[my_validator],    # control (returns HookResult)
        ),
    )
    ```
  </Accordion>

  <Accordion title="before_agent / after_agent / BEFORE_TOOL_DEFINITIONS cost nothing when unregistered">
    `before_agent`, `after_agent`, and `BEFORE_TOOL_DEFINITIONS` cost nothing when no hook is registered — the runtime checks `has_hooks()` before building the input (including `os.getcwd()`, the tools list, and any deep-copy of tool definitions) on both sync (`chat`) and async (`achat`) paths. Register these hooks in production without a per-turn overhead concern.

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents import Agent
    from praisonaiagents.hooks import add_hook

    # No before_agent / after_agent hook registered anywhere in the process.
    # The runtime skips input construction entirely — no os.getcwd(), no tools list build.
    agent = Agent(name="Fast", instructions="Be helpful.")
    agent.start("Say hi")

    # Only pay the cost when you actually opt in:
    @add_hook("after_agent")
    def log_turn(event_data):
        print(f"Turn completed by {event_data.agent_name} in {event_data.execution_time_ms:.0f}ms")

    agent.start("Say hi again")   # Now the input is built and after_agent fires.
    ```
  </Accordion>
</AccordionGroup>

***

## Related

<CardGroup cols={2}>
  <Card title="Hook Events" icon="webhook" href="/docs/features/hook-events">
    Complete list of \~40 events with input dataclasses and examples
  </Card>

  <Card title="fire_hook" icon="megaphone" href="/docs/features/fire-hook">
    Emit an event so subscribed hooks actually fire
  </Card>

  <Card title="Guardrails" icon="shield-halved" href="/docs/features/guardrails">
    Validate agent output quality with automatic retry
  </Card>

  <Card title="Callbacks" icon="circle-nodes" href="/docs/features/callbacks">
    Observe agent events for UI and logging purposes
  </Card>
</CardGroup>
