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.
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
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>"]
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-
- 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)
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
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"version": "1.0",
"capabilities": [],
"config": {}
}
}{
"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"]
}
}{
"jsonrpc": "2.0",
"method": "event",
"params": {
"type": "message.received",
"data": {
"networkId": 1,
"channel": "#general",
"user": "Alice",
"message": "Hello!"
}
}
}Called when plugin is loaded. Plugin must respond with metadata.
Parameters:
version(string): Protocol versioncapabilities([]string): Server capabilities (typically empty)config(object): User configuration for the plugin
Response:
name(string): Plugin nameversion(string): Plugin versiondescription(string): Plugin descriptionauthor(string): Plugin authorevents([]string): Event types plugin subscribes to (use["*"]for all)metadata_types([]string): Types of UI metadata plugin providesconfig_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"]
}
}Sent to plugin when subscribed events occur.
Parameters:
type(string): Event typedata(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
}
}
}Plugins can register slash commands that users invoke directly in the chat input (e.g. /remind, /weather).
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"
}
]
}
}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": {}
}- 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.
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 metadatachannel(string, optional): Channel-specific metadatapriority(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
}
}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
}
]
}
}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 (ashost: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.
- 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
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.
// Plugin manager automatically loads enabled plugins on startup
pm.DiscoverAndLoad()
// Or manually load a plugin
pm.LoadPlugin(pluginInfo)// Unload and disable
pm.SetPluginEnabled("plugin-name", false, storage)
// Or just unload (keeps enabled state)
pm.UnloadPlugin("plugin-name")Plugins can provide UI metadata that enhances the display of IRC information.
nickname_color: Color for nickname display (hex color string)nickname_badge: Badge/icon for nickname (future)nickname_icon: Icon for nickname (future)
Metadata can be scoped at three levels:
-
Global: Applies everywhere
- No
network_idorchannelspecified
- No
-
Network: Applies to a specific network
network_idspecified, nochannel
-
Channel: Applies to a specific channel on a network
- Both
network_idandchannelspecified
- Both
When multiple plugins set metadata for the same key:
- Higher priority wins
- On tie, newer metadata wins
From a plugin:
{
"jsonrpc": "2.0",
"method": "ui_metadata.set",
"params": {
"type": "nickname_color",
"key": "nickname:Alice",
"value": "#FF6B6B",
"network_id": 1,
"priority": 0
}
}From the application:
// Get single metadata value
color := pm.GetNicknameColor(networkID, "Alice")
// Get batch of metadata values
colors := pm.GetNicknameColorsBatch(networkID, []string{"Alice", "Bob"})A plugin must:
- Read JSON-RPC messages from stdin (line-delimited)
- Write JSON-RPC messages to stdout (line-delimited)
- Handle
initializerequest - Handle
eventnotifications - Send
ui_metadata.setnotifications as needed
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",
})
}
}
}
}
}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"
})Plugins can define a configuration schema using JSON Schema. The schema is stored in the database and used to generate configuration UI.
{
"type": "object",
"properties": {
"color_palette": {
"type": "array",
"items": {
"type": "string"
},
"default": ["#FF6B6B", "#4ECDC4", "#45B7D1"]
},
"enable_badges": {
"type": "boolean",
"default": true
}
}
}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
}
}
}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)plugins := pm.ListPlugins()
// Returns PluginInfo for all discovered plugins (loaded and unloaded)- 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)
- 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
- Verify plugin subscribes to event type in
initializeresponse - Check event type spelling matches exactly
- Review plugin manager logs
- 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
- Review stderr output
- Ensure plugin handles all expected event types
- Check for JSON parsing errors
- Verify plugin exits cleanly on EOF
- Plugin actions (send message, join channel, etc.)
- Plugin UI components
- Plugin-to-plugin communication
- Plugin sandboxing/security
- Plugin marketplace
- Hot reloading
- Plugin dependencies