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:
- Plugin Directory:
~/.cascade-chat/plugins/ - Can be a flat directory with executables
- Can contain subdirectories with executables
-
Executables should be named
cascade-<plugin-name> -
System PATH: Any executable in PATH starting with
cascade-
Discovery Process¶
- Scan plugin directory for executables
- Scan PATH for
cascade-*executables - Extract plugin name from filename (remove
cascade-prefix) - Validate executable exists and is executable
- 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.
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¶
- Discovery: Plugin discovered during startup or when enabled
- Validation: Executable validated (exists, is executable)
- Initialization: Plugin process started,
initializerequest sent - Active: Plugin receives events and can send metadata
- 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:
- Global: Applies everywhere
-
No
network_idorchannelspecified -
Network: Applies to a specific network
-
network_idspecified, nochannel -
Channel: Applies to a specific channel on a network
- Both
network_idandchannelspecified
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¶
Best Practices¶
- Error Handling: Always handle errors gracefully, don't crash
- Logging: Use stderr for debug logs (they're captured by Cascade)
- Buffering: Flush stdout after each message (or use line buffering)
- Event Filtering: Subscribe only to events you need (avoid
["*"]if possible) - Input Limits: Configure line readers for at least 48 KiB JSON-RPC frames
- Metadata Keys: Use consistent key formats (e.g.,
"nickname:Alice") - Configuration: Provide sensible defaults in config schema
- Versioning: Include version in plugin metadata
- 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
initializecorrectly - Review stderr output for errors
Plugin Not Receiving Events¶
- Verify plugin subscribes to event type in
initializeresponse - Check event type spelling matches exactly
- Review plugin manager logs
Metadata Not Appearing¶
- Verify
ui_metadata.setnotification 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