# 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/<plugin_name>/`:

```
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: `<entity>.<action>` (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: `<plugin_name>:<action>`.

### 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_<event_name>` 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: `<entity>.<action>`
- 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: `<plugin_name>:<action>`
- 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(<PluginRegistry />);
    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 `<entity>.<action>`
- **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.*
