Skip to content

Plugins

Warning

Plugins execute arbitrary Python code in the same process as MindRoom. A malicious plugin has full access to your credentials, Matrix sessions, file system, and network. Only install plugins you trust and have reviewed.

MindRoom plugins extend agents with custom tools, hooks, and skills. A plugin is a directory with a mindroom.plugin.json manifest and optional Python modules or skill directories. Plugins are loaded from paths listed under plugins: in config.yaml.

Plugin structure

A plugin is a directory containing mindroom.plugin.json:

my-plugin/
├── mindroom.plugin.json   # Required manifest
├── tools.py               # Tool factories (optional)
├── oauth.py               # OAuth providers (optional)
├── hooks.py               # Event hooks (optional)
└── skills/                # Skill directories (optional)
    └── my-skill/
        └── SKILL.md

Capability fields are optional, but the name field is required. A name-only manifest loads but contributes no tools, hooks, OAuth providers, or skills. A tools-only plugin exposes callable functions to agents. A plugin with oauth_module registers OAuth providers whose state, callbacks, and scoped credential storage are handled by MindRoom core. A hooks-only plugin observes or transforms events without adding agent-facing tools. Many plugins combine both.

Manifest format

The manifest is a JSON file named mindroom.plugin.json at the plugin root:

{
  "name": "my-plugin",
  "tools_module": "tools.py",
  "oauth_module": "oauth.py",
  "hooks_module": "hooks.py",
  "skills": ["skills"]
}
Field Type Required Description
name string yes Plugin identifier. Must be lowercase ASCII letters, digits, -, and _ only (pattern: ^[a-z0-9_-]+$). Must be unique across all configured plugins. Invalid or duplicate names abort plugin loading entirely.
tools_module string no Relative path to the Python module containing @register_tool_with_metadata factories. Must exist on disk if declared.
oauth_module string no Relative path to the Python module containing register_oauth_providers(settings, runtime_paths). Must exist on disk if declared.
hooks_module string no Relative path to the Python module containing @hook-decorated functions. Must exist on disk if declared.
skills list of strings no Relative directories containing skill subdirectories (each with a SKILL.md). Each directory must exist on disk.

Unknown fields are silently ignored. Invalid, duplicate, or malformed manifests are configuration errors and stop all plugin loading. All declared module files and skill directories must exist on disk.

If hooks_module is omitted, MindRoom auto-scans tools_module for @hook-decorated functions. If both fields point at the same file, MindRoom imports it once and reuses it for both tool registration and hook discovery.

Configure plugins

Add plugin paths under plugins: in config.yaml:

plugins:
  - ./plugins/my-plugin
  - python:my_skill_pack
  - path: ./plugins/personal-context
    enabled: true
    settings:
      dawarich_url: http://dawarich.local
      api_key: secret
    hooks:
      enrich_with_location:
        priority: 20
      audit_messages:
        enabled: false

Entry formats

Plugin entries can be strings (path only) or objects (with options). Both forms can be mixed in the same list.

String entry — just the path:

plugins:
  - ./plugins/my-plugin

Object entry — path plus options:

plugins:
  - path: ./plugins/my-plugin
    enabled: true
    settings:
      api_key: secret
    hooks:
      my_hook:
        enabled: false
Field Type Default Description
path string required Plugin path (see resolution rules below)
enabled bool true Set to false to disable the plugin without removing it from the list
settings dict {} Free-form key-value config passed to the plugin at load time
hooks dict {} Per-hook overrides keyed by hook function name

Each hook override supports:

Field Type Default Description
enabled bool true Disable a specific hook without removing it from the plugin
priority int null Override the hook's default execution priority
timeout_ms int null Override the hook's default timeout

Path resolution

Paths are resolved in this order:

  1. Absolute paths — used as-is
  2. Relative paths — resolved relative to the directory containing config.yaml
  3. Python package specs — see below

Python package plugins

MindRoom can resolve plugins from installed Python packages:

plugins:
  - my_skill_pack
  - python:my_skill_pack
  - pkg:my_skill_pack:plugins/demo
  - module:my_skill_pack:plugins/demo

