Plugins

Extend agent functionality with single-file plugins

Plugins add tools, hooks, and guardrails that can rewrite — or now block — what an agent sees and does; drop a Python file in ~/.praisonai/plugins/ and load it in one line.

python
from praisonaiagents import Agent, discover_and_load_plugins

discover_and_load_plugins()

agent = Agent(
    name="Assistant",
    instructions="Help users with weather queries.",
    tools=["get_weather"],
)
agent.start("What's the weather in Paris?")

The user asks a question; plugins add tools and hooks before the agent answers.

Diagram

🔧 Plugin file

discover_and_load_plugins

🤖 Agent gains tools + hooks

Quick Start

  1. Simple Usage

    Create ~/.praisonai/plugins/my_tools.py:

    python
    """
    Plugin Name: My Tools
    Description: Custom tools for my agent
    Version: 1.0.0
    """
    
    from praisonaiagents import tool
    
    @tool
    def get_weather(city: str) -> str:
        """Get weather for a city."""
        return f"Sunny in {city}, 22°C"

    Load and run:

    python
    from praisonaiagents import Agent, discover_and_load_plugins
    
    discover_and_load_plugins()
    
    agent = Agent(
        name="Assistant",
        instructions="Help users",
        tools=["get_weather"],
    )
    agent.start("What's the weather in Paris?")
  2. With Configuration

    Point the environment (or config file) at plugins and Agent construction wires them for you — no explicit plugins.enable() call needed:

    bash
    export PRAISONAI_PLUGINS=true
    python
    from praisonaiagents import Agent
    
    agent = Agent(name="Assistant", instructions="Help users")
    agent.start("Hello!")   # entry-point plugins are wired in before this runs

    Prefer to turn plugins on in code? Call plugins.enable(...) before creating the agent:

    python
    from praisonaiagents import Agent, plugins
    
    plugins.enable(["logging", "metrics"])
    
    agent = Agent(name="Assistant", instructions="Help users")
    agent.start("Hello!")

Selective Enable and Late Registration

After plugins.enable([names]), any plugin registered later stays disabled unless its name is in that list.

python
from praisonaiagents import Agent, plugins
from praisonaiagents.plugins.manager import get_plugin_manager

# Only "logging" is allowed.
plugins.enable(["logging"])

# Anything registered after this stays disabled unless it is in the allow-list.
get_plugin_manager().register(MyMetricsPlugin())     # registered but disabled
get_plugin_manager().register(MyLoggingPlugin())     # allow-listed → enabled

agent = Agent(name="Assistant", instructions="Help users")
agent.start("Hello!")     # only the logging plugin's tools/hooks are active

The allow-list keeps working even for plugins you add later, so nothing slips in after you declare the selected set.

Diagram

no

yes

yes

no

Is plugins.enable&(names&) active?

✅ Late registration → enabled

Plugin name in allow-list?

🛑 Late registration → registered but disabled

Entry-point discovery, auto-discovery, and manual PluginManager.register(...) all inherit the same rule, because each path flows through register().

Diagram
PluginPluginManager (singleton)plugins.enable([…])UserPluginPluginManager (singleton)plugins.enable([…])Userplugins.enable([“only_this”])set_registration_allow_list({“only_this”})register(late_plugin)_default_enabled_for_registration(“late”) → Falseregistered, is_enabled(“late”) == Falseregister(only_this)allow-listed → Trueregistered, is_enabled(“only_this”) == True

Widen or Reset the Allow-List

You want to…Do this
Turn on more plugins laterplugins.enable(["only_this", "also_that"]) — re-call with the new list
Return to default (every registration enabled)plugins.disable() — clears the allow-list
Enable everything at onceplugins.enable() — no argument
Keep a standalone manager unrestrictedConstruct via PluginManager() directly — the facade allow-list does not apply

How It Works

plugins.enable() auto-calls wire_into_hook_registry(), which registers each enabled plugin’s lifecycle methods on the default hook registry the agent consults at runtime — and Agent init calls this for you when the env var or config file requests it (see Auto-Enable from Env or Config).

Diagram
PluginAgentDefault HookRegistrywire_into_hook_registryplugins.enable()UserPluginAgentDefault HookRegistrywire_into_hook_registryplugins.enable()Userplugins.enable()auto-calledregister before_agent, before_llm, …agent.start(“…”)fire BEFORE_AGENTrun before_agent(prompt, ctx)modified prompt (in-place)HookResult.allow()response

The hooks fire in this order around each agent run:

Diagram

📥 User Input

🪝 before_agent

🪝 before_tool_definitions

🧠 LLM

🪝 before_tool

🔧 Tool

🪝 after_tool

🪝 after_agent

📤 Response

On Agent construction the plugin manager feeds more than the hook registry: a plugin declared PluginType.GUARDRAIL / POLICY / SKILL is routed into the Agent’s typed subsystems — GuardrailChain, PolicyEngine, and SkillManager — via _merge_plugin_subsystems():

Diagram

🔌 PluginManager

🪝 Hook Registry
(HOOK plugins)

🛡️ GuardrailChain
(GUARDRAIL plugins)

📜 PolicyEngine
(POLICY plugins)

🧩 SkillManager
(SKILL plugins)

Plugin typeWhat it adds
Tool (PluginType.TOOL)Functions registered via @tool in a single-file plugin, or returned from Plugin.get_tools() (see Tool Plugins)
Hook (PluginType.HOOK)Lifecycle callbacks (before_tool, before_tool_definitions, after_llm, …) — the default type
Guardrail (PluginType.GUARDRAIL)An object returned from Plugin.as_guardrail() that joins the Agent’s GuardrailChain — sees validate_input / validate_output / validate_tool_call / validate_tool_result, not just after_agent. See Typed Plugins.
Policy (PluginType.POLICY)Policy rules returned from Plugin.get_policies() and added to the Agent’s PolicyEngine. See Typed Plugins.
Skill (PluginType.SKILL)Filesystem paths returned from Plugin.get_skills() and appended to the Agent’s _skills so SkillManager discovers them as real, loadable SKILL.md skills. See Typed Plugins.

Choosing How to Load Plugins

Pick the loading method that fits your setup.

Diagram

How is the plugin delivered?

Drop a file in a plugins dir

Enable a built-in by name

Register a hook at runtime

Ship a class-based Plugin that returns callables

discover_and_load_plugins

plugins.enable list

add_hook decorator

register a Plugin with get_tools

Plugin Locations

LocationScope
~/.praisonai/plugins/User-wide
./.praisonai/plugins/Project-specific

Core Compatibility (Load-Time Gate)

A plugin can declare which praisonaiagents versions it supports in its existing dependencies field — PraisonAI checks it at load and skips mismatched plugins before they wire into the hook runtime.

Single-file header
"""
Plugin Name: PII Guardrail
Dependencies: praisonaiagents>=0.9,<2
"""
Class-based PluginInfo
from praisonaiagents import Plugin, PluginInfo

class PIIGuardrail(Plugin):
    @property
    def info(self) -> PluginInfo:
        return PluginInfo(
            name="pii_guardrail",
            dependencies=["praisonaiagents>=0.9,<2"],
        )
Programmatic register
from praisonaiagents import get_plugin_manager

manager = get_plugin_manager()
manager.register(PIIGuardrail())  # returns False if core is out of range
Diagram
Plugincore_incompatibilityPluginManagerStartupPlugincore_incompatibilityPluginManagerStartupalt[Compatible][Incompatible]register(plugin)dependenciesNone (OK) or reason (skip)on_init({})TrueFalse (warning logged)
ScenarioOutcome
No praisonaiagents entryLoads (permissive default)
praisonaiagents entry satisfiedLoads
praisonaiagents entry not satisfiedSkipped, warning logged, register() returns False, hooks never fire
Unparseable requirement stringLoads (permissive)
packaging not installedLoads (permissive)
Marker evaluates false on this platform (e.g. ; sys_platform == 'win32' on Linux)Entry ignored
Core __version__ is "unknown" (wheel install)Falls back to importlib.metadata.version("praisonaiagents")

Security: Project-Plugin Trust Gate

