Files
leocrm/docs/plugin-development-guide.md
T

1814 lines
57 KiB
Markdown
Raw Normal View History

2026-07-23 08:42:26 +02:00
# LeoCRM Plugin Development Guide
2026-07-23 19:01:18 +02:00
> **Version:** 2.0
> **Date:** 2026-07-23
> **Applies to:** All plugin developers
2026-07-23 08:42:26 +02:00
---
2026-07-23 19:01:18 +02:00
## 1. Overview
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
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>/`:
2026-07-23 08:42:26 +02:00
```
app/plugins/builtins/my_plugin/
2026-07-23 19:01:18 +02:00
├── __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
2026-07-23 08:42:26 +02:00
│ └── 0001_initial.sql
2026-07-23 19:01:18 +02:00
└── 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
),
]
2026-07-23 08:42:26 +02:00
```
2026-07-23 19:01:18 +02:00
| 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 |
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
#### 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
2026-07-23 08:42:26 +02:00
```python
from app.plugins.base import BasePlugin
2026-07-23 19:01:18 +02:00
from app.plugins.manifest import (
PluginManifest, PluginRouteDef, FieldDefinition,
FrontendMenuItem, FrontendPageRoute, FrontendDetailTab,
FrontendSettingsPage, FrontendDashboardWidget,
)
2026-07-23 08:42:26 +02:00
class MyPlugin(BasePlugin):
manifest = PluginManifest(
name="my_plugin",
version="1.0.0",
display_name="My Plugin",
2026-07-23 19:01:18 +02:00
description="A comprehensive example plugin.",
dependencies=["permissions"],
is_core=False,
2026-07-23 08:42:26 +02:00
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"],
2026-07-23 19:01:18 +02:00
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",
),
2026-07-23 08:42:26 +02:00
],
2026-07-23 19:01:18 +02:00
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",
),
2026-07-23 08:42:26 +02:00
],
)
```
2026-07-23 19:01:18 +02:00
---
## 4. Plugin Lifecycle
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
### 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:
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
```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
```
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
### 6.2 Publishing Events
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
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
---
## 23. Migration Runner
2026-07-23 19:01:18 +02:00
### 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
---
## 24. Service Container
2026-07-23 19:01:18 +02:00
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 |
---
## 25. RBAC / Permissions
2026-07-23 19:01:18 +02:00
### 9.1 Declaring Permissions
Permissions are declared in the manifest:
2026-07-23 08:42:26 +02:00
```python
permissions=[
"my_plugin:read",
"my_plugin:write",
"my_plugin:delete",
"my_plugin:admin",
2026-07-23 19:01:18 +02:00
]
2026-07-23 08:42:26 +02:00
```
2026-07-23 19:01:18 +02:00
### 9.2 Securing Routes
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
Use the `require_permission` dependency:
2026-07-23 08:42:26 +02:00
```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)):
...
```
2026-07-23 19:01:18 +02:00
### 9.3 Permission Naming Convention
2026-07-23 08:42:26 +02:00
- Format: `<plugin_name>:<action>`
2026-07-23 19:01:18 +02:00
- Standard actions: `read`, `write`, `delete`, `share`, `admin`
- Examples: `calendar:read`, `dms:write`, `tags:delete`
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
### 9.4 Field-Level Permissions
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
Field definitions in the manifest enable field-level access control:
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
```python
field_definitions=[
FieldDefinition(
module="companies",
field="annual_revenue",
label="Annual Revenue",
sensitivity="sensitive", # normal|sensitive|critical
),
]
2026-07-23 08:42:26 +02:00
```
2026-07-23 19:01:18 +02:00
---
2026-07-23 08:42:26 +02:00
## 26. AI Agent Integration
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
### 10.1 Agent Capabilities
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
Declare AI capabilities in the manifest:
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
```python
agent_capabilities=[
"contact_search",
"email_draft",
"calendar_scheduling",
]
```
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
### 10.2 Tool Registry
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
Plugins can register tools for the AI assistant:
2026-07-23 08:42:26 +02:00
```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",
)
```
2026-07-23 19:01:18 +02:00
### 10.3 Tool Handler
2026-07-23 08:42:26 +02:00
```python
async def my_search_handler(arguments: dict, context: dict) -> str:
query = arguments.get("query", "")
limit = arguments.get("limit", 10)
2026-07-23 19:01:18 +02:00
# Perform search...
2026-07-23 08:42:26 +02:00
return json.dumps({"results": results})
```
2026-07-23 19:01:18 +02:00
### 10.4 Cleanup on Deactivation
2026-07-23 08:42:26 +02:00
```python
2026-07-23 19:01:18 +02:00
async def on_deactivate(self, db, service_container, event_bus):
2026-07-23 08:42:26 +02:00
registry = get_tool_registry()
registry.unregister_plugin("my_plugin")
```
2026-07-23 19:01:18 +02:00
---
2026-07-23 08:42:26 +02:00
## 27. Testing Guide
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
### 11.1 Backend Tests
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
Tests live in the plugin's `tests/` directory or in the central `tests/` folder:
2026-07-23 08:42:26 +02:00
```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
```
2026-07-23 19:01:18 +02:00
### 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);
});
});
```
---
## 28. Do's and Don'ts
2026-07-23 19:01:18 +02:00
### 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
---
## 29. Examples
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
### 13.1 Minimal Plugin
2026-07-23 08:42:26 +02:00
```python
2026-07-23 19:01:18 +02:00
# app/plugins/builtins/minimal_example/plugin.py
2026-07-23 08:42:26 +02:00
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
2026-07-23 19:01:18 +02:00
class MinimalExamplePlugin(BasePlugin):
2026-07-23 08:42:26 +02:00
manifest = PluginManifest(
2026-07-23 19:01:18 +02:00
name="minimal_example",
2026-07-23 08:42:26 +02:00
version="1.0.0",
2026-07-23 19:01:18 +02:00
display_name="Minimal Example",
2026-07-23 08:42:26 +02:00
description="A minimal example plugin.",
dependencies=[],
routes=[
PluginRouteDef(
2026-07-23 19:01:18 +02:00
path="/api/v1/minimal-example",
module="app.plugins.builtins.minimal_example.routes",
2026-07-23 08:42:26 +02:00
router_attr="router",
),
],
events=[],
migrations=[],
2026-07-23 19:01:18 +02:00
permissions=["minimal_example:read"],
2026-07-23 08:42:26 +02:00
)
```
```python
2026-07-23 19:01:18 +02:00
# app/plugins/builtins/minimal_example/routes.py
2026-07-23 08:42:26 +02:00
from fastapi import APIRouter, Depends
from app.deps import get_current_user, require_permission
router = APIRouter()
2026-07-23 19:01:18 +02:00
@router.get("", dependencies=[Depends(require_permission("minimal_example:read"))])
2026-07-23 08:42:26 +02:00
async def list_items(current_user: dict = Depends(get_current_user)):
return {"items": []}
```
2026-07-23 19:01:18 +02:00
### 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}")
```
2026-07-23 08:42:26 +02:00
---
## 7. LLM Integration
LeoCRM stellt einen zentralen LLM-Client bereit über den alle LLM-Calls (Completion und Embedding) laufen. **Keine direkten `litellm.acompletion()` oder `litellm.aembedding()` Aufrufe in Plugin-Code.**
### 7.1 Completion
```python
from app.ai.llm_client import llm_complete
result = await llm_complete(
model="openai/gpt-4o", # oder None für Default-Model
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Summarize this email."},
],
temperature=0.3,
max_tokens=1000,
# Optional: API-Key/Base aus DB holen
db=db,
tenant_id=tenant_id,
# Optional: JSON-Response erzwingen
response_format={"type": "json_object"},
# Optional: Tools für Function-Calling
tools=[{"type": "function", "function": {...}}],
# Optional: Retry-Konfiguration
timeout=30,
max_retries=2,
)
content = result["content"] # str — LLM-Response-Text
usage = result["usage"] # dict — {prompt_tokens, completion_tokens, total_tokens}
cost_usd = result["cost_usd"] # float — geschätzte Kosten
model = result["model"] # str — verwendetes Modell
raw_response = result["raw_response"] # litellm-Response-Objekt für erweiterte Nutzung
```
### 7.2 Embedding
```python
from app.ai.llm_client import llm_embed
# Einzelne Embedding
embeddings = await llm_embed(
texts="Text to embed",
model="openai/text-embedding-3-small", # oder None für Default
db=db,
tenant_id=tenant_id,
dimensions=768, # Optional, für text-embedding-3 Modelle
)
# → [[0.01, 0.02, ...]]
# Batch-Embedding
embeddings = await llm_embed(
texts=["Text 1", "Text 2", "Text 3"],
db=db,
tenant_id=tenant_id,
)
# → [[...], [...], [...]]
```
### 7.3 Provider-Auswahl und API-Key-Auflösung
Der zentrale Client löst API-Keys automatisch aus der Datenbank (`AIProvider`-Tabelle) oder Environment-Variablen. Priorität:
1. Explizit übergebener `api_key` Parameter
2. DB-Lookup über `get_api_credentials(db, tenant_id)`
3. Environment-Variablen (`AI_API_KEY`, `AI_API_BASE`, `AI_PROVIDER`)
4. Mock-Mode (kein API-Key → Keyword-basierte Fallback-Antworten)
```python
from app.ai.llm_client import get_api_credentials, build_model
# API-Credentials aus DB holen
api_key, api_base, provider_type = await get_api_credentials(db, tenant_id)
# Model-String bauen (provider/model)
model = build_model("gpt-4o", provider_type) # → "openai/gpt-4o"
```
### 7.4 Error-Handling
Der zentrale Client klassifiziert Errors automatisch:
- **Transient** (Timeout, Rate-Limit 429, Service-Unavailable 503) → Retry mit Exponential-Backoff
- **Permanent** (Auth 401/403, Validation, Model-Not-Found) → Sofortiger Fehler, kein Retry
```python
try:
result = await llm_complete(model="openai/gpt-4o", messages=[...])
except Exception as e:
# Transient errors wurden bereits retried
# Permanent errors kommen hier an
logger.error(f"LLM call failed permanently: {e}")
```
### 7.5 Cost-Tracking
`llm_complete()` gibt `cost_usd` zurück — automatisch berechnet aus Token-Usage. Plugins sollen diesen Wert in ihren Cost-Tracking-Mechanismus übernehmen.
```python
result = await llm_complete(...)
total_cost += result["cost_usd"]
```
### 7.6 Was NICHT zu tun ist
-`import litellm` und direkte `litellm.acompletion()` / `litellm.aembedding()` Aufrufe
- ❌ Eigene API-Key-Verwaltung — immer über `get_api_credentials()` oder `llm_complete(db=db, tenant_id=tenant_id)`
- ❌ Eigene Retry-Logik — `llm_complete()` hat bereits Retry mit Backoff
- ❌ Eigene Cost-Tracking-Logik — `llm_complete()` gibt `cost_usd` zurück
- ❌ Eigene Provider-Auswahl — `build_model()` und `get_api_credentials()` zentralisieren das
---
## 8. Event-System Rollen
LeoCRM hat **4 Event-Systeme** mit klar getrennten Rollen. **Nicht dieselbe Funktion über Hook UND EventBus triggern.**
### Übersicht
| System | Rolle | Persistenz | Use Case |
|--------|-------|-----------|----------|
| **HookRegistry** | Lifecycle-Erweiterungspunkte | In-Memory | `contact.before_create`, `mail.after_send`, `dms.after_delete` — Plugins können Daten anpassen oder reagieren |
| **EventBus** | Flüchtige interne Events | In-Memory | `notification.created`, `ui.contact_selected` — asynchrone Notifikationen, UI-Events, Proactive Suggestions |
| **Outbox** | Dauerhafte Domain Events | DB (transactional) | `contact.created`, `mail.received`, `task.completed` — reliable Delivery, Retry, DLQ, Worker-Polling |
| **WebhookDispatcher** | Externe HTTP-Zustellung | DB + HTTP | Externe Webhooks an registrierte URLs — Retry, Auth, Payload-Signatur |
### 8.1 HookRegistry (`app/core/hooks.py`)
**Wann verwenden:** Wenn ein Plugin bei einem Lifecycle-Punkt Daten anpassen oder reagieren will.
```python
from app.core.hooks import get_hook_registry
reg = get_hook_registry()
# Action — kein Return, nur Seiteneffekte
reg.register_action("contact.before_create", self._on_contact_create, priority=10)
# Filter — Return modifizierten Wert
def _format_name(self, name: str) -> str:
return name.title()
reg.register_filter("contact.format_display_name", self._format_name, priority=10)
```
**Aufruf im Core/Plugin-Service:**
```python
from app.core.hooks import do_action, apply_filters
await do_action("contact.before_create", contact_data, db=db)
display_name = await apply_filters("contact.format_display_name", contact.name)
```
### 8.2 EventBus (`app/core/event_bus.py`)
**Wann verwenden:** Für flüchtige interne Notifikationen, UI-Events, Proactive Suggestions. **Nicht** für Events die reliable Delivery brauchen.
```python
from app.core.event_bus import get_event_bus
event_bus = get_event_bus()
# Subscribe
event_bus.subscribe("notification.created", self._on_notification)
# Publish (ephemeral — geht verloren bei Crash)
await event_bus.publish("notification.created", {"user_id": "...", "message": "..."})
```
### 8.3 Outbox (`app/core/outbox.py`)
**Wann verwenden:** Für dauerhafte Domain Events die reliable Delivery, Retry und Worker-Verarbeitung brauchen.
```python
from app.core.outbox import enqueue_outbox_event
# In derselben Transaktion wie die Business-Operation
await enqueue_outbox_event(db, tenant_id, "contact.created", {
"contact_id": str(contact.id),
"tenant_id": str(tenant_id),
})
# Transaction commit → Event ist durable → Worker pollt und published an EventBus
```
**Features:** DLQ (`error_message`, `failed_at`), Replay (`replay_failed_event`), Consumer Registry, Stats.
### 8.4 WebhookDispatcher (`app/core/webhook_dispatcher.py`)
**Wann verwenden:** Für externe HTTP-Zustellung an registrierte Webhook-URLs.
```python
from app.core.webhook_dispatcher import register_webhook_event_handlers
# Wird automatisch im Worker registriert — Plugins müssen nur Webhook-Configs erstellen
# und Events über die Outbox publishen
```
### 8.5 Entscheidungsregel
```text
Braucht das Event reliable Delivery + Retry?
→ JA → Outbox
→ NEIN → Braucht es Daten-Anpassung (Filter)?
→ JA → HookRegistry (register_filter)
→ NEIN → Braucht es nur Reaktion (Action)?
→ JA → HookRegistry (register_action)
→ NEIN → Ist es eine flüchtige Notifikation / UI-Event?
→ JA → EventBus
→ NEIN → Geht es an externe Systeme?
→ JA → WebhookDispatcher (über Outbox)
→ NEIN → Braucht kein Event
```
**Verboten:** Dieselbe Funktion über Hook UND EventBus triggern — das führt zu Doppel-Ausführung und Race-Conditions.
---
## 9. Schema Authority
LeoCRM hat eine klare Schema-Authority-Hierarchie. **Kein neuer Schema-Mechanismus.**
### Authority-Regeln
| Schema-Typ | Authority | Wie |
|-----------|----------|-----|
| **Core-Tabellen** | Alembic-Migrationen | `alembic revision --autogenerate -m "..."``alembic upgrade head` |
| **Plugin-Tabellen** | Plugin-Migrationsweg | `plugin/migrations/``sync_plugin_schema.py` bei Aktivierung |
| **Runtime Auto-Sync** | **Nicht authoritative** | `Base.metadata.create_all` in Tests/dev — nie in Produktion |
### Verbindliche Regeln
1. **Core-Schema-Änderungen** immer über Alembic-Migrationen — nie manuelle SQL-Statements in Produktion
2. **Plugin-Schema-Änderungen** über Plugin-Migrationen — nie Core-Migrationen für Plugin-Tabellen
3. **Runtime Auto-Sync** (`create_all`, `sync_plugin_schema.py`) ist Convenience für Dev/Tests — **nicht** für Produktion authoritative
4. **Migration-Staffelung** beachten: neu → migrieren → umstellen → testen → release → alt entfernen
5. **Keine Schema-Drift** — wenn Core und Plugin dasselbe Modell nutzen, ist Core authoritative
### Plugin-Migrationen
```python
# plugin/migrations/001_initial.py
from alembic import op
def upgrade():
op.create_table("my_plugin_table", ...)
def downgrade():
op.drop_table("my_plugin_table")
```
Plugin-Migrationen werden bei Plugin-Aktivierung automatisch ausgeführt (`sync_plugin_schema.py`). Bei Deaktivierung bleiben die Tabellen erhalten (Soft-Deactivate). Bei Uninstall werden sie gedroppt.
---
## 10. Trigger
Plugins können durch vier Trigger-Typen aktiviert werden: **Domain-Events**, **UI-Events**, **Cron-Jobs** und **manuelle Trigger**. Webhook-Trigger folgt in Phase G.
### 10.1 Domain-Event-Trigger (durable)
Domain-Events werden über die **Outbox** gepublished und sind reliable. Siehe Kapitel 8 für die Event-System-Rollen.
```python
# Plugin deklariert Events im Manifest
manifest = PluginManifest(
name="my_plugin",
events=["contact.created", "contact.updated"],
...
)
# Handler wird automatisch via on_activate registriert:
async def on_contact_created(self, payload: dict[str, Any]) -> None:
"""Reagiert auf contact.created Outbox-Event."""
contact_id = payload.get("contact_id")
# Business logic here
```
### 10.2 UI-Event-Trigger (ephemeral)
Flüchtige UI-Events (z.B. `ui.contact_selected`) laufen über den **EventBus** — nicht über die Outbox. Siehe Kapitel 18.
```python
event_bus = get_event_bus()
event_bus.subscribe("ui.contact_selected", self._on_contact_selected)
```
### 10.3 Cron-Trigger
Cron-Jobs werden im Manifest deklariert und vom Worker ausgeführt:
```python
from app.plugins.manifest import CronJobContribution
cron_jobs=[
CronJobContribution(
name="my_plugin_daily_cleanup",
cron_expression="0 3 * * *",
job_type="custom",
plugin_name="my_plugin",
),
],
```
### 10.4 Manuelle Trigger
Manuelle Trigger laufen über API-Routes oder Automation-Templates:
```python
@router.post("/api/v1/my-plugin/run-sync")
async def run_manual_sync(request: Request, db: AsyncSession = Depends(get_db)):
"""Manueller Trigger — Benutzer startet Sync von der UI."""
# Business logic
return {"status": "started"}
```
**Wichtig:** Durable Domain Events (Outbox) und ephemere UI-Events (EventBus) strikt trennen. Siehe Kapitel 8.5 Entscheidungsregel.
---
## 11. Message-System
Plugins können System-Nachrichten in Chat-Räume posten, eigene Chat-Räume erstellen und Mini-Apps registrieren. Das Communication-Plugin (`kommunikation`) stellt die Infrastruktur bereit.
### 11.1 System-Nachrichten posten
```python
from app.plugins.builtins.kommunikation.contracts import create_plugin_room, send_message
# Plugin-Room für einen Benutzer erstellen
await create_plugin_room(
db, tenant_id, user_id,
plugin_name="my_plugin",
title="My Plugin",
participant_type="system",
user_role="reader",
)
# Nachricht in den Room senden
await send_message(
db, tenant_id, conversation_id,
sender_id=None,
sender_type="system",
content="**Sync abgeschlossen**\n120 Kontakte aktualisiert",
content_format="markdown",
blocks=None,
metadata={"event_type": "sync.completed"},
)
```
### 11.2 Rich Content Blocks
Nachrichten können strukturierte Blocks enthalten (`app/plugins/builtins/kommunikation/content_types.py`):
```python
blocks = [
{
"block_type": "action_card",
"block_data": {
"title": "Backup fehlgeschlagen",
"body": "Letztes Backup um 03:00 Uhr ist gescheitert.",
"actions": [
{"label": "Öffnen", "action": "/settings/backup", "type": "primary"},
{"label": "Archivieren", "action": "dismiss", "type": "secondary"},
],
},
},
{
"block_type": "contact_card",
"block_data": {"contact_id": str(contact_id), "name": "Max Mustermann"},
},
{
"block_type": "miniapp",
"block_data": {"app_id": "my_miniapp", "config": {"contact_id": str(contact_id)}},
},
]
await send_message(db, tenant_id, conv_id, sender_id=None, sender_type="system",
content="Neuer Kontakt", content_format="markdown", blocks=blocks)
```
Unterstützte Block-Typen: `text`, `markdown`, `html`, `image`, `audio`, `video`, `file`, `action_card`, `contact_card`, `miniapp`.
### 11.3 Mini-Apps registrieren
Mini-Apps werden im Manifest deklariert:
```python
from app.plugins.manifest import MiniAppContribution
miniapps=[
MiniAppContribution(
app_id="my_miniapp",
name="My Mini App",
icon="AppWindow",
description="Interactive mini-app embedded in chat",
render_schema={"type": "object", "properties": {"contact_id": {"type": "string"}}},
),
],
```
Siehe `system_notif` Plugin als Referenz-Implementierung.
---
## 12. Search
Plugins können Search-Provider registrieren, um ihre Entitäten in der Unified Search bereitzustellen.
### 12.1 SearchProvider implementieren
Implementiere das `SearchProvider`-Protokoll oder erbe von `BaseSearchProvider` für automatische Visibility-Filterung:
```python
from app.plugins.builtins.unified_search.base_provider import BaseSearchProvider
class MyEntitySearchProvider(BaseSearchProvider):
entity_type = "my_entity"
async def _search_fts_filtered(self, db, tsquery, tenant_id, limit, visible_ids):
# FTS-Query mit visible_ids Filter
...
async def _search_vector_filtered(self, db, embedding, tenant_id, limit, visible_ids):
# Vector-Search mit pgvector + visible_ids Filter
...
async def get_embedding_text(self, db, entity_id, tenant_id) -> str:
# Text-Repräsentation für Embedding-Generierung
...
def to_search_result(self, entity) -> dict:
return {"id": str(entity.id), "type": "my_entity", "title": entity.name}
```
### 12.2 Provider registrieren
```python
from app.plugins.builtins.unified_search.provider_registry import get_search_registry
async def on_activate(self, db, service_container, event_bus):
await super().on_activate(db, service_container, event_bus)
registry = get_search_registry()
registry.register(MyEntitySearchProvider())
async def on_deactivate(self, db, service_container, event_bus):
get_search_registry().unregister("my_entity")
await super().on_deactivate(db, service_container, event_bus)
```
### 12.3 Unterstützte Modi
| Modus | Beschreibung |
|-------|-------------|
| **FTS** | PostgreSQL Full-Text Search (`tsquery`) |
| **Vector** | Semantische Suche via pgvector (`embedding`) |
| **RAG** | Retrieval-Augmented Generation (Vector + LLM) |
| **Graph** | Beziehungs-Suche (zukünftig) |
### 12.4 Auto-Indexierung
Wenn eine Entität erstellt/aktualisiert wird, wird ein Outbox-Event gepublished. Der Worker generiert das Embedding und aktualisiert den Such-Index automatisch. Plugins müssen nur `get_embedding_text()` korrekt implementieren.
---
## 13. File Storage
Plugins speichern Files über das zentrale Storage-Backend (`app/core/storage.py`). **Keine eigenen Storage-Backends implementieren.**
### 13.1 save_with_metadata()
```python
from app.core.storage import save_with_metadata, get_storage_backend
# Speichert mit MIME-Prüfung, Size-Limit und Hash-Berechnung
metadata = await save_with_metadata(
path=f"my_plugin/{tenant_id}/{file_name}",
data=file_bytes,
allowed_mimes=["application/pdf", "image/png", "image/jpeg"], # None = Default-Allowlist
max_size_mb=10, # None = aus Config
)
# Returns: {path, mime_type, size, hash, storage_path}
```
### 13.2 MIME-Prüfung und Path-Traversal-Schutz
```python
from app.core.storage import validate_mime, validate_size, compute_hash
# MIME wird content-based erkannt (python-magic) mit Extension-Fallback
mime = validate_mime(path, data, allowed_mimes=["application/pdf"])
# ValueError bei nicht erlaubtem MIME-Typ
# Path-Traversal wird im LocalStorage automatisch blockiert:
# _full_path() normt den Pfad und prüft, ob er innerhalb base_path bleibt
```
### 13.3 Storage-Backend lesen
```python
backend = get_storage_backend()
data = await backend.read(path)
url = await backend.get_url(path, expires=3600)
exists = await backend.exists(path)
await backend.delete(path)
```
Für File-Embedding siehe Kapitel 7.2 (LLM Integration — Embedding).
---
## 14. Redis
Plugins nutzen den globalen Redis-Singleton. **Keine eigenen Connections, kein `aioredis.from_url()`.**
### 14.1 Redis-Client
```python
from app.core.auth import get_redis
redis = get_redis() # Globaler Singleton
await redis.setex(f"my_plugin:lock:{resource_id}", 30, "locked")
value = await redis.get(f"my_plugin:lock:{resource_id}")
await redis.delete(f"my_plugin:lock:{resource_id}")
```
### 14.2 Cache-Wrapper
```python
from app.core.cache import cache_get, cache_set, cache_delete, cache_flush_pattern
# Set mit TTL (Default: 300s)
await cache_set(f"my_plugin:summary:{tenant_id}", {"count": 42}, ttl=60)
# Get
data = await cache_get(f"my_plugin:summary:{tenant_id}")
# Delete
await cache_delete(f"my_plugin:summary:{tenant_id}")
# Pattern-Flush (alle Keys mit Prefix)
await cache_flush_pattern("my_plugin:*")
```
**Verboten:** `aioredis.from_url()` in Plugin-Code — immer `get_redis()` oder `get_cache()` verwenden.
---
## 15. Permissions
Plugins deklarieren Permissions im Manifest und nutzen das bestehende RBAC/ABAC-System.
### 15.1 Permissions deklarieren
```python
manifest = PluginManifest(
name="my_plugin",
permissions=[
"my_plugin:read",
"my_plugin:write",
"my_plugin:admin",
],
...
)
```
### 15.2 Permissions in Routes erzwingen
```python
from app.deps import require_permission
@router.get("/api/v1/my-plugin/items",
dependencies=[Depends(require_permission("my_plugin:read"))])
async def list_items(...):
...
@router.post("/api/v1/my-plugin/items",
dependencies=[Depends(require_permission("my_plugin:write"))])
async def create_item(...):
...
```
### 15.3 Wildcard-Support
Das Permission-System unterstützt Wildcards: `my_plugin:*`, `*:read`, `*:*` (Superadmin). Bare `*` ist verboten.
### 15.4 Field-Level Permissions
```python
from app.plugins.manifest import FieldDefinition
field_definitions=[
FieldDefinition(module="my_plugin", field="secret_code",
label="Secret Code", sensitivity="sensitive"),
],
```
**Wichtig:** Tools, Skills und MCP dürfen keine Rechte verleihen. Siehe `docs/permissions.md` und `docs/permissions_plugin_dev.md`.
---
## 16. AI Tools
Plugins können Tools in der globalen `ToolRegistry` registrieren, die von AI-Agenten aufgerufen werden können.
### 16.1 Tool registrieren
```python
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
async def on_activate(self, db, service_container, event_bus):
await super().on_activate(db, service_container, event_bus)
registry = get_tool_registry()
registry.register(
name="my_plugin_lookup_contact",
description="Look up a contact by name in My Plugin",
parameters={
"type": "object",
"properties": {
"name": {"type": "string", "description": "Contact name"},
},
"required": ["name"],
},
handler=self._lookup_contact_handler,
plugin_name="my_plugin",
required_permission="my_plugin:read",
category="contacts",
)
async def _lookup_contact_handler(self, arguments: dict, context: dict) -> str:
name = arguments.get("name", "")
# Business logic — return JSON string
return f'{{"found": true, "name": "{name}"}}'
```
### 16.2 Tool bei Deaktivierung entfernen
```python
async def on_deactivate(self, db, service_container, event_bus):
get_tool_registry().unregister_plugin("my_plugin")
await super().on_deactivate(db, service_container, event_bus)
```
### 16.3 Permission-Prüfung
Jedes Tool hat ein `required_permission`-Feld. Der AI-Service prüft dies vor der Ausführung:
```python
# Wird automatisch im Service geprüft:
if tool.required_permission:
if not check_permission(user_context, tool.required_permission):
return f"Error: Permission '{tool.required_permission}' required"
```
### 16.4 Tool-Schema (OpenAI-kompatibel)
Das `AITool`-Dataclass konvertiert automatisch ins OpenAI Function-Calling-Format via `to_openai_schema()`.
---
## 17. MCP
MCP (Model Context Protocol) dient als **dünne Exposure-Schicht** auf bestehende Tools/Services. MCP erhält **keine eigenen Rechte**.
### 17.1 Architektur
```
External MCP Server → MCP Client Plugin → ToolRegistry → Existing Tools/Services
```
MCP-Tools werden mit dem Naming-Schema `mcp__{server}__{tool}` in der ToolRegistry registriert.
### 17.2 Tool-Registrierung
```python
# MCP Client Plugin registriert externe Tools automatisch:
registry.register(
name=f"mcp__{server_name}__{tool_name}",
description=f"[MCP:{server_name}] {tool.description}",
parameters=tool.parameters,
handler=_make_handler(server_cfg, tool.name),
plugin_name="mcp_client",
required_permission="mcp-client:read", # Bestehende Permission
category="mcp-external",
)
```
### 17.3 Auth- und Run-as-Kontext
Der vorhandene Auth-/Run-as-Kontext und normale Permission-Prüfungen bleiben maßgeblich. MCP-Tools erben die Permissions des aufrufenden Benutzers — MCP kann keine Rechte verleihen, die der Benutzer nicht hat.
**Verboten:** Eigene Auth-Bypass-Logik in MCP-Handlern. Immer den Standard-Permission-Check verwenden.
---
## 18. UI-Events
Plugins können auf UI-Events reagieren und eigene UI-Events publishen. UI-Events sind **ephemeral** — über EventBus, nicht über Outbox.
### 18.1 UI-Events abonnieren
```python
from app.core.event_bus import get_event_bus
async def on_activate(self, db, service_container, event_bus):
await super().on_activate(db, service_container, event_bus)
event_bus.subscribe("ui.contact_selected", self._on_contact_selected)
event_bus.subscribe("ui.page_navigated", self._on_page_navigated)
event_bus.subscribe("ui.mail_opened", self._on_mail_opened)
async def _on_contact_selected(self, payload: dict) -> None:
contact_id = payload.get("contact_id")
# React to contact selection — e.g. preload data
```
### 18.2 UI-Events publishen
```python
event_bus = get_event_bus()
await event_bus.publish("ui.my_plugin_widget_ready", {
"widget_id": "summary",
"tenant_id": str(tenant_id),
"user_id": str(user_id),
})
```
### 18.3 Bekannte UI-Events
| Event | Payload | Beschreibung |
|-------|---------|-------------|
| `ui.contact_selected` | `{contact_id, user_id}` | Benutzer hat Kontakt ausgewählt |
| `ui.page_navigated` | `{path, user_id}` | Benutzer hat Seite navigiert |
| `ui.mail_opened` | `{mail_id, user_id}` | Benutzer hat E-Mail geöffnet |
**Wichtig:** UI-Events gehen bei Crash verloren. Für reliable Delivery Outbox verwenden (Kapitel 8).
---
## 19. AI UI Control
Das `ai_ui_control` Plugin ermöglicht AI-Agenten, die Frontend-UI zu steuern: Navigation, Filter, Kontakte öffnen, Modals, Tabs und Settings.
### 19.1 Command-Typen
```python
from app.plugins.builtins.ai_ui_control.schemas import UICommandType
# Unterstützte Actions:
# navigate → {action: 'navigate', path: '/contacts/123'}
# filter → {action: 'filter', entity: 'contacts', filter: {type: 'company'}}
# open_contact → {action: 'open_contact', contact_id: '...'}
# modal → {action: 'modal', modal: 'edit', contact_id: '...'}
# tab → {action: 'tab', tab: 'emails', contact_id: '...'}
# settings → {action: 'settings', section: 'ai', key: 'model', value: 'gpt-4'}
```
### 19.2 Command senden (REST)
```python
# AI-Agent sendet Command via REST:
POST /api/v1/ai-ui-control/command
{
"action": "navigate",
"path": "/contacts/abc-123",
"description": "Opening contact detail page"
}
# Response: {command_id, status: "pending", action: "navigate"}
```
### 19.3 Command empfangen (WebSocket)
Das Frontend verbindet sich via WebSocket `/ws/ai-ui-control` und empfängt Commands in Echtzeit. Nach Ausführung sendet das Frontend Feedback zurück:
```python
UICommandFeedback(
command_id="...",
status=UICommandStatus.success,
action=UICommandType.navigate,
current_path="/contacts/abc-123",
)
```
### 19.4 Permissions
AI UI Control benötigt `ai_ui_control:write` für Commands und `ai_ui_control:read` für Status-Abfragen.
**Wichtig:** Persistente Mutationen (Daten ändern, erstellen, löschen) dürfen **nicht** über AI UI Control laufen. Diese müssen über reguläre Tools/Services mit Permission-Prüfungen gehen. AI UI Control ist nur für UI-Navigation und Anzeige.
---
## 20. Sensitive Data
Plugins müssen sensible Daten explizit deklarieren und sicherstellen, dass diese nicht in Snapshots, Such-Index, Embeddings, Exporten oder Logs landen.
### 20.1 SENSITIVE_FIELDS deklarieren
Im Manifest über `field_definitions` mit `sensitivity="sensitive"` oder `sensitivity="critical"`:
```python
from app.plugins.manifest import FieldDefinition
field_definitions=[
FieldDefinition(module="my_plugin", field="api_key",
label="API Key", sensitivity="critical"),
FieldDefinition(module="my_plugin", field="internal_notes",
label="Internal Notes", sensitivity="sensitive"),
],
```
### 20.2 Was NICHT in Snapshots/Index/Embeddings/Export/Logs darf
- **Passwords, Tokens, API Keys, Session-IDs** — niemals loggen, indexieren oder embedden
- **Personenbezogene Daten** (DSGVO-relevant) — nicht in Such-Embeddings ohne explizite Freigabe
- **Interne Notizen** mit `sensitivity="sensitive"` — nicht in Exporten ohne Berechtigung
### 20.3 Error-Logging Sanitization
Das Error-Logging-Endpoint (`/api/v1/errors`) sanitized automatisch sensible Keys:
```python
# app/routes/errors.py — _SENSITIVE_PATTERNS
# Erkennt: token, password, secret, authorization, cookie, session,
# api_key, access_token, refresh_token, csrf, bearer, private_key
# Diese Felder werden durch "[redacted]" ersetzt.
```
### 20.4 AI/Data Exposure Policy
- AI-Tools dürfen keine sensiblen Felder an LLM-Provider senden, ohne dass der Benutzer die entsprechende Permission hat
- Embeddings dürfen nur aus nicht-sensiblen Texten generiert werden
- Export-Service respektiert Field-Level Permissions (`hidden`, `readonly`, `read`)
---
## 21. Migration-Staffelung
Schema-Änderungen in Plugins müssen gestaffelt durchgeführt werden, um Downtime und Datenverlust zu vermeiden.
### 21.1 Sechs-Schritt-Prozess
```text
1. Neue Struktur erstellen (neue Tabelle/Spalte/Index)
→ Migration 0002_add_new_column.sql
2. Daten migrieren (Backfill)
→ Migration 0003_backfill_data.sql (oder Worker-Job)
3. Reads/Writes umstellen
→ Code schreibt in neue UND alte Struktur (Dual-Write)
→ Code liest aus neuer Struktur (mit Fallback auf alte)
4. Tests
→ Unit-Tests mit neuer Struktur
→ Integration-Tests mit Dual-Write
→ Migration-Tests (upgrade + downgrade)
5. Stabiler Release
→ Deploy mit neuer Struktur + Dual-Write
→ Verify: alle Daten korrekt migriert
6. Alte Struktur entfernen
→ Migration 0004_drop_old_column.sql
→ Code: Dual-Write entfernen, nur neue Struktur
```
### 21.2 Plugin-Migration-Beispiel
```sql
-- migrations/0002_add_status_v2.sql
ALTER TABLE my_plugin_items ADD COLUMN status_v2 VARCHAR(20) DEFAULT 'active';
-- migrations/0003_backfill_status.sql
UPDATE my_plugin_items SET status_v2 = CASE
WHEN status = 'pending' THEN 'pending'
WHEN status = 'done' THEN 'completed'
ELSE 'active'
END;
```
```python
# Schritt 3: Dual-Write in Service
item.status = old_status # alte Spalte
item.status_v2 = map_to_new_status(old_status) # neue Spalte
```
**Wichtig:** Jeder Schritt ist ein separater Release. Nie Struktur ändern und Daten migrieren in einer Migration.
---
## 22. Error-Handling
Plugins werfen strukturierte Errors über `ApiError` mit `code`, `detail`, `field` und `status`. Tracebacks werden niemals an den User gesendet.
### 22.1 ApiError werfen
```python
from app.core.error_codes import ApiError
@router.post("/api/v1/my-plugin/items")
async def create_item(body: ItemCreate, db: AsyncSession = Depends(get_db)):
if not body.name:
raise ApiError(code="validation_error", detail="Name is required", field="name")
existing = await check_duplicate(db, body.name)
if existing:
raise ApiError(code="not_found", detail="Item already exists", status=409)
# Service unavailable
raise ApiError(code="service_unavailable", detail="External API timeout")
```
### 22.2 Standardisierte Error-Codes
| Code | HTTP Status | Beschreibung |
|------|------------|-------------|
| `not_found` | 404 | Resource nicht gefunden |
| `permission_denied` | 403 | Keine Berechtigung |
| `validation_error` | 422 | Validierung fehlgeschlagen |
| `rate_limited` | 429 | Zu viele Requests |
| `internal_error` | 500 | Interner Fehler |
| `service_unavailable` | 503 | Service temporär nicht verfügbar |
### 22.3 Error-Propagation-Kette
```text
Plugin → ApiError(code, detail)
→ Core Exception Handler → JSON Response {error: {code, detail, field}}
→ Frontend API Client → TanStack Query onError
→ ErrorBoundary / Toast Notification → User
```
### 22.4 trace_id-Korrelation
Jeder Error bekommt eine `trace_id` für End-to-End-Tracing:
```python
# WebSocket-Errors via ws_helpers:
from app.core.ws_helpers import send_ws_error
await send_ws_error(websocket, code="validation_error",
detail="Invalid input", trace_id=trace_id)
```
### 22.5 Frontend ErrorBoundary
Plugin-Seiten und MiniApps **müssen** eine React ErrorBoundary haben. Bei unhandled Errors wird eine freundliche Fehlermeldung angezeigt — kein Stacktrace.
### 22.6 Partial-Failure bei Batch-Operationen
Bei Batch-Operationen (z.B. Bulk-Import) wird **nicht** die gesamte Operation abgebrochen. Erfolgreiche Items werden committed, fehlgeschlagene Items werden mit Fehlergrund gesammelt zurückgegeben:
```python
results = {
"success": [item_id_1, item_id_2],
"failed": [{"item": item_3, "error": "validation_error: Name required"}],
}
```
**Verboten:** Unbehandelte Tracebacks an den User senden. Alle Plugin-Errors müssen als `ApiError` geworfen werden.
---
2026-07-23 19:01:18 +02:00
*This document is authoritative for all plugin development at LeoCRM.*