Rules:

  • A bare package name (no slashes, no . or .. prefix) is tried as a Python package first.
  • python:, pkg:, and module: are explicit prefixes that force package resolution.
  • :sub/path after the package name points to a subdirectory inside the package.

MindRoom resolves the package location via importlib and looks for mindroom.plugin.json in that directory.

Tools module

A tools module is a Python file that registers one or more tool factories using the @register_tool_with_metadata decorator. Each factory function returns a Toolkit class (not an instance). MindRoom instantiates the class when building agents.

OAuth providers

An OAuth module registers provider definitions without registering FastAPI routes. MindRoom core owns state generation, callback consumption, authenticated requester binding, scoped OAuth token writes, status checks, and disconnect handling. The provider module supplies only provider-specific details such as endpoint URLs, scopes, token credential service names, optional tool config service names, optional PKCE requirements, token parsing, optional claim validators, and display metadata.

Declare the module in the manifest:

{
  "name": "drive-plugin",
  "tools_module": "tools.py",
  "oauth_module": "oauth.py"
}

Then expose register_oauth_providers(settings, runtime_paths):

from __future__ import annotations

from mindroom.oauth import OAuthProvider


def register_oauth_providers(settings, runtime_paths):
    del runtime_paths
    return [
        OAuthProvider(
            id="acme_drive",
            display_name="Acme Drive",
            authorization_url="https://accounts.acme.example/oauth/authorize",
            token_url="https://accounts.acme.example/oauth/token",
            scopes=("files.read",),
            credential_service="acme_drive_oauth",
            tool_config_service="acme_drive",
            client_config_services=(
                settings.get("client_config_service", "acme_drive_oauth_client"),
            ),
            allowed_email_domains=tuple(settings.get("allowed_email_domains", [])),
            allowed_hosted_domains=tuple(settings.get("allowed_hosted_domains", [])),
        ),
    ]

OAuth provider IDs are exposed through /api/oauth/{provider}/connect, /api/oauth/{provider}/authorize, /api/oauth/{provider}/callback, /api/oauth/{provider}/status, /api/oauth/{provider}/disconnect, and the authenticated GET/POST /api/oauth/{provider}/reset confirmation flow. Dashboard flows normally call connect and use the returned provider authorization URL. Conversation flows should show the browser-openable authorize URL, because that URL first authenticates the MindRoom user and then redirects to the external provider. Conversation-issued links include an opaque connect token so the callback can verify the requester before storing scoped credentials. The connect token is also bound to the runtime requester, and redemption fails unless the authenticated dashboard user resolves to that requester. The callback stores tokens under credential_service using the resolved requester and agent execution scope, including private user and user_agent scopes. Every OAuth token credential_service must end with _oauth so core can enforce primary-runtime isolation and reject worker grants without loading plugin code. If the tool also has editable dashboard settings, declare tool_config_service and store those settings separately through the normal credentials API. Set pkce_code_challenge_method="S256" when the upstream OAuth provider requires PKCE. MindRoom stores the verifier in pending state and passes it as the fifth argument to custom token_exchanger callbacks. For example, an Acme Drive provider can store OAuth tokens in acme_drive_oauth while the acme_drive tool settings document contains only options such as file-size limits or capability toggles. Tokens and client secrets must never be written to config.yaml, prompt files, logs, or tool responses.

Providers that publish protected-resource metadata can resolve endpoints and optionally register a client lazily with the generic discovery bootstrapper:

from mindroom.oauth import OAuthDiscoveryConfig, OAuthProvider, oauth_runtime_bootstrapper

provider = OAuthProvider(
    id="acme_drive",
    display_name="Acme Drive",
    authorization_url="",
    token_url="",
    scopes=("files.read",),
    credential_service="acme_drive_oauth",
    client_config_services=("acme_drive_oauth_client",),
    token_endpoint_auth_method="none",
    pkce_code_challenge_method="S256",
    runtime_bootstrapper=oauth_runtime_bootstrapper(
        OAuthDiscoveryConfig(
            resource="https://api.acme.example",
            token_endpoint_auth_method="none",
            pkce_code_challenge_method="S256",
        ),
    ),
)

