Skip to content

Plugin System

The Cascade Chat plugin system lets you extend the client with external processes that communicate over JSON-RPC 2.0. Plugins subscribe to events, provide UI metadata, and interact with the IRC client.

Overview

Plugins are standalone executables that: - Communicate via JSON-RPC 2.0 over stdin/stdout - Subscribe to IRC and system events - Provide UI metadata (colors, badges, icons) - Can be written in any language - Are discovered automatically from PATH or plugin directory

Architecture

flowchart TB
    subgraph host["Cascade Chat (host process)"]
        bus([EventBus]) --> pm[Plugin Manager]
    end
    pm -->|"IPC · stdin/stdout<br/>JSON-RPC 2.0"| proc["Plugin Process<br/><small>separate executable</small>"]

Plugin Discovery

Plugins are discovered from two locations:

  1. Plugin Directory: ~/.cascade-chat/plugins/
  2. Can be a flat directory with executables
  3. Can contain subdirectories with executables
  4. Executables should be named cascade-<plugin-name>

  5. System PATH: Any executable in PATH starting with cascade-

Discovery Process

  1. Scan plugin directory for executables
  2. Scan PATH for cascade-* executables
  3. Extract plugin name from filename (remove cascade- prefix)
  4. Validate executable exists and is executable
  5. Load plugin metadata from database (if available)

Plugin Protocol

Plugins communicate using JSON-RPC 2.0 over stdin/stdout. Each message is a single JSON object on one line (newline-delimited).

The exchange over a plugin's lifetime looks like this — requests (solid) expect a response, notifications (dashed) do not:

sequenceDiagram
    participant U as User
    participant C as Cascade (host)
    participant P as Plugin process

    Note over C,P: Startup
    C->>P: initialize (request)
    P-->>C: result: name, events, commands, metadata_types

    Note over C,P: Running
    C--)P: event — message.received, user.joined, …
    P--)C: ui_metadata.set — colors, badges

    U->>C: /remind 5m check the oven
    C->>P: command.invoke (request)
    P-->>C: result {} (or JSON-RPC error)
    P--)C: action — send_message
    C--)U: message delivered to channel

    Note over C,P: Unload
    C->>P: terminate process

JSON-RPC Messages

Request (from Cascade to Plugin)

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "version": "1.0",
    "capabilities": [],
    "config": {}
  }
}

Response (from Plugin to Cascade)

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "name": "my-plugin",
    "version": "1.0.0",
    "description": "My plugin description",
    "author": "Plugin Author",
    "events": ["message.received", "user.joined"],
    "metadata_types": ["nickname_color"]
  }
}

Notification (no response expected)

{
  "jsonrpc": "2.0",
  "method": "event",
  "params": {
    "type": "message.received",
    "data": {
      "networkId": 1,
      "channel": "#general",
      "user": "Alice",
      "message": "Hello!"
    }
  }
}

Methods

initialize (Request)

Called when plugin is loaded. Plugin must respond with metadata.

Parameters: - version (string): Protocol version - capabilities ([]string): Server capabilities (typically empty) - config (object): User configuration for the plugin

Response: - name (string): Plugin name - version (string): Plugin version - description (string): Plugin description - author (string): Plugin author - events ([]string): Event types plugin subscribes to (use ["*"] for all) - metadata_types ([]string): Types of UI metadata plugin provides - config_schema (object): JSON Schema for plugin configuration (optional)

Example:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "name": "nickname-colors",
    "version": "1.0.0",
    "description": "Assigns consistent colors to nicknames",
    "author": "Cascade Chat",
    "events": ["*"],
    "metadata_types": ["nickname_color"]
  }
}

event (Notification)

Sent to plugin when subscribed events occur.

Parameters: - type (string): Event type - data (object): Event-specific data (see Events Documentation)

Event notifications use newline-delimited JSON and are bounded to 48 KiB per frame. When a large batched event contains a top-level updates array, Cascade splits plugin delivery into multiple frames. Each frame includes updates, batch_index (1-based), and batch_total; process every chunk independently. The frontend still receives the event as one logical batch.

Example:

{
  "jsonrpc": "2.0",
  "method": "event",
  "params": {
    "type": "message.received",
    "data": {
      "networkId": 1,
      "channel": "#general",
      "user": "Alice",
      "message": "Hello!",
      "timestamp": 1234567890
    }
  }
}

