# LeoCRM Plugin Development Guide > **Version:** 2.0 > **Date:** 2026-07-23 > **Applies to:** All plugin developers --- ## 1. Overview Plugins are self-contained modules that extend LeoCRM's functionality. Each plugin lives in its own directory under `app/plugins/builtins/` and declares its capabilities via a `PluginManifest`. The plugin system supports: - **API Routes** — Register FastAPI route handlers - **Event Subscriptions** — React to domain events (contact.created, etc.) - **Database Migrations** — Versioned SQL migrations run on install - **RBAC Permissions** — Declare and enforce permissions - **Frontend UI Contributions** — Menu items, page routes, detail tabs, settings pages, dashboard widgets - **AI Agent Capabilities** — Register tools for the AI assistant - **Lifecycle Hooks** — on_install, on_activate, on_deactivate, on_uninstall --- ## 2. Plugin Structure Every plugin lives under `app/plugins/builtins//`: ``` app/plugins/builtins/my_plugin/ ├── __init__.py # Package init (can be empty) ├── plugin.py # Plugin class with manifest (required) ├── routes.py # FastAPI route definitions ├── models.py # SQLAlchemy models (optional) ├── schemas.py # Pydantic schemas (optional) ├── services.py # Business logic (optional) ├── migrations/ # SQL migration files │ └── 0001_initial.sql └── tests/ # Plugin tests (optional) ├── __init__.py └── test_plugin.py ``` --- ## 3. Manifest Format The `PluginManifest` is a Pydantic v2 model that declares all metadata and capabilities of a plugin. It is defined in `app/plugins/manifest.py`. ### 3.1 Core Fields | Field | Type | Required | Description | |---|---|---|---| | `name` | `str` | Yes | Unique plugin identifier (snake_case, max 80 chars, alphanumeric with underscores only) | | `version` | `str` | Yes | Semantic version string (e.g. "1.0.0") | | `display_name` | `str` | Yes | Human-readable plugin name (max 120 chars) | | `description` | `str` | No | Plugin description (max 500 chars) | | `dependencies` | `list[str]` | No | Other plugin names this plugin depends on | | `is_core` | `bool` | No | Whether this is a core plugin that cannot be deactivated (default: `false`) | ### 3.2 Route Definitions ```python from app.plugins.manifest import PluginRouteDef routes=[ PluginRouteDef( path="/api/v1/my-plugin", # URL path prefix module="app.plugins.builtins.my_plugin.routes", # Dotted module path router_attr="router", # Attribute name of the APIRouter in the module ), ] ``` ### 3.3 Event Subscriptions ```python events=[ "contact.created", "contact.updated", "contact.deleted", ] ``` Event naming convention: `.` (e.g. `company.created`, `task.assigned`). ### 3.4 Migrations ```python migrations=[ "0001_initial.sql", "0002_add_indexes.sql", ] ``` Migration files are stored in the plugin's `migrations/` directory and run in order on install. ### 3.5 Permissions ```python permissions=[ "my_plugin:read", "my_plugin:write", "my_plugin:delete", "my_plugin:admin", ] ``` Permission naming convention: `:`. ### 3.6 Field Definitions (Field-Level Permissions) ```python from app.plugins.manifest import FieldDefinition field_definitions=[ FieldDefinition( module="companies", # Module name (e.g. 'companies', 'contacts') field="annual_revenue", # Field name label="Annual Revenue", # Human-readable label sensitivity="sensitive", # normal|sensitive|critical ), ] ``` ### 3.7 Agent Capabilities ```python agent_capabilities=[ "contact_search", # Contact search capability "email_draft", # Email draft generation "calendar_scheduling", # Calendar scheduling ] ``` ### 3.8 Frontend UI Fields (Phase 3) #### FrontendMenuItem — Sidebar Navigation ```python from app.plugins.manifest import FrontendMenuItem menu_items=[ FrontendMenuItem( label_key="nav.myPlugin", # i18n key for the menu label label="My Plugin", # Fallback label if i18n key is missing path="/my-plugin", # Frontend route path icon="Sparkles", # lucide-react icon name group="", # Optional group label_key for tree-style nesting order=100, # Sort order within the sidebar badge_key="", # Optional store key for badge count ), ] ``` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `label_key` | `str` | Yes | — | i18n key for the menu label | | `label` | `str` | No | `""` | Fallback label if i18n key is missing | | `path` | `str` | Yes | — | Frontend route path, e.g. `/mail` | | `icon` | `str` | No | `"FileText"` | lucide-react icon name | | `group` | `str` | No | `""` | Optional group label_key for tree-style nesting | | `order` | `int` | No | `100` | Sort order within the sidebar | | `badge_key` | `str` | No | `""` | Optional store key for badge count | #### FrontendPageRoute — Page Routes ```python from app.plugins.manifest import FrontendPageRoute page_routes=[ FrontendPageRoute( path="/my-plugin", # Frontend route path component="@/pages/MyPlugin", # Dotted path to the React component parent="", # Parent route path for nested routes protected=True, # Whether the route requires authentication order=100, # Sort order ), ] ``` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `path` | `str` | Yes | — | Frontend route path, e.g. `/mail` or `/mail/settings` | | `component` | `str` | Yes | — | Dotted path to the React component, e.g. `@/pages/Mail` | | `parent` | `str` | No | `""` | Parent route path for nested routes (e.g. `/settings` for a settings sub-page) | | `protected` | `bool` | No | `True` | Whether the route requires authentication | | `order` | `int` | No | `100` | Sort order | #### FrontendDetailTab — Entity Detail Tabs ```python from app.plugins.manifest import FrontendDetailTab detail_tabs=[ FrontendDetailTab( entity_type="contact", # Entity type this tab applies to label_key="tabs.myPlugin", # i18n key for the tab label label="My Tab", # Fallback label component="@/components/MyTab", # Dotted path to the React component icon="FileText", # lucide-react icon name order=50, # Sort order within the detail view permission="my_plugin:read", # Optional permission required to see this tab ), ] ``` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `entity_type` | `str` | Yes | — | Entity type this tab applies to, e.g. `'contact'` | | `label_key` | `str` | Yes | — | i18n key for the tab label | | `label` | `str` | No | `""` | Fallback label | | `component` | `str` | Yes | — | Dotted path to the React component | | `icon` | `str` | No | `"FileText"` | lucide-react icon name | | `order` | `int` | No | `100` | Sort order within the detail view | | `permission` | `str` | No | `""` | Optional permission required to see this tab | #### FrontendSettingsPage — Settings Sub-Pages ```python from app.plugins.manifest import FrontendSettingsPage settings_pages=[ FrontendSettingsPage( path="my-plugin", # Settings sub-route path label_key="settings.myPlugin", # i18n key for the settings nav label label="My Plugin", # Fallback label component="@/pages/MyPluginSettings", # Dotted path to the React component icon="Sparkles", # lucide-react icon name order=100, # Sort order within settings nav permission="my_plugin:admin", # Optional permission required ), ] ``` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `path` | `str` | Yes | — | Settings sub-route path, e.g. `mail` or `notifications` | | `label_key` | `str` | Yes | — | i18n key for the settings nav label | | `label` | `str` | No | `""` | Fallback label | | `component` | `str` | Yes | — | Dotted path to the React component | | `icon` | `str` | No | `"Settings"` | lucide-react icon name | | `order` | `int` | No | `100` | Sort order within settings nav | | `permission` | `str` | No | `""` | Optional permission required | #### FrontendDashboardWidget — Dashboard Widgets ```python from app.plugins.manifest import FrontendDashboardWidget dashboard_widgets=[ FrontendDashboardWidget( id="my_plugin_stats", # Unique widget identifier label_key="widgets.myPlugin", # i18n key for the widget title label="My Plugin Stats", # Fallback label component="@/components/MyWidget", # Dotted path to the React component icon="LayoutDashboard", # lucide-react icon name order=100, # Sort order on the dashboard col_span=1, # Grid column span (1-4) row_span=1, # Grid row span permission="my_plugin:read", # Optional permission required ), ] ``` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `id` | `str` | Yes | — | Unique widget identifier | | `label_key` | `str` | Yes | — | i18n key for the widget title | | `label` | `str` | No | `""` | Fallback label | | `component` | `str` | Yes | — | Dotted path to the React component | | `icon` | `str` | No | `"LayoutDashboard"` | lucide-react icon name | | `order` | `int` | No | `100` | Sort order on the dashboard | | `col_span` | `int` | No | `1` | Grid column span (1-4) | | `row_span` | `int` | No | `1` | Grid row span | | `permission` | `str` | No | `""` | Optional permission required | ### 3.9 Complete Manifest Example ```python from app.plugins.base import BasePlugin from app.plugins.manifest import ( PluginManifest, PluginRouteDef, FieldDefinition, FrontendMenuItem, FrontendPageRoute, FrontendDetailTab, FrontendSettingsPage, FrontendDashboardWidget, ) class MyPlugin(BasePlugin): manifest = PluginManifest( name="my_plugin", version="1.0.0", display_name="My Plugin", description="A comprehensive example plugin.", dependencies=["permissions"], is_core=False, routes=[ PluginRouteDef( path="/api/v1/my-plugin", module="app.plugins.builtins.my_plugin.routes", router_attr="router", ), ], events=["contact.created", "contact.updated"], migrations=["0001_initial.sql"], permissions=["my_plugin:read", "my_plugin:write", "my_plugin:admin"], field_definitions=[ FieldDefinition( module="contacts", field="custom_field", label="Custom Field", sensitivity="normal", ), ], agent_capabilities=["my_plugin:search"], menu_items=[ FrontendMenuItem( label_key="nav.myPlugin", label="My Plugin", path="/my-plugin", icon="Sparkles", order=100, ), ], page_routes=[ FrontendPageRoute( path="/my-plugin", component="@/pages/MyPlugin", protected=True, ), ], detail_tabs=[ FrontendDetailTab( entity_type="contact", label_key="tabs.myPlugin", label="My Tab", component="@/components/MyTab", icon="FileText", order=50, permission="my_plugin:read", ), ], settings_pages=[ FrontendSettingsPage( path="my-plugin", label_key="settings.myPlugin", label="My Plugin", component="@/pages/MyPluginSettings", icon="Sparkles", order=100, permission="my_plugin:admin", ), ], dashboard_widgets=[ FrontendDashboardWidget( id="my_plugin_stats", label_key="widgets.myPlugin", label="My Plugin Stats", component="@/components/MyWidget", icon="LayoutDashboard", order=100, col_span=2, row_span=1, permission="my_plugin:read", ), ], ) ``` --- ## 4. Plugin Lifecycle ### 4.1 Installation (`on_install`) Called when the plugin is installed, after migrations are run. Override to perform seed data or initial setup. ```python async def on_install(self, db: AsyncSession, service_container: ServiceContainer) -> None: """Perform initial setup after migrations.""" # Create default settings settings_service = service_container.settings await settings_service.create_defaults(db, plugin_name=self.name) ``` ### 4.2 Activation (`on_activate`) Called when the plugin is activated. Override to register event listeners and prepare runtime state. The default implementation subscribes to events listed in the manifest. ```python async def on_activate( self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus ) -> None: """Register event listeners and prepare runtime state.""" # Default: subscribes to manifest events for event_name in self.manifest.events: handler = self._make_event_handler(event_name) self._event_handlers[event_name] = handler event_bus.subscribe(event_name, handler) self._container = service_container ``` ### 4.3 Deactivation (`on_deactivate`) Called when the plugin is deactivated. Override to clean up runtime state. The default implementation unsubscribes all event listeners. ```python async def on_deactivate( self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus ) -> None: """Clean up runtime state.""" for event_name, handler in self._event_handlers.items(): event_bus.unsubscribe(event_name, handler) self._event_handlers.clear() ``` ### 4.4 Uninstallation (`on_uninstall`) Called when the plugin is uninstalled (before data tables are dropped). Override to clean up external resources. ```python async def on_uninstall(self, db: AsyncSession, service_container: ServiceContainer) -> None: """Clean up external resources before tables are dropped.""" # Remove external API webhooks, etc. pass ``` --- ## 5. UI Registration Plugins contribute frontend UI elements through their manifest. The frontend `PluginRegistry` component fetches active manifests and populates the `pluginStore` (Zustand), which is then consumed by: ### 5.1 Sidebar Menu Items Menu items from all active plugins are merged and sorted by `order` via `getAllMenuItems()`. The sidebar renders them alongside built-in navigation items. ### 5.2 Page Routes Page routes are registered via `PluginRouteRenderer`, a catch-all route handler that checks the current URL against all plugin `page_routes`. When a match is found, it renders the plugin's page component using `PluginPage` (React.lazy + Suspense + ErrorBoundary). ### 5.3 Detail Tabs Detail tabs are filtered by `entity_type` via `getDetailTabsForEntity(entityType)`. Entity detail views (contacts, companies, etc.) render these tabs alongside built-in tabs. ### 5.4 Settings Pages Settings pages are merged and sorted via `getAllSettingsPages()`. The settings navigation renders them alongside built-in settings pages. ### 5.5 Dashboard Widgets Dashboard widgets are merged and sorted via `getAllDashboardWidgets()`. The dashboard grid renders them with their specified `col_span` and `row_span`. ### 5.6 Frontend Component Resolution Component paths use the `@/` alias (resolved to `src/` by Vite). The `PluginPage` component converts `@/pages/MyPlugin` to `../pages/MyPlugin` for dynamic import: ```typescript // PluginLoader.tsx const importPath = componentPath.replace(/^@\//, '../'); const LazyComp = lazy(() => import(/* @vite-ignore */ importPath).then((m) => ({ default: m.default || m[Object.keys(m)[0]], })) ); ``` --- ## 6. Event Bus ### 6.1 Subscribing to Events Events are declared in the manifest and handled by methods named `on_` with dots replaced by underscores: ```python # Manifest events=["contact.created", "contact.updated"] # Handler methods async def on_contact_created(self, event_data: dict): contact_id = event_data.get("contact_id") # React to new contact async def on_contact_updated(self, event_data: dict): contact_id = event_data.get("contact_id") # React to contact update ``` ### 6.2 Publishing Events Events are published via the EventBus service: ```python from app.core.event_bus import get_event_bus event_bus = get_event_bus() await event_bus.publish("contact.created", {"contact_id": str(contact.id)}) ``` ### 6.3 Event Naming Conventions - Format: `.` - Standard actions: `created`, `updated`, `deleted`, `assigned`, `completed` - Examples: `company.created`, `task.assigned`, `email.sent` - Use past tense for actions --- ## 7. Migration Runner ### 7.1 Writing Migrations SQL migration files are stored in the plugin's `migrations/` directory and referenced in the manifest: ```sql -- migrations/0001_initial.sql CREATE TABLE my_plugin_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id), name VARCHAR(200) NOT NULL, config JSONB DEFAULT '{}', is_active BOOLEAN DEFAULT true, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); CREATE INDEX idx_my_plugin_items_tenant ON my_plugin_items(tenant_id); ``` ### 7.2 Migration Naming - Files are named with a zero-padded sequence number and a descriptive slug: `0001_initial.sql`, `0002_add_indexes.sql` - Migrations run in alphanumeric order - Each migration runs exactly once per plugin installation ### 7.3 Migration Guidelines - Always include `tenant_id` for multi-tenant tables - Use `UUID` primary keys with `gen_random_uuid()` - Include `created_at` and `updated_at` timestamps - Add appropriate indexes for foreign keys and frequently queried columns - Use `IF NOT EXISTS` / `IF EXISTS` for idempotent operations --- ## 8. Service Container After activation, plugins can access shared services via `self.services`: ```python class MyPlugin(BasePlugin): async def on_activate(self, db, service_container, event_bus): self._container = service_container async def do_something(self): # Access services after activation settings = self.services.settings audit = self.services.audit cache = self.services.cache ``` Available services (defined in `app/core/service_container.py`): | Service | Accessor | Description | |---|---|---| | Settings | `self.services.settings` | Global and per-tenant settings | | Audit | `self.services.audit` | Audit logging | | Cache | `self.services.cache` | Redis/In-memory cache | | EventBus | `self.services.event_bus` | Event publishing/subscribing | | Notifications | `self.services.notifications` | User notifications | --- ## 9. RBAC / Permissions ### 9.1 Declaring Permissions Permissions are declared in the manifest: ```python permissions=[ "my_plugin:read", "my_plugin:write", "my_plugin:delete", "my_plugin:admin", ] ``` ### 9.2 Securing Routes Use the `require_permission` dependency: ```python from app.deps import get_current_user, require_permission from fastapi import Depends @router.get("", dependencies=[Depends(require_permission("my_plugin:read"))]) async def list_items(current_user: dict = Depends(get_current_user)): ... @router.post("", status_code=201, dependencies=[Depends(require_permission("my_plugin:write"))]) async def create_item(data: ItemCreate, current_user: dict = Depends(get_current_user)): ... ``` ### 9.3 Permission Naming Convention - Format: `:` - Standard actions: `read`, `write`, `delete`, `share`, `admin` - Examples: `calendar:read`, `dms:write`, `tags:delete` ### 9.4 Field-Level Permissions Field definitions in the manifest enable field-level access control: ```python field_definitions=[ FieldDefinition( module="companies", field="annual_revenue", label="Annual Revenue", sensitivity="sensitive", # normal|sensitive|critical ), ] ``` --- ## 10. AI Agent Integration ### 10.1 Agent Capabilities Declare AI capabilities in the manifest: ```python agent_capabilities=[ "contact_search", "email_draft", "calendar_scheduling", ] ``` ### 10.2 Tool Registry Plugins can register tools for the AI assistant: ```python from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry registry = get_tool_registry() registry.register( name="search_contacts", description="Search contacts by name, email, or phone number", parameters={ "type": "object", "properties": { "query": {"type": "string", "description": "Search query"}, "limit": {"type": "integer", "description": "Max results", "default": 10}, }, "required": ["query"], }, handler=my_search_handler, plugin_name="my_plugin", required_permission="contacts:read", category="search", ) ``` ### 10.3 Tool Handler ```python async def my_search_handler(arguments: dict, context: dict) -> str: query = arguments.get("query", "") limit = arguments.get("limit", 10) # Perform search... return json.dumps({"results": results}) ``` ### 10.4 Cleanup on Deactivation ```python async def on_deactivate(self, db, service_container, event_bus): registry = get_tool_registry() registry.unregister_plugin("my_plugin") ``` --- ## 11. Testing Guide ### 11.1 Backend Tests Tests live in the plugin's `tests/` directory or in the central `tests/` folder: ```python # tests/test_my_plugin.py import pytest from httpx import AsyncClient @pytest.mark.asyncio async def test_list_items_requires_permission(client: AsyncClient, auth_headers): response = await client.get("/api/v1/my-plugin", headers=auth_headers) assert response.status_code == 200 @pytest.mark.asyncio async def test_list_items_without_permission_returns_403(client: AsyncClient, no_perm_headers): response = await client.get("/api/v1/my-plugin", headers=no_perm_headers) assert response.status_code == 403 ``` ### 11.2 Frontend Tests Frontend tests use vitest, @testing-library/react, and jsdom: ```typescript // frontend/src/components/plugins/__tests__/PluginRegistry.test.tsx import React from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render } from '@testing-library/react'; import { PluginRegistry } from '../PluginRegistry'; import { usePluginStore } from '@/store/pluginStore'; // Mock the API hook vi.mock('@/api/pluginManifests', () => ({ useActivePluginManifests: () => ({ data: { plugins: [/* ... */], total: 1 }, isLoading: false, error: null, }), })); describe('PluginRegistry', () => { beforeEach(() => { usePluginStore.getState().reset(); }); it('populates store with manifests on mount', () => { render(); const manifests = usePluginStore.getState().manifests; expect(manifests).toHaveLength(1); }); }); ``` --- ## 12. Do's and Don'ts ### Do's - **Do** use snake_case for plugin names - **Do** declare all permissions in the manifest - **Do** secure every API route with `require_permission` - **Do** include `tenant_id` in all multi-tenant tables - **Do** use UUID primary keys - **Do** prefix i18n keys with the plugin name - **Do** clean up resources in `on_deactivate` and `on_uninstall` - **Do** write tests for both backend and frontend - **Do** follow the event naming convention `.` - **Do** use semantic versioning for plugin versions ### Don'ts - **Don't** hardcode tenant IDs or user IDs - **Don't** use synchronous database operations - **Don't** store secrets in the database without encryption - **Don't** modify other plugins' data directly (use events instead) - **Don't** create circular dependencies between plugins - **Don't** use `is_core=True` unless the plugin is essential for system operation - **Don't** skip error handling in event handlers (they run asynchronously) - **Don't** register the same route path in multiple plugins --- ## 13. Examples ### 13.1 Minimal Plugin ```python # app/plugins/builtins/minimal_example/plugin.py from app.plugins.base import BasePlugin from app.plugins.manifest import PluginManifest, PluginRouteDef class MinimalExamplePlugin(BasePlugin): manifest = PluginManifest( name="minimal_example", version="1.0.0", display_name="Minimal Example", description="A minimal example plugin.", dependencies=[], routes=[ PluginRouteDef( path="/api/v1/minimal-example", module="app.plugins.builtins.minimal_example.routes", router_attr="router", ), ], events=[], migrations=[], permissions=["minimal_example:read"], ) ``` ```python # app/plugins/builtins/minimal_example/routes.py from fastapi import APIRouter, Depends from app.deps import get_current_user, require_permission router = APIRouter() @router.get("", dependencies=[Depends(require_permission("minimal_example:read"))]) async def list_items(current_user: dict = Depends(get_current_user)): return {"items": []} ``` ### 13.2 Plugin with UI See the complete manifest example in Section 3.9 for a plugin with full UI contributions (menu items, page routes, detail tabs, settings pages, dashboard widgets). ### 13.3 Plugin with Events ```python # app/plugins/builtins/event_example/plugin.py from app.plugins.base import BasePlugin from app.plugins.manifest import PluginManifest class EventExamplePlugin(BasePlugin): manifest = PluginManifest( name="event_example", version="1.0.0", display_name="Event Example", description="Demonstrates event handling.", dependencies=[], events=["contact.created", "contact.updated", "contact.deleted"], migrations=[], permissions=[], ) async def on_contact_created(self, event_data: dict): contact_id = event_data.get("contact_id") print(f"Contact created: {contact_id}") async def on_contact_updated(self, event_data: dict): contact_id = event_data.get("contact_id") print(f"Contact updated: {contact_id}") async def on_contact_deleted(self, event_data: dict): contact_id = event_data.get("contact_id") print(f"Contact deleted: {contact_id}") ``` --- *This document is authoritative for all plugin development at LeoCRM.*