> ## 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="Order with priority">
    Set `priority` to control which hook runs first — lower runs earlier, default is `100`:

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

    @add_hook('after_llm', priority=10)
    def redact(event_data):
        return None  # runs first — allow

    @add_hook('after_llm', priority=90)
    def trace(event_data):
        print("emitted after redaction")  # runs after — sees redacted payload

    agent = Agent(name="Assistant", instructions="Help with anything.")
    agent.start("Say hi")
    ```

    Hooks in the same priority bucket keep registration order.
  </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>

  <Step title="Per-agent event-hook isolation">
    Pass a private `HookRegistry()` so an agent's event hooks only fire on that agent:

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

    # Private registry — hooks registered here only fire on this agent.
    private = HookRegistry()

    @private.on(HookEvent.BEFORE_TOOL)
    def audit(event):
        print(f"[secure-agent] tool: {event.tool_name}")
        return event

    secure_agent = Agent(
        name="SecureAgent",
        instructions="Handle sensitive data.",
        hooks=HooksConfig(registry=private),
    )

    other_agent = Agent(name="OtherAgent", instructions="Help with coding.")
    # other_agent uses the shared default registry — audit() never fires here.
    ```

    Without `registry=`, every agent shares the process-wide default registry, so a `BEFORE_TOOL` hook registered for one agent fires on all of them. `registry=HookRegistry()` keeps them isolated. Available since PraisonAI [PR #5247](https://github.com/MervinPraison/PraisonAI/pull/5247).
  </Step>
</Steps>

***

## Per-agent event-hook isolation

Pick the widest scope that still keeps unrelated agents clean.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Q{Do hooks apply to every agent?}
    Q -->|Yes, global rule| A[add_hook / default registry]
    Q -->|No, per agent| B{Different agents need different hooks?}
    B -->|One shared set| C[HooksConfig middleware= / on_step]
    B -->|Fully isolated| D[HooksConfig registry=HookRegistry]

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

    class Q,B question
    class A,C,D answer
```

A "SecureAgent" audits every tool call while a plain "HelperAgent" runs untouched — the audit hook fires only on the agent that owns the private registry.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant User
    participant SecureAgent
    participant HelperAgent
    participant Audit as audit() (private registry)

    User->>SecureAgent: "read the customer record"
    SecureAgent->>Audit: BEFORE_TOOL(read_record)
    Audit-->>SecureAgent: logged
    User->>HelperAgent: "format this snippet"
    Note over HelperAgent,Audit: HelperAgent uses the default<br/>registry — audit() never fires
    HelperAgent-->>User: formatted snippet
```

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

private = HookRegistry()
fired_on = []

@private.on(HookEvent.BEFORE_TOOL)
def audit(event):
    fired_on.append(event.tool_name)   # only records SecureAgent's tools
    return event

secure_agent = Agent(
    name="SecureAgent",
    instructions="Handle sensitive data.",
    hooks=HooksConfig(registry=private),
)
helper_agent = Agent(name="HelperAgent", instructions="Help with coding.")

secure_agent.start("Read the customer record")
helper_agent.start("Format this code snippet")
# audit() recorded only SecureAgent's tools — HelperAgent never triggered it.
```

<Note>
  The default registry stays the fallback, so plugins bridged into it still fire on every agent. `registry=` only isolates the event hooks **you** register on that private registry.
</Note>

***

## API Surface

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

| Function | Purpose |
| - | - |
| `add_hook(event, callback, priority=100)` | Register a callback for an event; `priority=100` by default (lower runs earlier) |
| `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`.

`priority` applies **before** the sequential/parallel split, so a lower-priority sequential hook runs before a higher-priority parallel one — a redact (`sequential=True`, `priority=10`) now runs ahead of a trace (`priority=20`), closing the redact-then-trace leak the runner previously had. See [Ordering hooks with priority](#ordering-hooks-with-priority) below.

```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>

### Ordering hooks with priority

`priority` decides which hook on the same event runs first — **lower runs earlier**, and the default bucket is **100**.

Ties fall back to deterministic registration order via a monotonic `_seq` counter, so ordering is reproducible across environments and processes. Every registration API accepts `priority=`: `register_function(..., priority=100)`, `register_command(..., priority=100)`, `@registry.on(..., priority=100)`, and `add_hook(event, callback, priority=100)`. The `add_hook` default is `100` — the same bucket as `register_function` and `@on`.

The runner honors priority across the parallel/sequential boundary. It walks the priority-sorted list and runs each *contiguous* run of same-mode hooks together, so a redact (`sequential=True`, `priority=10`) that must precede a trace (`priority=20`) still runs first. Adjacent parallel hooks are still gathered concurrently. A denial inside a sequential run breaks the whole chain — later groups do not run after a deny.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    A[Hooks for event] --> B{Sort by priority, _seq}
    B --> C[Walk sorted list]
    C --> D{Same mode<br/>as previous?}
    D -->|Yes| E[Extend current group]
    D -->|No| F[Run current group<br/>then start new one]
    E --> G{Sequential<br/>denial?}
    F --> G
    G -->|Yes| H[Stop chain]
    G -->|No| I[Continue to next group]

    classDef question fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef process fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef stop fill:#8B0000,stroke:#7C90A0,color:#fff

    class B,D,G question
    class A,C,E,F,I process
    class H stop
```

`list_hooks()` returns each hook dict with a `"priority"` key, and the list is already in effective execution order — an operator can see the exact sequence the runner will use.

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

registry = HookRegistry()

@registry.on(HookEvent.AFTER_LLM, priority=90)
def trace(event_data):
    return HookResult.allow()

@registry.on(HookEvent.AFTER_LLM, sequential=True, priority=10)
def redact(event_data):
    return HookResult.allow()

# Returned in execution order: redact (10) before trace (90)
for hook in registry.list_hooks()["after_llm"]:
    print(hook["name"], hook["priority"])
```

<Note>
  As of PraisonAI [PR #5263](https://github.com/MervinPraison/PraisonAI/pull/5263), `priority` is honored — the parameter is no longer *"Reserved for future use"*. Hooks registered without an explicit priority land in the default bucket (`100`) and keep registration order, so existing code behaves the same. The `add_hook` default was aligned from `10` → `100` to match `register_function` / `@on`, so a hook added via `add_hook` with no `priority=` no longer jumps in front of one added via `register_function` with no `priority=`.
</Note>

### 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 |
| `registry` | `HookRegistry \| None` | `None` | Private per-agent event-hook registry. When set, this agent's `BEFORE_TOOL` / `AFTER_AGENT` / … hooks only fire on this agent — they never leak onto unrelated agents that share the process-wide default registry. |

<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")
```

### Guardrails before tracing

Order a redaction hook ahead of an observer so the tracer only ever sees the scrubbed payload — set the redactor to a lower priority.

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

registry = HookRegistry()

# Runs FIRST — redacts secrets in-place, sequential to apply modified_input
@registry.on(HookEvent.AFTER_LLM, sequential=True, priority=10)
def redact_pii(event_data):
    return HookResult.allow()

# Runs AFTER redaction — cheap observer, parallel
@registry.on(HookEvent.AFTER_LLM, priority=90)
def add_trace_span(event_data):
    return HookResult.allow()

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

### 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.

### Testing with hooks (pytest-xdist safety)

Pass a private `HookRegistry()` in tests so a hook registered by one test can't fire on an Agent in another.

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

def test_my_agent_runs_tools():
    agent = Agent(
        instructions="Test tools",
        tools=[my_tool],
        hooks=HookRegistry(),   # private registry — no global hooks leak in
    )
    assert agent.start("run the tool") is not None
```

A global `BEFORE_TOOL` deny-hook registered by another test will silently block your tools and flake only under parallel runners. A private registry gives each Agent zero shared state, so test order and worker assignment stop mattering.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Start([Running a hook-gated Agent in tests?]) --> Q1{How do tests run?}
    Q1 -->|Single process, single test| A[Default registry is fine]
    Q1 -->|Parallel: pytest -n auto / xdist / pytest-forked| B[Private HookRegistry per Agent]
    Q1 -->|Shared @add_hook in a conftest| C[Private HookRegistry per test's Agent]

    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 answer
```

Share one fixture so every test gets a fresh registry:

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

@pytest.fixture
def fresh_hooks():
    return HookRegistry()

def test_tool_runs_clean(fresh_hooks):
    agent = Agent(instructions="Test tools", tools=[my_tool], hooks=fresh_hooks)
    assert agent.start("run the tool") is not None
```

This is the same mechanism as [Per-agent event-hook isolation](#per-agent-event-hook-isolation) — a private registry keeps one Agent's hooks from leaking onto another. See PraisonAI [PR #5700](https://github.com/MervinPraison/PraisonAI/pull/5700) for a real-world fix that isolates hooks in the tool-executor concurrency tests.

<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.

    Prefer `HooksConfig(registry=HookRegistry())` when different agents in the same process need genuinely independent hook wiring — e.g. a security agent whose `BEFORE_TOOL` audit should never fire on unrelated agents. The default registry remains the fallback, so plugins bridged into it still fire. Available since PraisonAI [PR #5247](https://github.com/MervinPraison/PraisonAI/pull/5247).
  </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="Use priority to order guardrails before observers">
    Register security/redaction hooks with a lower priority (e.g. `10`) and tracers/loggers with a higher priority (e.g. `90`) so redaction always runs before the observer sees the payload. Hooks in the same bucket keep registration order. Default priority is `100`.
  </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>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.