Hook Events

Complete reference for all available hook events

Hook events are triggered at specific points in the agent lifecycle, allowing you to intercept, modify, or block operations.

Diagram

Hook Events

📋 User Request

⚙️ Hook Events

✅ Result

Diagram
Hook EventsAgentUserHook EventsAgentUserRequestProcess requestResultResponse
python
from praisonaiagents import Agent
from praisonaiagents.hooks import add_hook

@add_hook("before_tool")
def log_tool(event_data):
    print(f"Tool: {event_data.tool_name}")

agent = Agent(name="MyAgent", instructions="Be helpful.")
agent.start("Run a tool and show lifecycle hooks")

The user sends a prompt; hooks fire at each lifecycle event as the agent runs.

Diagram

Hook Events

📝 Prompt

🪝 Lifecycle Hooks

🤖 Agent

✅ Response

How It Works

Diagram
ToolHookAgentUserToolHookAgentUserSend promptFire BEFORE_TOOLAllow / modify / blockExecuteResult (AFTER_TOOL fires)Response

Lifecycle Events

Any HookEvent can be forwarded to a shell command via Gateway Outbound Hooks.

Diagram

Agent Lifecycle Events

📥 SESSION_START

🤖 BEFORE_AGENT

🧠 BEFORE_LLM

📋 BEFORE_TOOL_DEFINITIONS

📤 AFTER_LLM

🔧 BEFORE_TOOL

⚙️ Tool Execution

📦 AFTER_TOOL

✅ AFTER_AGENT

📴 SESSION_END

Quick Start

  1. Import Hook Components
    python
    from praisonaiagents.hooks import HookRegistry, HookEvent, HookResult
  2. Register a Hook
    python
    registry = HookRegistry()
    
    @registry.on(HookEvent.BEFORE_TOOL)
    def log_tool(event_data):
        print(f"Tool: {event_data.tool_name}")
        return HookResult.allow()
  3. Use with Agent
    python
    from praisonaiagents import Agent
    
    agent = Agent(
        name="MyAgent",
        instructions="You are helpful",
        hooks=registry
    )

Core Events

Tool Events

EventTriggerInput TypeUse Case
BEFORE_TOOLBefore tool execution — register with sequential=True when the hook needs to rewrite arguments via modified_inputBeforeToolInputSecurity checks, logging
AFTER_TOOLAfter tool executionAfterToolInputRedact/rewrite tool_output, block the result, validation, logging
python
@registry.on(HookEvent.BEFORE_TOOL)
def before_tool(event_data):
    print(f"Calling: {event_data.tool_name}")
    print(f"Args: {event_data.arguments}")
    return HookResult.allow()

@registry.on(HookEvent.AFTER_TOOL)
def after_tool(event_data):
    print(f"Result: {event_data.tool_output}")
    return HookResult.allow()

# Rewrite the tool output in place (propagates to the model)
@registry.on(HookEvent.AFTER_TOOL)
def redact(event_data):
    event_data.tool_output = str(event_data.tool_output).replace(
        "sk-SECRET123", "[REDACTED]"
    )
    return HookResult.allow()

# Block the tool output entirely
@registry.on(HookEvent.AFTER_TOOL)
def block_pii(event_data):
    if "ssn=" in str(event_data.tool_output):
        return HookResult.block("PII detected — result suppressed")
    return HookResult.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 codeDecisionNotes
0allowJSON on stdout (if any) is honoured — decision, reason, modified_input, additional_context. No JSON is treated as allow.
2denyBlocking sentinel. stderr (or stdout) becomes reason. Explicit deny-JSON is still honoured.
Any other non-zero (127, 126, 1, 137, …)denyHook 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)denySame reason format; matches the exception/timeout path.
  • exit 0 + JSON → JSON is honoured (allow, deny, modified_input, etc.).
  • exit 0 + no JSON → treated as allow.
  • exit 2 → treated as deny regardless of stdout; stderr (or stdout) is the reason. Explicit deny-JSON is honoured and its reason is preserved.
  • Any other exit code (127 command-not-found, 126 not-executable, 1, 137, …) → treated as deny with a reason naming the exit code. If stdout was {"decision": "allow"}, the allow is discarded — a gating hook that crashed after printing allow-JSON has not actually approved the call.

Tool Definition Events

EventTriggerInput TypeUse Case
BEFORE_TOOL_DEFINITIONSAfter advertised tool list is assembled, before sent to LLM — register with sequential=True when the hook needs to rewrite the list via modified_inputBeforeToolDefinitionsInputRedact tools per request, append usage notes, constrain schema per model

BEFORE_TOOL_DEFINITIONS lets you shape the tool list the LLM actually sees — without changing the agent’s permanent tool registration. Mutate tool_definitions in place; the runtime only adopts in-place mutations.

BeforeToolDefinitionsInput Fields

FieldTypeDefaultDescription
tool_definitionsList[Dict[str, Any]][]The fully-assembled OpenAI-style tool definition list about to be sent to the LLM. Mutate in place to filter or rewrite.
modelstr""Model id the call will be sent to (e.g. "gpt-4o"). Use for per-model filtering.
session_id, cwd, event_name, timestamp, agent_name——Standard HookInput fields.
python
from praisonaiagents import Agent
from praisonaiagents.hooks import HookRegistry, HookEvent, HookResult

registry = HookRegistry()

@registry.on(HookEvent.BEFORE_TOOL_DEFINITIONS)
def sandbox_tools(event_data):
    # Drop dangerous tools for this request
    event_data.tool_definitions[:] = [
        t for t in event_data.tool_definitions
        if t["function"]["name"] != "delete_file"
    ]
    # Annotate remaining tools
    for t in event_data.tool_definitions:
        if t["function"]["name"] == "read_file":
            t["function"]["description"] += " (sandboxed: /workspace only)"
    return HookResult.allow()

agent = Agent(
    name="SafeAgent",
    instructions="Help the user with file operations.",
    hooks=registry,
)

agent.start("List my files")

Per-model filtering using the model field:

python
@registry.on(HookEvent.BEFORE_TOOL_DEFINITIONS)
def small_model_subset(event_data):
    if event_data.model.startswith("gpt-3.5"):
        event_data.tool_definitions[:] = event_data.tool_definitions[:5]
    return HookResult.allow()

Agent Events

EventTriggerInput TypeUse Case
BEFORE_AGENTBefore agent runsBeforeAgentInputSetup, initialization
AFTER_AGENTAfter agent completesAfterAgentInputCleanup, reporting, output guardrails
python
@registry.on(HookEvent.BEFORE_AGENT)
def before_agent(event_data):
    print(f"Agent starting: {event_data.agent_name}")
    return HookResult.allow()

@registry.on(HookEvent.AFTER_AGENT)
def after_agent(event_data):
    print(f"Agent completed: {event_data.response}")
    return HookResult.allow()

AFTER_AGENT can rewrite or block the final response. Changing event_data.response (a string) rewrites what the caller receives; returning HookResult.deny(reason) replaces it with "[Response blocked by hook: <reason>]". Streamed output, callbacks and session persistence run earlier and are not gated. See Output Guardrails.

LLM Events

EventTriggerInput TypeUse Case
BEFORE_LLMBefore LLM API call — register with sequential=True when the hook needs to rewrite messages via modified_inputBeforeLLMInputRequest modification
AFTER_LLMAfter LLM responseAfterLLMInputResponse validation, usage tracking
MODEL_FALLBACKPrimary model unavailable mid-turn — runtime switched to the next fallback_models entryModelFallbackInputAlerts, metrics, per-user notice on silent quality/cost degradation
python
@registry.on(HookEvent.BEFORE_LLM)
def before_llm(event_data):
    print(f"Model: {event_data.model}")
    print(f"Messages: {len(event_data.messages)}")
    return HookResult.allow()

@registry.on(HookEvent.AFTER_LLM)
def after_llm(event_data):
    print(f"Response length: {len(event_data.response)}")
    print(f"Tokens: {event_data.tokens_used}")
    return HookResult.allow()

MODEL_FALLBACK — silent model switch became observable

Fires the moment the runtime swaps the primary model for the next entry in fallback_models after a retryable failure, so an otherwise silent quality/cost degradation becomes an observable state transition. Since PR #6053 it also fires on the custom-LLM path — provider/model strings, llm={...} dicts, base_url, and model objects — on both sync and async turns.

# ModelFallbackInput Fields

FieldTypeDefaultDescription
from_modelstr""Model id the turn was using when the failure hit (e.g. "gpt-4o").
to_modelstr""Next entry in the fallback_models chain that the turn continues on.
reason_categorystr""Failure classification from the LLM error classifier (e.g. "rate_limit", "provider_down", "timeout"). Provider internals stay redacted.
fallback_indexint00-based index into fallback_models of the entry now in use.
session_id, cwd, event_name, timestamp, agent_name——Standard HookInput fields.
python
from praisonaiagents import Agent
from praisonaiagents.config import LLMConfig
from praisonaiagents.hooks import HookRegistry, HookEvent, HookResult

registry = HookRegistry()

@registry.on(HookEvent.MODEL_FALLBACK)
def on_fallback(event_data):
    print(f"[fallback] {event_data.from_model} → {event_data.to_model}")
    print(f"           reason={event_data.reason_category} index={event_data.fallback_index}")
    return HookResult.allow()

agent = Agent(
    name="assistant",
    instructions="Be helpful.",
    llm=LLMConfig(model="gpt-4o", fallback_models=["claude-3-5-sonnet", "gpt-4o-mini"]),
    hooks=registry,
)
agent.start("Answer even during a provider outage")

The paired MODEL_FALLBACK stream event carries the same fields for live UIs. See Model Fallback → Observing the Switch for the primary user-facing page.

Session Events

EventTriggerInput TypeUse Case
SESSION_STARTWhen session startsSessionStartInputSession initialization
SESSION_ENDWhen session endsSessionEndInputSession cleanup
SESSION_PERSIST_FAILEDA durable session write failed (disk-full / SQLite corruption / permission / OSError) or a stored session file was found corrupt at load time (malformed JSON / invalid UTF-8)SessionPersistFailedInputObserve-only — alert/metric on silent data-loss; a failed write is salvaged to a spill file and re-ingested on next load, a corrupt read is quarantined to <file>.json.corrupt-<epoch_ms>
python
@registry.on(HookEvent.SESSION_START)
def session_start(event_data):
    print(f"Session: {event_data.session_id}")
    print(f"Source: {event_data.source}")
    print(f"Platform: {event_data.session_name}")
    return HookResult.allow()

@registry.on(HookEvent.SESSION_END)
def session_end(event_data):
    print(f"Session ended: {event_data.session_id}")
    print(f"Reason: {event_data.reason}")
    return HookResult.allow()

SESSION_PERSIST_FAILED — a durable write failed

Fires when the session store cannot persist a turn (add_message / set_chat_history hits disk-full, SQLite/FTS corruption, or a permission / OSError). Before this hook, a failed durable write silently collapsed to False and the already-produced turn lived only in memory — lost on the next shutdown. Now the store salvages the turn to a spill file and fires this event so the failure is observable.

# SessionPersistFailedInput Fields

FieldTypeDefaultDescription
rolestr""Role of the message that failed to persist (user / assistant)
contentstr""Message content (truncated to 500 chars in to_dict)
errorstr""Stringified error from the failing write (e.g. "atomic write failed" or the OSError text)
spilledboolFalseWhether the last-resort fallback write itself succeeded
spill_pathOptional[str]NoneThe file the turn was salvaged to (when spilled=True)
session_id, cwd, event_name, timestamp, agent_name——Standard HookInput fields
python
from praisonaiagents import Agent
from praisonaiagents.hooks import HookRegistry, HookEvent, HookResult

registry = HookRegistry()

@registry.on(HookEvent.SESSION_PERSIST_FAILED)
def on_persist_failed(data):
    # Alert / metric — do NOT retry the write here; the store has already spilled.
    print(f"[persist-fail] role={data.role} spilled={data.spilled} path={data.spill_path}")
    print(f"[persist-fail] error={data.error}")
    return HookResult.allow()