Slash Commands

Plugins can register slash commands that users invoke directly in the chat input (e.g. /remind, /weather).

Declaring commands in initialize

Return a commands array in the initialize result. Each entry is a CommandSpecWire object:

Field Type Description
name string Primary command name (without the / prefix)
aliases []string Optional alternative names for the same command
usage string Short usage hint shown in help (e.g. "remind <duration> <text>")
description string One-line description shown in the command list and help dialog

Example initialize result with commands:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "name": "remind-plugin",
    "version": "1.0.0",
    "description": "Reminder plugin",
    "author": "Me",
    "events": [],
    "metadata_types": [],
    "commands": [
      {
        "name": "remind",
        "aliases": ["reminder"],
        "usage": "remind <duration> <text>",
        "description": "Set a reminder that fires after a duration"
      }
    ]
  }
}

command.invoke (Request from host to plugin)

When a user runs a plugin command, Cascade sends a command.invoke request to the owning plugin:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "command.invoke",
  "params": {
    "command": "remind",
    "args": ["5m", "check", "the", "oven"],
    "networkId": 1,
    "channel": "#general"
  }
}

Params: - command (string): The command name as typed (canonical name, not an alias). - args ([]string): Tokenised arguments after the command name. - networkId (int64): ID of the network the command was sent from. - channel (string): Channel or query window where the command was typed.

The plugin must send a standard JSON-RPC result to signal success. To report a failure, return a JSON-RPC error object. The error message is surfaced to the user.

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {}
}

Conflict policy

  • Built-in commands take precedence. A plugin command whose name or alias matches a built-in is silently ignored; the collision is logged as a warning.
  • First-registration-wins between plugins. If two plugins register the same name or alias, the second registration is ignored and the collision is logged.
  • Commands are unregistered automatically when a plugin is unloaded or disabled.

Notifications from Plugin

ui_metadata.set (Notification)

Plugin sends this to set UI metadata (colors, badges, etc.).

Parameters: - type (string): Metadata type (e.g., "nickname_color") - key (string): Metadata key (e.g., "nickname:Alice") - value (interface{}): Metadata value (e.g., "#FF6B6B") - network_id (int64, optional): Network-specific metadata - channel (string, optional): Channel-specific metadata - priority (int, optional): Priority (higher = more important, default: 0)

Example:

{
  "jsonrpc": "2.0",
  "method": "ui_metadata.set",
  "params": {
    "type": "nickname_color",
    "key": "nickname:Alice",
    "value": "#FF6B6B",
    "network_id": 1,
    "priority": 0
  }
}

ui_metadata.set_batch (Notification)

Stores multiple metadata values in one registry transaction and publishes one coalesced update to the frontend. Use this for snapshots such as a completed channel roster. The manager also coalesces bursts of legacy ui_metadata.set notifications, so one-at-a-time plugins cannot saturate the shared event bus.

{
  "jsonrpc": "2.0",
  "method": "ui_metadata.set_batch",
  "params": {
    "updates": [
      {
        "type": "nickname_color",
        "key": "nickname:alice",
        "value": "#4ECDC4",
        "network_id": 1
      },
      {
        "type": "nickname_color",
        "key": "nickname:bob",
        "value": "#F8B739",
        "network_id": 1
      }
    ]
  }
}

action (Notification)

Plugins send action notifications to ask Cascade to perform side-effects on their behalf, such as sending an IRC message.

{
  "jsonrpc": "2.0",
  "method": "action",
  "params": {
    "type": "send_message",
    "data": {
      "server": "irc.libera.chat:6667",
      "target": "#general",
      "message": "Reminder: check the oven"
    }
  }
}

Params: - type (string): Action type. The only currently supported value is "send_message". - data (object): Action-specific payload.

send_message data fields: - server (string): Server address of the network (as host:port). - target (string): Channel or nickname to send the message to. - message (string): Text to send.

Queue and overflow: Actions are delivered through a bounded in-memory queue (capacity 100). If the queue is full when an action arrives, for example because the app is processing a burst of actions, the action is dropped and a warning is logged. Plugins should not rely on delivery guarantees during high load.

