# 🔌 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(¶ms) 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! 🚀**