Skip to content

Plugin SDK

chatwire plugins extend the bridge with new integrations. A plugin is a Python package that declares a chatwire.integrations entry point pointing at a BaseIntegration subclass.

Two plugin surfaces

  1. Backend (Python) — hook into the bridge to receive messages, send replies, and access plugin config. Uses chatwire-sdk.
  2. Frontend (React) — inject UI components into named slots in the web app. Uses the window.chatwire slot API.

The Stats integration (integrations/stats/) is the canonical end-to-end example.

Backend plugins

Install the SDK

pip install chatwire-sdk
# or in-tree during development:
pip install -e packages/sdk/

Scaffold a plugin

chatwire-plugin init my_greeter
cd my_greeter

Generated structure:

my_greeter/
  pyproject.toml          # Package metadata + entry point
  my_greeter/
    __init__.py
    plugin.py             # BaseIntegration subclass
  tests/
    __init__.py
    test_plugin.py
  README.md

Implement hooks

from chatwire_sdk import BaseIntegration, chatwire_plugin

@chatwire_plugin
class GreeterIntegration(BaseIntegration):
    NAME = "greeter"               # stable snake_case key; matches config block
    DISPLAY_NAME = "Greeter"       # shown in Settings
    DESCRIPTION = "Sends a greeting on new messages."
    VERSION = "1.0.0"
    AUTHOR = "Your Name"
    TIER = "notify"                # notify | official

    SETTINGS_SCHEMA = {
        "type": "object",
        "properties": {
            "enabled": {"type": "boolean", "default": False},
            "greeting": {"type": "string", "default": "Hello!"},
        },
    }

    async def on_startup(self) -> None:
        self.log_info("greeter started")

    async def on_shutdown(self) -> None:
        self.log_info("greeter stopped")

    async def on_message_received(self, msg) -> None:
        """Called for every inbound message the relay scope allows."""
        self.log_info(f"new message from {msg.handle}")

    async def on_message_sent(self, msg) -> None:
        """Called after every outbound send."""

Hook reference

Hook When Available data
on_startup() Bridge starts
on_shutdown() Bridge stops
on_message_received(msg) Inbound message msg.text, msg.handle, msg.is_from_me, msg.chat_guid
on_message_sent(msg) Outbound send confirmed Same fields + msg.outcome.status

For notify-tier plugins, msg.text is empty — only sender name, group, and timestamp are available. Use official tier for full message text access.

Reading config

Plugin config is the plugin's block under integrations in config.json:

async def on_startup(self) -> None:
    cfg = self.config          # dict, freshly loaded on each call
    api_key = cfg.get("api_key", "")
    enabled = cfg.get("enabled", False)

Logging

self.log_info("message")      # visible in Settings → Logs
self.log_warn("warning")
self.log_error(f"error: {exc}")

Set LOGS_VISIBLE = False to redirect logs to ~/.chatwire/plugins/<name>/plugin.log instead of the shared log viewer:

class MyPlugin(BaseIntegration):
    LOGS_VISIBLE = False

Official-tier plugins (send capability)

official-tier plugins can receive full message text and send replies:

from chatwire_sdk import BaseIntegration, OfficialMessage

class MyOfficialPlugin(BaseIntegration):
    TIER = "official"

    async def on_official_message(self, msg: OfficialMessage) -> None:
        if "hello" in msg.text.lower():
            await self._ctx.send_text(msg.conversation_id, "Hello back!")

send_text takes an opaque conversation_id UUID — plugins never receive raw phone numbers or emails.

official tier requires maintainer review and package signing before the plugin will load in production.

Settings schema

The SETTINGS_SCHEMA is a JSON Schema object. The web UI renders form fields automatically:

SETTINGS_SCHEMA = {
    "type": "object",
    "properties": {
        "api_key": {
            "type": "string",
            "title": "API Key",
            "description": "From your dashboard at example.com",
        },
        "timeout_s": {
            "type": "number",
            "title": "Timeout (seconds)",
            "default": 10,
            "minimum": 1,
            "maximum": 60,
        },
        "mode": {
            "type": "string",
            "title": "Mode",
            "enum": ["fast", "reliable"],
            "default": "fast",
        },
        "enabled": {
            "type": "boolean",
            "title": "Enable",
            "default": False,
        },
    },
    "required": ["api_key"],
}

Supported JSON Schema types: string, number, boolean, array. enum renders as a dropdown. "format": "password" renders a password input (value masked).

Entry point declaration

In pyproject.toml:

[project.entry-points."chatwire.integrations"]
my_greeter = "my_greeter.plugin:GreeterIntegration"

The bridge discovers all chatwire.integrations entry points at startup.

Install during development

pipx inject chatwire -e /path/to/my_greeter
launchctl kickstart -k gui/$(id -u)/dev.chatwire.bridge

Enable in Settings → Plugins.

Frontend slot plugins

Register React components into named slots in the web UI.

Available slots

Slot Host Extra props forwarded
sidebar.panel Layout sidebar
message.toolbar MessageBubble msgRowid: number, fromMe: boolean
compose.extension ComposeBox handle: string
settings.page SettingsPage

Option A: In-tree registration

For plugins bundled with chatwire core:

// web/frontend/src/main.tsx
import { registerSlot } from './plugins/registry'
import { MyWidget } from './plugins/MyWidget'

registerSlot('sidebar.panel', MyWidget, { key: 'my-widget' })

Option B: External script

For plugins that serve their own bundle via FastAPI:

<!-- Injected by the plugin's FastAPI route -->
<script src="/plugins/my_plugin/bundle.js" defer></script>
// bundle.js
window.addEventListener('chatwire:ready', () => {
  window.chatwire.registerSlot('sidebar.panel', function MyWidget(props) {
    return window.React.createElement('div', { className: 'px-3 py-2' }, 'Hello!')
  }, { key: 'my-plugin' })
})

Slot component contract

All slot components receive SlotProps:

interface SlotProps {
  slot: SlotName          // which slot this is
  [key: string]: unknown  // extra props from the host
}

Components are wrapped in <PluginErrorBoundary> — a crash shows an inline error chip, not a page crash.

message.toolbar example

function ReactionButton({ msgRowid, fromMe }: SlotProps) {
  if (fromMe) return null
  return <button onClick={() => react(msgRowid)}>👍</button>
}
registerSlot('message.toolbar', ReactionButton, { key: 'reaction-button' })

sidebar.panel example

function MyPanel(_props: SlotProps) {
  const { data } = useQuery({
    queryKey: ['my-widget'],
    queryFn: () => fetch('/api/ui/my-data').then(r => r.json()),
    staleTime: 5 * 60_000,
  })
  if (!data) return null
  return (
    <div className="px-3 py-2 text-xs text-[--color-text-muted]">
      {data.count} items
    </div>
  )
}
registerSlot('sidebar.panel', MyPanel, { key: 'my-panel' })

Publishing

  1. Bump version in pyproject.toml
  2. Build: python -m build
  3. Publish: twine upload dist/*
  4. Users install: pipx inject chatwire my-plugin-name

For official tier, contact the maintainer for signing before publishing.