Automatic discovery checks protected-resource metadata at the resource origin and path, then uses the advertised authorization server or falls back to authorization-server metadata at the resource origin. Dynamic client registration requires a provider-specific client_config_services entry and runs only in the primary runtime.

OAuth-backed tools should set setup_type=SetupType.OAUTH and auth_provider="<provider_id>" in @register_tool_with_metadata. When credentials are missing, return a concise instruction containing a browser-openable URL built with mindroom.oauth.build_oauth_connect_instruction(provider, runtime_paths, worker_target=...). The user can complete OAuth and retry the same tool request.

Deployment restrictions belong in plugin settings. Use allowed_email_domains to restrict verified email claims by domain. Use allowed_hosted_domains when the provider supplies a verified hosted-domain claim. If a configured restriction cannot be checked from verified claims, MindRoom fails the callback closed and does not save credentials.

Minimal example

from __future__ import annotations

from typing import TYPE_CHECKING

from mindroom.tool_system.declarations import (
    SetupType,
    ToolCategory,
    ToolStatus,
)
from mindroom.tool_system.registration import register_tool_with_metadata

if TYPE_CHECKING:
    from agno.tools import Toolkit


@register_tool_with_metadata(
    name="greeter",
    display_name="Greeter",
    description="A simple greeting tool",
    category=ToolCategory.DEVELOPMENT,
    status=ToolStatus.AVAILABLE,
    setup_type=SetupType.NONE,
)
def greeter_tools() -> type[Toolkit]:
    from agno.tools import Toolkit

    class GreeterTools(Toolkit):
        def __init__(self) -> None:
            super().__init__(name="greeter", tools=[self.greet])

        def greet(self, name: str) -> str:
            """Greet someone by name."""
            return f"Hello, {name}!"

    return GreeterTools

After registering the plugin, assign the tool to agents in config.yaml:

plugins:
  - ./plugins/my-greeter

agents:
  assistant:
    tools:
      - greeter

Decorator fields

All @register_tool_with_metadata arguments are keyword-only.

Required fields:

Field Type Description
name string Tool identifier — used to reference the tool in config.yaml agent tools: lists
display_name string Human-readable name shown in the dashboard
description string Brief description of what the tool does
category ToolCategory Category for dashboard grouping (see values below)

ToolCategory values: COMMUNICATION, DEVELOPMENT, EMAIL, ENTERTAINMENT, INFORMATION, INTEGRATIONS, PRODUCTIVITY, RESEARCH, SMART_HOME, SOCIAL.

Optional fields:

Field Type Default Description
status ToolStatus AVAILABLE AVAILABLE or REQUIRES_CONFIG — controls whether the tool appears as ready or needs setup in the dashboard
setup_type SetupType NONE NONE, API_KEY, OAUTH, or SPECIAL — tells the dashboard what kind of setup flow to show
config_fields list of ConfigField None Describes constructor parameters configurable through the dashboard (see ConfigField)
dependencies list of strings None Python packages the tool requires (see Dependencies)
docs_url string None Link to external documentation
icon string None Icon name for the dashboard (e.g., "FaGoogle", "Home")
icon_color string None Tailwind color class for the icon (e.g., "text-blue-500")
helper_text string None Markdown help text shown in the dashboard setup panel
auth_provider string None OAuth provider identifier when using OAuth-based setup
managed_init_args tuple of ToolManagedInitArg () Declares which MindRoom-managed values the toolkit constructor expects (see Managed init args)
default_execution_target ToolExecutionTarget PRIMARY PRIMARY or WORKER — controls whether the tool runs on the primary agent or a sandbox worker

Dependencies

The dependencies field lists Python packages that the tool requires at runtime. MindRoom checks whether each package is importable before the tool is instantiated.

For built-in tools (those shipped inside src/mindroom/tools/), missing dependencies trigger automatic installation via uv sync or pip install using the matching optional extra from MindRoom's pyproject.toml.

For plugin tools, automatic installation does not apply — there is no matching optional extra in MindRoom's package metadata. If the listed dependencies are not already installed in the environment, MindRoom raises an ImportError with a message listing the missing packages. Plugin authors should document their dependencies in their README so users can install them manually:

pip install openviking-client aiohttp

