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.
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.
How It Works
Lifecycle Events
Any HookEvent can be forwarded to a shell command via Gateway Outbound Hooks.
Quick Start
- Import Hook Components
python from praisonaiagents.hooks import HookRegistry, HookEvent, HookResult - 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() - Use with Agent
python from praisonaiagents import Agent agent = Agent( name="MyAgent", instructions="You are helpful", hooks=registry )
Core Events
Tool Events
| Event | Trigger | Input Type | Use Case |
|---|---|---|---|
BEFORE_TOOL | Before tool execution — register with sequential=True when the hook needs to rewrite arguments via modified_input | BeforeToolInput | Security checks, logging |
AFTER_TOOL | After tool execution | AfterToolInput | Redact/rewrite tool_output, block the result, validation, logging |
@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 code | Decision | Notes |
|---|---|---|
0 | allow | JSON on stdout (if any) is honoured — decision, reason, modified_input, additional_context. No 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. |
exit 0+ JSON → JSON is honoured (allow,deny,modified_input, etc.).exit 0+ no JSON → treated asallow.exit 2→ treated asdenyregardless of stdout;stderr(orstdout) is the reason. Explicitdeny-JSON is honoured and itsreasonis preserved.- Any other exit code (
127command-not-found,126not-executable,1,137, …) → treated asdenywith 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
| Event | Trigger | Input Type | Use Case |
|---|---|---|---|
BEFORE_TOOL_DEFINITIONS | After advertised tool list is assembled, before sent to LLM — register with sequential=True when the hook needs to rewrite the list via modified_input | BeforeToolDefinitionsInput | Redact 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
| Field | Type | Default | Description |
|---|---|---|---|
tool_definitions | List[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. |
model | str | "" | 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. |
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:
@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
| Event | Trigger | Input Type | Use Case |
|---|---|---|---|
BEFORE_AGENT | Before agent runs | BeforeAgentInput | Setup, initialization |
AFTER_AGENT | After agent completes | AfterAgentInput | Cleanup, reporting, output guardrails |
@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
| Event | Trigger | Input Type | Use Case |
|---|---|---|---|
BEFORE_LLM | Before LLM API call — register with sequential=True when the hook needs to rewrite messages via modified_input | BeforeLLMInput | Request modification |
AFTER_LLM | After LLM response | AfterLLMInput | Response validation, usage tracking |
MODEL_FALLBACK | Primary model unavailable mid-turn — runtime switched to the next fallback_models entry | ModelFallbackInput | Alerts, metrics, per-user notice on silent quality/cost degradation |
@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
| Field | Type | Default | Description |
|---|---|---|---|
from_model | str | "" | Model id the turn was using when the failure hit (e.g. "gpt-4o"). |
to_model | str | "" | Next entry in the fallback_models chain that the turn continues on. |
reason_category | str | "" | Failure classification from the LLM error classifier (e.g. "rate_limit", "provider_down", "timeout"). Provider internals stay redacted. |
fallback_index | int | 0 | 0-based index into fallback_models of the entry now in use. |
session_id, cwd, event_name, timestamp, agent_name | — | — | Standard HookInput fields. |
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
| Event | Trigger | Input Type | Use Case |
|---|---|---|---|
SESSION_START | When session starts | SessionStartInput | Session initialization |
SESSION_END | When session ends | SessionEndInput | Session cleanup |
SESSION_PERSIST_FAILED | A 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) | SessionPersistFailedInput | Observe-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> |
@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
| Field | Type | Default | Description |
|---|---|---|---|
role | str | "" | Role of the message that failed to persist (user / assistant) |
content | str | "" | Message content (truncated to 500 chars in to_dict) |
error | str | "" | Stringified error from the failing write (e.g. "atomic write failed" or the OSError text) |
spilled | bool | False | Whether the last-resort fallback write itself succeeded |
spill_path | Optional[str] | None | The file the turn was salvaged to (when spilled=True) |
session_id, cwd, event_name, timestamp, agent_name | — | — | Standard HookInput fields |
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.
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
| Event | Trigger | Input Type | Use Case |
|---|---|---|---|
ON_ERROR | When error occurs | OnErrorInput | Error handling |
ON_RETRY | Before retry attempt | OnRetryInput | Retry logic |
@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 toolattempt: Current attempt number (1-based)max_attempts: Maximum attempts configureddelay_ms: Delay before this retry in millisecondserror_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 attemptattempt: Current attempt number (0-based)operation:"llm_request"(sync) or"async_llm_request"(async)error_message: String representation of the failingLLMErrormax_retries: ConfiguredRetryBackoffConfig.max_retriesretry_count: Same asattempt + 1(1-based legacy alias)
Legacy fields (for backward compatibility):
retry_count: Same asattemptmax_retries: Same asmax_attemptserror_message: String representation oferror
Agent-Level Error Callbacks
In addition to hook events, agents support a direct on_error callback for LLM failures:
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
| Approach | Scope | Return Value | When Called |
|---|---|---|---|
| HookEvent.ON_ERROR | Global, all agents | HookResult | Any hook system error |
| agent.on_error | Single agent | None | LLM chat completion errors |
# 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
| Event | Trigger | Use Case |
|---|---|---|
SUBAGENT_STOP | A 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.
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
| Field | Type | Default | Description |
|---|---|---|---|
task | str | "" | The task string the subagent was spawned with |
success | bool | True | False when the subagent raised |
output | Any | None | Terminal output — serialised via str() in to_dict() |
error | Optional[str] | None | Error message when success is False |
llm | Optional[str] | None | Effective LLM used |
permission_mode | Optional[str] | None | Effective permission mode |
depth | int | 0 | Depth of the subagent in the spawn tree |
duration_ms | float | 0.0 | Wall-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
| Event | Trigger | Payload |
|---|---|---|
ON_INIT | Emitted by PluginManager.register() right after the plugin runs its own on_init | PluginLifecycleInput |
ON_SHUTDOWN | Emitted by PluginManager.unregister() right after the plugin runs its own on_shutdown | PluginLifecycleInput |
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().
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
| Field | Type | Default | Description |
|---|---|---|---|
plugin_name | str | "" | Registered plugin name |
plugin_version | str | "" | Plugin version |
plugin_description | str | "" | Plugin description |
agent_name, session_id, cwd, event_name, timestamp | — | — | Standard HookInput fields |
System Events
| Event | Trigger | Use Case |
|---|---|---|
BEFORE_COMPACTION | Before context compaction | Pre-compaction hooks |
AFTER_COMPACTION | After context compaction | Post-compaction validation |
@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
| Event | Trigger | Payload |
|---|---|---|
ON_INIT | Emitted by PluginManager.register() right after the plugin runs its own on_init | PluginLifecycleInput |
ON_SHUTDOWN | Emitted by PluginManager.unregister() right after the plugin runs its own on_shutdown | PluginLifecycleInput |
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().
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
| Field | Type | Default | Description |
|---|---|---|---|
plugin_name | str | "" | Registered plugin name |
plugin_version | str | "" | Plugin version |
plugin_description | str | "" | Plugin description |
session_id, cwd, event_name, timestamp, agent_name | — | — | Standard HookInput fields |
Message Events
| Event | Trigger | Input Type | Use Case |
|---|---|---|---|
MESSAGE_RECEIVED | Inbound message from a bot channel, before agent dispatch | MessageReceivedInput | Inbound gate — drop (deny), redact/rewrite content, authorise sender, rate-limit |
MESSAGE_SENDING | Before message sent | MessageSendingInput | Outbound gate — cancel or rewrite outbound text |
MESSAGE_SENT | After message sent | MessageSentInput | Confirmation |
MESSAGE_UNDELIVERED | Reply permanently undeliverable (target confirmed dead / retries exhausted) | MessageUndeliveredInput | Operator routing — mirror to home channel, alert, or re-queue |
Inbound Platform Events
| Event | Trigger | Input Type | Use Case |
|---|---|---|---|
REACTION_RECEIVED | User adds or removes a reaction; kind disambiguates | PlatformEventInput | Reaction-as-approval, quick polls, moderation |
MESSAGE_EDITED | User edits a previously-sent message | PlatformEventInput | Refresh cached content, re-summarise, invalidate downstream artefacts |
MESSAGE_DELETED | User deletes a message | PlatformEventInput | Retract stored plans, cancel scheduled follow-ups |
MEMBER_JOINED | User joins a channel/guild | PlatformEventInput | Welcome message, onboarding, seat provisioning |
MEMBER_LEFT | User leaves a channel/guild | PlatformEventInput | Offboarding, cleanup |
THREAD_CREATED | New thread created | PlatformEventInput | Thread-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.
| Alias | Live event | Notes |
|---|---|---|
HookEvent.BEFORE_MESSAGE | HookEvent.MESSAGE_RECEIVED | The plugin-facing Plugin.before_message method has always routed here |
HookEvent.AFTER_MESSAGE | HookEvent.MESSAGE_SENDING | Outbound counterpart |
HookEvent.TOOL_RESULT_PERSIST | HookEvent.AFTER_TOOL | AFTER_TOOL already sees the result before it is written into the conversation |
>>> HookEvent.BEFORE_MESSAGE is HookEvent.MESSAGE_RECEIVED
True
>>> HookEvent("before_message") is HookEvent.MESSAGE_RECEIVED
TrueLegacy 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.
| Alias | Live event | Notes |
|---|---|---|
HookEvent.BEFORE_MESSAGE | HookEvent.MESSAGE_RECEIVED | The plugin-facing Plugin.before_message method has always routed here |
HookEvent.AFTER_MESSAGE | HookEvent.MESSAGE_SENDING | Outbound counterpart |
HookEvent.TOOL_RESULT_PERSIST | HookEvent.AFTER_TOOL | AFTER_TOOL already sees the result before it is written into the conversation |
>>> HookEvent.BEFORE_MESSAGE is HookEvent.MESSAGE_RECEIVED
True
>>> HookEvent("before_message") is HookEvent.MESSAGE_RECEIVED
TrueLegacy 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.NoneorHookResult.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
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
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
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
| Decision | Effect |
|---|---|
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 |
@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.
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
| Event | Trigger | Input Type | Key Fields | Use Case |
|---|---|---|---|---|
GATEWAY_START | BotOS.start() | GatewayStartInput | platforms, bot_count | Initialization |
GATEWAY_STOP | BotOS.stop() | GatewayStopInput | platforms, bot_count, reason | Cleanup |
@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).
| Event | Trigger | Input Type | Key Fields | Use Case |
|---|---|---|---|---|
SCHEDULE_ADD | Schedule job persisted | ScheduleAddInput | job_name, job_id, schedule, message, agent_id, principal, enabled | Audit schedule changes |
SCHEDULE_REMOVE | Schedule job deleted | ScheduleRemoveInput | job_name, job_id, schedule, principal | Audit schedule changes |
SCHEDULE_TRIGGER | Scheduled job runs | ScheduleTriggerInput | job_name, job_id, message | Observability, metrics |
JOB_COMPLETED | Any managed background job reaches a terminal state (COMPLETED or FAILED) | JobCompletedInput | job_id, status, result, error, agent_name, session_id, deliver, platform, chat_id, thread_id | Observability, custom delivery routing, chat-back delivery (see Background Subagents) |
@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 raisesValueErroron 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 tenantprincipalfires 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.
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).
| Field | Type | Description | |
|---|---|---|---|
job_id | str | Unique job identifier | |
status | str | "completed" or "failed" | |
result | Any | Return value on success; None on failure | |
error | `str \ | None` | Error message on failure; None on success |
agent_name | str | Always "background" | |
session_id | str | Origin session (empty when unset) | |
deliver | str | Delivery target (empty when the job was not routed) | |
platform | str | Origin platform | |
chat_id | str | Origin chat/channel ID | |
thread_id | str | Origin 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
| Event | Trigger | Use Case |
|---|---|---|
TOOL_RESULT_PERSIST | Alias of AFTER_TOOL (see Aliased Events) — subscribe to AFTER_TOOL directly | Result 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.
@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
| Event | Trigger | Input Type | Use Case |
|---|---|---|---|
CLI_BACKEND_EXECUTE | Agent delegates a turn to a CLI backend subprocess | CliBackendExecuteInput | Observability — 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.
CliBackendExecuteInput Fields
| Field | Type | Default | Description |
|---|---|---|---|
backend | str | "" | Human-readable backend id, e.g. "claude", "codex", "grok", "gemini". Comes from config.command when available, else the backend class name. |
command | Optional[List[Any]] | None | The full subprocess argv the CLI backend spawned. Prompt/system values are redacted ("<redacted>") at serialisation, so log sinks never see the user prompt. |
content | Optional[str] | None | The backend’s textual response. Truncated to the first 500 characters at serialisation. |
error | Optional[str] | None | Non-null on failure — carries the exception message from the CLI subprocess or timeout. |
transport | str | "subprocess" | Always "subprocess" today. Reserved for future transports. |
praisonai_llm_http | bool | False | Always 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
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:
pip install praisonai-plugins
export PRAISONAI_CLI_BACKEND_DEBUG=1
praisonai plugins enable cli_backend_tracerOr use the standard Python knob:
export LOGLEVEL=DEBUGWith 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:
| Flag | Reason it’s masked |
|---|---|
-p, --prompt | The user prompt |
-i, --input | Alternate prompt form (Grok) |
-m, --message | Chat message form |
--system | System instruction |
Non-list command values (already-serialised strings, None) pass through unchanged.
Kanban Events
| Event | Emitted From | Use Case |
|---|---|---|
KANBAN_TASK_CREATED | create_task, after commit | Task added to board |
KANBAN_TASK_CLAIMED | claim_task, only when the CAS won | Task assigned |
KANBAN_TASK_MOVED | move_task (every status change), reclaim_stale_claims, dispatcher promotion | Task status changed |
KANBAN_TASK_DONE | move_task when destination is done (rides alongside MOVED) | Task completed |
KANBAN_TASK_BLOCKED | move_task when destination is blocked, and record_failure on a circuit-breaker trip | Task blocked |
KANBAN_TASK_FAILED | record_failure — one per recorded attempt failure | Task 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.
| Key | On | Notes |
|---|---|---|
assignee | most | worker_id for CLAIMED/reclaim, current assignee for MOVED/DONE/BLOCKED. |
task | CREATED/MOVED/DONE/BLOCKED | Full task.to_dict(). Absent on FAILED and reclaim moves. |
reclaimed | MOVED (reclaim only) | True when a stale claim was reclaimed. |
reason | MOVED (reclaim only) | "dead" or "stale_heartbeat". |
worker_pid | CLAIMED | The claiming worker’s PID. |
claim_expires | CLAIMED | ISO-8601 UTC timestamp. |
consecutive_failures | FAILED, BLOCKED (on break) | Attempt counter. |
max_retries | FAILED, BLOCKED (on break) | Retry limit for the task. |
circuit_broken | FAILED, BLOCKED (on break) | True when this failure tripped the breaker. |
error | FAILED, BLOCKED (on break) | First 500 chars of the failure text. |
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.
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.
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.
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:
{
"task_id": "t_child",
"to_status": "ready",
"task": {...},
}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
| Event | Category | Description |
|---|---|---|
BEFORE_TOOL | Tool | Before tool execution |
AFTER_TOOL | Tool | After tool execution — mutate tool_output to rewrite/redact, or HookResult.block(reason) to suppress the result before it reaches the model |
BEFORE_TOOL_DEFINITIONS | Tool | Before tool definitions are sent to LLM |
BEFORE_AGENT | Agent | Before agent runs |
AFTER_AGENT | Agent | After agent completes |
BEFORE_LLM | LLM | Before LLM API call — fires sync + async |
AFTER_LLM | LLM | After LLM response — fires sync + async |
MODEL_FALLBACK | LLM | Primary model unavailable mid-turn — runtime switched to the next fallback_models entry (observe-only; provider internals redacted). See Model Fallback → Observing the Switch. |
ON_INIT | Plugin | A plugin was registered — fires from PluginManager.register() after plugin.on_init. Payload: PluginLifecycleInput |
ON_SHUTDOWN | Plugin | A plugin was unregistered — fires from PluginManager.unregister() after plugin.on_shutdown. Payload: PluginLifecycleInput |
SESSION_START | Session | Session starts |
SESSION_END | Session | Session ends |
SESSION_PERSIST_FAILED | Session | A 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_ERROR | Error | Error occurs |
ON_RETRY | Error | Before retry |
SUBAGENT_STOP | Subagent | A 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_COMPACTION | Context | Before compaction |
AFTER_COMPACTION | Context | After compaction |
MESSAGE_RECEIVED | Message | Inbound 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_SENDING | Message | Outbound gate — cancel or rewrite before sending; payload carries the enriched identity fields |
BEFORE_MESSAGE | Message | Alias of MESSAGE_RECEIVED. Subscribe to MESSAGE_RECEIVED directly |
AFTER_MESSAGE | Message | Alias of MESSAGE_SENDING. Subscribe to MESSAGE_SENDING directly |
MESSAGE_SENT | Message | After successful delivery; enriched identity payload — also the plugin message_sent(message) bridge |
MESSAGE_UNDELIVERED | Message | Reply permanently undeliverable — operator routing; enriched identity payload plus error/notice_delivered, also the plugin message_undelivered(message) bridge (see Undelivered Message Notice) |
REACTION_RECEIVED | Platform | User adds or removes a reaction (kind disambiguates); opt-in via events: [reactions] — see Inbound Platform Events |
MESSAGE_EDITED | Platform | User edits a message; new_text carries the new content; opt-in via events: [edits] |
MESSAGE_DELETED | Platform | User deletes a message; opt-in via events: [edits] |
MEMBER_JOINED | Platform | User joins a channel/guild; opt-in via events: [members] (privileged intent) |
MEMBER_LEFT | Platform | User leaves a channel/guild; opt-in via events: [members] |
THREAD_CREATED | Platform | New thread created; thread_id populated; opt-in via events: [threads] |
GATEWAY_START | Gateway | Gateway starts |
GATEWAY_STOP | Gateway | Gateway stops |
SCHEDULE_ADD | Schedule | Schedule job persisted (store add) |
SCHEDULE_REMOVE | Schedule | Schedule job deleted (store remove/remove_by_name/one-shot cleanup) |
SCHEDULE_TRIGGER | Schedule | Scheduled job runs |
JOB_COMPLETED | Background | Background job reached terminal state (COMPLETED or FAILED) |
TOOL_RESULT_PERSIST | Storage | Alias of AFTER_TOOL. Subscribe to AFTER_TOOL directly — the two names resolve to the same enum member |
CLI_BACKEND_EXECUTE | CLI Backend | Fires after an Agent delegates a turn to a CLI backend subprocess (success and failure). Observe-only. |
KANBAN_TASK_CREATED | Kanban | Task added to board |
KANBAN_TASK_CLAIMED | Kanban | Task assigned |
KANBAN_TASK_MOVED | Kanban | Task status changed (includes auto-promotion: dispatcher fires this with to_status='ready' for each dependent task whose parents are all terminal) |
KANBAN_TASK_DONE | Kanban | Task completed |
KANBAN_TASK_BLOCKED | Kanban | Task blocked |
KANBAN_TASK_FAILED | Kanban | Task 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_AGENTare fired byagent.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.