> ## Documentation Index
> Fetch the complete documentation index at: https://praison.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    Plugin[🔧 Plugin file] --> Load[discover_and_load_plugins]
    Load --> Agent[🤖 Agent gains tools + hooks]

    classDef input fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef process fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef output fill:#10B981,stroke:#7C90A0,color:#fff
    class Plugin input
    class Load process
    class Agent output
```

## Quick Start

<Steps>
  <Step title="Simple Usage">
    Create `~/.praisonai/plugins/my_tools.py`:

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    """
    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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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?")
    ```
  </Step>

  <Step title="With Configuration">
    Point the environment (or config file) at plugins and Agent construction wires them for you — no explicit `plugins.enable()` call needed:

    ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    export PRAISONAI_PLUGINS=true
    ```

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents import Agent, plugins

    plugins.enable(["logging", "metrics"])

    agent = Agent(name="Assistant", instructions="Help users")
    agent.start("Hello!")
    ```
  </Step>
</Steps>

<Note>
  Place plugins in `~/.praisonai/plugins/` (user-wide) or `./.praisonai/plugins/` (project-specific).
</Note>

<Note>
  `discover_and_load_plugins()` loads and registers plugins. To read plugin metadata **without loading** (e.g. to build a config-validation gate), use `discover_plugins()` and the capability manifest fields — see [Static Capability Manifest](#static-capability-manifest).
</Note>

<Warning>
  `plugins.enable()` auto-discovers filesystem and entry-point plugins only when `PRAISONAI_ALLOW_PLUGIN_DISCOVERY=true` (or `1`/`yes`). Without this env var, `plugins.enable()` still bridges plugins you register manually via `PluginManager.register(...)`, but no directory or entry-point scan runs.
</Warning>

***

## Selective Enable and Late Registration

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

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Q{Is plugins.enable&#40;names&#41; active?}
    Q -->|no| E[✅ Late registration → enabled]
    Q -->|yes| A{Plugin name in allow-list?}
    A -->|yes| E
    A -->|no| D[🛑 Late registration → registered but disabled]

    classDef decision fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef ok fill:#10B981,stroke:#7C90A0,color:#fff
    classDef block fill:#8B0000,stroke:#7C90A0,color:#fff

    class Q,A decision
    class E ok
    class D block
```

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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant User
    participant Facade as plugins.enable([...])
    participant Manager as PluginManager (singleton)
    participant Plugin

    User->>Facade: plugins.enable(["only_this"])
    Facade->>Manager: set_registration_allow_list({"only_this"})
    User->>Manager: register(late_plugin)
    Manager->>Manager: _default_enabled_for_registration("late") → False
    Manager-->>User: registered, is_enabled("late") == False
    User->>Manager: register(only_this)
    Manager->>Manager: allow-listed → True
    Manager-->>User: registered, is_enabled("only_this") == True
```

### Widen or Reset the Allow-List

| You want to… | Do this |
| - | - |
| Turn on more plugins later | `plugins.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 once | `plugins.enable()` — no argument |
| Keep a standalone manager unrestricted | Construct via `PluginManager()` directly — the facade allow-list does not apply |

<Warning>
  Re-calling `plugins.enable([...])` **resets** the allow-list to exactly the new list — it does not merge with the previous one. Pass every plugin you still want in the same call.
</Warning>

<Note>
  A manager created directly via `PluginManager()` is not bound by the facade's allow-list — every registration on it starts enabled. Only the singleton used by `plugins.enable(...)` carries the restriction.
</Note>

<Note>
  `plugins.enable()` with no argument (unrestricted) enables everything currently registered and every future registration — the late-registration rule only applies to a selective `enable([list])`.
</Note>

***

## 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](#auto-enable-from-env-or-config)).

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant User
    participant Enable as plugins.enable()
    participant Bridge as wire_into_hook_registry
    participant Registry as Default HookRegistry
    participant Agent
    participant Plugin

    User->>Enable: plugins.enable()
    Enable->>Bridge: auto-called
    Bridge->>Registry: register before_agent, before_llm, ...
    User->>Agent: agent.start("...")
    Agent->>Registry: fire BEFORE_AGENT
    Registry->>Plugin: run before_agent(prompt, ctx)
    Plugin-->>Registry: modified prompt (in-place)
    Registry-->>Agent: HookResult.allow()
    Agent-->>User: response
```

The hooks fire in this order around each agent run:

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    INPUT["📥 User Input"] --> BA["🪝 before_agent"]
    BA --> BTD["🪝 before_tool_definitions"]
    BTD --> LLM["🧠 LLM"]
    LLM --> BT["🪝 before_tool"]
    BT --> TOOL["🔧 Tool"]
    TOOL --> AT["🪝 after_tool"]
    AT --> AA["🪝 after_agent"]
    AA --> OUTPUT["📤 Response"]

    classDef input fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef hook fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef process fill:#189AB4,stroke:#7C90A0,color:#fff

    class INPUT,OUTPUT input
    class BA,BTD,BT,AT,AA hook
    class TOOL,LLM process
```

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()`:

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Mgr["🔌 PluginManager"] --> Hooks["🪝 Hook Registry<br/>(HOOK plugins)"]
    Mgr --> GC["🛡️ GuardrailChain<br/>(GUARDRAIL plugins)"]
    Mgr --> PE["📜 PolicyEngine<br/>(POLICY plugins)"]
    Mgr --> SM["🧩 SkillManager<br/>(SKILL plugins)"]

    classDef mgr fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef hook fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef sub fill:#189AB4,stroke:#7C90A0,color:#fff

    class Mgr mgr
    class Hooks hook
    class GC,PE,SM sub
```

| Plugin type | What it adds |
| - | - |
| **Tool** (`PluginType.TOOL`) | Functions registered via `@tool` in a single-file plugin, **or** returned from `Plugin.get_tools()` (see [Tool Plugins](#tool-plugins-subclass-plugin-with-get-tools)) |
| **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](#typed-plugins-guardrail--policy--skill). |
| **Policy** (`PluginType.POLICY`) | `Policy` rules returned from `Plugin.get_policies()` and added to the Agent's `PolicyEngine`. See [Typed Plugins](#typed-plugins-guardrail--policy--skill). |
| **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](#typed-plugins-guardrail--policy--skill). |

***

## Choosing How to Load Plugins

Pick the loading method that fits your setup.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Q[How is the plugin delivered?] --> A[Drop a file in a plugins dir]
    Q --> B[Enable a built-in by name]
    Q --> C[Register a hook at runtime]
    Q --> D[Ship a class-based Plugin that returns callables]
    A --> R1[discover_and_load_plugins]
    B --> R2[plugins.enable list]
    C --> R3[add_hook decorator]
    D --> R4[register a Plugin with get_tools]

    classDef decision fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef option fill:#10B981,stroke:#7C90A0,color:#fff
    class Q,A,B,C,D decision
    class R1,R2,R3,R4 option
```

<Note>
  Enabling built-ins by name with `plugins.enable([...])` starts a selective allow-list — see [Selective Enable and Late Registration](#selective-enable-and-late-registration) for what happens to plugins you register afterwards.
</Note>

## Plugin Locations

| Location | Scope |
| - | - |
| `~/.praisonai/plugins/` | User-wide |
| `./.praisonai/plugins/` | Project-specific |

***

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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Q{Where does the plugin live?}
    Q -->|./.praisonai/plugins/*.py| Gate{Gate open?}
    Q -->|~/.praisonai/plugins/*.py| Trust[✅ Loaded]
    Q -->|pip package / entry point| Trust
    Gate -->|PRAISONAI_ALLOW_PROJECT_PLUGINS=true| Trust
    Gate -->|plugins.allow_project_plugins: true| Trust
    Gate -->|default| Deny[🛑 Refused with warning]

    classDef decision fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef trust fill:#10B981,stroke:#7C90A0,color:#fff
    classDef deny fill:#8B0000,stroke:#7C90A0,color:#fff

    class Q,Gate decision
    class Trust trust
    class Deny deny
```

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

```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
export PRAISONAI_ALLOW_PROJECT_PLUGINS=true
```

Or in `.praisonai/config.yaml`:

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
plugins:
  allow_project_plugins: true
```

| Behaviour | Rule |
| - | - |
| **Default** | Gate closed — project-local `.py` plugins are refused with a warning |
| **User-global** | `~/.praisonai/plugins/` plugins remain trusted (you placed them there) |
| **pip / entry point** | Installed entry-point plugins remain trusted |
| **Env precedence** | `PRAISONAI_ALLOW_PROJECT_PLUGINS` wins over config |
| **Explicit off** | `PRAISONAI_ALLOW_PROJECT_PLUGINS=false` overrides a `true` in config |

<Warning>
  The gate judges a plugin by **where its file is located**, not where a symlink points. A repository-controlled symlink at `.praisonai/plugins/evil.py -> /tmp/evil.py` still counts as project-local and stays gated — it cannot slip past by resolving elsewhere.
</Warning>

<Note>
  This gate decides **whether a plugin loads at all**. A separate boundary decides whether a loaded plugin may read or rewrite conversation content — see [Security: Conversation-Content Capability Gate](#security-conversation-content-capability-gate).
</Note>

***

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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    Hook[📩 Hook fires] --> Kind{🔎 Conversation<br/>hook?}
    Kind -->|No — tool/channel/utility| Fire[✅ Dispatch]
    Kind -->|Yes| Origin{🏛️ First-party?}
    Origin -->|Yes — bundled| Fire
    Origin -->|No| Grant{🔐 allow_conversation<br/>= true?}
    Grant -->|Yes| Fire
    Grant -->|No| Skip[⏭️ Silently skipped]

    classDef event fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef check fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef allow fill:#10B981,stroke:#7C90A0,color:#fff
    classDef deny fill:#8B0000,stroke:#7C90A0,color:#fff

    class Hook event
    class Kind,Origin,Grant check
    class Fire allow
    class Skip deny
```

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 method | Hook | Direction |
| - | - | - |
| `before_agent` | `BEFORE_AGENT` | Inbound prompt |
| `before_llm` | `BEFORE_LLM` | Inbound messages |
| `after_llm` | `AFTER_LLM` | Model response |
| `after_agent` | `AFTER_AGENT` | Agent response |
| `before_message` | `MESSAGE_RECEIVED` | Inbound message |
| `after_message` | `MESSAGE_SENDING` | Outbound reply |
| `message_sent` | `MESSAGE_SENT` | Outbound reply |
| `message_undelivered` | `MESSAGE_UNDELIVERED` | Outbound reply |

<Warning>
  The outbound `message_sending` / `message_sent` / `message_undelivered` events are gated **on purpose**. `message_sending` can rewrite the assistant reply, and `message_sent` / `message_undelivered` expose it — so "conversation content" means both directions, not just inbound prompts.
</Warning>

### Grant a third-party plugin access

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

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.plugins.manager import PluginManager

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

<Note>
  `allow_conversation` is **framework-reserved** — the manager interprets it and does **not** pass it through to the plugin's `on_config`. Every other key in the block still reaches `on_config` as before.
</Note>

### 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](#security-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.

<Warning>
  A malicious pip package that sets `__module__ = "praisonaiagents.evil"` is **rejected** — its file lives in the attacker's `site-packages`, not inside the framework's package directory, so the second check fails. Claiming first-party trust is not spoofable by a module string alone.
</Warning>

### 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 origin | `before_tool`, tools, channels | `before_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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant User
    participant Agent
    participant Manager as PluginManager
    participant Bundled as First-party plugin
    participant Third as Third-party plugin<br/>(ungranted)
    participant Granted as Third-party plugin<br/>(allow_conversation=true)

    User->>Agent: prompt
    Agent->>Manager: before_llm(messages)
    Manager->>Bundled: before_llm(...) ✅
    Manager-->>Third: skipped (default-deny)
    Manager->>Granted: before_llm(...) ✅
    Manager-->>Agent: (rewritten) messages
```

<Warning>
  **Upgrading?** Any third-party plugin whose value depended on `before_llm` / `after_llm` / `message_*` — guardrails, PII redactors, translators, message-lifecycle telemetry — silently stops rewriting until you grant it. Enumerate the affected plugins:

  ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  praisonai plugins list --json | jq '.[] | select(.requests_conversation == true and .conversation_granted == false)'
  ```
</Warning>

***

## 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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:

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

<Note>
  `enabled` reflects **both** the live manager state and the persisted config allow-list, so it renders correctly even before any agent starts.
</Note>

Two fields surface the [conversation-content capability gate](#security-conversation-content-capability-gate):

| Field | Type | Meaning |
| - | - | - |
| `requests_conversation` | `bool` or `null` | `true` 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_granted` | `bool` | `true` 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](/docs/cli/plugins#reload-plugins).

***

## Hook Plugin Example

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

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
"""
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
"""
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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:

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant Plugin as GUARDRAIL Plugin
    participant Mgr as PluginManager
    participant Init as Agent.__init__
    participant Chain as GuardrailChain
    participant Tool

    Plugin->>Mgr: as_guardrail() → PIIGuardrail()
    Init->>Mgr: get_all_guardrails()
    Mgr-->>Init: [PIIGuardrail()]
    Init->>Chain: fold into GuardrailChain
    Note over Init: chain appended to<br/>_tool_call_guardrails +<br/>_tool_result_guardrails
    Tool-->>Chain: raw tool result "a SECRET value"
    Chain-->>Tool: "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:

| Collector | Calls | Returns |
| - | - | - |
| `PluginManager.get_all_guardrails()` | `as_guardrail()` on every enabled `PluginType.GUARDRAIL` plugin | Non-`None` guardrail objects |
| `PluginManager.get_all_skills()` | `get_skills()` on every enabled `PluginType.SKILL` plugin | Filesystem path strings |
| `PluginManager.get_all_policies()` | `get_policies()` on every enabled `PluginType.POLICY` plugin | `Policy` objects |

### Quick Start

<Steps>
  <Step title="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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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.
  </Step>

  <Step title="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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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")
    ```
  </Step>

  <Step title="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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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")
    ```
  </Step>
</Steps>

### Composition and precedence rules

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

| Situation | Behaviour |
| - | - |
| Agent has no user guardrail; plugin guardrail exists | Plugin chain becomes the Agent's guardrail directly (`self.guardrail = chain`; `self._guardrail_fn = chain`). |
| Agent has a user guardrail callable; plugin guardrail exists | Both run in composition: `GuardrailChain([_CallableGuardrailAdapter(user_fn), plugin_chain])`. Neither is dropped. |
| Plugin guardrail's `validate_tool_call` / `validate_tool_result` | Appended 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 `X` | Plugin's `X` is **skipped with a warning** — the user always wins. |
| Plugin has two policies both named `X` | The **first** wins; the second is skipped with a warning. |
| Plugin skill path is not a string | Skipped with a warning (`SkillManager` loads by path). |
| Plugin skill path already in `_skills` | Silently 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 **disabled** | Not collected — enabled-only iteration under the manager lock. |
| A plugin is registered but not declared with the matching `PluginType` | Not 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.

<Note>
  Each typed collector returns `[]` when no plugin declares the matching type, and each merge step early-outs — zero cost when unused. See [Guardrails from Plugins](/docs/features/guardrails#guardrails-from-plugins), [Policies from Plugins](/docs/features/policy-engine#policies-from-plugins), and [Skills from Plugins](/docs/features/skills#skills-from-plugins) for how each subsystem receives the contribution.
</Note>

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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant User
    participant Init as Agent.__init__
    participant Merge as _merge_plugin_tools
    participant Mgr as PluginManager
    participant Plugin as Registered Tool Plugin
    participant Agent

    User->>Init: Agent(instructions=...)
    Init->>Merge: after tools=[...] resolved
    Merge->>Mgr: get_all_tools()
    Mgr->>Plugin: plugin.get_tools()
    Plugin-->>Mgr: [random_number]
    Mgr-->>Merge: [random_number]
    Merge-->>Init: append to self.tools (skip collisions)
    Init-->>User: 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

<Steps>
  <Step title="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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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()
    ```
  </Step>

  <Step title="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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    pip install praisonai-plugins
    praisonai plugins enable basic_tools
    ```

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents import Agent

    agent = Agent(instructions="Give me a random number using your tools.")
    agent.start()   # random_number is callable — no tools=[...] needed
    ```
  </Step>
</Steps>

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

| Rule | Behaviour |
| - | - |
| **Agent tools win on name collision** | If `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 skipped** | A 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 plugins** | Tool 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 unused** | If the plugin system is not enabled, the merge short-circuits — no scan, no cost. |
| **Never breaks agent init** | Any failure in plugin discovery / merge is caught; the agent is constructed regardless. |
| **Thread-safe iteration** | `get_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. |

<Note>
  **Zero-overhead when no plugins are registered.** Both merge steps check `has_registered_plugins()` first — an allocation-free gate that does not even construct the plugin-manager singleton — and early-return when nothing is registered. If you never call `get_plugin_manager().register(...)`, Agent init pays no plugin-system cost.
</Note>

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

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Q[How is the tool shipped?] --> A[Standalone .py file in ~/.praisonai/plugins/]
    Q --> B[Class-based plugin — bundled with hooks, config, session lifecycle]
    Q --> C[One-off callable at runtime]

    A --> R1["@tool decorator in a single-file plugin"]
    B --> R2["Subclass Plugin, return callables from get_tools()"]
    C --> R3["FunctionPlugin(hooks={...})"]

    classDef decision fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef option fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef result fill:#10B981,stroke:#7C90A0,color:#fff
    class Q decision
    class A,B,C option
    class R1,R2,R3 result
```

<Note>
  The bundled `basic_tools` plugin in the separate `praisonai-plugins` package is the canonical `PluginType.TOOL` example — its `get_tools()` returns `[random_number]`, and that tool now reaches every agent end-to-end.
</Note>

***

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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    Header[📄 Plugin header] --> Parser[🔍 parse_plugin_header]
    Parser --> Meta[📋 PluginMetadata]
    Meta --> Disc[🗺️ Discovery / list]
    Meta --> Val[✅ Config validation]
    Meta --> Gate[🚦 Capability gating]

    classDef input fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef parse fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef meta fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef out fill:#10B981,stroke:#7C90A0,color:#fff

    class Header input
    class Parser parse
    class Meta meta
    class Disc,Val,Gate out
```

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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
"""
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) | Type | Default | Purpose |
| - | - | - | - | - |
| `Channels` | — | list | `[]` | Channels/adapters this plugin contributes (e.g. `telegram, slack`) |
| `Provides` | `Provides Tools`, `Tools` | list | `[]` | Tool/function names this plugin exposes (e.g. `get_weather, send_email`) |
| `Config` | `Config Schema` | list | `[]` | Config keys this plugin reads (e.g. `api_key, timeout`) |
| `Auto Enable When Configured` | `auto_enable_when_configured` | list | `[]` | 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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", []),
    )
```

<Note>
  Omitted fields are absent from the raw `discover_plugins()` dict, so read them with `.get(key, [])`. `PluginMetadata.to_dict()` normalizes them to empty lists.
</Note>

### 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?

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Q[Does the plugin expose tools or channels?] --> A[Single-file plugin with tools/channels]
    Q --> B[Hook-only plugin]
    A --> R1[✅ Declare a capability manifest]
    B --> R2[Optional — add fields only if useful]

    classDef decision fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef option fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef result fill:#10B981,stroke:#7C90A0,color:#fff
    class Q decision
    class A,B option
    class R1,R2 result
```

***

## 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
"""
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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
```

<Warning>
  `PIIRedactor` uses `after_llm`, a conversation-content hook. If you install it from pip (third-party), it won't fire until you grant `plugins.pii_redactor.allow_conversation: true` — see [Conversation-Content Capability Gate](#security-conversation-content-capability-gate).
</Warning>

***

## 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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(...)`).

See [Hook Events → Plugin Lifecycle Events](/docs/features/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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Q[What do you want to react to?] --> A[Every session boundary]
    Q --> B[Errors during a run]
    Q --> C[Runtime config injection]
    Q --> D[Credential resolution]
    A --> R1[session_start / session_end]
    B --> R2[on_error]
    C --> R3[on_config]
    D --> R4[on_auth]

    classDef decision fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef option fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef result fill:#10B981,stroke:#7C90A0,color:#fff
    class Q decision
    class A,B,C,D option
    class R1,R2,R3,R4 result
```

### Observe sessions

`session_start` and `session_end` observe when a session opens and closes.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent, plugins

plugins.enable()

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

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

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
"""
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
"""
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent, plugins

plugins.enable()

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

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

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
"""
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
"""
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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Q[What do you want to observe?] --> A[Bot / gateway starts or stops]
    Q --> B[Scheduled job is added, removed, or fires]
    Q --> C[Conversation memory is compacted]
    Q --> D[A spawned subagent finishes]
    Q --> E[Runtime falls back to a secondary model]
    Q --> F[Kanban board state changes]
    A --> R1[gateway_start / gateway_stop]
    B --> R2[schedule_add / schedule_remove / schedule_trigger]
    C --> R3[before_compaction / after_compaction]
    D --> R4[subagent_stop]
    E --> R5[model_fallback]
    F --> R6[kanban_task_created / kanban_task_moved / kanban_task_done]

    classDef decision fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef option fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef result fill:#10B981,stroke:#7C90A0,color:#fff
    class Q decision
    class A,B,C,D,E,F option
    class R1,R2,R3,R4,R5,R6 result
```

<Warning>
  All nine methods (`gateway_start`, `gateway_stop`, `schedule_add`, `schedule_remove`, `schedule_trigger`, `before_compaction`, `after_compaction`, `subagent_stop`, `model_fallback`) are **observe-only**. Returning a value has no effect on the runtime. For rewrite/deny semantics on any of these events, register a low-level hook via `HookRegistry.register_function` — see [Hooks](/docs/features/hooks).
</Warning>

### Observe gateway lifecycle

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

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
"""
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
"""
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
"""
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()
```

<Note>
  This is the **observability** path. For a memory backend that reacts to compaction with an actual write (summary, table switch), subclass `Memory` — see [Memory Lifecycle Hooks](/docs/features/memory-lifecycle-hooks). The `Memory` callbacks mutate; the plugin method observes.
</Note>

### Observe subagent completion

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

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
"""
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
"""
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
"""
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()
```

<Note>
  Kanban events have **no base no-op method** on `Plugin`. A plugin doesn't need the base class to define them — the bridge treats "no base counterpart" as a genuine override, so a declared `KANBAN_TASK_*` hook with a matching `kanban_task_*` method is wired.
</Note>

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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Dec{Declared in info.hooks?} -->|No| N1[Registers nothing]
    Dec -->|Yes| Ovr{Method overridden - or no base?}
    Ovr -->|No| N2[Registers nothing]
    Ovr -->|Yes| B[✅ Bridged as observe-only hook]

    classDef decision fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef nothing fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef result fill:#10B981,stroke:#7C90A0,color:#fff
    class Dec,Ovr decision
    class N1,N2 nothing
    class B result
```

<Note>
  The bundled `GatewayForensicsPlugin` in `praisonai-plugins` fires as designed once this bridge is present. Operators upgrading past this fix will see their forensics plugin start emitting gateway/schedule/kanban signals that were previously silently inert.
</Note>

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

<Warning>
  All four `message_*` methods are conversation-content hooks. A third-party plugin overriding them needs `allow_conversation: true` before they fire — see [Conversation-Content Capability Gate](#security-conversation-content-capability-gate). Bundled and first-party plugins are granted automatically.
</Warning>

### Choosing a message hook

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Q[What do you want to react to?] --> A[Inbound message before agent sees it]
    Q --> B[Outbound reply before delivery]
    Q --> C[Successful delivery]
    Q --> D[Permanent delivery failure]
    A --> R1[before_message → MESSAGE_RECEIVED - can block/rewrite]
    B --> R2[after_message → MESSAGE_SENDING - can rewrite]
    C --> R3[message_sent → MESSAGE_SENT - observe-only]
    D --> R4[message_undelivered → MESSAGE_UNDELIVERED - observe-only, +error/notice_delivered]

    classDef decision fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef option fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef result fill:#10B981,stroke:#7C90A0,color:#fff
    class Q decision
    class A,B,C,D option
    class R1,R2,R3,R4 result
```

### Enriched payload keys

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

| Key | Type | Present on | Description |
| - | - | - | - |
| `content` | `str` | All four events | Always emitted (empty string when the input has none). |
| `platform` | `str` | All four events (when set) | Adapter/channel identifier (e.g. `"telegram"`, `"slack"`, `"cli"`). |
| `sender_id` | `str` | Inbound (when set) | Stable identifier for the user/agent that sent the message. |
| `channel_id` | `str` | Inbound + outbound (when set) | Stable identifier for the channel/thread/DM. |
| `channel_type` | `str` | Inbound (when set) | e.g. `"dm"`, `"group"`, `"channel"`, `"broadcast"`. |
| `message_id` | `str` | Inbound + `message_sent` (when set) | Provider-native message id for correlation. |
| `session_id` | `str` | All four events (when set) | PraisonAI session id — pair with `session_start` / `session_end`. |
| `error` | `str` | **`message_undelivered` only** | Failure classification / message from the delivery pipeline. |
| `notice_delivered` | `bool` | **`message_undelivered` only** | `True` when a user-visible "your message wasn't delivered" notice was sent. |

<Note>
  Absent fields are **skipped** — the bridge does not fabricate empty strings for keys the concrete input doesn't carry. Read fields with `.get(key)`, not subscripting, so a plugin written against inbound input degrades cleanly on outbound events.
</Note>

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

<CodeGroup>
  ```python ~/.praisonai/plugins/per_user_rate_limit.py theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  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()
  ```

  ```python ~/.praisonai/plugins/delivery_counter.py theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  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()
  ```

  ```python ~/.praisonai/plugins/dead_letter.py theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  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()
  ```
</CodeGroup>

<Warning>
  * Absent fields are **skipped**, not emitted as `""` — always `.get(key)` and handle `None`.
  * `message_sent` and `message_undelivered` are **observe-only**; return values are ignored (no rewrite, no block).
  * The new hooks only fire when a plugin **overrides them** (`_overrides` guard) — plain plugins still incur zero cost on the two new events.
</Warning>

<Note>
  `message_undelivered` is the counterpart to the plain `MESSAGE_UNDELIVERED` hook already covered in [Undelivered Messages](/docs/features/undelivered-messages) and [Gateway → Undelivered Notice](/docs/features/gateway-undelivered-notice) — this hook exposes the same signal at the plugin surface, so `Plugin` subclass authors don't need to reach for the raw `HookRegistry`.
</Note>

***

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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Q[How much do you need?] --> A[One or two hook callbacks]
    Q --> B[State, config, multiple hooks, session lifecycle]
    A --> R1[FunctionPlugin - wrap plain functions]
    B --> R2[Subclass Plugin - full base class]

    classDef decision fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef option fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef result fill:#10B981,stroke:#7C90A0,color:#fff
    class Q decision
    class A,B option
    class R1,R2 result
```

### Quick Start

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

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
FunctionPlugin(
    name: str,
    hooks: Optional[Dict[PluginHook, Callable]] = None,
    version: str = "1.0.0",
    description: str = "",
)
```

| Parameter | Type | Default | Description |
| - | - | - | - |
| `name` | `str` | — | Plugin name shown in the registry. |
| `hooks` | `Dict[PluginHook, Callable]` | `None` | Maps each `PluginHook` enum value to the callable that handles it. |
| `version` | `str` | `"1.0.0"` | Semantic version. |
| `description` | `str` | `""` | 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` key | Callable signature | Effect |
| - | - | - |
| `PluginHook.BEFORE_TOOL` | `fn(tool_name: str, args: dict) -> dict` | Rewrites 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_AGENT` | `fn(prompt: str, context: dict) -> str` | Rewrites prompt |
| `PluginHook.AFTER_AGENT` | `fn(response: str, context: dict) -> str` | Rewrites response |
| `PluginHook.MESSAGE_RECEIVED` | `fn(message: dict) -> dict \| PluginDecision \| None` | Rewrites `content`, or `deny` drops the inbound message |
| `PluginHook.MESSAGE_SENDING` | `fn(message: dict) -> dict` | Rewrites outbound `content` |
| `PluginHook.MESSAGE_SENT` | `fn(message: dict) -> None` | Observe-only |
| `PluginHook.MESSAGE_UNDELIVERED` | `fn(message: dict) -> None` | Observe-only (payload adds `error`, `notice_delivered`) |

### Message-lifecycle examples

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

<CodeGroup>
  ```python rate-limit a channel theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  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},
  ))
  ```

  ```python log every successful delivery theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  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},
  ))
  ```

  ```python capture permanent delivery failures theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  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},
  ))
  ```
</CodeGroup>

<Warning>
  * The `hooks` dict key must be a `PluginHook` enum value, not the string `"before_tool"` — a string key silently no-ops.
  * `FunctionPlugin` receives the **same enriched payload** as `Plugin` subclasses, so absent identity fields are **skipped**, not empty strings. Always use `.get(key)` and handle `None`.
  * `MESSAGE_SENT` / `MESSAGE_UNDELIVERED` callbacks are observe-only — return values are ignored (no rewrite, no block).
</Warning>

<Note>
  `FunctionPlugin`'s message-lifecycle dispatch (`MESSAGE_RECEIVED`, `MESSAGE_SENDING`, `MESSAGE_SENT`, `MESSAGE_UNDELIVERED`) is a recent addition ([commit `857ebbe`](https://github.com/MervinPraison/PraisonAI/commit/857ebbe344cc295a5f1c52d0167c039fae4c3332)) — earlier versions silently no-op'd these four keys, so users had to subclass `Plugin`.
</Note>

<Note>
  For the full payload contract (identity keys, per-event availability, `error` / `notice_delivered` special-cases), see [Message-Lifecycle Plugins](#message-lifecycle-plugins) — `FunctionPlugin` and `Plugin` subclasses share the exact same `_message_payload` bridge.
</Note>

***

## 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
export PRAISONAI_PLUGINS=true
```

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
[plugins]
enabled = true          # or false, or ["logging", "metrics"]
```

<Tip>
  A list like `enabled = ["logging", "metrics"]` flows through `plugins.enable(...)`, so it triggers the same late-registration allow-list rule — plugins registered afterwards stay disabled unless they are in the list. See [Selective Enable and Late Registration](#selective-enable-and-late-registration).
</Tip>

<Tip>
  Want each plugin to receive its own options from one file? See [Configure Plugins from `.praisonai/config.yaml`](#configure-plugins-from-praisonai-config-yaml) for per-plugin option blocks.
</Tip>

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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Q[How do you turn plugins on?] --> A[Set PRAISONAI_PLUGINS=true]
    Q --> B[Add plugins enabled = true in config.toml]
    Q --> C[Call plugins.enable in code]
    A --> R1[Agent init auto-enables]
    B --> R1
    C --> R2[Enabled immediately, before Agent init]

    classDef decision fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef option fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef result fill:#10B981,stroke:#7C90A0,color:#fff
    class Q decision
    class A,B,C option
    class R1,R2 result
```

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

<Note>
  `maybe_enable_from_config()` is idempotent and runs at most once per process, so instantiating multiple agents is safe — plugins are wired a single time.
</Note>

<Warning>
  Auto-enable does **not** imply discovery. Filesystem and entry-point scanning still require `PRAISONAI_ALLOW_PLUGIN_DISCOVERY=true` (or `1`/`yes`). Without it, only plugins registered manually via `PluginManager.register(...)` are bridged.
</Warning>

<Tip>
  Verify auto-enable at runtime — constructing an Agent triggers the wiring:

  ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  from praisonaiagents import Agent, plugins

  Agent(name="probe", instructions="noop")   # triggers maybe_enable_from_config
  print(plugins.is_enabled(), plugins.list_plugins())
  ```
</Tip>

***

## 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
praisonai run --pure "Summarise this file"
```

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

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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`.

See [Pure Mode](/docs/features/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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
plugins:
  enabled: true
  pii_guardrail:
    redact: [email, phone]
  memory_watchdog:
    interval: 30
```

`app.py`:

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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
```

<Note>
  Agent construction auto-loads the config, enables plugins, and hands `pii_guardrail` its `{redact: [email, phone]}` dict via `on_config`. Nothing else to wire up.
</Note>

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    Config[".praisonai/config.yaml"] --> Loader["maybe_enable_from_config"]
    Loader --> Enable["enable(names, options_by_name)"]
    Enable --> Apply["apply_plugin_options"]
    Apply --> OnConfig["plugin.on_config(options)"]

    classDef config fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef loader fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef manager fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef plugin fill:#10B981,stroke:#7C90A0,color:#fff

    class Config config
    class Loader,Enable loader
    class Apply manager
    class OnConfig plugin
```

### 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)
```

<Info>
  The first file found wins — TOML and YAML have equal standing; whichever appears first in the search stops the walk.
</Info>

<Warning>
  PyYAML is an *optional* dependency. Without it, `.yaml` configs return empty (debug log only) — install with `pip install pyyaml` to enable the YAML surface.
</Warning>

### The four shapes of `enabled`

| `enabled` value | Meaning | Example |
| - | - | - |
| `true` | Enable every discovered plugin — **preserved** across the loader/CLI round-trip | `enabled: true` |
| `false` | Disable every plugin — **wins over per-plugin blocks** | `enabled: false` |
| `[names]` (non-empty list) | Enable exactly these plugins | `enabled: [pii_guardrail, metrics]` |
| `"name"` (bare string, TOML) | Enable exactly this one plugin | `enabled = "pii_guardrail"` |
| *omitted* + per-plugin blocks | Enable each plugin block whose own `enabled` is not `false` | (see below) |

<Info>
  The JSON config schema advertises `boolean` and `array` for `enabled`; the loader also accepts a bare string at runtime for TOML users who prefer `enabled = "pii_guardrail"`.
</Info>

<Note>
  A bare `enabled: true` is now **preserved** — enabling a plugin while it is set is a no-op (leaving "all enabled" intact). You **cannot** run `praisonai plugins disable X` while `enabled: true`: the CLI refuses with a clear error, since collapsing "all" to an allow-list would silently disable every other plugin. Set `plugins.enabled` to an explicit list of names first, then disable individual plugins.
</Note>

### 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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
```

<Note>
  `allow_project_plugins: true` opens the [project-plugin trust gate](#security-project-plugin-trust-gate) so single-file plugins under `./.praisonai/plugins/` load. The `PRAISONAI_ALLOW_PROJECT_PLUGINS` env var takes precedence over this config key.
</Note>

<Note>
  An explicit top-level `enabled: false` wins over any per-plugin block. Omit `enabled` at the top level and per-plugin blocks decide.
</Note>

### 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
"""
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()
```

<Note>
  `on_config` fires once per enable — the reserved `enabled` flag (if present in the plugin block) is preserved so plugins can read it too.
</Note>

### 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import plugins

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

<Note>
  `.praisonai/config.yaml` calls this for you — `maybe_enable_from_config()` runs `enable(get_enabled_plugins(), options_by_name=get_plugin_options())` under the hood. Call `get_plugin_options()` yourself to read the per-plugin maps the loader parsed.
</Note>

### Choose a plugin config surface

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Q[How do you configure plugins?] --> A[One plugin, no options]
    Q --> B[Multiple plugins with options, checked into repo]
    Q --> C[Ephemeral per-shell override]
    A --> R1["plugins.enable('name')"]
    B --> R2[".praisonai/config.yaml"]
    C --> R3["PRAISONAI_PLUGINS env var"]

    classDef decision fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef option fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef result fill:#10B981,stroke:#7C90A0,color:#fff
    class Q decision
    class A,B,C option
    class R1,R2,R3 result
```

### Precedence

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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Code["plugins.enable(...) in code"] --> Env["PRAISONAI_PLUGINS env var"]
    Env --> File["[plugins] in config.yaml / config.toml"]
    File --> Off["disabled by default"]

    classDef top fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef mid fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef low fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef off fill:#8B0000,stroke:#7C90A0,color:#fff

    class Code top
    class Env,File mid
    class Off off
```

### 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)
```

<Warning>
  Writing a `.toml` config requires `tomli_w`. Without it the CLI **fails fast** with a remediation hint (`Install tomli-w (pip install tomli-w), or convert the config to .praisonai/config.yaml.`) rather than silently writing a `.yaml` sidecar the runtime would ignore.
</Warning>

<Note>
  `praisonai plugins disable <name>` unloads a single-file plugin's **tools** as well as its hooks — `unload_plugin(module_name)` removes the functions that module contributed (tracked via a pre-exec registry snapshot), so `disable` truly removes the plugin's functionality for the rest of the run without touching tools owned by another plugin or the core.
</Note>

***

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

<Note>
  The five `before_*` methods (`before_agent`, `before_llm`, `before_tool`, `before_tool_definitions`, `before_message`) also accept a deny/block decision instead of a rewrite — see [Blocking Plugins](#blocking-plugins-guardrails--policies). `after_tool` additionally accepts a rewrite **or** block — see [Redact / Block Tool Output](/docs/features/redact-tool-output).
</Note>

| Plugin method | Hook event | Requires 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 `data.response` |
| `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`) |

<Note>
  🔐 marks the eight [conversation-content hooks](#security-conversation-content-capability-gate). For a third-party plugin they fire only after `allow_conversation: true`; first-party plugins are granted automatically.
</Note>

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

<Note>
  **`after_tool` is now a redaction / block seam, not an observer** (PraisonAI PRs [#3968](https://github.com/MervinPraison/PraisonAI/issues/3968) / [#3969](https://github.com/MervinPraison/PraisonAI/pull/3969)). A `Plugin.after_tool` return value is written back to the effective tool result before it reaches the model — mirroring `before_tool` (arg rewrite) and `after_llm` (response rewrite). Return a scrubbed value to redact, `PluginDecision.deny("reason")` to suppress, or raise `GuardrailBlocked("reason")` for the same effect via the exception form. See [Redact / Block Tool Output](/docs/features/redact-tool-output).
</Note>

***

## Reference Plugins in `praisonai-plugins`

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

| Plugin | Type | Enable |
| - | - | - |
| `cli_backend_tracer` | `cli_backend_execute` hook | `PRAISONAI_CLI_BACKEND_DEBUG=1 praisonai plugins enable cli_backend_tracer` |
| `basic_tools` | `PluginType.TOOL` | `praisonai 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
pip install praisonai-plugins
export PRAISONAI_CLI_BACKEND_DEBUG=1
praisonai plugins enable cli_backend_tracer
```

See [Hook Events → CLI Backend Events](/docs/docs/features/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.

<Warning>
  A third-party guardrail that hooks `before_llm` or `before_message` — both conversation-content hooks — is silently skipped until granted `allow_conversation: true`. Blocking on `before_tool` / `after_tool` needs no grant. See [Conversation-Content Capability Gate](#security-conversation-content-capability-gate).
</Warning>

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    subgraph "Blocking Plugin"
        Request[📥 Action] --> Guard{🛡️ Guardrail}
        Guard -->|allow / rewrite| Run[✅ Execute]
        Guard -->|deny / block| Stop[🛑 Skipped]
    end

    classDef input fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef guard fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef ok fill:#10B981,stroke:#7C90A0,color:#fff
    classDef stop fill:#6366F1,stroke:#7C90A0,color:#fff

    class Request input
    class Guard guard
    class Run ok
    class Stop stop
```

### Quick Start

<Steps>
  <Step title="Block a tool call">
    Return `PluginDecision.block(reason)` from `before_tool` to skip a forbidden tool.

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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
    ```
  </Step>

  <Step title="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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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]
    ```
  </Step>

  <Step title="Drop an inbound message">
    Return `PluginDecision.deny(reason)` from `before_message` to drop a message before the agent sees it.

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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
    ```
  </Step>
</Steps>

### Three Ways to Block

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

| Style | Return / raise | When to prefer |
| - | - | - |
| `PluginDecision` | `return PluginDecision.deny(reason)` / `.block(reason)` | Default. One import, reads like data. |
| `HookResult` | `return HookResult.deny(reason)` / `.block(reason)` | You already work with `HookResult` elsewhere. |
| `GuardrailBlocked` | `raise GuardrailBlocked(reason)` | Deep in a validator that would otherwise `raise` anyway. |

<CodeGroup>
  ```python PluginDecision theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  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
  ```

  ```python HookResult theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  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
  ```

  ```python GuardrailBlocked theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  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
  ```
</CodeGroup>

`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 **and from `after_tool`** — the tool has already run there, but the output is suppressed before it reaches the model (see [Redact / Block Tool Output](/docs/features/redact-tool-output)).

### Where Blocks Fire

The five `before_*` methods and `after_tool` can block; the remaining `after_*` / observational methods cannot.

| Plugin method | Can 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](/docs/features/redact-tool-output)) |
| other `after_*` | ✅ | ❌ | *(observational — no block path)* |

### How a Block Flows

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant User
    participant Agent
    participant Plugin as Guardrail Plugin
    participant Runner as HookRunner
    participant Target as LLM/Tool

    User->>Agent: request
    Agent->>Runner: fire BEFORE_TOOL
    Runner->>Plugin: before_tool(name, args)
    alt allow / rewrite
        Plugin-->>Runner: dict (new args) OR None
        Runner-->>Agent: HookResult.allow()
        Agent->>Target: execute
        Target-->>Agent: result
    else deny / block
        Plugin-->>Runner: PluginDecision.deny(reason) / raise GuardrailBlocked
        Runner-->>Agent: HookResult.deny(reason) — is_blocked=True
        Agent-->>User: blocked (reason surfaced)
    end
```

### Which Style Should I Use?

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Start[🛡️ Need to block?] --> Q1{Inside a validator<br/>that already raises?}
    Q1 -->|Yes| Raise[raise GuardrailBlocked]
    Q1 -->|No| Q2{Already import<br/>HookResult?}
    Q2 -->|Yes| HR[return HookResult.deny]
    Q2 -->|No| PD[return PluginDecision.deny]

    classDef start fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef question fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef answer fill:#10B981,stroke:#7C90A0,color:#fff

    class Start start
    class Q1,Q2 question
    class Raise,HR,PD answer
```

***

## Ship a Plugin as a pip Package

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

```toml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
[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](/docs/features/plugins-add).

<Note>
  **A shipped plugin cannot replace a built-in name.** PraisonAI's registries subclass the base `PluginRegistry`, so since [PR #4176](https://github.com/MervinPraison/PraisonAI/pull/4176) any entry point whose name (case-insensitive) matches a shipped built-in is skipped and the built-in is kept (a `DEBUG` line notes the collision). Pick a name unique to your package. Runtime `register(...)` is the deliberate override path. See [Plugin Precedence](/docs/features/plugin-precedence).
</Note>

***

## CLI Commands

```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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](/docs/cli/plugins) page.

***

## Configuration Options

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

| Field | Required | Type | Description |
| - | - | - | - |
| `Plugin Name` | Yes | string | Display name in plugin header |
| `Description` | No | string | What the plugin does |
| `Version` | No | string | Semantic version (defaults to `1.0.0`) |
| `Author` | No | string | Plugin author |
| `Hooks` | No | list | Hook events this plugin registers |
| `Dependencies` (or `Deps`) | No | list | Python packages the plugin needs |
| `Channels` | No | list | Channels/adapters this plugin contributes |
| `Provides` (or `Provides Tools`, `Tools`) | No | list | Tool/function names this plugin exposes |
| `Config` (or `Config Schema`) | No | list | Config keys this plugin reads |
| `Auto Enable When Configured` | No | list | Env/credential keys that auto-enable this plugin when set |

***

## Best Practices

<AccordionGroup>
  <Accordion title="Keep plugins single-purpose">
    One file per concern — weather tools, logging, or guardrails, not all three.
  </Accordion>

  <Accordion title="Call discover_and_load_plugins() once">
    Load before creating the agent so tools and hooks register globally.
  </Accordion>

  <Accordion title="Reference tools by name">
    Pass `tools=["get_weather"]` — the string must match the `@tool` function name.
  </Accordion>

  <Accordion title="Use plugins.enable for built-ins">
    Enable `logging` and `metrics` without writing plugin files.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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).
  </Accordion>
</AccordionGroup>

***

## 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)](/docs/features/thread-safety#pluginmanager-pr-5204) for the mechanism.

***

## Related

<CardGroup cols={2}>
  <Card title="Hooks" icon="webhook" href="/docs/features/hooks">
    Hook events and the HookRegistry API
  </Card>

  <Card title="Toolsets" icon="wrench" href="/docs/features/toolsets">
    Create and register custom agent tools
  </Card>

  <Card title="Plugins CLI" icon="plug" href="/docs/cli/plugins">
    List, enable, disable, reload, and diagnose plugins
  </Card>

  <Card title="Config File" icon="file-code" href="/docs/features/config-file">
    Turn plugins on from `[plugins]` in `config.toml`
  </Card>

  <Card title="Tool Discovery" icon="list-tree" href="/docs/features/tool-discovery-order">
    How Agent resolves tool names at runtime
  </Card>

  <Card title="Guardrails" icon="shield-halved" href="/docs/features/guardrails">
    Validate agent output — automatic retry on failure
  </Card>

  <Card title="Redact Tool Output" icon="shield-halved" href="/docs/features/redact-tool-output">
    Scrub secrets or block tool results before the model sees them
  </Card>

  <Card title="Conversation-Content Gate" icon="lock" href="/docs/features/plugins#security-conversation-content-capability-gate">
    Grant third-party plugins access to prompts and responses
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.