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 are best-effort. Cascade hands them to a bounded writer queue so a plugin that stops reading stdin cannot stall IRC socket processing. If that queue fills, Cascade drops notifications for that plugin and writes a warning to the application log. Plugins should continuously drain stdin and must not treat the event stream as a durable audit log.
Replaceable snapshot events may also be coalesced before plugin delivery. In
particular, user.meta contains a complete current value and is
last-write-wins per network and nickname during a burst. Process the snapshot
as state; do not depend on receiving every intermediate value.
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.
The flush occurs after the burst has been quiet for the debounce interval, and
repeated writes with the same network, channel, type, and key use the latest
value. Consumers of metadata.updated receive either one update directly or a
top-level updates array, which may be split into bounded plugin event frames.
{
"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)
- Input Draining: Continuously read stdin; event notifications are dropped if the bounded writer queue fills
- 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