Even though plugin dependencies are not auto-installed, listing them in the decorator is still useful: MindRoom surfaces a clear error at tool load time rather than failing with an obscure traceback when an agent tries to call the tool mid-conversation.

You can disable automatic dependency installation entirely (for built-in tools too) by setting the environment variable MINDROOM_NO_AUTO_INSTALL_TOOLS=1.

ConfigField

Each ConfigField describes one constructor parameter that can be configured through the dashboard or credentials store.

Field Type Default Description
name string required Constructor kwarg name (e.g., "api_key")
label string required Display label shown in the dashboard
type string "text" Input type: text, password, url, number, boolean, or select
required bool True Whether the field must be set before the tool can be used
default any None Default value when not configured
placeholder string None Placeholder text shown in the input
description string None Help text for the field
options list None For select type: list of {"label": "...", "value": "..."} dicts
validation dict None Optional validation rules (min, max, pattern, etc.)

Example — a tool that requires an API key:

from mindroom.tool_system.declarations import (
    ConfigField,
    SetupType,
    ToolCategory,
    ToolStatus,
)
from mindroom.tool_system.registration import register_tool_with_metadata

@register_tool_with_metadata(
    name="weather",
    display_name="Weather",
    description="Get current weather data",
    category=ToolCategory.INFORMATION,
    status=ToolStatus.REQUIRES_CONFIG,
    setup_type=SetupType.API_KEY,
    config_fields=[
        ConfigField(name="api_key", label="API Key", type="password"),
        ConfigField(
            name="units",
            label="Units",
            type="select",
            required=False,
            default="metric",
            options=[
                {"label": "Metric (°C)", "value": "metric"},
                {"label": "Imperial (°F)", "value": "imperial"},
            ],
        ),
    ],
)
def weather_tools() -> type[Toolkit]:
    ...

Managed init args

If your toolkit constructor needs MindRoom-managed runtime values, declare them with managed_init_args. MindRoom does not auto-detect constructor parameter names — undeclared managed args are not passed through.

Value Constructor kwarg Description
RUNTIME_PATHS runtime_paths Storage paths, environment values, and data directory access
CREDENTIALS_MANAGER credentials_manager Read and write the per-tool credentials store
WORKER_TARGET worker_target Resolved worker routing context (scope, execution identity, worker key)
TOOL_OUTPUT_WORKSPACE_ROOT tool_output_workspace_root Workspace root used for managed tool-output saves
WORKER_TOOLS_OVERRIDE worker_tools_override Effective worker-routed tool override
CURRENT_ROOM_ID current_room_id Active Matrix room ID when available

Example:

from agno.tools import Toolkit
from mindroom.tool_system.declarations import ToolCategory, ToolManagedInitArg
from mindroom.tool_system.registration import register_tool_with_metadata


@register_tool_with_metadata(
    name="needs_runtime",
    display_name="Needs Runtime",
    description="Example tool that needs runtime paths",
    category=ToolCategory.DEVELOPMENT,
    managed_init_args=(ToolManagedInitArg.RUNTIME_PATHS,),
)
def needs_runtime_tools() -> type[Toolkit]:
    class NeedsRuntimeTools(Toolkit):
        def __init__(self, *, runtime_paths):
            self.runtime_paths = runtime_paths
            super().__init__(name="needs_runtime", tools=[])

    return NeedsRuntimeTools

Dispatch-time worker targets

WORKER_TARGET covers toolkits that receive their worker target at construction. Tools that compose other registered tools while handling a call (for example building a sub-toolkit with get_tool_by_name) resolve the same target from the live runtime context instead:

from mindroom.tool_system.runtime_context import get_tool_runtime_context

context = get_tool_runtime_context()
worker_target = context.resolve_worker_target()

This returns the exact worker target that agent toolkit construction uses, so worker-scoped state such as OAuth MCP sessions and scoped credentials resolves identically. resolve_worker_target() raises ValueError for team and router dispatches, because those contexts have no single agent execution scope to mirror; catch it if your tool can run inside a team.

MCP via plugins (advanced)

MindRoom supports native MCP servers in config.yaml — see MCP for the normal setup path. This plugin pattern is still useful when you want a custom wrapper around Agno MCPTools:

