Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

Plugin protocol

Plugins are Python classes implementing BasePlugin. The framework discovers them at startup from a registry list.

Class#

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from app.plugins.base import BasePlugin
from app.plugins.protocol import (
    PluginContext,
    PluginManifest,
    PluginPermission,
    ToolDefinition,
)

class MyPlugin(BasePlugin):
    @property
    def manifest(self) -> PluginManifest: ...

    async def get_system_context(self, ctx: PluginContext) -> str | None: ...

    async def execute_tool(
        self, tool_name: str, args: str, ctx: PluginContext,
    ) -> str: ...

    # Optional:
    async def get_dynamic_tools(self, ctx: PluginContext) -> list[dict] | None:
        """Return additional function-calling tool defs at runtime
        (e.g. user-installed skills)."""

PluginManifest#

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@dataclass(frozen=True)
class PluginManifest:
    id: str                               # "namespace.name", unique
    name: str                             # display
    version: str                          # semver
    description: str
    tools: list[ToolDefinition] = ...
    permissions: list[PluginPermission] = field(default_factory=list)
    injects_context: bool = False         # if True, AI sees get_system_context output
    scope: str | None = None              # "private" = caller's data only, etc.

ToolDefinition#

python
1
2
3
4
5
6
7
8
@dataclass(frozen=True)
class ToolDefinition:
    name: str                             # function-call name
    description: str                      # what + when to use
    parameters: dict                      # OpenAI function-calling schema
    usage: str | None = None              # "[/foo <bar>]" hint for text-mode UIs
    permissions_required: list[PluginPermission] = field(default_factory=list)
    side_effects: bool = False            # True → routed through approval flow

PluginPermission#

python
1
2
3
4
5
6
7
8
9
class PluginPermission(str, Enum):
    READ_MESSAGES = "read_messages"
    WRITE_MESSAGES = "write_messages"
    READ_NOTES = "read_notes"
    WRITE_NOTES = "write_notes"
    READ_USERS = "read_users"
    EXTERNAL_HTTP = "external_http"
    SEARCH = "search"
    MANAGE_TASKS = "manage_tasks"

PluginContext#

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@dataclass
class PluginContext:
    db: AsyncSession
    tenant: TenantContext
    settings: Settings
    redis: aioredis.Redis | None
    room_id: str
    sender_id: str                        # AI participant making the call
    user_token: str | None = None         # human user's JWT (for token exchange)
    scaigrid: ScaiGridClient | None = None
    arq: ArqRedis | None = None
    approval_service: ApprovalService | None = None
    event_service_factory: Callable | None = None

Tool result conventions#

  • Return a string. Markdown is fine. Empty string == "no result".
  • For errors, return a friendly message ending with a "Fallback suggestions" line (the AI's tool-etiquette rule expects this shape).

Example:

python
1
2
3
4
5
return (
    "Drive search failed: HTTP 401.\n"
    "Fallback suggestions: `find_note` for tenant-private notes; "
    "`web_search` for public info."
)

Registering#

Add to app/plugins/builtin/__init__.py:

python
1
2
3
4
5
6
from app.plugins.builtin.my_plugin import MyPlugin

BUILTIN_PLUGINS = [
    # … existing entries …
    MyPlugin(),
]

Restart the API; the plugin is discoverable in GET /v1/admin/plugins. Enable it with:

bash
1
2
curl -X POST "$BASE/v1/admin/plugins/<plugin-id>/enable" \
  -H "Authorization: Bearer $ADMIN_TOKEN"

Lifecycle hooks (optional)#

python
1
2
3
4
5
6
class MyPlugin(BasePlugin):
    async def on_install(self, ctx: PluginContext) -> None:
        """Called once when an admin enables the plugin for a tenant."""

    async def on_uninstall(self, ctx: PluginContext) -> None:
        """Called once when an admin disables."""

Testing#

The db and tenant_context fixtures in tests/conftest.py give you everything you need:

python
1
2
3
4
5
6
7
8
async def test_my_plugin(db, tenant_context, settings):
    plugin = MyPlugin()
    ctx = PluginContext(
        db=db, tenant=tenant_context, settings=settings,
        redis=None, room_id="r", sender_id="ai",
    )
    out = await plugin.execute_tool("my_tool", '{"x": 1}', ctx)
    assert "expected" in out

See also#

Updated 2026-05-17 13:10:03 View source (.md) rev 3