Skip to main content
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.
The user asks a question; plugins add tools and hooks before the agent answers.

Quick Start

1

Simple Usage

Create ~/.praisonai/plugins/my_tools.py:
Load and run:
2

With Configuration

Point the environment (or config file) at plugins and Agent construction wires them for you — no explicit plugins.enable() call needed:
Prefer to turn plugins on in code? Call plugins.enable(...) before creating the agent:
Place plugins in ~/.praisonai/plugins/ (user-wide) or ./.praisonai/plugins/ (project-specific).
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.
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.

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). The hooks fire in this order around each agent run:

Choosing How to Load Plugins

Pick the loading method that fits your setup.

Plugin Locations


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. Open the gate with an environment variable (accepts true/1/yes/on):
Or in .praisonai/config.yaml:
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.

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.
Each entry is a dict: {name, version, description, source, enabled, hooks} (single-file entries also include path). The source field records provenance:
enabled reflects both the live manager state and the persisted config allow-list, so it renders correctly even before any agent starts.

Reload Plugins at Runtime

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

Hook Plugin Example

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

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

Field reference

Read the manifest from code

discover_plugins() scans plugin directories and returns metadata dicts — the plugin body is never imported.
Omitted fields are absent from the raw discover_plugins() dict, so read them with .get(key, []). PluginMetadata.to_dict() normalizes them to empty lists.

Rules

  • Fields are optional — omitting them yields empty lists (safe default).
  • Field keys are case-insensitiveChannels, 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?


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:
Load and enable:

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

Observe sessions

session_start and session_end observe when a session opens and closes.
Create ~/.praisonai/plugins/session_logger.py:

Observe errors

on_error observes errors during a run — use it to log without changing behavior.
Create ~/.praisonai/plugins/error_reporter.py:

Rewrite config

on_config returns a dict to rewrite runtime configuration in place.
Create ~/.praisonai/plugins/config_defaults.py:

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.
Create ~/.praisonai/plugins/token_injector.py:

Message-Lifecycle Plugins

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

Choosing a message hook

Enriched payload keys

Every message-lifecycle method receives (at most) these keys:
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.

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.
  • 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.
message_undelivered is the counterpart to the plain MESSAGE_UNDELIVERED hook already covered in Undelivered Messages and 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.

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

Quick Start

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

Constructor

Hook dispatch table

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

Message-lifecycle examples

Lead with the outcome: rate-limit a channel, log every delivery, or capture permanent failures.
  • 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).
FunctionPlugin’s message-lifecycle dispatch (MESSAGE_RECEIVED, MESSAGE_SENDING, MESSAGE_SENT, MESSAGE_UNDELIVERED) is a recent addition (commit 857ebbe) — earlier versions silently no-op’d these four keys, so users had to subclass Plugin.
For the full payload contract (identity keys, per-event availability, error / notice_delivered special-cases), see Message-Lifecycle PluginsFunctionPlugin and Plugin subclasses share the exact same _message_payload bridge.

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:
Or turn them on in .praisonai/config.toml:
Want each plugin to receive its own options from one file? See Configure Plugins from .praisonai/config.yaml for per-plugin option blocks.
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. Precedence: explicit plugins.enable(...) > PRAISONAI_PLUGINS env var > [plugins] in config.yaml / config.toml > disabled.
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.
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.
Verify auto-enable at runtime — constructing an Agent triggers the wiring:

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:
Or forced-off in embedded Python, regardless of the env var:
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 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:
app.py:
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.

Where the config file is found

The first file found wins — TOML and YAML have equal standing; whichever appears first in the search stops the walk.
PyYAML is an optional dependency. Without it, .yaml configs return empty (debug log only) — install with pip install pyyaml to enable the YAML surface.

The four shapes of enabled

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

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.
allow_project_plugins: true opens the 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.
An explicit top-level enabled: false wins over any per-plugin block. Omit enabled at the top level and per-plugin blocks decide.

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:
on_config fires once per enable — the reserved enabled flag (if present in the plugin block) is preserved so plugins can read it too.

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

Choose a plugin config surface

Precedence

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

Edge cases

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

Config-write single source of truth

praisonai plugins enable/disable write to the same file the runtime reads — no separate JSON. The write target is resolved in this order:
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.
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.

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.
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. after_tool additionally accepts a rewrite or block — see Redact / Block Tool Output.
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.
after_tool is now a redaction / block seam, not an observer (PraisonAI PRs #3968 / #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.

Reference Plugins in praisonai-plugins

Batteries-included plugins ship in the separate praisonai-plugins package — install once, then enable by name. 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.
See Hook Events → CLI Backend Events for the full CliBackendExecuteInput payload reference.

Blocking Plugins (Guardrails & Policies)

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

Quick Start

1

Block a tool call

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

Refuse an LLM request

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

Drop an inbound message

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

Three Ways to Block

The same block, written three ways — pick the one that reads cleanest in your code.
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).

Where Blocks Fire

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

How a Block Flows

Which Style Should I Use?


Ship a Plugin as a pip Package

Register your plugin class in the praisonai.plugins entry-point group in pyproject.toml:
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.
A shipped plugin cannot replace a built-in name. PraisonAI’s registries subclass the base PluginRegistry, so since PR #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.

CLI Commands

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

Configuration Options

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

Best Practices

One file per concern — weather tools, logging, or guardrails, not all three.
Load before creating the agent so tools and hooks register globally.
Pass tools=["get_weather"] — the string must match the @tool function name.
Enable logging and metrics without writing plugin files.
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.
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).

Hooks

Hook events and the HookRegistry API

Toolsets

Create and register custom agent tools

Plugins CLI

List, enable, disable, reload, and diagnose plugins

Config File

Turn plugins on from [plugins] in config.toml

Tool Discovery

How Agent resolves tool names at runtime

Guardrails

Validate agent output — automatic retry on failure

Redact Tool Output

Scrub secrets or block tool results before the model sees them