from agno.tools.mcp import MCPTools
from mindroom.tool_system.declarations import (
    SetupType,
    ToolCategory,
    ToolStatus,
)
from mindroom.tool_system.registration import register_tool_with_metadata


class FilesystemMCPTools(MCPTools):
    def __init__(self, **kwargs):
        super().__init__(
            command="npx -y @modelcontextprotocol/server-filesystem /path/to/dir",
            **kwargs,
        )


@register_tool_with_metadata(
    name="mcp_filesystem",
    display_name="MCP Filesystem",
    description="Tools from an MCP filesystem server",
    category=ToolCategory.DEVELOPMENT,
    status=ToolStatus.AVAILABLE,
    setup_type=SetupType.NONE,
)
def mcp_filesystem_tools():
    return FilesystemMCPTools

Reference the plugin and tool in config.yaml:

plugins:
  - ./plugins/mcp-filesystem

agents:
  assistant:
    tools:
      - mcp_filesystem

The factory function must return the toolkit class, not an instance. MCP toolkits are async; Agno's async agent runs (arun, aprint_response) handle MCP connect and disconnect automatically.

Plugin skills

List skill directories in the manifest skills array. Each listed directory is added to MindRoom's skill search roots. Skill subdirectories must contain a SKILL.md file. YAML frontmatter is optional: discovery falls back to the directory name, and a missing description falls back to the resolved skill name. Requirements are declared only when needed.

Hooks

Plugins can ship typed event hooks for message enrichment, response transformation, lifecycle observation, tool call gating, reactions, schedules, and custom events. See the Hooks page for full documentation including:

  • The @hook decorator and all parameters
  • The built-in events and their execution modes
  • The enrichment pipeline (message:enrich)
  • Custom events
  • Error handling without cooldowns or circuit breakers
  • Testing patterns

Installing plugins

Vendor a plugin directly from GitHub into your local plugins directory:

mindroom plugins install ping-hook-plugin
mindroom plugins install mindroom-ai/ping-hook-plugin@v1.2.0

Bare names install from the mindroom-ai organization, and @ref pins a branch, tag, or exact commit (the default follows the repository default branch). The command resolves the reference to an exact commit, downloads that commit's archive, validates it with the strict compatibility check, and only then moves it into <config dir>/plugins/<name>. Each vendored plugin carries a .mindroom-plugin.lock.json file recording the repository, the requested reference, and the exact installed commit. Plugin Python dependencies are not installed automatically; installs report a note when the plugin declares a pyproject.toml.

After installing, reference the plugin from config.yaml:

plugins:
  - path: plugins/ping-hook-plugin

Update vendored plugins with:

mindroom plugins update ping-hook-plugin
mindroom plugins update --all
mindroom plugins update ping-hook-plugin --ref v1.3.0

Updates re-resolve the pinned reference, skip plugins already at the resolved commit, and atomically replace the plugin directory only after the new revision passes the strict check. A failed update leaves the installed version untouched. Both commands accept --path to target a specific config file's directory and --plugins-dir to override the vendor directory. Set GITHUB_TOKEN to authenticate GitHub requests, which raises API rate limits and allows private repositories.

Compatibility checks

Validate a plugin against the installed MindRoom version before deployment:

mindroom plugins check ./my-plugin

The command strictly loads and validates the manifest, tools, hooks, OAuth providers, and skills using isolated temporary runtime paths. It reports discovered tools, hooks, and skills, then exits nonzero without leaving plugin registrations active when validation fails.

Live development (hot reload)

Plugins hot-reload automatically. When you edit any file inside a configured plugin directory, MindRoom notices the change on the next poll, waits out the debounce window, re-imports the plugin's modules in place, swaps the new hooks and tools into the live registry, and the next event invokes your new code. In practice the new code is usually live about 1-2 seconds after a save. No service restart and no agent session disruption.

