Add comprehensive plugin specification and development guide

This commit is contained in:
2026-02-03 23:09:01 +00:00
parent 867160c8c4
commit 00b069811f

759
docs/PLUGIN_SPEC.md Normal file
View File

@@ -0,0 +1,759 @@
# 🔌 Plugin Specification - Extension Development Guide
## Philosophy: Everything is a Plugin
**Core Principle:** If core features (Chat, Channels, Notes) are implemented as plugins, we prove that ANY feature can be a plugin.
```
Minimal Core (Go) Extensions (Any Language)
├── HTTP Router ├── Chat Engine (Python)
├── WebSocket Hub ├── Channels (Go)
├── Extension Manager ├── RAG Engine (Python)
├── Auth/Users ├── Workflows (Go + Python)
└── PostgreSQL └── Your Custom Plugin...
```
## Plugin Types
### 1. Frontend Plugins (UI Only)
**Language:** JavaScript
**Runs:** In browser
**Capabilities:** UI modifications, client-side logic
**Modes:** Both managed and unmanaged
### 2. Backend Plugins (Full Power)
**Language:** Python, Go, Node.js, Rust, etc.
**Runs:** As separate process
**Capabilities:** Tools, models, data processing, external APIs
**Modes:** Managed only
## Frontend Plugin API
### Plugin Structure
```
extensions/frontend/token-counter/
├── manifest.json # Plugin metadata
├── main.js # Entry point
├── styles.css # Optional styles
└── README.md
```
### manifest.json
```json
{
"name": "token-counter",
"version": "1.0.0",
"author": "Your Name",
"description": "Display token count for messages",
"type": "frontend",
"entry": "main.js",
"permissions": [
"message:read",
"ui:inject"
],
"hooks": [
"onMessageSend",
"onMessageReceive",
"onUIRender"
]
}
```
### main.js Example
```javascript
// Frontend plugin template
(function() {
'use strict';
// Plugin initialization
window.ChatSwitchboard = window.ChatSwitchboard || {};
window.ChatSwitchboard.plugins = window.ChatSwitchboard.plugins || [];
const TokenCounter = {
name: 'token-counter',
version: '1.0.0',
// Called when plugin loads
init: function() {
console.log('Token Counter plugin loaded');
this.addUI();
},
// Hook: Before message is sent
hooks: {
onMessageSend: function(message) {
const tokens = this.estimateTokens(message.content);
console.log(`Message has ~${tokens} tokens`);
// Show warning if too long
if (tokens > 4000) {
window.ChatSwitchboard.showToast(
`⚠️ Long message: ${tokens} tokens`,
'warning'
);
}
// Can modify message before sending
return message;
},
onMessageReceive: function(message) {
// Process incoming messages
return message;
},
onUIRender: function() {
// Inject UI elements
this.updateTokenDisplay();
}
},
// Add token counter to UI
addUI: function() {
const inputArea = document.querySelector('.input-area');
const counter = document.createElement('div');
counter.id = 'token-counter';
counter.className = 'plugin-token-counter';
counter.textContent = '0 tokens';
inputArea.appendChild(counter);
// Update on input
const textarea = document.getElementById('messageInput');
textarea.addEventListener('input', () => {
const tokens = this.estimateTokens(textarea.value);
counter.textContent = `${tokens} tokens`;
});
},
// Token estimation (rough approximation)
estimateTokens: function(text) {
return Math.ceil(text.length / 4);
},
updateTokenDisplay: function() {
const counter = document.getElementById('token-counter');
const input = document.getElementById('messageInput');
if (counter && input) {
const tokens = this.estimateTokens(input.value);
counter.textContent = `${tokens} tokens`;
}
}
};
// Register plugin
window.ChatSwitchboard.plugins.push(TokenCounter);
// Auto-init when DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => TokenCounter.init());
} else {
TokenCounter.init();
}
})();
```
### Available Hooks
```javascript
// Message lifecycle
onMessageSend(message) // Before sending to API
onMessageReceive(message) // After receiving from API
onMessageRender(element, message) // When rendering to DOM
// UI lifecycle
onUIRender() // After UI updates
onChatSwitch(chatId) // When switching chats
onModelChange(model) // When model changes
// Extension points
onToolCall(tool, params) // Before calling tool
onToolResult(tool, result) // After tool returns
// Settings
onSettingsOpen() // Settings modal opened
onSettingsSave(settings) // Settings saved
// Files
onFileUpload(file) // File uploaded
onFileSelect(file) // File selected for chat
```
### Plugin API Methods
```javascript
// Toast notifications
ChatSwitchboard.showToast(message, type); // type: success, error, warning, info
// Storage (scoped to plugin)
ChatSwitchboard.storage.set(key, value);
ChatSwitchboard.storage.get(key);
ChatSwitchboard.storage.remove(key);
// UI manipulation
ChatSwitchboard.ui.addButton(location, config);
ChatSwitchboard.ui.addPanel(config);
ChatSwitchboard.ui.addMenuItem(config);
// State access (read-only)
ChatSwitchboard.state.getCurrentChat();
ChatSwitchboard.state.getCurrentModel();
ChatSwitchboard.state.getSettings();
// Events
ChatSwitchboard.events.on(event, callback);
ChatSwitchboard.events.off(event, callback);
ChatSwitchboard.events.emit(event, data);
```
## Backend Plugin API
### Plugin Structure
```
extensions/backend/web-search/
├── extension.json # Manifest
├── main.py # Entry point (or main.go, server.js, etc)
├── requirements.txt # Dependencies (Python)
├── config.yaml # Configuration
└── README.md
```
### extension.json
```json
{
"name": "web-search",
"version": "1.0.0",
"author": "Chat Switchboard Team",
"description": "Search the web using DuckDuckGo API",
"type": "backend",
"runtime": "python",
"entry": "main.py",
"port": 9001,
"capabilities": ["tool"],
"dependencies": {
"python": ">=3.9",
"packages": ["fastapi", "uvicorn", "duckduckgo-search"]
},
"tools": [
{
"name": "search_web",
"description": "Search the web for information",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query"
},
"max_results": {
"type": "integer",
"description": "Maximum results to return",
"default": 5
}
},
"required": ["query"]
},
"returns": {
"type": "object",
"properties": {
"results": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {"type": "string"},
"url": {"type": "string"},
"snippet": {"type": "string"}
}
}
}
}
}
}
],
"config": {
"max_requests_per_minute": 30,
"timeout_seconds": 10
}
}
```
### Python Plugin Template
```python
# extensions/backend/web-search/main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from duckduckgo_search import DDGS
import uvicorn
import os
app = FastAPI(title="Web Search Plugin")
# Load config
PORT = int(os.getenv("PLUGIN_PORT", 9001))
MAX_RESULTS = int(os.getenv("MAX_RESULTS", 5))
# Request/response models
class SearchRequest(BaseModel):
query: str
max_results: int = 5
class SearchResult(BaseModel):
title: str
url: str
snippet: str
class SearchResponse(BaseModel):
results: list[SearchResult]
# Tool endpoint
@app.post("/tools/search_web", response_model=SearchResponse)
async def search_web(request: SearchRequest):
"""Search the web using DuckDuckGo"""
try:
ddgs = DDGS()
results = ddgs.text(
request.query,
max_results=min(request.max_results, MAX_RESULTS)
)
return SearchResponse(
results=[
SearchResult(
title=r.get("title", ""),
url=r.get("href", ""),
snippet=r.get("body", "")
)
for r in results
]
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# Health check
@app.get("/health")
async def health():
return {"status": "ok", "plugin": "web-search"}
# Plugin info
@app.get("/info")
async def info():
return {
"name": "web-search",
"version": "1.0.0",
"tools": ["search_web"]
}
if __name__ == "__main__":
uvicorn.run(app, host="127.0.0.1", port=PORT)
```
### Go Plugin Template
```go
// extensions/backend/file-ops/main.go
package main
import (
"github.com/gin-gonic/gin"
"os"
"io/ioutil"
)
type ReadFileRequest struct {
Path string `json:"path" binding:"required"`
}
type ReadFileResponse struct {
Content string `json:"content"`
Size int64 `json:"size"`
}
func main() {
r := gin.Default()
// Tool endpoint
r.POST("/tools/read_file", func(c *gin.Context) {
var req ReadFileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
content, err := ioutil.ReadFile(req.Path)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
info, _ := os.Stat(req.Path)
c.JSON(200, ReadFileResponse{
Content: string(content),
Size: info.Size(),
})
})
// Health check
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
// Start server
port := os.Getenv("PLUGIN_PORT")
if port == "" {
port = "9002"
}
r.Run(":" + port)
}
```
## Extension Manager (Backend Core)
### Go Extension Manager
```go
// server/extensions/manager.go
package extensions
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"sync"
)
type Extension struct {
Name string `json:"name"`
Version string `json:"version"`
Runtime string `json:"runtime"`
Entry string `json:"entry"`
Port int `json:"port"`
Tools []Tool `json:"tools"`
Process *os.Process
BaseURL string
}
type Tool struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
}
type Manager struct {
extensions map[string]*Extension
tools map[string]*Tool
mu sync.RWMutex
}
func NewManager() *Manager {
return &Manager{
extensions: make(map[string]*Extension),
tools: make(map[string]*Tool),
}
}
// Load all extensions from directory
func (m *Manager) LoadExtensions(dir string) error {
entries, err := ioutil.ReadDir(dir)
if err != nil {
return err
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
extPath := filepath.Join(dir, entry.Name())
if err := m.LoadExtension(extPath); err != nil {
log.Printf("Failed to load extension %s: %v", entry.Name(), err)
}
}
return nil
}
// Load single extension
func (m *Manager) LoadExtension(path string) error {
// Read manifest
manifestPath := filepath.Join(path, "extension.json")
data, err := ioutil.ReadFile(manifestPath)
if err != nil {
return err
}
var ext Extension
if err := json.Unmarshal(data, &ext); err != nil {
return err
}
// Start extension process
if err := m.startExtension(&ext, path); err != nil {
return err
}
// Register tools
for i := range ext.Tools {
ext.Tools[i].ExtensionName = ext.Name
m.tools[ext.Tools[i].Name] = &ext.Tools[i]
}
m.mu.Lock()
m.extensions[ext.Name] = &ext
m.mu.Unlock()
log.Printf("✅ Loaded extension: %s v%s", ext.Name, ext.Version)
return nil
}
// Start extension process
func (m *Manager) startExtension(ext *Extension, path string) error {
var cmd *exec.Cmd
switch ext.Runtime {
case "python":
cmd = exec.Command("python3", ext.Entry)
case "go":
cmd = exec.Command("go", "run", ext.Entry)
case "node":
cmd = exec.Command("node", ext.Entry)
default:
return fmt.Errorf("unsupported runtime: %s", ext.Runtime)
}
cmd.Dir = path
cmd.Env = append(os.Environ(), fmt.Sprintf("PLUGIN_PORT=%d", ext.Port))
if err := cmd.Start(); err != nil {
return err
}
ext.Process = cmd.Process
ext.BaseURL = fmt.Sprintf("http://localhost:%d", ext.Port)
// Wait for extension to be ready
time.Sleep(2 * time.Second)
return nil
}
// Call tool
func (m *Manager) CallTool(name string, params map[string]interface{}) (interface{}, error) {
m.mu.RLock()
tool, exists := m.tools[name]
m.mu.RUnlock()
if !exists {
return nil, fmt.Errorf("tool not found: %s", name)
}
ext := m.extensions[tool.ExtensionName]
url := fmt.Sprintf("%s/tools/%s", ext.BaseURL, name)
// HTTP POST to extension
body, _ := json.Marshal(params)
resp, err := http.Post(url, "application/json", bytes.NewBuffer(body))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result interface{}
json.NewDecoder(resp.Body).Decode(&result)
return result, nil
}
// List all tools
func (m *Manager) ListTools() []Tool {
m.mu.RLock()
defer m.mu.RUnlock()
tools := make([]Tool, 0, len(m.tools))
for _, tool := range m.tools {
tools = append(tools, *tool)
}
return tools
}
// Shutdown all extensions
func (m *Manager) Shutdown() {
m.mu.Lock()
defer m.mu.Unlock()
for _, ext := range m.extensions {
if ext.Process != nil {
ext.Process.Kill()
}
}
}
```
### Extension Discovery
```go
// server/main.go
func main() {
// ... existing setup ...
// Load extensions
extManager := extensions.NewManager()
if err := extManager.LoadExtensions("./extensions/backend"); err != nil {
log.Fatal("Failed to load extensions:", err)
}
defer extManager.Shutdown()
// Expose extension tools via API
r.GET("/api/tools", func(c *gin.Context) {
tools := extManager.ListTools()
c.JSON(200, tools)
})
r.POST("/api/tools/:name", func(c *gin.Context) {
toolName := c.Param("name")
var params map[string]interface{}
c.BindJSON(&params)
result, err := extManager.CallTool(toolName, params)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, result)
})
}
```
## Plugin Development Workflow
### 1. Create from Template
```bash
# Frontend plugin
cp -r extensions/frontend/_template extensions/frontend/my-plugin
cd extensions/frontend/my-plugin
# Edit manifest.json, main.js
# Backend plugin (Python)
cp -r extensions/backend/_template-python extensions/backend/my-plugin
cd extensions/backend/my-plugin
# Edit extension.json, main.py
pip install -r requirements.txt
```
### 2. Test Locally
```bash
# Backend plugin
python main.py # Runs on port from extension.json
# Test tool
curl -X POST http://localhost:9001/tools/my_tool \
-H "Content-Type: application/json" \
-d '{"param": "value"}'
```
### 3. Install in Chat Switchboard
```bash
# Move to extensions directory
mv my-plugin ../chat-switchboard/extensions/backend/
# Restart backend
cd ../chat-switchboard
docker-compose restart backend
```
### 4. Publish to Marketplace
```bash
# Package extension
tar -czf my-plugin-1.0.0.tar.gz my-plugin/
# Upload to marketplace
curl -X POST https://marketplace.chatswitch.io/api/publish \
-F "file=@my-plugin-1.0.0.tar.gz" \
-F "category=tools" \
-H "Authorization: Bearer $TOKEN"
```
## Security & Sandboxing
### Backend Plugin Isolation
```yaml
# docker-compose.yml - Run plugins in containers
services:
plugin-web-search:
build: ./extensions/backend/web-search
ports:
- "9001:9001"
environment:
- PLUGIN_PORT=9001
networks:
- extension-network
restart: always
```
### Permission System
```json
// Plugins declare required permissions
{
"permissions": [
"network:outbound", // Make external HTTP requests
"storage:read", // Read from DB
"storage:write", // Write to DB
"file:read", // Read files
"tool:call:*" // Call other tools
]
}
```
## Official Plugin List
### Bundled with Core
1. **chat-engine** (Python) - LLM conversation handling
2. **channels** (Go) - Multi-user chat rooms
3. **rag-engine** (Python) - Vector search, embeddings
4. **workflows** (Go/Python) - Workflow orchestration
5. **notes** (Go) - Note management
### Official Extensions
6. **web-search** (Python) - DuckDuckGo search
7. **code-runner** (Python) - Sandboxed code execution
8. **image-gen** (Python) - DALL-E/Stable Diffusion
9. **file-ops** (Go) - File system operations
10. **calculator** (Python) - Math evaluation
### Community Extensions (Example Ideas)
- **slack-integration** - Post to Slack channels
- **github-integration** - Create issues, PRs
- **email-sender** - Send emails via SMTP
- **pdf-parser** - Extract text from PDFs
- **scraper** - Web scraping tool
- **translate** - Multi-language translation
- **tts** - Text-to-speech
- **stt** - Speech-to-text
---
## Next Steps
1. Read `docs/ARCHITECTURE.md` for system overview
2. Check `extensions/_template-python/` for starter code
3. Browse `extensions/backend/` for examples
4. Join developer Discord for help
**Build something awesome! 🚀**