Skip to main content
Hooks intercept agent actions at lifecycle points so you can log, modify, or block them without changing agent code.
The user sends a request; hooks intercept tools and lifecycle steps without changing agent code.
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.See Memory HooksManager and Hooks CLI.
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.

Quick Start

1

Simple Usage

Register a hook with add_hook and any agent picks it up automatically:
Hook return values:
  • None or no return → Allow
  • False → Deny
  • "reason" → Deny with custom message
2

Order with priority

Set priority to control which hook runs first — lower runs earlier, default is 100:
Hooks in the same priority bucket keep registration order.
3

With HooksConfig

Attach a HooksConfig to a specific agent for scoped hooks:
4

Per-agent event-hook isolation

Pass a private HookRegistry() so an agent’s event hooks only fire on that agent:
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.

Per-agent event-hook isolation

Pick the widest scope that still keeps unrelated agents clean. 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.
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.

API Surface

The simplified API covers registering, inspecting, and emitting hooks — all import from praisonaiagents.hooks. Runtime components call fire_hook() at real state transitions, so subscribing with add_hook is all a plugin author needs. See 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.

How It Works

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.

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.

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

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. 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.
As of PraisonAI PR #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=.

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 that race is fixed. fire_hook() targets that default registry too; pass registry= for a scoped one.

Available Hook Events

The events most agents will ever need are the agent / tool / LLM / error / session ones — start here.
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 for the complete reference with input dataclasses and examples for each, and fire_hook for the sibling emitter that delivers these events to subscribers.
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.
Since PraisonAI PR #3908, before_llm and after_llm fire on both chat() and achat(). See Hook Events → LLM Events for the parity note and blocking semantics.
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.

Configuration Options

HooksConfig SDK Reference

Full parameter reference for HooksConfig
on_step and on_tool_call are observers, wired into the same middleware chain that powers middleware=[...]:
  • on_step maps to the after_model slot and receives the ModelResponse for that step — once per model call, not per token.
  • on_tool_call maps to the before_tool 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.

Common Patterns

Security Filtering

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.

Audit Logging

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.

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.
after_tool returns are honoured as of PraisonAI PR #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.

Tool Matching with HookRegistry

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.
See Redact / Block Tool Output for the plugin-style equivalent.
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 / #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.

Best Practices

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.
Hooks run synchronously before/after each operation. Avoid network calls or heavy computation inside hook functions — use async queues for heavy processing.
The simplest hook contract: return nothing (or None) to allow, return a string with a reason to block. This keeps hooks readable.
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.
Parallel is the default and is faster. Only mark a hook sequential=True when it needs to mutate the payload via modified_input.
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.
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.
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.
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.
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.

Hook Events

Complete list of ~40 events with input dataclasses and examples

fire_hook

Emit an event so subscribed hooks actually fire

Guardrails

Validate agent output quality with automatic retry

Callbacks

Observe agent events for UI and logging purposes