How it works

  • A background watcher polls each configured plugin root every ~1s and debounces saves over a ~1s window.
  • On change, the synthetic plugin package subtree is evicted from sys.modules, load_plugins() re-runs, a fresh HookRegistry is built, and the live registry is swapped atomically.
  • Module-level asyncio.Task objects, and one-level containers like dict[..., Task], on the old module are best-effort cancelled before the swap.
  • The watcher ignores __pycache__/, *.pyc, *.pyo, editor swap files (*.swp, *~, .#*, *.tmp), and tool caches (.ruff_cache/, .mypy_cache/, .pytest_cache/).

Iterating on a plugin

# 1. Edit any file under your plugin
$EDITOR ~/.mindroom/plugins/my-plugin/hooks.py

# 2. Save. Watch the journal:
journalctl -u mindroom.service -f | grep -E 'Reloading plugins|Plugin reload complete'

# 3. Trigger your hook (send a message, fire the matching event, etc.)
#    The new code path is live.

You can break and fix a plugin freely. A normal mindroom run startup skips broken plugins and disables tools known to belong to them rather than crash-looping. If a broken tools module prevents MindRoom from resolving its complete tool namespace, otherwise-unknown authored tool names are also disabled with a warning; mindroom config validate remains strict. A broken save that prevents reload, such as an import error or plugin validation error, can deactivate the affected plugin set, and the next valid save reloads it successfully. A hook that only raises at runtime is different: that failure is logged for that event, and the hook is tried again on the next matching event. There is no quarantine, failure threshold, or cooldown. Each save just reloads.

Manual reload

If you need to force a reload, for example because the watcher missed something or you want to confirm a swap explicitly, an admin user can send the chat command:

!reload-plugins

The bot replies with the active plugin set and the count of cancelled background tasks. Admin gating uses authorization.global_users from config.yaml.

Caveats and tradeoffs

The hot-reload path is intentionally best-effort, not transactional.

  • In-flight turns keep their old code. A reload swaps the registry for new events, but any callback already running on the old module finishes there, and only new events use the new module.
  • No partial-write detection. If your editor saves the file in two writes, the watcher may briefly load the half-written first state, log an import error, and then reload again on the second write.
  • CPU-bound infinite loops still wedge the event loop. The hook dispatcher uses asyncio.timeout() for cooperative cancellation, so truly blocking CPU code is not preempted.
  • Background resources held by the old module can leak until natural cleanup. Reload cancels direct module-global asyncio.Task objects and tasks in one-level built-in dict, tuple, list, or set containers; deeper or custom containers and non-task resources need their own cleanup bookkeeping.
  • New plugins added to disk are not auto-enabled. You still have to add them under plugins: in config.yaml, because the watcher only reloads plugins that are already configured.

Production tip

Hot reload is enabled by default in production. Edit any configured plugin directory directly while mindroom.service is running. ~/.mindroom/plugins/<name>/ is the common local layout, and active agent sessions, in-flight conversations, and streaming responses continue untouched.

Community plugins

The mindroom-ai organization maintains a collection of open-source plugins. Clone any of them into your plugins directory and add the path to config.yaml:

git clone https://github.com/mindroom-ai/ping-hook-plugin.git ~/.mindroom/plugins/ping-hook
plugins:
  - ~/.mindroom/plugins/ping-hook

Hooks-only plugins

Plugin Description
ping-hook-plugin Minimal example — responds to !ping-hook with a pong message. Good starting point for learning the hook system.
shell-guard-plugin Blocks dangerous shell commands (e.g., systemctl restart mindroom) via tool:before_call gating.
voice-enrich-plugin Injects AI-only metadata when a voice-transcribed message arrives, warning the model about possible transcription errors.
location-enrich-plugin Enriches prompts with real-time GPS location from Dawarich, including place matching and movement classification.
restart-resume-plugin Re-activates threads tagged pending-restart after a bot restart.

Hooks + tools plugins

Plugin Description
thread-snooze-plugin Snooze and unsnooze threads — temporarily resolves a thread and wakes it at a specified time.
thread-goal-plugin Persistent per-thread goals stored in Matrix room state that survive context compaction and restarts.
workloop-plugin Legacy external workloop implementation; native per-thread todo plans and auto-poke behavior are now built into MindRoom, so this plugin is not needed for that workflow.
openviking-plugin Long-term memory via OpenViking — automatic memory extraction, recall, and compaction archiving.