This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/docs/PLUGIN_SPEC.md

18 KiB

🔌 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

{
  "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

// 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

// 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

// 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

{
  "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

# 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

// 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

// 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

// 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

# 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

# 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

# Move to extensions directory
mv my-plugin ../chat-switchboard/extensions/backend/

# Restart backend
cd ../chat-switchboard
docker-compose restart backend

4. Publish to Marketplace

# 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

# 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

// 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

  1. web-search (Python) - DuckDuckGo search
  2. code-runner (Python) - Sandboxed code execution
  3. image-gen (Python) - DALL-E/Stable Diffusion
  4. file-ops (Go) - File system operations
  5. 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! 🚀