Plugin Lifecycle

  1. Discovery: Plugin discovered during startup or when enabled
  2. Validation: Executable validated (exists, is executable)
  3. Initialization: Plugin process started, initialize request sent
  4. Active: Plugin receives events and can send metadata
  5. Unload: Plugin process terminated, metadata cleared

plugin-lifecycle frontend event

Whenever a plugin is loaded or unloaded, Cascade emits a plugin-lifecycle event to the frontend. The frontend uses it to refetch the current command list (e.g. to update autocomplete). No action is required from the plugin itself.

Loading a Plugin

// Plugin manager automatically loads enabled plugins on startup
pm.DiscoverAndLoad()

// Or manually load a plugin
pm.LoadPlugin(pluginInfo)

Unloading a Plugin

// Unload and disable
pm.SetPluginEnabled("plugin-name", false, storage)

// Or just unload (keeps enabled state)
pm.UnloadPlugin("plugin-name")

UI Metadata System

Plugins can provide UI metadata that enhances the display of IRC information.

Metadata Types

  • nickname_color: Color for nickname display (hex color string)
  • nickname_badge: Badge/icon for nickname (future)
  • nickname_icon: Icon for nickname (future)

Metadata Scoping

Metadata can be scoped at three levels:

  1. Global: Applies everywhere
  2. No network_id or channel specified

  3. Network: Applies to a specific network

  4. network_id specified, no channel

  5. Channel: Applies to a specific channel on a network

  6. Both network_id and channel specified

Metadata Priority

When multiple plugins set metadata for the same key: - Higher priority wins - On tie, newer metadata wins

Setting Metadata

From a plugin:

{
  "jsonrpc": "2.0",
  "method": "ui_metadata.set",
  "params": {
    "type": "nickname_color",
    "key": "nickname:Alice",
    "value": "#FF6B6B",
    "network_id": 1,
    "priority": 0
  }
}

Retrieving Metadata

From the application:

// Get single metadata value
color := pm.GetNicknameColor(networkID, "Alice")

// Get batch of metadata values
colors := pm.GetNicknameColorsBatch(networkID, []string{"Alice", "Bob"})

Writing a Plugin

Basic Structure

A plugin must: 1. Read JSON-RPC messages from stdin (line-delimited) 2. Write JSON-RPC messages to stdout (line-delimited) 3. Handle initialize request 4. Handle event notifications 5. Send ui_metadata.set notifications as needed

Example Plugin (Go)

package main

import (
    "bufio"
    "encoding/json"
    "fmt"
    "os"
)

type Request struct {
    JSONRPC string      `json:"jsonrpc"`
    ID      interface{} `json:"id,omitempty"`
    Method  string      `json:"method"`
    Params  interface{} `json:"params,omitempty"`
}

type Response struct {
    JSONRPC string      `json:"jsonrpc"`
    ID      interface{} `json:"id,omitempty"`
    Result  interface{} `json:"result,omitempty"`
    Error   *RPCError   `json:"error,omitempty"`
}

type RPCError struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
}

type EventParams struct {
    Type string                 `json:"type"`
    Data map[string]interface{} `json:"data"`
}

func sendResponse(id interface{}, result interface{}) {
    resp := Response{
        JSONRPC: "2.0",
        ID:      id,
        Result:  result,
    }
    data, _ := json.Marshal(resp)
    os.Stdout.Write(append(data, '\n'))
}

func sendNotification(method string, params interface{}) {
    req := Request{
        JSONRPC: "2.0",
        Method:  method,
        Params:  params,
    }
    data, _ := json.Marshal(req)
    os.Stdout.Write(append(data, '\n'))
}

func main() {
    reader := bufio.NewReader(os.Stdin)

    for {
        line, err := reader.ReadString('\n')
        if err != nil {
            break
        }

        var req Request
        if err := json.Unmarshal([]byte(line), &req); err != nil {
            continue
        }

        switch req.Method {
        case "initialize":
            sendResponse(req.ID, map[string]interface{}{
                "name":          "my-plugin",
                "version":       "1.0.0",
                "description":   "My plugin",
                "author":        "Me",
                "events":        []string{"message.received"},
                "metadata_types": []string{"nickname_color"},
            })

        case "event":
            paramsBytes, _ := json.Marshal(req.Params)
            var eventParams EventParams
            json.Unmarshal(paramsBytes, &eventParams)

            if eventParams.Type == "message.received" {
                // Process message and set metadata
                if user, ok := eventParams.Data["user"].(string); ok {
                    sendNotification("ui_metadata.set", map[string]interface{}{
                        "type":  "nickname_color",
                        "key":   "nickname:" + user,
                        "value": "#FF6B6B",
                    })
                }
            }
        }
    }
}