agent = Agent(name="assistant", instructions="Help the user.", hooks=registry)
agent.start("Hello")

# Spill & recovery flow

A failed durable write salvages the turn to a spill file, fires this hook, then re-ingests the spill on the next session load.

Diagram

✅ ok

❌ disk-full / corruption / OSError

💬 add_message / set_chat_history

💾 Durable write

✅ Done

📄 Spill to session_spill/*.json

🪝 Fire SESSION_PERSIST_FAILED

♻️ Re-ingest on next load

📏 Enforce window → persist → delete spill

Spill files land under ~/.praisonai/state/session_spill/ (written 0600, atomic temp-file + os.replace).

Observe-only — never retry

The hook is a notification. Do not retry the write from it — the store has already spilled the turn and re-ingests it on the next load.

Collision-safe filenames

Spill filenames carry a random token, so consecutive same-millisecond/PID failures never overwrite each other — no lost turns.

Recovery respects retention

_reingest_spill runs _enforce_window before persisting, so recovered turns obey the retention policy like any ordinary write.

Malformed spills are skipped

A non-object root, non-list messages, or non-object message is skipped — a bad spill never blocks recovery.

Error Events

EventTriggerInput TypeUse Case
ON_ERRORWhen error occursOnErrorInputError handling
ON_RETRYBefore retry attemptOnRetryInputRetry logic
python
@registry.on(HookEvent.ON_ERROR)
def on_error(event_data):
    print(f"Error: {event_data.error}")
    # Log to external service
    return HookResult.allow()

@registry.on(HookEvent.ON_RETRY)
def on_retry(event_data):
    # New tool-level fields
    print(f"[retry] {event_data.tool_name} attempt {event_data.attempt}/{event_data.max_attempts}")
    print(f"Error type: {event_data.error_type}, delay: {event_data.delay_ms}ms")

    # Legacy fields still available for backward compatibility
    # event_data.retry_count, event_data.max_retries, event_data.error_message

    if event_data.attempt > 2:
        return HookResult.deny("Too many retries")
    return HookResult.allow()

Async vs Sync

ON_RETRY fires on both sync and async retry paths. If you register the handler as a regular def, it is dispatched in a thread executor on the async path; if you register an async def, it is awaited directly. Either form is safe on both paths.

OnRetryInput Fields

The OnRetryInput event includes both new tool-specific fields and legacy fields for backward compatibility:

New fields (recommended):

  • tool_name: Name of the failing tool
  • attempt: Current attempt number (1-based)
  • max_attempts: Maximum attempts configured
  • delay_ms: Delay before this retry in milliseconds
  • error_type: Classified error type (timeout, rate_limit, connection_error, unknown)
  • error: Original exception object

LLM retry fields (populated whenever a transient LLM error is retried, including the default RetryBackoffConfig() policy applied to every Agent — see Agent Retry). ON_RETRY now fires on default Agents too; pass retry=False to disable retries and stop these fires:

  • delay_seconds: Seconds the agent will sleep before the next attempt
  • attempt: Current attempt number (0-based)
  • operation: "llm_request" (sync) or "async_llm_request" (async)
  • error_message: String representation of the failing LLMError
  • max_retries: Configured RetryBackoffConfig.max_retries
  • retry_count: Same as attempt + 1 (1-based legacy alias)

Legacy fields (for backward compatibility):

  • retry_count: Same as attempt
  • max_retries: Same as max_attempts
  • error_message: String representation of error

Agent-Level Error Callbacks

In addition to hook events, agents support a direct on_error callback for LLM failures:

python
from praisonaiagents import Agent
from praisonaiagents import LLMError

def agent_error_handler(error):
    """Called when LLM chat completion fails"""
    print(f"Agent {error.agent_id} LLM error: {error.message}")
    print(f"Model: {error.model_name}")
    print(f"Retryable: {error.is_retryable}")

    # Custom error recovery logic
    if error.is_retryable:
        print("Will be retried by orchestration")
    else:
        print("Fatal error - check configuration")

# Agent-specific error handling
agent = Agent(
    name="Error Aware Agent",
    instructions="Process user requests",
    on_error=agent_error_handler  # Direct callback, not a hook
)

Hook Events vs Agent Callbacks

ApproachScopeReturn ValueWhen Called
HookEvent.ON_ERRORGlobal, all agentsHookResultAny hook system error
agent.on_errorSingle agentNoneLLM chat completion errors
python
# Using both together for comprehensive error handling
registry = HookRegistry()

@registry.on(HookEvent.ON_ERROR)
def global_hook_error(event_data):
    """Catches all hook system errors"""
    return HookResult.allow()

def llm_specific_error(error):
    """Catches LLM errors for this agent only"""
    pass

agent = Agent(
    name="Comprehensive Error Handling",
    hooks=registry,           # Global error hooks
    on_error=llm_specific_error  # Agent-specific LLM errors
)

Extended Events

User Interaction Events

Subagent Events

EventTriggerUse Case
SUBAGENT_STOPA subagent spawned via spawn_subagent reaches a terminal state (success or failure)Result handling, telemetry, cost accounting

Fires exactly once per subagent from the single completion chokepoint in tools/subagent_tool.py — covers synchronous spawns, background=True jobs, resolver-routed named agents, generic factory spawns, simulation mode, and failures.

python
from praisonaiagents.hooks import HookRegistry, HookEvent, HookResult, SubagentStopInput

@registry.on(HookEvent.SUBAGENT_STOP)
def on_subagent_stop(event_data: SubagentStopInput):
    print(f"Subagent: {event_data.agent_name}")
    print(f"Task:     {event_data.task}")
    print(f"Success:  {event_data.success}")
    if event_data.success:
        print(f"Output:   {event_data.output}")
    else:
        print(f"Error:    {event_data.error}")
    print(f"LLM:      {event_data.llm}")
    print(f"Depth:    {event_data.depth}")
    print(f"Duration: {event_data.duration_ms:.1f} ms")
    return HookResult.allow()

Observe-only. Errors in the hook never break the subagent — the emit is fully guarded.

SubagentStopInput Fields

FieldTypeDefaultDescription
taskstr""The task string the subagent was spawned with
successboolTrueFalse when the subagent raised
outputAnyNoneTerminal output — serialised via str() in to_dict()
errorOptional[str]NoneError message when success is False
llmOptional[str]NoneEffective LLM used
permission_modeOptional[str]NoneEffective permission mode
depthint0Depth of the subagent in the spawn tree
duration_msfloat0.0Wall-clock time from spawn to terminal state
agent_name, session_id, cwd, event_name, timestamp——Standard HookInput fields (agent_name carries the subagent name)

Plugin Lifecycle Events

EventTriggerPayload
ON_INITEmitted by PluginManager.register() right after the plugin runs its own on_initPluginLifecycleInput
ON_SHUTDOWNEmitted by PluginManager.unregister() right after the plugin runs its own on_shutdownPluginLifecycleInput

plugin.on_init / plugin.on_shutdown are the plugin’s own callbacks; ON_INIT / ON_SHUTDOWN are the runtime events other listeners (metrics/audit plugins, gateways) subscribe to. Lets an observability plugin see the plugin set change at runtime instead of polling list_plugins().

python
from praisonaiagents.hooks import HookRegistry, HookEvent, HookResult, PluginLifecycleInput

@registry.on(HookEvent.ON_INIT)
def on_plugin_registered(event_data: PluginLifecycleInput):
    print(f"Plugin loaded: {event_data.plugin_name} v{event_data.plugin_version}")
    return HookResult.allow()

@registry.on(HookEvent.ON_SHUTDOWN)
def on_plugin_removed(event_data: PluginLifecycleInput):
    print(f"Plugin unloaded: {event_data.plugin_name}")
    return HookResult.allow()

Observe-only. Fully guarded — a failing hook never breaks plugin loading. Zero cost when nothing is listening (early-out on registry.has_hooks(...)). The early-out is a lock-guarded any() with no snapshot copy or priority sort (PR #5853).

PluginLifecycleInput Fields

FieldTypeDefaultDescription
plugin_namestr""Registered plugin name
plugin_versionstr""Plugin version
plugin_descriptionstr""Plugin description
agent_name, session_id, cwd, event_name, timestamp——Standard HookInput fields

System Events

EventTriggerUse Case
BEFORE_COMPACTIONBefore context compactionPre-compaction hooks
AFTER_COMPACTIONAfter context compactionPost-compaction validation
python
@registry.on(HookEvent.BEFORE_COMPACTION)
def before_compaction(event_data):
    print(f"Compacting context: {event_data.token_count} tokens")
    return HookResult.allow()

@registry.on(HookEvent.AFTER_COMPACTION)
def after_compaction(event_data):
    # event_data is the CompactionResult for this pass.
    print(f"Compacted to: {event_data.compacted_tokens} tokens")

    # 1.6.152+: summary is populated for summarising strategies.
    # Pre-1.6.152 the field existed but was always "".
    if event_data.summary:
        # e.g. mirror into your own long-term memory store here
        print(f"Summary: {event_data.summary[:120]}")
    return HookResult.allow()

Plugin Lifecycle Events

EventTriggerPayload
ON_INITEmitted by PluginManager.register() right after the plugin runs its own on_initPluginLifecycleInput
ON_SHUTDOWNEmitted by PluginManager.unregister() right after the plugin runs its own on_shutdownPluginLifecycleInput

plugin.on_init / plugin.on_shutdown are the plugin’s own callbacks; ON_INIT / ON_SHUTDOWN are the runtime events other listeners (metrics/audit plugins, gateways) subscribe to. Lets an observability plugin see the plugin set change at runtime instead of polling list_plugins().

python
from praisonaiagents.hooks import HookRegistry, HookEvent, HookResult, PluginLifecycleInput

@registry.on(HookEvent.ON_INIT)
def on_plugin_registered(event_data: PluginLifecycleInput):
    print(f"Plugin loaded: {event_data.plugin_name} v{event_data.plugin_version}")
    return HookResult.allow()

@registry.on(HookEvent.ON_SHUTDOWN)
def on_plugin_removed(event_data: PluginLifecycleInput):
    print(f"Plugin unloaded: {event_data.plugin_name}")
    return HookResult.allow()

Observe-only. Fully guarded — a failing hook never breaks plugin loading. Zero cost when nothing is listening (early-out on registry.has_hooks(...)). The early-out is a lock-guarded any() with no snapshot copy or priority sort (PR #5853).

PluginLifecycleInput Fields

FieldTypeDefaultDescription
plugin_namestr""Registered plugin name
plugin_versionstr""Plugin version
plugin_descriptionstr""Plugin description
session_id, cwd, event_name, timestamp, agent_name——Standard HookInput fields

Message Events

EventTriggerInput TypeUse Case
MESSAGE_RECEIVEDInbound message from a bot channel, before agent dispatchMessageReceivedInputInbound gate — drop (deny), redact/rewrite content, authorise sender, rate-limit
MESSAGE_SENDINGBefore message sentMessageSendingInputOutbound gate — cancel or rewrite outbound text
MESSAGE_SENTAfter message sentMessageSentInputConfirmation
MESSAGE_UNDELIVEREDReply permanently undeliverable (target confirmed dead / retries exhausted)MessageUndeliveredInputOperator routing — mirror to home channel, alert, or re-queue

Inbound Platform Events

EventTriggerInput TypeUse Case
REACTION_RECEIVEDUser adds or removes a reaction; kind disambiguatesPlatformEventInputReaction-as-approval, quick polls, moderation
MESSAGE_EDITEDUser edits a previously-sent messagePlatformEventInputRefresh cached content, re-summarise, invalidate downstream artefacts
MESSAGE_DELETEDUser deletes a messagePlatformEventInputRetract stored plans, cancel scheduled follow-ups
MEMBER_JOINEDUser joins a channel/guildPlatformEventInputWelcome message, onboarding, seat provisioning
MEMBER_LEFTUser leaves a channel/guildPlatformEventInputOffboarding, cleanup
THREAD_CREATEDNew thread createdPlatformEventInputThread-scoped session, per-thread agent config

Aliased Events

Three HookEvent members are aliases — the enum member and its live target are the same object. Registration on the alias is registration on the live event.

AliasLive eventNotes
HookEvent.BEFORE_MESSAGEHookEvent.MESSAGE_RECEIVEDThe plugin-facing Plugin.before_message method has always routed here
HookEvent.AFTER_MESSAGEHookEvent.MESSAGE_SENDINGOutbound counterpart
HookEvent.TOOL_RESULT_PERSISTHookEvent.AFTER_TOOLAFTER_TOOL already sees the result before it is written into the conversation
python
>>> HookEvent.BEFORE_MESSAGE is HookEvent.MESSAGE_RECEIVED
True
>>> HookEvent("before_message") is HookEvent.MESSAGE_RECEIVED
True

Legacy string values ("before_message", "after_message", "tool_result_persist") still resolve — config files, add_hook("before_message", ...) calls, and any other string-based registration keep working unchanged.

MESSAGE_RECEIVED is a first-class control point: hooks can drop an inbound message so the agent never runs, or rewrite the message content before the agent (or memory) sees it. This is symmetric with MESSAGE_SENDING on the outbound side.

Aliased Events

Three HookEvent members are aliases — the enum member and its live target are the same object. Registration on the alias is registration on the live event.

AliasLive eventNotes
HookEvent.BEFORE_MESSAGEHookEvent.MESSAGE_RECEIVEDThe plugin-facing Plugin.before_message method has always routed here
HookEvent.AFTER_MESSAGEHookEvent.MESSAGE_SENDINGOutbound counterpart
HookEvent.TOOL_RESULT_PERSISTHookEvent.AFTER_TOOLAFTER_TOOL already sees the result before it is written into the conversation
python
>>> HookEvent.BEFORE_MESSAGE is HookEvent.MESSAGE_RECEIVED
True
>>> HookEvent("before_message") is HookEvent.MESSAGE_RECEIVED
True

Legacy string values ("before_message", "after_message", "tool_result_persist") still resolve — config files, add_hook("before_message", ...) calls, and any other string-based registration keep working unchanged.

The hook now returns a decision that every platform adapter (Telegram, Slack, Discord, WhatsApp, Email, AgentMail) honours:

  • HookResult.deny(reason=...) → the message is dropped; agent dispatch is skipped entirely.
  • HookResult(modified_input={"content": "..."}) → the inbound message content is rewritten before dispatch.
  • None or HookResult.allow() → message passes through unchanged (default).
  • Hook errors are non-fatal — a raising hook logs the error and lets the message through.

Drop / block an inbound message

python
from praisonaiagents import Agent
from praisonaiagents.hooks import HookRegistry, HookEvent, HookResult

registry = HookRegistry()

@registry.on(HookEvent.MESSAGE_RECEIVED)
def block_bots(event_data):
    if event_data.sender_id.endswith("bot"):
        return HookResult.deny("no bots")
    return HookResult.allow()

agent = Agent(name="Concierge", instructions="Be helpful.", hooks=registry)

Redact PII before the agent sees it

python
import re
from praisonaiagents import Agent
from praisonaiagents.hooks import HookRegistry, HookEvent, HookResult

registry = HookRegistry()

@registry.on(HookEvent.MESSAGE_RECEIVED)
def redact_pii(event_data):
    cleaned = re.sub(r"\b\d{3}-\d{2}-\d{4}\b", "[SSN]", event_data.content)
    return HookResult(decision="allow", modified_input={"content": cleaned})

agent = Agent(name="SafeAgent", instructions="Be helpful.", hooks=registry)

Authorise sender against an allowlist

python
from praisonaiagents import Agent
from praisonaiagents.hooks import HookRegistry, HookEvent, HookResult

registry = HookRegistry()
ALLOWED = {"U123", "U456"}

@registry.on(HookEvent.MESSAGE_RECEIVED)
def only_allowed_users(event_data):
    if event_data.sender_id not in ALLOWED:
        return HookResult.deny("unauthorised")
    return HookResult.allow()

agent = Agent(name="PrivateAgent", instructions="Be helpful.", hooks=registry)

How the gate applies decisions

DecisionEffect
HookResult.deny("reason")Message is dropped; the agent is never invoked; the adapter returns immediately
HookResult(decision="allow", modified_input={"content": "..."})Content is rewritten in place; agent, memory, and command parser all see the new text
HookResult.allow()Message passes through unchanged
python
@registry.on(HookEvent.MESSAGE_SENDING)
def on_message_sending(event_data):
    print(f"Sending: {event_data.content}")
    return HookResult.allow()

Since PraisonAI PR #2589, every messaging adapter (Telegram, Slack, Discord, WhatsApp, email, agentmail) honours these decisions. Hook exceptions are non-fatal — the message passes through.

See Inbound Message Gate for patterns and failure semantics.

MESSAGE_UNDELIVERED — close the loop on a permanent failure

Fires when the gateway’s DeliveryRouter classifies a send as permanently failed. Observability only — the hook does not gate anything.

python
from praisonaiagents import Agent
from praisonaiagents.hooks import HookRegistry, HookEvent, HookResult

registry = HookRegistry()

@registry.on(HookEvent.MESSAGE_UNDELIVERED)
def route_undelivered(event_data):
    # event_data is a MessageUndeliveredInput
    print(
        f"[UNDELIVERED] {event_data.platform}:{event_data.channel_id} "
        f"error={event_data.error} notice_delivered={event_data.notice_delivered}"
    )
    return HookResult.allow()

agent = Agent(name="broadcaster", instructions="Send updates.", hooks=registry)

See Undelivered Message Notice for the full opt-in flow and the gateway.notify_on_undelivered config.

Gateway Events

EventTriggerInput TypeKey FieldsUse Case
GATEWAY_STARTBotOS.start()GatewayStartInputplatforms, bot_countInitialization
GATEWAY_STOPBotOS.stop()GatewayStopInputplatforms, bot_count, reasonCleanup
python
@registry.on(HookEvent.GATEWAY_START)
def on_gateway_start(event_data):
    print(f"Gateway starting on: {event_data.platforms}")
    print(f"Bot count: {event_data.bot_count}")
    return HookResult.allow()

@registry.on(HookEvent.GATEWAY_STOP)
def on_gateway_stop(event_data):
    print(f"Gateway stopping: {event_data.platforms}")
    print(f"Reason: {event_data.reason}")
    return HookResult.allow()

Schedule & background events

Schedule hooks fire when scheduled jobs are managed and triggered by BotOS. JOB_COMPLETED fires from BackgroundJobManager.start_job for every managed background job when it reaches a terminal state (COMPLETED or FAILED) — best-effort (a raising handler cannot crash the worker).

EventTriggerInput TypeKey FieldsUse Case
SCHEDULE_ADDSchedule job persistedScheduleAddInputjob_name, job_id, schedule, message, agent_id, principal, enabledAudit schedule changes
SCHEDULE_REMOVESchedule job deletedScheduleRemoveInputjob_name, job_id, schedule, principalAudit schedule changes
SCHEDULE_TRIGGERScheduled job runsScheduleTriggerInputjob_name, job_id, messageObservability, metrics
JOB_COMPLETEDAny managed background job reaches a terminal state (COMPLETED or FAILED)JobCompletedInputjob_id, status, result, error, agent_name, session_id, deliver, platform, chat_id, thread_idObservability, custom delivery routing, chat-back delivery (see Background Subagents)
python
@registry.on(HookEvent.SCHEDULE_TRIGGER)
def on_schedule_trigger(event_data):
    print(f"Job fired: {event_data.job_name}")
    print(f"Job ID: {event_data.job_id}")
    print(f"Message: {event_data.message}")
    return HookResult.allow()

@registry.on(HookEvent.JOB_COMPLETED)
def on_job_completed(event_data):
    print(f"Job finished: {event_data.job_id} → {event_data.status}")
    if event_data.error:
        print(f"Error: {event_data.error}")
    return HookResult.allow()

@registry.on(HookEvent.SCHEDULE_ADD)
def on_schedule_add(event_data):
    print(f"Schedule added: {event_data.job_name} ({event_data.job_id})")
    print(f"When: {event_data.schedule}")
    print(f"Enabled: {event_data.enabled}")
    return HookResult.allow()

@registry.on(HookEvent.SCHEDULE_REMOVE)
def on_schedule_remove(event_data):
    print(f"Schedule removed: {event_data.job_name} ({event_data.job_id})")
    print(f"When: {event_data.schedule}")
    return HookResult.allow()

Where SCHEDULE events fire: SCHEDULE_ADD and SCHEDULE_REMOVE are emitted by the schedule store — the one chokepoint every add/remove path funnels through — from add(), remove(), remove_by_name(), the spent-job retire inside ScheduleRunner.mark_run() (one-shot delete_after_run or max_runs budget met, on run completion), and from claim_due() only when a spent job’s crash retry was abandoned too or its until deadline has elapsed. The same hook fires whether the mutation came from the schedule_add/schedule_remove tools, the praisonai schedule CLI, or the gateway config.yaml reconciler.

Events fire only on a real change and never in these four cases:

  • Duplicate add — an add() that raises ValueError on a duplicate name/id fires nothing.
  • Missing remove — a remove()/remove_by_name() that matches no job fires nothing.
  • Principal-scoped skip — a remove_by_name() that matches nothing under the caller’s tenant principal fires nothing.
  • Rolled-back persist — if _save() fails and the deletion is rolled back, nothing is emitted.

A spent delete_after_run one-shot fires SCHEDULE_REMOVE exactly once, after its run completes — a crash mid-run no longer loses the job. A failed one-shot is kept (disabled) and emits nothing.

Background Job Events

JOB_COMPLETED fires from BackgroundJobManager.start_job for every managed background job when it reaches a terminal state (COMPLETED or FAILED) — not only jobs launched via spawn_subagent(background=True). It does not fire for CANCELLED or LOST. Best-effort: a raising handler cannot crash the worker.

python
from praisonaiagents.hooks import add_hook, HookResult

def log_job_result(data):
    print(f"Job {data.job_id} → {data.status}")
    if data.status == "completed":
        print(f"Result: {data.result}")
    else:
        print(f"Error: {data.error}")
    return HookResult.allow()

add_hook("job_completed", log_job_result)

JobCompletedInput Fields

The payload carries flat, typed fields — origin fields are always strings (empty string when unset).

FieldTypeDescription
job_idstrUnique job identifier
statusstr"completed" or "failed"
resultAnyReturn value on success; None on failure
error`str \None`Error message on failure; None on success
agent_namestrAlways "background"
session_idstrOrigin session (empty when unset)
deliverstrDelivery target (empty when the job was not routed)
platformstrOrigin platform
chat_idstrOrigin chat/channel ID
thread_idstrOrigin thread ID

Typical uses:

  • Observability and metrics on background job failure rates
  • Custom delivery routing when the built-in deliver= token is insufficient
  • External side effects on completion (webhooks, database writes)

Storage Events

EventTriggerUse Case
TOOL_RESULT_PERSISTAlias of AFTER_TOOL (see Aliased Events) — subscribe to AFTER_TOOL directlyResult modification

TOOL_RESULT_PERSIST resolves to the same enum member as AFTER_TOOL. Register on AFTER_TOOL — it already receives the tool result before it is written into the conversation, and mutating event_data.tool_output is exactly what gets stored.

python
@registry.on(HookEvent.AFTER_TOOL)
def on_persist(event_data):
    # Mutate event_data.tool_output to rewrite the value stored + sent to the model
    return HookResult.allow()

CLI Backend Events

EventTriggerInput TypeUse Case
CLI_BACKEND_EXECUTEAgent delegates a turn to a CLI backend subprocessCliBackendExecuteInputObservability — confirm CLI delegation, log subprocess argv, capture errors

CLI_BACKEND_EXECUTE fires on both success and failure, so subprocess startup errors and timeouts stay traceable — the payload’s error field carries the exception message on the failure path.

Diagram
CLI_BACKEND_EXECUTECLI BackendAgentUserCLI_BACKEND_EXECUTECLI BackendAgentUseralt[success][failure]promptsubprocess (codex exec / grok -p / …)textemit (backend, command redacted, content)error / timeoutemit (backend, command redacted, error=<msg>)HookResult.allow()reply

CliBackendExecuteInput Fields

FieldTypeDefaultDescription
backendstr""Human-readable backend id, e.g. "claude", "codex", "grok", "gemini". Comes from config.command when available, else the backend class name.
commandOptional[List[Any]]NoneThe full subprocess argv the CLI backend spawned. Prompt/system values are redacted ("<redacted>") at serialisation, so log sinks never see the user prompt.
contentOptional[str]NoneThe backend’s textual response. Truncated to the first 500 characters at serialisation.
errorOptional[str]NoneNon-null on failure — carries the exception message from the CLI subprocess or timeout.
transportstr"subprocess"Always "subprocess" today. Reserved for future transports.
praisonai_llm_httpboolFalseAlways False — the hook exists precisely because PraisonAI did not make an HTTP LLM call this turn. Useful for dashboards proving the LiteLLM path was skipped.
session_id, cwd, event_name, timestamp, agent_name——Standard HookInput fields.

Trace CLI delegation programmatically

python
from praisonaiagents import Agent
from praisonaiagents.hooks import HookRegistry, HookEvent, HookResult

registry = HookRegistry()

@registry.on(HookEvent.CLI_BACKEND_EXECUTE)
def trace_cli_backend(event_data):
    if event_data.error:
        print(f"[cli-backend] {event_data.backend} FAILED: {event_data.error}")
    else:
        argv = " ".join(str(a) for a in (event_data.command or []))
        print(f"[cli-backend] {event_data.backend} ok → {argv}")
    return HookResult.allow()

agent = Agent(
    name="assistant",
    instructions="Be helpful.",
    cli_backend="codex-cli",   # or claude-code, grok-cli, gemini-cli
    hooks=registry,
)

agent.start("Refactor utils.py")

The prompt is redacted at the payload boundary:

[cli-backend] codex ok → codex exec --skip-git-repo-check -C /workspace <redacted>

Enable the built-in tracer plugin

The cli_backend_tracer plugin from praisonai-plugins is the batteries-included consumer of this hook — it logs every delegation to the standard praisonai logger. Enable it with one env var and a plugin toggle:

bash
pip install praisonai-plugins
export PRAISONAI_CLI_BACKEND_DEBUG=1
praisonai plugins enable cli_backend_tracer

Or use the standard Python knob:

bash
export LOGLEVEL=DEBUG

With either set and the plugin enabled, every codex exec / grok -p / gemini -p / claude delegation appears in the log stream, with the prompt value already redacted.

What gets masked

redact_command masks the value that follows these flags, leaving the flag itself visible for verification:

FlagReason it’s masked
-p, --promptThe user prompt
-i, --inputAlternate prompt form (Grok)
-m, --messageChat message form
--systemSystem instruction

Non-list command values (already-serialised strings, None) pass through unchanged.

Kanban Events

EventEmitted FromUse Case
KANBAN_TASK_CREATEDcreate_task, after commitTask added to board
KANBAN_TASK_CLAIMEDclaim_task, only when the CAS wonTask assigned
KANBAN_TASK_MOVEDmove_task (every status change), reclaim_stale_claims, dispatcher promotionTask status changed
KANBAN_TASK_DONEmove_task when destination is done (rides alongside MOVED)Task completed
KANBAN_TASK_BLOCKEDmove_task when destination is blocked, and record_failure on a circuit-breaker tripTask blocked
KANBAN_TASK_FAILEDrecord_failure — one per recorded attempt failureTask failed

Every kanban event delivers a KanbanHookInput. These keys are always present: task_id, board, status, plus to_status on every transition and from_status on everything except CREATED.

KeyOnNotes
assigneemostworker_id for CLAIMED/reclaim, current assignee for MOVED/DONE/BLOCKED.
taskCREATED/MOVED/DONE/BLOCKEDFull task.to_dict(). Absent on FAILED and reclaim moves.
reclaimedMOVED (reclaim only)True when a stale claim was reclaimed.
reasonMOVED (reclaim only)"dead" or "stale_heartbeat".
worker_pidCLAIMEDThe claiming worker’s PID.
claim_expiresCLAIMEDISO-8601 UTC timestamp.
consecutive_failuresFAILED, BLOCKED (on break)Attempt counter.
max_retriesFAILED, BLOCKED (on break)Retry limit for the task.
circuit_brokenFAILED, BLOCKED (on break)True when this failure tripped the breaker.
errorFAILED, BLOCKED (on break)First 500 chars of the failure text.
python
from praisonaiagents.hooks import add_hook, HookResult

def track_progress(data):
    print(f"Task {data.task_id}: {data.from_status} → {data.to_status}")
    return HookResult.allow()

add_hook("kanban_task_moved", track_progress)

A subscriber runs the moment a user changes the board.

Diagram
Subscriberfire_hookKanban StoreUserSubscriberfire_hookKanban StoreUserpraisonai kanban add “Ship docs”Commit the new taskfire_hook(“kanban_task_created”, {…})KANBAN_TASK_CREATEDLogs the new task

Circuit-breaker double-emit

A record_failure that trips the breaker fires both KANBAN_TASK_FAILED and KANBAN_TASK_BLOCKED, in that order. A subscriber on both events sees one of each for the same transition — use circuit_broken to detect it.

python
from praisonaiagents.hooks import add_hook, HookResult

def on_failed(data):
    if data.extra.get("circuit_broken"):
        print(f"Task {data.task_id} tripped the breaker; BLOCKED follows")
    return HookResult.allow()

add_hook("kanban_task_failed", on_failed)

Reclaim

Reclaiming a stale claim (running → ready) fires KANBAN_TASK_MOVED with reclaimed=True and reason in {"dead", "stale_heartbeat"}. There is no separate reclaim event — reclaim is a move.

python
from praisonaiagents.hooks import add_hook, HookResult

def on_reclaim(data):
    if data.extra.get("reclaimed"):
        print(f"Task {data.task_id} reclaimed ({data.extra.get('reason')})")
    return HookResult.allow()

add_hook("kanban_task_moved", on_reclaim)

Dependency Auto-Promotion Events

When the dispatcher auto-promotes a child task, KANBAN_TASK_MOVED fires with this payload:

python
{
    "task_id": "t_child",
    "to_status": "ready",
    "task": {...},
}
python
from praisonaiagents.hooks import add_hook, HookResult

def on_auto_promotion(data):
    if data.to_status == "ready" and not data.from_status:
        print(f"Task {data.task_id} auto-promoted to ready")
    return HookResult.allow()

add_hook("kanban_task_moved", on_auto_promotion)

Complete Event Reference

EventCategoryDescription
BEFORE_TOOLToolBefore tool execution
AFTER_TOOLToolAfter tool execution — mutate tool_output to rewrite/redact, or HookResult.block(reason) to suppress the result before it reaches the model
BEFORE_TOOL_DEFINITIONSToolBefore tool definitions are sent to LLM
BEFORE_AGENTAgentBefore agent runs
AFTER_AGENTAgentAfter agent completes
BEFORE_LLMLLMBefore LLM API call — fires sync + async
AFTER_LLMLLMAfter LLM response — fires sync + async
MODEL_FALLBACKLLMPrimary model unavailable mid-turn — runtime switched to the next fallback_models entry (observe-only; provider internals redacted). See Model Fallback → Observing the Switch.
ON_INITPluginA plugin was registered — fires from PluginManager.register() after plugin.on_init. Payload: PluginLifecycleInput
ON_SHUTDOWNPluginA plugin was unregistered — fires from PluginManager.unregister() after plugin.on_shutdown. Payload: PluginLifecycleInput
SESSION_STARTSessionSession starts
SESSION_ENDSessionSession ends
SESSION_PERSIST_FAILEDSessionA durable session write failed or a stored session file was found corrupt at load time — observe-only; a failed write is spilled and re-ingested (see Write-failure salvage), a corrupt read is quarantined (see Corruption-quarantine on load)
ON_ERRORErrorError occurs
ON_RETRYErrorBefore retry
SUBAGENT_STOPSubagentA subagent spawned via spawn_subagent reached a terminal state. Fires once from the single completion chokepoint — covers sync, background, resolver-routed, factory spawns, simulation, and failures. Payload: SubagentStopInput
BEFORE_COMPACTIONContextBefore compaction
AFTER_COMPACTIONContextAfter compaction
MESSAGE_RECEIVEDMessageInbound gate — drop (deny) or redact/rewrite before agent dispatch; payload carries enriched identity (platform, sender_id, channel_id, channel_type, message_id, session_id) (see Inbound Message Gate)
MESSAGE_SENDINGMessageOutbound gate — cancel or rewrite before sending; payload carries the enriched identity fields
BEFORE_MESSAGEMessageAlias of MESSAGE_RECEIVED. Subscribe to MESSAGE_RECEIVED directly
AFTER_MESSAGEMessageAlias of MESSAGE_SENDING. Subscribe to MESSAGE_SENDING directly
MESSAGE_SENTMessageAfter successful delivery; enriched identity payload — also the plugin message_sent(message) bridge
MESSAGE_UNDELIVEREDMessageReply permanently undeliverable — operator routing; enriched identity payload plus error/notice_delivered, also the plugin message_undelivered(message) bridge (see Undelivered Message Notice)
REACTION_RECEIVEDPlatformUser adds or removes a reaction (kind disambiguates); opt-in via events: [reactions] — see Inbound Platform Events
MESSAGE_EDITEDPlatformUser edits a message; new_text carries the new content; opt-in via events: [edits]
MESSAGE_DELETEDPlatformUser deletes a message; opt-in via events: [edits]
MEMBER_JOINEDPlatformUser joins a channel/guild; opt-in via events: [members] (privileged intent)
MEMBER_LEFTPlatformUser leaves a channel/guild; opt-in via events: [members]
THREAD_CREATEDPlatformNew thread created; thread_id populated; opt-in via events: [threads]
GATEWAY_STARTGatewayGateway starts
GATEWAY_STOPGatewayGateway stops
SCHEDULE_ADDScheduleSchedule job persisted (store add)
SCHEDULE_REMOVEScheduleSchedule job deleted (store remove/remove_by_name/one-shot cleanup)
SCHEDULE_TRIGGERScheduleScheduled job runs
JOB_COMPLETEDBackgroundBackground job reached terminal state (COMPLETED or FAILED)
TOOL_RESULT_PERSISTStorageAlias of AFTER_TOOL. Subscribe to AFTER_TOOL directly — the two names resolve to the same enum member
CLI_BACKEND_EXECUTECLI BackendFires after an Agent delegates a turn to a CLI backend subprocess (success and failure). Observe-only.
KANBAN_TASK_CREATEDKanbanTask added to board
KANBAN_TASK_CLAIMEDKanbanTask assigned
KANBAN_TASK_MOVEDKanbanTask status changed (includes auto-promotion: dispatcher fires this with to_status='ready' for each dependent task whose parents are all terminal)
KANBAN_TASK_DONEKanbanTask completed
KANBAN_TASK_BLOCKEDKanbanTask blocked
KANBAN_TASK_FAILEDKanbanTask failed

Bot Runtime Lifecycle

Gateway and session hooks are emitted by BotOS and BotSessionManager — no extra wiring needed when your agent is passed to a bot.

  • All emission is best-effort and a no-op when no hooks are registered (zero overhead)
  • BEFORE_AGENT / AFTER_AGENT are fired by agent.chat() itself — they are not re-fired at the gateway boundary to avoid double-dispatch
  • In async contexts (e.g. inside BotSessionManager.chat), emission is fire-and-forget; in sync contexts it is blocking

Best Practices

Keep hooks lightweight

Hooks run synchronously. Avoid heavy operations that could slow down agent execution.

Use matchers for filtering

Use pattern matchers to only run hooks for specific tools or operations.

Return early

Return HookResult.allow() quickly for non-matching cases to minimize overhead.

Handle errors gracefully

Wrap hook logic in try/except to prevent breaking agent execution.


Related