Plugins

July 20, 2026 · View on GitHub

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, one or more Python modules, and optionally 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

A plugin must have at least one of tools_module, hooks_module, oauth_module, 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"]
}
FieldTypeRequiredDescription
namestringyesPlugin 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_modulestringnoRelative path to the Python module containing @register_tool_with_metadata factories. Must exist on disk if declared.
oauth_modulestringnoRelative path to the Python module containing register_oauth_providers(settings, runtime_paths). Must exist on disk if declared.
hooks_modulestringnoRelative path to the Python module containing @hook-decorated functions. Must exist on disk if declared.
skillslist of stringsnoRelative 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
FieldTypeDefaultDescription
pathstringrequiredPlugin path (see resolution rules below)
enabledbooltrueSet to false to disable the plugin without removing it from the list
settingsdict{}Free-form key-value config passed to the plugin at load time
hooksdict{}Per-hook overrides keyed by hook function name

Each hook override supports:

FieldTypeDefaultDescription
enabledbooltrueDisable a specific hook without removing it from the plugin
priorityintnullOverride the hook's default execution priority
timeout_msintnullOverride 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, and /api/oauth/{provider}/disconnect. 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. 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.

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:

FieldTypeDescription
namestringTool identifier — used to reference the tool in config.yaml agent tools: lists
display_namestringHuman-readable name shown in the dashboard
descriptionstringBrief description of what the tool does
categoryToolCategoryCategory for dashboard grouping (see values below)

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

Optional fields:

FieldTypeDefaultDescription
statusToolStatusAVAILABLEAVAILABLE or REQUIRES_CONFIG — controls whether the tool appears as ready or needs setup in the dashboard
setup_typeSetupTypeNONENONE, API_KEY, OAUTH, or SPECIAL — tells the dashboard what kind of setup flow to show
config_fieldslist of ConfigFieldNoneDescribes constructor parameters configurable through the dashboard (see ConfigField)
dependencieslist of stringsNonePython packages the tool requires (see Dependencies)
docs_urlstringNoneLink to external documentation
iconstringNoneIcon name for the dashboard (e.g., "FaGoogle", "Home")
icon_colorstringNoneTailwind color class for the icon (e.g., "text-blue-500")
helper_textstringNoneMarkdown help text shown in the dashboard setup panel
auth_providerstringNoneOAuth provider identifier when using OAuth-based setup
managed_init_argstuple of ToolManagedInitArg()Declares which MindRoom-managed values the toolkit constructor expects (see Managed init args)
default_execution_targetToolExecutionTargetPRIMARYPRIMARY 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.

FieldTypeDefaultDescription
namestringrequiredConstructor kwarg name (e.g., "api_key")
labelstringrequiredDisplay label shown in the dashboard
typestring"text"Input type: text, password, url, number, boolean, or select
requiredboolTrueWhether the field must be set before the tool can be used
defaultanyNoneDefault value when not configured
placeholderstringNonePlaceholder text shown in the input
descriptionstringNoneHelp text for the field
optionslistNoneFor select type: list of {"label": "...", "value": "..."} dicts
validationdictNoneOptional 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.

ValueConstructor kwargDescription
RUNTIME_PATHSruntime_pathsStorage paths, environment values, and data directory access
CREDENTIALS_MANAGERcredentials_managerRead and write the per-tool credentials store
WORKER_TARGETworker_targetResolved worker routing context (scope, execution identity, worker key)

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 requester-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 with YAML frontmatter (name, description, requirements).

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. Only asyncio.Task objects directly attached to module globals are cancelled, so plugins that hold long-lived 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

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

Hooks + tools plugins

PluginDescription
thread-snooze-pluginSnooze and unsnooze threads — temporarily resolves a thread and wakes it at a specified time.
thread-goal-pluginPersistent per-thread goals stored in Matrix room state that survive context compaction and restarts.
workloop-pluginLegacy 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-pluginLong-term memory via OpenViking — automatic memory extraction, recall, and compaction archiving.