Example Plugin (Python)

import json
import sys

def send_response(id, result):
    resp = {
        "jsonrpc": "2.0",
        "id": id,
        "result": result
    }
    print(json.dumps(resp), flush=True)

def send_notification(method, params):
    req = {
        "jsonrpc": "2.0",
        "method": method,
        "params": params
    }
    print(json.dumps(req), flush=True)

for line in sys.stdin:
    line = line.strip()
    if not line:
        continue

    req = json.loads(line)

    if req.get("method") == "initialize":
        send_response(req.get("id"), {
            "name": "my-plugin",
            "version": "1.0.0",
            "description": "My plugin",
            "author": "Me",
            "events": ["message.received"],
            "metadata_types": ["nickname_color"]
        })

    elif req.get("method") == "event":
        params = req.get("params", {})
        if params.get("type") == "message.received":
            data = params.get("data", {})
            user = data.get("user")
            if user:
                send_notification("ui_metadata.set", {
                    "type": "nickname_color",
                    "key": f"nickname:{user}",
                    "value": "#FF6B6B"
                })

Plugin Configuration

Plugins can define a configuration schema using JSON Schema. The schema is stored in the database and used to generate configuration UI.

Config Schema Example

{
  "type": "object",
  "properties": {
    "color_palette": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "default": ["#FF6B6B", "#4ECDC4", "#45B7D1"]
    },
    "enable_badges": {
      "type": "boolean",
      "default": true
    }
  }
}

Accessing Configuration

Configuration is passed to the plugin during initialization:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "version": "1.0",
    "capabilities": [],
    "config": {
      "color_palette": ["#FF6B6B", "#4ECDC4"],
      "enable_badges": true
    }
  }
}

Plugin Management

Enabling/Disabling Plugins

Plugins can be enabled or disabled through the UI or API. The state is persisted in the database.

// Enable a plugin
pm.SetPluginEnabled("plugin-name", true, storage)

// Disable a plugin
pm.SetPluginEnabled("plugin-name", false, storage)

Listing Plugins

plugins := pm.ListPlugins()
// Returns PluginInfo for all discovered plugins (loaded and unloaded)

Best Practices

  1. Error Handling: Always handle errors gracefully, don't crash
  2. Logging: Use stderr for debug logs (they're captured by Cascade)
  3. Buffering: Flush stdout after each message (or use line buffering)
  4. Event Filtering: Subscribe only to events you need (avoid ["*"] if possible)
  5. Input Limits: Configure line readers for at least 48 KiB JSON-RPC frames
  6. Metadata Keys: Use consistent key formats (e.g., "nickname:Alice")
  7. Configuration: Provide sensible defaults in config schema
  8. Versioning: Include version in plugin metadata
  9. Graceful Shutdown: Exit cleanly when stdin closes (EOF)

Troubleshooting

Plugin Not Loading

  • Check executable permissions: chmod +x cascade-my-plugin
  • Verify plugin is in PATH or plugin directory
  • Check plugin implements initialize correctly
  • Review stderr output for errors

Plugin Not Receiving Events

  • Verify plugin subscribes to event type in initialize response
  • Check event type spelling matches exactly
  • Review plugin manager logs

Metadata Not Appearing

  • Verify ui_metadata.set notification format
  • Check metadata key format matches retrieval pattern
  • Verify network_id/channel scope matches query
  • Check metadata priority isn't being overridden

Plugin Crashes

  • Review stderr output
  • Ensure plugin handles all expected event types
  • Check for JSON parsing errors
  • Verify plugin exits cleanly on EOF

Future Enhancements

  • Plugin actions (send message, join channel, etc.)
  • Plugin UI components
  • Plugin-to-plugin communication
  • Plugin sandboxing/security
  • Plugin marketplace
  • Hot reloading
  • Plugin dependencies