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

With HooksConfig

Attach a HooksConfig to a specific agent for scoped hooks:

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

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

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.
Parallel is the default and is faster. Only mark a hook sequential=True when it needs to mutate the payload via modified_input.
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