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¶
- Backend (Python) — hook into the bridge to receive messages, send replies, and access plugin config. Uses
chatwire-sdk. - Frontend (React) — inject UI components into named slots in the web app. Uses the
window.chatwireslot API.
The Stats integration (integrations/stats/) is the canonical end-to-end example.
Backend plugins¶
Install the SDK¶
Scaffold a plugin¶
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:
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:
The bridge discovers all chatwire.integrations entry points at startup.
Install during development¶
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¶
- Bump version in
pyproject.toml - Build:
python -m build - Publish:
twine upload dist/* - Users install:
pipx inject chatwire my-plugin-name
For official tier, contact the maintainer for signing before publishing.