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, 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"]
}
| 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:
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:
- Absolute paths — used as-is
- Relative paths — resolved relative to the directory containing
config.yaml - 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:, andmodule:are explicit prefixes that force package resolution.:sub/pathafter 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:
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.metadata import (
SetupType,
ToolCategory,
ToolStatus,
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:
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:
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.metadata import (
ConfigField,
SetupType,
ToolCategory,
ToolStatus,
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) |
Example:
from agno.tools import Toolkit
from mindroom.tool_system.metadata import ToolCategory, ToolManagedInitArg, 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
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.metadata import (
SetupType,
ToolCategory,
ToolStatus,
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:
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
@hookdecorator 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
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 freshHookRegistryis built, and the live registry is swapped atomically. - Module-level
asyncio.Taskobjects, and one-level containers likedict[..., 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 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:
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.Taskobjects 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:inconfig.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:
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 | Autonomous work plans with dependencies, priorities, auto-poke, and template-driven task creation. |
| openviking-plugin | Long-term memory via OpenViking — automatic memory extraction, recall, and compaction archiving. |