Files
leocrm/docs/plugin-development-guide.md
T
Agent Zero fc96a2f86c Phase 3: Plugin-UI-System (WordPress-Style)
Backend:
- PluginManifest um 5 neue UI-Felder erweitert: menu_items, page_routes,
  detail_tabs, settings_pages, dashboard_widgets (FrontendMenuItem,
  FrontendPageRoute, FrontendDetailTab, FrontendSettingsPage,
  FrontendDashboardWidget)
- GET /api/v1/plugins/active-manifests Endpoint liefert UI-Manifeste
  aller aktiven Plugins
- Registry.get_active_manifests() + PluginService.get_active_manifests()
- 12 Built-in Plugins mit UI-Manifest-Daten gefuellt (menu_items,
  page_routes, detail_tabs, settings_pages)
- Plugin-Install-System: POST /upload (ZIP), POST /install-url (URL)
  mit Validierung (Manifest, dangerous imports, SQL migrations)

Frontend:
- pluginStore.ts (Zustand) mit PluginUiManifest Typen + Selektoren
- useActivePluginManifests() React Query Hook
- PluginRegistry.tsx — fetcht Manifeste beim App-Start
- PluginLoader.tsx — dynamisches React.lazy() mit ErrorBoundary
- PluginRouteRenderer.tsx — Catch-all fuer Plugin-Routes
- routes/index.tsx — Catch-all Routes fuer Plugin-Pages + Settings
- Sidebar.tsx — dynamische Plugin Menu-Items mit Grouping + Icons
- Settings.tsx — dynamische Plugin Settings-Pages
- ContactDetail.tsx — dynamische Plugin Detail-Tabs mit Permissions
- AppShell.tsx — PluginRegistry Provider eingebunden
- SettingsPlugins.tsx — Install-UI (ZIP Upload + URL Install)
- plugins.ts — useUploadPlugin() + useInstallPluginFromUrl() Hooks

Docs & Templates:
- docs/plugin-development-guide.md — komplette Entwickler-Doku
- templates/plugin-template/ — Boilerplate mit allen Manifest-Feldern

Tests:
- 34 Vitest-Tests (PluginRegistry, PluginLoader, PluginRouteRenderer,
  pluginStore) — alle bestanden
- TSC: keine neuen Errors (nur pre-existing Dms.tsx)
2026-07-23 19:01:18 +02:00

27 KiB

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

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

events=[
    "contact.created",
    "contact.updated",
    "contact.deleted",
]

Event naming convention: <entity>.<action> (e.g. company.created, task.assigned).

3.4 Migrations

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

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)

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

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

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

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

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

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

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

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.

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.

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.

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.

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:

// 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:

# 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:

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:

-- 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:

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:

permissions=[
    "my_plugin:read",
    "my_plugin:write",
    "my_plugin:delete",
    "my_plugin:admin",
]

9.2 Securing Routes

Use the require_permission dependency:

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:

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:

agent_capabilities=[
    "contact_search",
    "email_draft",
    "calendar_scheduling",
]

10.2 Tool Registry

Plugins can register tools for the AI assistant:

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

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

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:

# 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:

// 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

# 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"],
    )
# 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

# 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.