A single-file plugin is the most privileged extension surface there is — it can hook every lifecycle event and intercept every tool call. So a plugin dropped into a project’s ./.praisonai/plugins/*.py (for example, one that arrived with a cloned repository) does not run until you opt in. This mirrors PRAISONAI_ALLOW_LOCAL_TOOLS for tools: a cloned repo carrying a malicious .praisonai/plugins/exfil.py stays inert by default.

Diagram

./.praisonai/plugins/*.py

~/.praisonai/plugins/*.py

pip package / entry point

PRAISONAI_ALLOW_PROJECT_PLUGINS=true

plugins.allow_project_plugins: true

default

Where does the plugin live?

Gate open?

✅ Loaded

🛑 Refused with warning

Open the gate with an environment variable (accepts true/1/yes/on):

bash
export PRAISONAI_ALLOW_PROJECT_PLUGINS=true

Or in .praisonai/config.yaml:

yaml
plugins:
  allow_project_plugins: true
BehaviourRule
DefaultGate closed — project-local .py plugins are refused with a warning
User-global~/.praisonai/plugins/ plugins remain trusted (you placed them there)
pip / entry pointInstalled entry-point plugins remain trusted
Env precedencePRAISONAI_ALLOW_PROJECT_PLUGINS wins over config
Explicit offPRAISONAI_ALLOW_PROJECT_PLUGINS=false overrides a true in config

Security: Conversation-Content Capability Gate

A plugin that isn’t bundled must be explicitly granted allow_conversation before it can read or rewrite prompts, responses, and outbound messages — its tools still run either way.

Diagram

No — tool/channel/utility

Yes

Yes — bundled

No

Yes

No

📩 Hook fires

🔎 Conversation
hook?

✅ Dispatch

🏛️ First-party?

🔐 allow_conversation
= true?

⏭️ Silently skipped

A third-party plugin’s tools still register and fire; only the eight conversation-content hooks are withheld until you grant access.

Which hooks are gated

These eight hooks carry — and can rewrite — the user’s prompt, the model’s response, or an outbound reply, so they are default-deny for third-party plugins:

Plugin methodHookDirection
before_agentBEFORE_AGENTInbound prompt
before_llmBEFORE_LLMInbound messages
after_llmAFTER_LLMModel response
after_agentAFTER_AGENTAgent response
before_messageMESSAGE_RECEIVEDInbound message
after_messageMESSAGE_SENDINGOutbound reply
message_sentMESSAGE_SENTOutbound reply
message_undeliveredMESSAGE_UNDELIVEREDOutbound reply

Grant a third-party plugin access

Add allow_conversation: true to the plugin’s own option block in .praisonai/config.yaml:

yaml
plugins:
  my_pii_redactor:
    allow_conversation: true      # opt in to before_llm / after_llm / message_*
    # ...any other plugin options are still delivered to on_config as before

Or grant it in code:

python
from praisonaiagents.plugins.manager import PluginManager

manager = PluginManager()
manager.set_plugin_options({"my_pii_redactor": {"allow_conversation": True}})

What “first-party” means

First-party plugins — those bundled inside the installed praisonaiagents/ package on disk — are granted conversation access automatically. This is a different notion from the location trust levels (project vs user-global vs entry-point) of the Project-Plugin Trust Gate.

A plugin is first-party only when both hold:

  1. its class’s module name is praisonaiagents or starts with praisonaiagents., and
  2. that module’s real on-disk file resolves to a path inside the installed praisonaiagents/ package directory.

Interaction with the project-plugin trust gate

The two gates are complementary. A project-local single-file plugin authorised to load via PRAISONAI_ALLOW_PROJECT_PLUGINS=true is still not first-party — it must also carry allow_conversation: true in its config block before it sees any conversation-content hook.

Plugin originbefore_tool, tools, channelsbefore_llm / after_llm / message_*
First-party (bundled)✅ Fires✅ Fires
Third-party, no grant✅ Fires❌ Silently skipped
Third-party with allow_conversation: true✅ Fires✅ Fires

Tools and channels are never gated

An ungranted plugin still registers get_tools(), before_tool, after_tool, before_tool_definitions, on_permission_ask, channel adapters, on_init / on_shutdown, session_start / session_end, on_error, on_config, and on_auth. Only the eight conversation-content hooks above require the grant.

Which plugins see prompt content

Diagram
Third-party plugin(allow_conversation=true)Third-party plugin(ungranted)First-party pluginPluginManagerAgentUserThird-party plugin(allow_conversation=true)Third-party plugin(ungranted)First-party pluginPluginManagerAgentUserpromptbefore_llm(messages)before_llm(…) ✅skipped (default-deny)before_llm(…) ✅(rewritten) messages

Inspect the Plugin Registry

get_plugin_registry() returns a truthful, unified view of every discoverable plugin from three real sources — no hardcoded entries. It reads without a running agent, so a CLI or health check can render it directly.

python
from praisonaiagents.plugins import get_plugin_registry

for p in get_plugin_registry():
    print(p["name"], p["source"], p["enabled"], p["hooks"])

Each entry is a dict: {name, version, description, source, enabled, hooks, requests_conversation, conversation_granted} (single-file entries also include path). The source field records provenance:

sourceOrigin
entry_point:<distribution-name>A pip package registering in the praisonai.plugins group
registeredA Plugin instance held by the PluginManager
single_fileA .py file discovered on disk in .praisonai/plugins/ or ~/.praisonai/plugins/ (metadata only — never executed)

Two fields surface the conversation-content capability gate:

FieldTypeMeaning
requests_conversationbool or nulltrue if the plugin declares any of the eight conversation-content hooks. null for an entry-point plugin not yet loaded — its hooks are unknown without executing it, which the runtime avoids.
conversation_grantedbooltrue if the plugin is first-party or has allow_conversation: true.

Reload Plugins at Runtime

Edited a plugin, or dropped a new one in, and want it live in the current process? Run praisonai plugins reload — it forces rediscovery and rewires hooks so newly-added or edited plugins take effect without a restart. See the Plugins CLI.


Hook Plugin Example

Create ~/.praisonai/plugins/my_logger.py:

python
"""
Plugin Name: My Logger
Description: Logs tool calls
Version: 1.0.0
Hooks: before_tool, after_tool
"""

from praisonaiagents.hooks import add_hook

@add_hook("before_tool")
def log_before(data):
    print(f"Calling: {data.tool_name}")

@add_hook("after_tool")
def log_after(data):
    print(f"Done: {data.tool_name}")

Create ~/.praisonai/plugins/tool_sandbox.py to filter advertised tools:

python
"""
Plugin Name: Tool Sandbox
Description: Filters tool definitions sent to the LLM
Version: 1.0.0
Hooks: before_tool_definitions
"""

from praisonaiagents.hooks import add_hook

BLOCKED = {"delete_file", "shell_exec"}

@add_hook("before_tool_definitions")
def sandbox_tools(data):
    data.tool_definitions[:] = [
        t for t in data.tool_definitions
        if t["function"]["name"] not in BLOCKED
    ]
python
from praisonaiagents import Agent, discover_and_load_plugins

discover_and_load_plugins()
agent = Agent(name="Assistant", instructions="Help users")
agent.start("Search for Python tutorials")

Typed Plugins (Guardrail / Policy / Skill)

Declaring PluginInfo(plugin_type=PluginType.GUARDRAIL) (or POLICY / SKILL) moves a plugin off the generic lifecycle-hook path and into the Agent’s typed subsystem. A guardrail plugin then sees the raw tool call and the raw tool result — the blind spot the after_llm hook could never cover, because after_llm only ever saw the model’s paraphrase.

On Agent.__init__, right after _setup_guardrail() runs and _skills / _policy are set, _merge_plugin_subsystems() collects the typed plugins and folds them in:

Diagram
ToolGuardrailChainAgent.__init__PluginManagerGUARDRAIL PluginToolGuardrailChainAgent.__init__PluginManagerGUARDRAIL Pluginchain appended to_tool_call_guardrails +_tool_result_guardrailsas_guardrail() → PIIGuardrail()get_all_guardrails()[PIIGuardrail()]fold into GuardrailChainraw tool result “a SECRET value”“a [REDACTED] value”

Each collector dispatches on the declared plugin.info.plugin_type and iterates enabled plugins only, snapshotting (name, plugin) pairs under the manager’s lock:

CollectorCallsReturns
PluginManager.get_all_guardrails()as_guardrail() on every enabled PluginType.GUARDRAIL pluginNon-None guardrail objects
PluginManager.get_all_skills()get_skills() on every enabled PluginType.SKILL pluginFilesystem path strings
PluginManager.get_all_policies()get_policies() on every enabled PluginType.POLICY pluginPolicy objects

Quick Start

  1. Guardrail plugin — redact a raw tool result

    A PluginType.GUARDRAIL plugin returns a guardrail object from as_guardrail(). The object joins the Agent’s GuardrailChain and sees validate_tool_result — the raw tool output, before the model paraphrases it.

    python
    from praisonaiagents import Agent, Plugin, PluginInfo, PluginType
    from praisonaiagents.plugins import get_plugin_manager
    
    class PIIGuardrail:
        """Sees the raw tool result, not the model's paraphrase."""
        def validate_tool_result(self, tool_name, result, **kw):
            if isinstance(result, str) and "SECRET" in result:
                return True, result.replace("SECRET", "[REDACTED]")
            return True, result
    
    class PIIGuardrailPlugin(Plugin):
        @property
        def info(self):
            return PluginInfo(
                name="pii_redactor",
                plugin_type=PluginType.GUARDRAIL,   # the dispatch key
            )
        def as_guardrail(self):
            return PIIGuardrail()
    
    get_plugin_manager().register(PIIGuardrailPlugin())
    get_plugin_manager().enable("pii_redactor")
    
    agent = Agent(name="Assistant", instructions="Help users.", llm="gpt-4o-mini")
    # The plugin's PIIGuardrail is now on agent._tool_result_guardrails.
    # Any tool that returns "a SECRET value" becomes "a [REDACTED] value"
    # before the model or the user ever sees it.

    The same effect through after_llm (the pre-PR pattern) could not intercept the raw tool result — only the model’s paraphrase — so a secret leaked whenever the model quoted a tool verbatim or a downstream tool consumed the raw output.

  2. Policy plugin — add execution rules

    A PluginType.POLICY plugin returns Policy objects from get_policies(). They are added to the Agent’s PolicyEngine (one is created if the Agent has no policy=).

    python
    from praisonaiagents import Agent, Plugin, PluginInfo, PluginType
    from praisonaiagents.plugins import get_plugin_manager
    from praisonaiagents.policy import Policy, PolicyRule, PolicyAction
    
    class NoDeletePlugin(Plugin):
        @property
        def info(self):
            return PluginInfo(name="no_delete", plugin_type=PluginType.POLICY)
        def get_policies(self):
            return [Policy(
                name="no_delete",
                rules=[PolicyRule(
                    action=PolicyAction.DENY,
                    resource="tool:delete_*",
                    reason="Delete operations blocked by plugin",
                )],
            )]
    
    get_plugin_manager().register(NoDeletePlugin())
    get_plugin_manager().enable("no_delete")
    
    agent = Agent(name="Assistant", instructions="Manage files safely.")
    # agent.policy now denies tool:delete_* before dispatch.
    agent.start("Please delete /etc/passwd")
  3. Skill plugin — contribute loadable skills

    A PluginType.SKILL plugin returns filesystem path strings from get_skills(). They are appended to the Agent’s _skills so SkillManager loads them as real SKILL.md skills.

    python
    from praisonaiagents import Agent, Plugin, PluginInfo, PluginType
    from praisonaiagents.plugins import get_plugin_manager
    
    class PdfSkillPlugin(Plugin):
        @property
        def info(self):
            return PluginInfo(name="pdf_skill", plugin_type=PluginType.SKILL)
        def get_skills(self):
            return ["./skills/pdf-processing"]   # a dir containing SKILL.md
    
    get_plugin_manager().register(PdfSkillPlugin())
    get_plugin_manager().enable("pdf_skill")
    
    agent = Agent(name="PDF Assistant", instructions="You are a helpful assistant.")
    # ./skills/pdf-processing is now on agent._skills — discovered like a user-configured dir.
    agent.start("Extract the main findings from report.pdf")

Composition and precedence rules

These rules are locked in by the PR’s TestPluginTypedSubsystems suite:

SituationBehaviour
Agent has no user guardrail; plugin guardrail existsPlugin chain becomes the Agent’s guardrail directly (self.guardrail = chain; self._guardrail_fn = chain).
Agent has a user guardrail callable; plugin guardrail existsBoth run in composition: GuardrailChain([_CallableGuardrailAdapter(user_fn), plugin_chain]). Neither is dropped.
Plugin guardrail’s validate_tool_call / validate_tool_resultAppended to _tool_call_guardrails / _tool_result_guardrails — runs on raw tool call args and raw tool results, before the model paraphrases.
User policy has a rule named X; plugin policy tries to add another XPlugin’s X is skipped with a warning — the user always wins.
Plugin has two policies both named XThe first wins; the second is skipped with a warning.
Plugin skill path is not a stringSkipped with a warning (SkillManager loads by path).
Plugin skill path already in _skillsSilently de-duplicated on merge.
A plugin raises inside as_guardrail() / get_policies() / get_skills()Warning logged, other plugins keep loading, Agent construction succeeds — a fail-open per-plugin boundary.
A plugin is disabledNot collected — enabled-only iteration under the manager lock.
A plugin is registered but not declared with the matching PluginTypeNot collected — the collector dispatches on plugin.info.plugin_type. A PluginType.HOOK plugin returning an as_guardrail() object is ignored on the guardrail path.

User Interaction Flow

A PII guardrail plugin redacts a secret from the raw tool result — before the model or the user sees it:

$ python app.py
User: fetch the demo token
Agent → tool call: fetch_token()
Tool  → "your token is SECRET-abc-123"    (raw)
Guardrail chain (plugin) → "your token is [REDACTED]-abc-123"
Agent → your token is [REDACTED]-abc-123

Before this wiring the redaction only ran on the model’s final paraphrase; if the model quoted the tool verbatim, or a downstream tool used the raw result, the secret leaked.

Backward compatibility

  • Every new base method (as_guardrail(), get_skills(), get_policies()) is a no-op by default — existing Plugin subclasses that don’t override them behave identically.
  • The default PluginInfo.plugin_type is PluginType.HOOK, so existing plugins that don’t set the field are not collected by the typed collectors and keep routing through the generic lifecycle-hook path.
  • No new required parameters on Agent(...) or Plugin(...).

Tool Plugins (subclass Plugin with get_tools())

Subclass Plugin, return callables from get_tools(), register once — every agent in the process can call them.

Diagram
AgentRegistered Tool PluginPluginManager_merge_plugin_toolsAgent.__init__UserAgentRegistered Tool PluginPluginManager_merge_plugin_toolsAgent.__init__UserAgent(instructions=…)after tools=[…] resolvedget_all_tools()plugin.get_tools()[random_number][random_number]append to self.tools (skip collisions)Agent ready — plugin tool callable

A class-based PluginType.TOOL plugin’s get_tools() output is merged into self.tools during Agent.__init__ — you do not call plugins.enable(), touch PluginManager.get_all_tools(), or pass anything special to Agent(...). Registering the plugin marks it enabled in the manager, and that is enough for its tools to reach every subsequent agent.

Quick Start

  1. Register a tool plugin in code

    Return callables from get_tools(), register once, and the tool reaches the agent — no plugins.enable(), no tools=[...].

    python
    from praisonaiagents import Agent, Plugin, PluginInfo
    from praisonaiagents.plugins import get_plugin_manager
    
    def random_number() -> int:
        """Returns a random number."""
        return 42
    
    class BasicToolPlugin(Plugin):
        @property
        def info(self):
            return PluginInfo(name="basic_tools")
    
        def get_tools(self):
            return [random_number]
    
    # Register once for the process.
    get_plugin_manager().register(BasicToolPlugin())
    
    # No plugins.enable(), no tools=[...] — the plugin's tool reaches the agent.
    agent = Agent(instructions="Give me a random number using your tools.")
    agent.start()
  2. Load the bundled plugin by name

    The reference basic_tools plugin ships in the separate praisonai-plugins package. Install it, enable it once, and every subsequent agent gains its tool.

    bash
    pip install praisonai-plugins
    praisonai plugins enable basic_tools
    python
    from praisonaiagents import Agent
    
    agent = Agent(instructions="Give me a random number using your tools.")
    agent.start()   # random_number is callable — no tools=[...] needed

User Interaction Flow

Before this wiring, an agent asked for a random number would fabricate one. Now it actually calls the plugin’s tool:

$ python app.py
User: give me a random number using your tools
Agent → tool call: random_number()
Tool  → 42
Agent → 42

Precedence and Safety Rules

RuleBehaviour
Agent tools win on name collisionIf Agent(tools=[foo]) and a plugin also returns a foo, the agent’s foo is kept and the plugin’s foo is skipped with a WARNING log — never silently shadowed.
Metadata-only descriptors are skippedA bare {"name": "phantom_tool"} dict with no callable is not appended (logged as a WARNING) — it advertises a tool the agent could never actually invoke.
Registration alone enables tool pluginsTool plugins do not require plugins.enable(). The PluginManager marks a plugin enabled on register(...); that is enough for get_all_tools() to include it. Only hook / metric / lifecycle plugins need the package-wide plugins.enable() flag.
Zero cost when plugin system unusedIf the plugin system is not enabled, the merge short-circuits — no scan, no cost.
Never breaks agent initAny failure in plugin discovery / merge is caught; the agent is constructed regardless.
Thread-safe iterationget_all_tools() snapshots the enabled (name, plugin) pairs under the manager’s lock, so a concurrent register / unregister on another thread cannot raise RuntimeError: dictionary changed size during iteration and drop every plugin tool.

The collision rule in code — the agent’s own tool always wins:

python
from praisonaiagents import Agent
from praisonaiagents.plugins import get_plugin_manager
# BasicToolPlugin from the Quick Start already registered — it exposes random_number

def random_number() -> str:
    return "agent's own version"

agent = Agent(instructions="test", tools=[random_number])
# agent.tools contains ONE random_number: the user's.
# The plugin's random_number is skipped with:
#   WARNING  Plugin tool 'random_number' skipped: an agent tool with the same name
#            already exists (existing tool wins).

@tool vs Plugin.get_tools() vs FunctionPlugin

Two ways to add a tool from a plugin — pick the one that matches how the tool is shipped.

Diagram

How is the tool shipped?

Standalone .py file in ~/.praisonai/plugins/

Class-based plugin — bundled with hooks, config, session lifecycle

One-off callable at runtime

@tool decorator in a single-file plugin

Subclass Plugin, return callables from get_tools()

FunctionPlugin(hooks={…})


Static Capability Manifest

Declare a plugin’s capabilities in its header so onboarding, doctor and config validation can read them without importing the plugin’s runtime.

Diagram

📄 Plugin header

🔍 parse_plugin_header

📋 PluginMetadata

🗺️ Discovery / list

✅ Config validation

🚦 Capability gating

Add four optional fields to the header — they parse as plain text, so the plugin’s runtime is never imported to read them.

Create ~/.praisonai/plugins/telegram_channel.py:

python
"""
Plugin Name: Telegram Channel
Description: Telegram bot adapter
Version: 1.0.0
Author: Your Name
Hooks: before_tool, after_tool
Dependencies: python-telegram-bot
Channels: telegram
Provides: send_message, get_updates
Config: api_key, timeout
Auto Enable When Configured: TELEGRAM_TOKEN
"""

from praisonaiagents import tool

@tool
def send_message(chat_id: str, text: str) -> str:
    """Send a Telegram message."""
    return f"Sent to {chat_id}: {text}"

Field reference

Field (header)Alias(es)TypeDefaultPurpose
Channels—list[]Channels/adapters this plugin contributes (e.g. telegram, slack)
ProvidesProvides Tools, Toolslist[]Tool/function names this plugin exposes (e.g. get_weather, send_email)
ConfigConfig Schemalist[]Config keys this plugin reads (e.g. api_key, timeout)
Auto Enable When Configuredauto_enable_when_configuredlist[]Env/credential keys that, when set, auto-enable the plugin (e.g. TELEGRAM_TOKEN)

Read the manifest from code

discover_plugins() scans plugin directories and returns metadata dicts — the plugin body is never imported.

python
from praisonaiagents.plugins.discovery import discover_plugins

for meta in discover_plugins():
    print(
        meta["name"],
        meta.get("channels", []),
        meta.get("provides", []),
        meta.get("config", []),
        meta.get("auto_enable_when_configured", []),
    )

Rules

  • Fields are optional — omitting them yields empty lists (safe default).
  • Field keys are case-insensitive — Channels, channels, and CHANNELS all work.
  • Aliases: Provides = Provides Tools = Tools; Config = Config Schema; Auto Enable When Configured = auto_enable_when_configured.
  • The plugin file’s runtime is never imported to read these fields — safe to scan untrusted plugin directories for a capability inventory.

Do I need a manifest?

Diagram

Does the plugin expose tools or channels?

Single-file plugin with tools/channels

Hook-only plugin

✅ Declare a capability manifest

Optional — add fields only if useful


Lifecycle-Method Plugins

Subclass Plugin and override a lifecycle method to transform prompts, messages, or responses. Call plugins.enable() — or set PRAISONAI_PLUGINS=true / [plugins] enabled = true and let Agent construction wire it — to activate the method at runtime.

Create ~/.praisonai/plugins/pii_redactor.py:

python
"""
Plugin Name: PII Redactor
Description: Scrubs SSNs from LLM responses
Version: 1.0.0
"""
import re
from praisonaiagents import Plugin, PluginInfo, PluginHook

class PIIRedactor(Plugin):
    @property
    def info(self):
        return PluginInfo(
            name="pii_redactor",
            version="1.0.0",
            description="Scrubs SSNs from LLM responses",
            hooks=[PluginHook.AFTER_LLM],
        )

    def after_llm(self, response: str, usage: dict) -> str:
        return re.sub(r"\b\d{3}-\d{2}-\d{4}\b", "[SSN]", response)

def create_plugin():
    return PIIRedactor()

Load and enable:

python
from praisonaiagents import Agent, plugins

plugins.enable()   # bridges lifecycle methods into the runtime hook registry

agent = Agent(name="Assistant", instructions="Help users.")
agent.start("My SSN is 123-45-6789")   # response is redacted before the user sees it

Every lifecycle method may also be async def — the dispatcher awaits it. Reach for it when a hook does I/O, such as warming a cache at registration:

python
from praisonaiagents import Plugin, PluginInfo

class AsyncWarmup(Plugin):
    @property
    def info(self):
        return PluginInfo(name="warmup")

    async def on_init(self, context):
        # Awaited before the plugin receives any hook dispatch
        self.cache = await warmup_cache()

    async def on_shutdown(self):
        await self.cache.aclose()

See Sync or Async Hook Methods for how async hooks run.


Plugin Lifecycle Observability

plugin.on_init and plugin.on_shutdown are the plugin’s own callbacks — they run on the plugin being registered or unregistered.

To have a different plugin (a metrics collector, an audit log, a gateway health monitor) observe the plugin set changing at runtime, subscribe to the ON_INIT / ON_SHUTDOWN hook events. They are emitted from PluginManager.register() / PluginManager.unregister() immediately after the plugin’s own callback runs.

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

registry = HookRegistry()

@registry.on(HookEvent.ON_INIT)
def audit_plugin_registered(event_data: PluginLifecycleInput):
    print(f"plugin_registered name={event_data.plugin_name} version={event_data.plugin_version}")
    return HookResult.allow()

@registry.on(HookEvent.ON_SHUTDOWN)
def audit_plugin_unregistered(event_data: PluginLifecycleInput):
    print(f"plugin_unregistered name={event_data.plugin_name}")
    return HookResult.allow()

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

See Hook Events → Plugin Lifecycle Events for the payload reference.


Session & Error Lifecycle Plugins

Override session_start, session_end, on_error, on_config, or on_auth to react to session boundaries, errors, config, and credential resolution.

Choosing a lifecycle event

Diagram

What do you want to react to?

Every session boundary

Errors during a run

Runtime config injection

Credential resolution

session_start / session_end

on_error

on_config

on_auth

Observe sessions

session_start and session_end observe when a session opens and closes.

python
from praisonaiagents import Agent, plugins

plugins.enable()

agent = Agent(name="Assistant", instructions="Help users.")
agent.start("Hello!")

Create ~/.praisonai/plugins/session_logger.py:

python
"""
Plugin Name: Session Logger
Description: Records when sessions start and end
Version: 1.0.0
"""
from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook

class SessionLogger(Plugin):
    @property
    def info(self):
        return PluginInfo(
            name="session_logger",
            hooks=[PluginHook.SESSION_START, PluginHook.SESSION_END],
        )

    def session_start(self, context):
        print(f"session started: {context.get('session_name')}")

    def session_end(self, context):
        print(f"session ended: {context.get('reason')} after {context.get('total_turns')} turns")

def create_plugin():
    return SessionLogger()

Observe errors

on_error observes errors during a run — use it to log without changing behavior.

python
from praisonaiagents import Agent, plugins

plugins.enable()

agent = Agent(name="Assistant", instructions="Help users.")
agent.start("Do something that might fail")

Create ~/.praisonai/plugins/error_reporter.py:

python
"""
Plugin Name: Error Reporter
Description: Logs errors during agent runs
Version: 1.0.0
"""
from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook

class ErrorReporter(Plugin):
    @property
    def info(self):
        return PluginInfo(
            name="error_reporter",
            hooks=[PluginHook.ON_ERROR],
        )

    def on_error(self, error_type, error_message, context):
        print(f"{error_type}: {error_message}")
        print(context.get("stack_trace"))

def create_plugin():
    return ErrorReporter()

Rewrite config

on_config returns a dict to rewrite runtime configuration in place.

python
from praisonaiagents import Agent, plugins

plugins.enable()

agent = Agent(name="Assistant", instructions="Help users.")
agent.start("Hello!")

Create ~/.praisonai/plugins/config_defaults.py:

python
"""
Plugin Name: Config Defaults
Description: Injects a default temperature into runtime config
Version: 1.0.0
"""
from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook

class ConfigDefaults(Plugin):
    @property
    def info(self):
        return PluginInfo(
            name="config_defaults",
            hooks=[PluginHook.ON_CONFIG],
        )

    def on_config(self, config):
        config.setdefault("temperature", 0.2)
        return config

def create_plugin():
    return ConfigDefaults()

Inject credentials

on_auth returns a dict of credentials — the bridge writes them back even when credentials starts as None, so first-time injection works.

python
from praisonaiagents import Agent, plugins

plugins.enable()

agent = Agent(name="Assistant", instructions="Help users.")
agent.start("Call the authenticated API")

Create ~/.praisonai/plugins/token_injector.py:

python
"""
Plugin Name: Token Injector
Description: Supplies credentials on first-time auth
Version: 1.0.0
"""
import os
from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook

class TokenInjector(Plugin):
    @property
    def info(self):
        return PluginInfo(
            name="token_injector",
            hooks=[PluginHook.ON_AUTH],
        )

    def on_auth(self, auth_type, credentials):
        return {"token": os.environ["MY_API_TOKEN"]}

def create_plugin():
    return TokenInjector()

Gateway, Schedule & Runtime Lifecycle Plugins

Override gateway_start, schedule_trigger, before_compaction, subagent_stop, model_fallback (and their siblings) to observe the framework’s runtime lifecycle from a plugin — no low-level HookRegistry needed.

Choosing a lifecycle event

Diagram

What do you want to observe?

Bot / gateway starts or stops

Scheduled job is added, removed, or fires

Conversation memory is compacted

A spawned subagent finishes

Runtime falls back to a secondary model

Kanban board state changes

gateway_start / gateway_stop

schedule_add / schedule_remove / schedule_trigger

before_compaction / after_compaction

subagent_stop

model_fallback

kanban_task_created / kanban_task_moved / kanban_task_done

Observe gateway lifecycle

gateway_start and gateway_stop fire when the gateway (BotOS) starts and stops.

python
from praisonaiagents import Agent, plugins

plugins.enable()   # bridges plugin methods into the runtime hook registry

agent = Agent(name="Assistant", instructions="Help users.")
agent.start("Hello!")

Create ~/.praisonai/plugins/gateway_watch.py:

python
"""
Plugin Name: Gateway Watch
Description: Observes gateway start / stop
Version: 1.0.0
"""
from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook

class GatewayWatch(Plugin):
    @property
    def info(self):
        return PluginInfo(
            name="gateway_watch",
            hooks=[PluginHook.GATEWAY_START, PluginHook.GATEWAY_STOP],
        )

    def gateway_start(self, context):
        print(f"gateway_start platforms={context.get('platforms')} bots={context.get('bot_count')}")

    def gateway_stop(self, context):
        print(f"gateway_stop reason={context.get('reason')}")

def create_plugin():
    return GatewayWatch()

Observe schedule lifecycle

schedule_add, schedule_remove, and schedule_trigger fire when a job is persisted, deleted, or fires.

python
from praisonaiagents import Agent, plugins

plugins.enable()

agent = Agent(name="Assistant", instructions="Help users.")
agent.start("Schedule a daily report.")

Create ~/.praisonai/plugins/schedule_watch.py:

python
"""
Plugin Name: Schedule Watch
Description: Observes schedule add / remove / trigger
Version: 1.0.0
"""
from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook

class ScheduleWatch(Plugin):
    @property
    def info(self):
        return PluginInfo(
            name="schedule_watch",
            hooks=[
                PluginHook.SCHEDULE_ADD,
                PluginHook.SCHEDULE_REMOVE,
                PluginHook.SCHEDULE_TRIGGER,
            ],
        )

    def schedule_add(self, context):
        print(f"schedule_add job={context.get('job_name')} id={context.get('job_id')}")

    def schedule_remove(self, context):
        print(f"schedule_remove job={context.get('job_name')} id={context.get('job_id')}")

    def schedule_trigger(self, context):
        print(f"schedule_trigger job={context.get('job_name')} message={context.get('message')}")

def create_plugin():
    return ScheduleWatch()

Observe compaction

before_compaction sees the pre-compaction snapshot; after_compaction sees the post-compaction summary.

python
from praisonaiagents import Agent, plugins

plugins.enable()

agent = Agent(name="Assistant", instructions="Help users.")
agent.start("Have a long conversation that triggers compaction.")

Create ~/.praisonai/plugins/compaction_watch.py:

python
"""
Plugin Name: Compaction Watch
Description: Observes conversation memory compaction
Version: 1.0.0
"""
from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook

class CompactionWatch(Plugin):
    @property
    def info(self):
        return PluginInfo(
            name="compaction_watch",
            hooks=[PluginHook.BEFORE_COMPACTION, PluginHook.AFTER_COMPACTION],
        )

    def before_compaction(self, context):
        print(f"before_compaction session={context.get('session_id')} messages={context.get('message_count')}")

    def after_compaction(self, context):
        print(f"after_compaction {context.get('messages_before')} → {context.get('messages_after')}")

def create_plugin():
    return CompactionWatch()

Observe subagent completion

subagent_stop fires when a spawned subagent reaches a terminal state — useful for audit and metrics.

python
from praisonaiagents import Agent, plugins

plugins.enable()

agent = Agent(name="Assistant", instructions="Delegate work to subagents.")
agent.start("Research three topics in parallel.")

Create ~/.praisonai/plugins/subagent_watch.py:

python
"""
Plugin Name: Subagent Watch
Description: Observes spawned subagent completion
Version: 1.0.0
"""
from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook

class SubagentWatch(Plugin):
    @property
    def info(self):
        return PluginInfo(
            name="subagent_watch",
            hooks=[PluginHook.SUBAGENT_STOP],
        )

    def subagent_stop(self, context):
        print(f"subagent_stop task={context.get('task')} success={context.get('success')}")

def create_plugin():
    return SubagentWatch()

Observe model fallback

model_fallback fires when the runtime falls back to a secondary model — log the primary_model → fallback_model transition and its reason.

python
from praisonaiagents import Agent, plugins

plugins.enable()

agent = Agent(name="Assistant", instructions="Help users.")
agent.start("Answer even if the primary model is unavailable.")

Create ~/.praisonai/plugins/fallback_watch.py:

python
"""
Plugin Name: Fallback Watch
Description: Observes runtime model fallback
Version: 1.0.0
"""
from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook

class FallbackWatch(Plugin):
    @property
    def info(self):
        return PluginInfo(
            name="fallback_watch",
            hooks=[PluginHook.MODEL_FALLBACK],
        )

    def model_fallback(self, context):
        print(f"model_fallback {context.get('primary_model')} → {context.get('fallback_model')} ({context.get('reason')})")

def create_plugin():
    return FallbackWatch()

Observe kanban board changes

Declare one or more of KANBAN_TASK_CREATED, KANBAN_TASK_MOVED, KANBAN_TASK_DONE and define the matching kanban_task_*(context) methods.

python
from praisonaiagents import Agent, plugins

plugins.enable()

agent = Agent(name="Assistant", instructions="Manage a kanban board.")
agent.start("Create and move tasks on the board.")

Create ~/.praisonai/plugins/kanban_watch.py:

python
"""
Plugin Name: Kanban Watch
Description: Observes kanban board transitions
Version: 1.0.0
"""
from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook

class KanbanWatch(Plugin):
    @property
    def info(self):
        return PluginInfo(
            name="kanban_watch",
            hooks=[
                PluginHook.KANBAN_TASK_CREATED,
                PluginHook.KANBAN_TASK_MOVED,
                PluginHook.KANBAN_TASK_DONE,
            ],
        )

    def kanban_task_created(self, context):
        print(f"kanban_task_created {context.get('task_id')}")

    def kanban_task_moved(self, context):
        print(f"kanban_task_moved {context.get('task_id')} → {context.get('column')}")

    def kanban_task_done(self, context):
        print(f"kanban_task_done {context.get('task_id')}")

def create_plugin():
    return KanbanWatch()

How the declaration-driven bridge works

A Plugin method is bridged when both of these are true:

  1. The matching HookEvent is listed in PluginInfo.hooks.
  2. The method is either a genuine override of the base no-op, or the event has no base counterpart at all (e.g. kanban_*).

Declaring the hook in info.hooks without overriding the method registers nothing (no dead hooks). Overriding the method without declaring the hook also registers nothing — the runtime is strictly declaration-driven so authors keep control of what fires.

Diagram

No

Yes

No

Yes

Declared in info.hooks?

Registers nothing

Method overridden – or no base?

Registers nothing

✅ Bridged as observe-only hook

Message-Lifecycle Plugins

Override before_message, after_message, message_sent, or message_undelivered to react to every hop of a message’s lifecycle — per-user policy, per-channel redaction, delivery telemetry, or dead-letter escalation.

Choosing a message hook

Diagram

What do you want to react to?

Inbound message before agent sees it

Outbound reply before delivery

Successful delivery

Permanent delivery failure

before_message → MESSAGE_RECEIVED – can block/rewrite

after_message → MESSAGE_SENDING – can rewrite

message_sent → MESSAGE_SENT – observe-only

message_undelivered → MESSAGE_UNDELIVERED – observe-only, +error/notice_delivered

Enriched payload keys

Every message-lifecycle method receives (at most) these keys:

KeyTypePresent onDescription
contentstrAll four eventsAlways emitted (empty string when the input has none).
platformstrAll four events (when set)Adapter/channel identifier (e.g. "telegram", "slack", "cli").
sender_idstrInbound (when set)Stable identifier for the user/agent that sent the message.
channel_idstrInbound + outbound (when set)Stable identifier for the channel/thread/DM.
channel_typestrInbound (when set)e.g. "dm", "group", "channel", "broadcast".
message_idstrInbound + message_sent (when set)Provider-native message id for correlation.
session_idstrAll four events (when set)PraisonAI session id — pair with session_start / session_end.
errorstrmessage_undelivered onlyFailure classification / message from the delivery pipeline.
notice_deliveredboolmessage_undelivered onlyTrue when a user-visible “your message wasn’t delivered” notice was sent.

React per user, per channel, and per delivery

Lead with the outcome: rate-limit a noisy sender, count deliveries per channel, or escalate a dead letter.

~/.praisonai/plugins/per_user_rate_limit.py
from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook, PluginDecision

class PerUserRateLimit(Plugin):
    def __init__(self):
        self._counts = {}

    @property
    def info(self):
        return PluginInfo(name="per_user_rate_limit",
                          hooks=[PluginHook.MESSAGE_RECEIVED])

    def before_message(self, message):
        sender = message.get("sender_id")
        if sender is None:
            return message              # outbound/unknown — no scope to enforce
        self._counts[sender] = self._counts.get(sender, 0) + 1
        if self._counts[sender] > 20:
            return PluginDecision.deny(f"Rate limit exceeded for {sender}")
        return message

def create_plugin():
    return PerUserRateLimit()
~/.praisonai/plugins/delivery_counter.py
from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook

class DeliveryCounter(Plugin):
    def __init__(self):
        self._sent = {}

    @property
    def info(self):
        return PluginInfo(name="delivery_counter",
                          hooks=[PluginHook.MESSAGE_SENT])

    def message_sent(self, message):
        key = (message.get("platform"), message.get("channel_id"))
        self._sent[key] = self._sent.get(key, 0) + 1

def create_plugin():
    return DeliveryCounter()
~/.praisonai/plugins/dead_letter.py
from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook

class DeadLetter(Plugin):
    @property
    def info(self):
        return PluginInfo(name="dead_letter",
                          hooks=[PluginHook.MESSAGE_UNDELIVERED])

    def message_undelivered(self, message):
        # Payload adds error + notice_delivered on this hook only
        print(f"[dead-letter] {message.get('platform')} "
              f"channel={message.get('channel_id')} "
              f"error={message.get('error')} "
              f"notice_delivered={message.get('notice_delivered')}")

def create_plugin():
    return DeadLetter()

Function-Based Plugins (FunctionPlugin)

FunctionPlugin wraps plain functions into a plugin without subclassing — useful for one-off hooks or when the whole plugin is a single callable.

When to reach for it

Diagram

How much do you need?

One or two hook callbacks

State, config, multiple hooks, session lifecycle

FunctionPlugin – wrap plain functions

Subclass Plugin – full base class

Quick Start

Register a FunctionPlugin on the plugin manager, then start the agent — the callable runs before the agent sees the message.

python
from praisonaiagents import Agent, FunctionPlugin, PluginHook
from praisonaiagents.plugins import get_plugin_manager

def redact_secrets(message):
    text = message.get("content", "")
    return {"content": text.replace("sk-", "sk-****")}

get_plugin_manager().register(FunctionPlugin(
    name="redact_secrets",
    hooks={PluginHook.MESSAGE_RECEIVED: redact_secrets},
))

agent = Agent(name="Assistant", instructions="Help users.")
agent.start("here is my key sk-live-abc")   # agent sees redacted content

Constructor

python
FunctionPlugin(
    name: str,
    hooks: Optional[Dict[PluginHook, Callable]] = None,
    version: str = "1.0.0",
    description: str = "",
)
ParameterTypeDefaultDescription
namestr—Plugin name shown in the registry.
hooksDict[PluginHook, Callable]NoneMaps each PluginHook enum value to the callable that handles it.
versionstr"1.0.0"Semantic version.
descriptionstr""What the plugin does.

Hook dispatch table

Each key in hooks={} is a PluginHook enum value; the callable’s return value drives the effect below.

PluginHook keyCallable signatureEffect
PluginHook.BEFORE_TOOLfn(tool_name: str, args: dict) -> dictRewrites tool args
PluginHook.AFTER_TOOL`fn(tool_name: str, result: Any) -> Any \PluginDecision \None`Rewrites/redacts tool result, or deny blocks it before it reaches the model
PluginHook.BEFORE_AGENTfn(prompt: str, context: dict) -> strRewrites prompt
PluginHook.AFTER_AGENT`fn(response: str, context: dict) -> str \PluginDecision \None`Rewrites the final response, or deny withholds it — see Output Guardrails
PluginHook.MESSAGE_RECEIVED`fn(message: dict) -> dict \PluginDecision \None`Rewrites content, or deny drops the inbound message
PluginHook.MESSAGE_SENDINGfn(message: dict) -> dictRewrites outbound content
PluginHook.MESSAGE_SENTfn(message: dict) -> NoneObserve-only
PluginHook.MESSAGE_UNDELIVEREDfn(message: dict) -> NoneObserve-only (payload adds error, notice_delivered)

Message-lifecycle examples

Lead with the outcome: rate-limit a channel, log every delivery, or capture permanent failures.

rate-limit a channel
from praisonaiagents import FunctionPlugin, PluginHook, PluginDecision
from praisonaiagents.plugins import get_plugin_manager

counts = {}
def gate(message):
    ch = message.get("channel_id")
    if ch is None:
        return message
    counts[ch] = counts.get(ch, 0) + 1
    if counts[ch] > 20:
        return PluginDecision.deny(f"rate limit exceeded for {ch}")
    return message

get_plugin_manager().register(FunctionPlugin(
    name="channel_rate_limit",
    hooks={PluginHook.MESSAGE_RECEIVED: gate},
))
log every successful delivery
from praisonaiagents import FunctionPlugin, PluginHook
from praisonaiagents.plugins import get_plugin_manager

def on_sent(message):
    print(f"[sent] {message.get('platform')} channel={message.get('channel_id')}")

get_plugin_manager().register(FunctionPlugin(
    name="delivery_log",
    hooks={PluginHook.MESSAGE_SENT: on_sent},
))
capture permanent delivery failures
from praisonaiagents import FunctionPlugin, PluginHook
from praisonaiagents.plugins import get_plugin_manager

def on_dead(message):
    # error + notice_delivered are only added on the MESSAGE_UNDELIVERED bridge
    print(f"[dead] {message.get('channel_id')} err={message.get('error')} "
          f"notice_delivered={message.get('notice_delivered')}")

get_plugin_manager().register(FunctionPlugin(
    name="dead_letter_log",
    hooks={PluginHook.MESSAGE_UNDELIVERED: on_dead},
))

Auto-Enable from Env or Config

Agent construction auto-enables plugins when the environment or config file requests it — no explicit plugins.enable() call needed.

Set the env var, then any Agent(...) wires plugins before it runs:

bash
export PRAISONAI_PLUGINS=true
python
from praisonaiagents import Agent

agent = Agent(name="Assistant", instructions="Help users")
agent.start("Hello!")   # plugins are already active

Or turn them on in .praisonai/config.toml:

toml
[plugins]
enabled = true          # or false, or ["logging", "metrics"]

Under the hood, Agent init calls plugins.maybe_enable_from_config(), which reads the env var and config file, then runs enable(get_enabled_plugins()) exactly once per process.

Diagram

How do you turn plugins on?

Set PRAISONAI_PLUGINS=true

Add plugins enabled = true in config.toml

Call plugins.enable in code

Agent init auto-enables

Enabled immediately, before Agent init

Precedence: explicit plugins.enable(...) > PRAISONAI_PLUGINS env var > [plugins] in config.yaml / config.toml > disabled.


Suppress Plugins for One Run (Pure Mode)

The inverse of auto-enable — skip external-plugin discovery for a single run without touching persisted state.

Pass --pure (or --no-plugins) to run, chat, or code:

bash
praisonai run --pure "Summarise this file"

Or forced-off in embedded Python, regardless of the env var:

python
from praisonaiagents.plugins import PluginManager

manager = PluginManager(disabled=True)
manager.discover_entry_points()   # returns 0; no plugins loaded

Precedence: constructor disabled=True > PRAISONAI_NO_PLUGINS env var. The CLI flag is scoped and restored on return, and never mutates .praisonai/config.yaml.

praisonai gateway start honours the same --pure / --no-plugins suppression, with gateway-specific semantics (persisted across restart, also disables inbound YAML hooks) — see Gateway Safe Mode.

See Pure Mode for the full guide.


Configure Plugins from .praisonai/config.yaml

Turn plugins on and hand each one its own options from a single YAML file — no code.

~/.praisonai/config.yaml:

yaml
plugins:
  enabled: true
  pii_guardrail:
    redact: [email, phone]
  memory_watchdog:
    interval: 30

app.py:

python
from praisonaiagents import Agent

agent = Agent(name="Assistant", instructions="Help users.")
agent.start("My email is user@example.com")   # pii_guardrail redacts before the LLM sees it
Diagram

.praisonai/config.yaml

maybe_enable_from_config

enable(names, options_by_name)

apply_plugin_options

plugin.on_config(options)

Where the config file is found

1. .praisonai/config.toml   → .praisonai/config.yaml   → .praisonai/config.yml   (project-local)
2. praisonai.toml           → praisonai.yaml           → praisonai.yml           (project root)
3. ~/.praisonai/config.toml → ~/.praisonai/config.yaml → ~/.praisonai/config.yml (user global)

The four shapes of enabled

enabled valueMeaningExample
trueEnable every discovered plugin — preserved across the loader/CLI round-tripenabled: true
falseDisable every plugin — wins over per-plugin blocksenabled: false
[names] (non-empty list)Enable exactly these pluginsenabled: [pii_guardrail, metrics]
"name" (bare string, TOML)Enable exactly this one pluginenabled = "pii_guardrail"
omitted + per-plugin blocksEnable each plugin block whose own enabled is not false(see below)

Per-plugin option blocks

Reserved keys (enabled, auto_discover, directories, allow_project_plugins) configure the plugin system; any other key whose value is a mapping is treated as a per-plugin option map delivered to that plugin’s on_config hook. Inside a per-plugin block, allow_conversation is also framework-reserved — the manager reads it to gate conversation-content hooks and does not forward it to on_config.

yaml
plugins:
  enabled: true
  allow_project_plugins: true   # authorise ./.praisonai/plugins/*.py
  pii_guardrail:
    redact: [email, phone]
    enabled: true
  memory_watchdog:
    interval: 30
    enabled: false     # disable just this one; pii_guardrail still runs

Reading options inside a plugin — on_config

apply_plugin_options() invokes plugin.on_config(options) for every enabled plugin that has a configured option map — errors in a single plugin are logged and do not abort delivery to the others.

Create ~/.praisonai/plugins/pii_guardrail.py:

python
"""
Plugin Name: PII Guardrail
Description: Redacts configured fields before the LLM sees them
Version: 1.0.0
"""
from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook

class PIIGuardrail(Plugin):
    def __init__(self):
        self._fields = []

    @property
    def info(self):
        return PluginInfo(
            name="pii_guardrail",
            hooks=[PluginHook.ON_CONFIG, PluginHook.BEFORE_LLM],
        )

    def on_config(self, config):
        # config is the exact dict from `plugins.pii_guardrail:` in YAML.
        self._fields = list(config.get("redact", []))
        return config

    def before_llm(self, messages, params):
        # Use self._fields to scrub before the LLM call...
        return messages, params

def create_plugin():
    return PIIGuardrail()

Deliver options from code — options_by_name

Skip the file and hand each plugin its options directly. plugins.enable() takes an options_by_name map that it delivers to each plugin’s on_config hook:

python
from praisonaiagents import plugins

plugins.enable(
    ["pii_guardrail"],
    options_by_name={"pii_guardrail": {"redact": ["email"]}},
)

Choose a plugin config surface

Diagram

How do you configure plugins?

One plugin, no options

Multiple plugins with options, checked into repo

Ephemeral per-shell override

plugins.enable(‘name’)

.praisonai/config.yaml

PRAISONAI_PLUGINS env var

Precedence

Explicit plugins.enable(...) in code > PRAISONAI_PLUGINS env var > [plugins] in config.yaml / config.toml > disabled by default.

Diagram

plugins.enable(…) in code

PRAISONAI_PLUGINS env var

[plugins] in config.yaml / config.toml

disabled by default

Edge cases

  • PyYAML missing — YAML configs silently return empty (debug log only); install pyyaml to enable.
  • Unknown reserved key — the wrapper resolver’s typo-suggestion validator only checks against {enabled, auto_discover, directories, allow_project_plugins}; per-plugin blocks (dicts) are always accepted.
  • Removed plugin block on reload — the manager stores options with replace=True by default, so a block dropped from a later config no longer delivers stale options.
  • Plugin on_config raises — logged as a warning; delivery to other plugins is not aborted.

Config-write single source of truth

praisonai plugins enable/disable write to the same file the runtime reads — no separate JSON. The write target is resolved in this order:

1. .praisonai/config.toml   → .praisonai/config.yaml     (project-local)
2. praisonai.toml           → praisonai.yaml             (project root)
3. ~/.praisonai/config.toml → ~/.praisonai/config.yaml   (user global)
4. (none found)             → .praisonai/config.yaml     (default)

How the Bridge Works

plugins.enable() auto-calls wire_into_hook_registry() — no manual step. Only lifecycle methods a plugin actually overrides (or declares in PluginInfo.hooks) are bridged, so a plugin with one guardrail never fires on every event. Return a new value and the bridge writes it back onto the payload in place. Errors in a lifecycle method are non-fatal, and plugins.disable([...]) calls unwire_from_hook_registry(name) so the plugin truly stops firing.

Every method in this table may be declared either def or async def — see Sync or Async Hook Methods.

Plugin methodHook eventRequires grant?In-place mutation
before_agent(prompt, ctx)BEFORE_AGENT🔐Return a str → rewrites data.prompt
after_agent(response, ctx)AFTER_AGENT🔐Return a str → rewrites the final response returned to the caller. Return PluginDecision.deny(reason) / .block(reason) / a denying HookResult, or raise GuardrailBlocked(reason) → caller gets "[Response blocked by hook: <reason>]". None → no-op. Non-string (structured / Pydantic) outputs are left unchanged. See Output Guardrails.
before_llm(messages, params)BEFORE_LLM🔐Return (new_messages, ...) → replaces data.messages
after_llm(response, usage)AFTER_LLM🔐Return a str → rewrites data.response
before_tool(name, args)BEFORE_TOOL—Return a dict → replaces data.tool_input
after_tool(name, result)AFTER_TOOL—Return value → rewrites data.tool_output (redact/scrub). Return PluginDecision.deny(reason) / raise GuardrailBlocked → block; the model sees the block reason instead of the tool output. None → passthrough. Applies to sync chat() and async achat().
before_tool_definitions(defs)BEFORE_TOOL_DEFINITIONS—Return a list → replaces data.tool_definitions
before_message(msg)MESSAGE_RECEIVED🔐Payload carries content + identity (platform, sender_id, channel_id, channel_type, message_id, session_id when set). Return {"content": "..."} → rewrites data.content. Return PluginDecision.deny(reason) → drops the inbound message.
after_message(msg)MESSAGE_SENDING🔐Same enriched payload. Return {"content": "..."} → rewrites data.content.
message_sent(msg)MESSAGE_SENT🔐Observational (successful delivery). Payload carries the same enriched fields; return value is ignored.
message_undelivered(msg)MESSAGE_UNDELIVERED🔐Observational (permanent delivery failure). Payload adds error and notice_delivered to the enriched fields; return value is ignored.
on_permission_ask(target, reason)ON_PERMISSION_ASK—True/False/None → allow/deny/allow
on_config(config)ON_CONFIG—Return a dict → rewrites data.config
on_auth(auth_type, credentials)ON_AUTH—Return a dict → rewrites data.credentials (works when starting as None)
session_start(context)SESSION_START—Observational (session source, session_name, session_id)
session_end(context)SESSION_END—Observational (reason, total_turns, total_tokens, session_id)
on_error(error_type, error_message, context)ON_ERROR—Observational (stack_trace, nested context, session_id)
cli_backend_execute(context)CLI_BACKEND_EXECUTE—Observational (backend, command — redacted, content, error, transport, praisonai_llm_http, session_id)

on_config and on_auth write their returned dict back onto the payload even when the target attribute starts as None — so a plugin can inject credentials the first time they’re requested, not only edit an existing dict.

Sync or Async Hook Methods

Any method on Plugin — before_tool, before_message, session_start, gateway_start, on_init, on_shutdown, on_config, and the rest — may be def or async def. Sync hooks behave exactly as before; an async hook’s coroutine is awaited before the next plugin’s hook runs.

Diagram

Plugin Hook Dispatcher

def

async def

📝 Hook Fires

🧠 Method Type?

⚡ Call directly

🔁 Await on
worker thread’s loop

✅ Return value

python
import aiohttp
from praisonaiagents import Agent, plugins
from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook, GuardrailBlocked

class AsyncPolicyPlugin(Plugin):
    @property
    def info(self):
        return PluginInfo(name="async_policy", hooks=[PluginHook.BEFORE_TOOL])

    async def before_tool(self, tool_name, args):
        # Create the client inside the hook (see Warning below)
        timeout = aiohttp.ClientTimeout(total=10)
        async with aiohttp.ClientSession(timeout=timeout) as session:
            async with session.post(
                "https://policy.example.com/check",
                json={"tool": tool_name, "args": args},
            ) as resp:
                allowed = (await resp.json())["allowed"]
        if not allowed:
            raise GuardrailBlocked(f"{tool_name} denied by policy")
        return args

plugins.get_plugin_manager().register(AsyncPolicyPlugin())
plugins.enable()

agent = Agent(name="Policy-Guarded Agent", instructions="Help the user.", tools=["bash"])
agent.start("Delete the temp folder")

GuardrailBlocked and PluginDecision from an async hook block exactly like their sync forms — the model sees the reason and the tool is skipped.

Registration guarantees for an async on_init:

  • The plugin receives no hook dispatch until on_init finishes.
  • A failing on_init rolls the registration back — retrying with a fixed plugin works.
  • on_init may call back into the manager (disable(name), enable(name)) without deadlocking, and a plugin that disables itself during on_init stays disabled.

Reference Plugins in praisonai-plugins

Batteries-included plugins ship in the separate praisonai-plugins package — install once, then enable by name.

PluginTypeEnable
cli_backend_tracercli_backend_execute hookPRAISONAI_CLI_BACKEND_DEBUG=1 praisonai plugins enable cli_backend_tracer
basic_toolsPluginType.TOOLpraisonai plugins enable basic_tools — every subsequent agent gains random_number

cli_backend_tracer overrides Plugin.cli_backend_execute(context) and logs every CLI backend delegation to the standard praisonai logger — with the prompt already redacted, so the log stream is safe to ship to an aggregator.

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

See Hook Events → CLI Backend Events for the full CliBackendExecuteInput payload reference.


Blocking Plugins (Guardrails & Policies)

Guardrail and policy plugins can now stop an action before it happens, not just rewrite it — refuse a dangerous tool call, drop a spam message, or decline an LLM request.

Diagram

Blocking Plugin

allow / rewrite

deny / block

📥 Action

🛡️ Guardrail

✅ Execute

🛑 Skipped

Quick Start

  1. Block a tool call

    Return PluginDecision.block(reason) from before_tool to skip a forbidden tool.

    python
    from praisonaiagents import Agent, plugins
    from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook, PluginDecision
    
    class BlockDeleteFiles(Plugin):
        @property
        def info(self):
            return PluginInfo(
                name="block_delete_files",
                hooks=[PluginHook.BEFORE_TOOL],
            )
    
        def before_tool(self, tool_name, args):
            if tool_name == "delete_file":
                return PluginDecision.block("File deletion is not permitted")
            return args
    
    plugins.get_plugin_manager().register(BlockDeleteFiles())
    plugins.enable()
    
    agent = Agent(name="Assistant", instructions="Help users.", tools=["delete_file", "read_file"])
    agent.start("Delete /etc/hosts")   # tool call skipped; block reason surfaced
  2. Refuse an LLM request

    Return PluginDecision.deny(reason) from before_llm and the agent returns "[LLM request blocked by hook: <reason>]" without calling the model.

    python
    from praisonaiagents import Agent, plugins
    from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook, PluginDecision
    
    class BlockLLM(Plugin):
        @property
        def info(self):
            return PluginInfo(
                name="block_llm",
                hooks=[PluginHook.BEFORE_LLM],
            )
    
        def before_llm(self, messages, params):
            return PluginDecision.deny("LLM request refused by policy")
    
    plugins.get_plugin_manager().register(BlockLLM())
    plugins.enable()
    
    agent = Agent(name="Assistant", instructions="Help users.")
    print(agent.start("Hello"))   # [LLM request blocked by hook: LLM request refused by policy]
  3. Drop an inbound message

    Return PluginDecision.deny(reason) from before_message to drop a message before the agent sees it.

    python
    from praisonaiagents import Agent, plugins
    from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook, PluginDecision
    
    class BlockSpam(Plugin):
        @property
        def info(self):
            return PluginInfo(
                name="block_spam",
                hooks=[PluginHook.MESSAGE_RECEIVED],
            )
    
        def before_message(self, message):
            if "spam" in message.get("content", ""):
                return PluginDecision.deny("Spam blocked")
            return message
    
    plugins.get_plugin_manager().register(BlockSpam())
    plugins.enable()
    
    agent = Agent(name="Assistant", instructions="Help users.")
    agent.start("buy spam now")   # message dropped before the agent runs

Three Ways to Block

The same block, written three ways — pick the one that reads cleanest in your code.

StyleReturn / raiseWhen to prefer
PluginDecisionreturn PluginDecision.deny(reason) / .block(reason)Default. One import, reads like data.
HookResultreturn HookResult.deny(reason) / .block(reason)You already work with HookResult elsewhere.
GuardrailBlockedraise GuardrailBlocked(reason)Deep in a validator that would otherwise raise anyway.
PluginDecision
from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook, PluginDecision

class BlockDelete(Plugin):
    @property
    def info(self):
        return PluginInfo(name="block_delete", hooks=[PluginHook.BEFORE_TOOL])

    def before_tool(self, tool_name, args):
        if tool_name == "delete_file":
            return PluginDecision.block("File deletion not permitted")
        return args
HookResult
from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook
from praisonaiagents.hooks.types import HookResult

class BlockDelete(Plugin):
    @property
    def info(self):
        return PluginInfo(name="block_delete", hooks=[PluginHook.BEFORE_TOOL])

    def before_tool(self, tool_name, args):
        if tool_name == "delete_file":
            return HookResult.deny("Denied via HookResult")
        return args
GuardrailBlocked
from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook, GuardrailBlocked

class BlockDelete(Plugin):
    @property
    def info(self):
        return PluginInfo(name="block_delete", hooks=[PluginHook.BEFORE_TOOL])

    def before_tool(self, tool_name, args):
        if tool_name == "delete_file":
            raise GuardrailBlocked("File deletion not permitted")
        return args

PluginDecision.deny(reason) and PluginDecision.block(reason) both stop the action (is_denied() is True for each); allow(reason=None) is an explicit no-op. GuardrailBlocked(reason: str = "Blocked by guardrail plugin") is caught by the bridge and converted to a block. It may be raised from any before_* method, from after_tool — the tool has already run there, but the output is suppressed before it reaches the model (see Redact / Block Tool Output) — and from after_agent, which withholds the final response (see Output Guardrails).

Where Blocks Fire

The five before_ methods, after_tool and after_agent can block; the remaining after_ / observational methods cannot. after_llm stays non-blocking because it also fires on intermediate tool-calling turns — use after_agent to gate the final response.

Plugin methodCan rewrite?Can block?Runtime effect of block
before_agent✅✅Agent run aborts before the first LLM call
before_llm✅✅Model call skipped; agent returns "[LLM request blocked by hook: <reason>]"
before_tool_definitions✅✅Tool surface dropped for this turn (LLM sees no tools)
before_tool✅✅Tool call skipped; reason surfaced
before_message✅✅Inbound message dropped
after_tool✅✅Tool output rewritten/redacted, or suppressed — the model sees the block reason instead (details)
after_agent✅✅Final response rewritten, or replaced with "[Response blocked by hook: <reason>]" (details)
other after_*✅❌(observational — no block path)

Output Guardrails

after_agent is the final-response gate — return a rewritten string to redact, or deny to withhold the response.

Diagram

None

str

deny / GuardrailBlocked

🤖 Agent response

🪝 after_agent

✅ Returned as-is

✏️ Rewrite adopted

⛔ Response blocked by hook

python
from praisonaiagents import Agent, plugins
from praisonaiagents.plugins import Plugin, PluginInfo, PluginHook, PluginDecision, GuardrailBlocked

class SecretRedactor(Plugin):
    @property
    def info(self):
        return PluginInfo(name="secret_redactor", hooks=[PluginHook.AFTER_AGENT])

    def after_agent(self, response, context):
        return response.replace("sk-abc123xyz", "[REDACTED]")   # rewrite is adopted

class PiiBlocker(Plugin):
    @property
    def info(self):
        return PluginInfo(name="pii_blocker", hooks=[PluginHook.AFTER_AGENT])

    def after_agent(self, response, context):
        if "123-45-6789" in response:
            raise GuardrailBlocked("PII in response")   # or: return PluginDecision.deny("PII in response")
        return response

manager = plugins.get_plugin_manager()
manager.register(SecretRedactor())
manager.register(PiiBlocker())
# after_agent is a conversation-content hook — your own plugins need the grant
plugins.enable(options_by_name={
    "secret_redactor": {"allow_conversation": True},
    "pii_blocker": {"allow_conversation": True},
})

agent = Agent(name="Assistant", instructions="Help users.")
print(agent.start("What is my SSN?"))   # [Response blocked by hook: PII in response]
  • Stacked plugins compose. AFTER_AGENT plugins run sequentially in registration order (same as MESSAGE_SENDING), so each sees the previous rewrite; the first denial wins.
  • Blocked text is not kept. The matching in-memory assistant turn is replaced with the final value, so later turns, auto-memory and self-learning never see the withheld or unredacted text.
  • Non-string outputs pass through. Structured / Pydantic responses are left unchanged.
  • Needs the conversation grant. Non-bundled plugins must set allow_conversation: true or after_agent never fires — see Conversation-Content Capability Gate.

How a Block Flows

Diagram
LLM/ToolHookRunnerGuardrail PluginAgentUserLLM/ToolHookRunnerGuardrail PluginAgentUseralt[allow / rewrite][deny / block]requestfire BEFORE_TOOLbefore_tool(name, args)dict (new args) OR NoneHookResult.allow()executeresultPluginDecision.deny(reason) / raise GuardrailBlockedHookResult.deny(reason) — is_blocked=Trueblocked (reason surfaced)

Which Style Should I Use?

Diagram

Yes

No

Yes

No

🛡️ Need to block?

Inside a validator
that already raises?

raise GuardrailBlocked

Already import
HookResult?

return HookResult.deny

return PluginDecision.deny


Ship a Plugin as a pip Package

Register your plugin class in the praisonai.plugins entry-point group in pyproject.toml:

toml
[project.entry-points."praisonai.plugins"]
my_plugin = "my_package.plugins:MyPlugin"

Agent construction (or plugins.enable()) auto-discovers and bridges it — no user code changes needed.

Once installed, verify the plugin registered with praisonai plugins add <package> --dry-run — see plugins add.


CLI Commands

bash
praisonai plugins list
praisonai plugins enable pii_guardrail
praisonai plugins disable pii_guardrail
praisonai plugins reload
praisonai plugins doctor
praisonai plugins add praisonai-my-plugin
praisonai plugins add praisonai-my-plugin --dry-run

praisonai plugins list shows every plugin with its source (entry_point:<dist>, registered, or single_file). enable/disable persist to the config the runtime reads, reload picks up edits without a restart, and doctor diagnoses issues. See the full reference on the Plugins CLI page.


Configuration Options

Only Plugin Name is required; every other field is optional.

FieldRequiredTypeDescription
Plugin NameYesstringDisplay name in plugin header
DescriptionNostringWhat the plugin does
VersionNostringSemantic version (defaults to 1.0.0)
AuthorNostringPlugin author
HooksNolistHook events this plugin registers
Dependencies (or Deps)NolistPython packages the plugin needs
ChannelsNolistChannels/adapters this plugin contributes
Provides (or Provides Tools, Tools)NolistTool/function names this plugin exposes
Config (or Config Schema)NolistConfig keys this plugin reads
Auto Enable When ConfiguredNolistEnv/credential keys that auto-enable this plugin when set

Best Practices

Keep plugins single-purpose

One file per concern — weather tools, logging, or guardrails, not all three.

Call discover_and_load_plugins() once

Load before creating the agent so tools and hooks register globally.

Reference tools by name

Pass tools=["get_weather"] — the string must match the @tool function name.

Use plugins.enable for built-ins

Enable logging and metrics without writing plugin files.

Call plugins.enable() to activate lifecycle methods

Tools and guardrails work without plugins.enable(). But lifecycle-method plugins (subclasses of Plugin that override before_llm, after_llm, and so on) only fire after they are wired into the runtime hook registry. Call plugins.enable() explicitly, or set PRAISONAI_PLUGINS=true / [plugins] enabled = true and Agent construction wires them automatically.

Reach for PluginDecision before HookResult

PluginDecision.deny(reason) reads as one line and only needs the plugin import. Use HookResult.deny(reason) only when you already import it for another reason, and raise GuardrailBlocked(reason) only when you’re inside a validator that already raises on failure. Prefer block over deny when the action is categorically forbidden; use deny when it’s contextually refused (this input, this user, this time).

Use `async def` for I/O-bound hooks

Prefer async def when a hook calls an HTTP service, an async database, a queue, or a telemetry sink. Sync def is fine for pure-Python work (redaction, regex, local counters). Mixing both in one plugin is supported. Treat the 60-second per-hook deadline as a safety net — set your own client timeout.


Thread Safety

The process-wide plugin manager returned by get_plugin_manager() is safe to share across concurrently running agents.

All lifecycle methods — register, unregister, enable, disable, execute_hook — synchronise on a single RLock, so concurrent registration and hook firing cannot collide. See Thread-Safe Agent State → PluginManager (PR #5204) for the mechanism.


Related