The marchat plugin system provides a modular, extensible architecture for adding functionality to the chat application. Plugins are external binaries that communicate with marchat via JSON over stdin/stdout.
- Plugins run as isolated subprocesses
- Communication via JSON over stdin/stdout
- Headless-first design with optional TUI extensions
- Graceful failure - plugins cannot crash the main app
- Discovery: Plugins are discovered in the plugin directory
- Loading: Plugin manifest is parsed and validated
- Initialization: Plugin receives configuration and user list
- Runtime: Plugin processes messages and commands
- Shutdown: Plugin receives shutdown signal and exits gracefully
Each plugin must have the following structure:
myplugin/
├── plugin.json # Plugin manifest
├── myplugin # Binary executable
└── README.md # Optional documentation
{
"name": "myplugin",
"version": "1.0.0",
"description": "A description of what this plugin does",
"author": "Your Name",
"license": "MIT",
"repository": "https://github.com/user/myplugin",
"commands": [
{
"name": "mycommand",
"description": "Description of the command",
"usage": ":mycommand <args>",
"admin_only": false
}
],
"permissions": [],
"settings": {},
"min_version": "0.1.0"
}plugin/sdk has its own go.mod, so the repo root go test ./... command does not execute its tests. From the repository root:
cd plugin/sdk
go test ./...The sample under plugin/examples/echo is also a small standalone module (cd plugin/examples/echo && go test ./...); it may report [no test files]. Full-suite notes and merged coverage for the main module are in TESTING.md.
type Plugin interface {
Name() string
Init(Config) error
OnMessage(Message) ([]Message, error)
Commands() []PluginCommand
}The sdk.Message struct carries chat context from the hub to plugins and back:
type Message struct {
Sender string `json:"sender"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
Type string `json:"type,omitempty"`
Channel string `json:"channel,omitempty"`
Encrypted bool `json:"encrypted,omitempty"`
MessageID int64 `json:"message_id,omitempty"`
Recipient string `json:"recipient,omitempty"`
Edited bool `json:"edited,omitempty"`
}| Field | Description |
|---|---|
Sender |
Username of the message author |
Content |
Message text (opaque ciphertext when Encrypted is true) |
CreatedAt |
Timestamp |
Type |
"text", "file", "dm", etc. (matches shared.MessageType values) |
Channel |
Channel the message belongs to (empty = default general) |
Encrypted |
true when content is E2E encrypted; plugins should skip parsing |
MessageID |
Server-assigned ID (useful for reactions, edits) |
Recipient |
Target user for DMs (empty = broadcast) |
Edited |
true if the message was edited after send |
Backwards compatibility: All extended fields use omitempty. Plugins compiled against older SDK versions silently ignore unknown JSON keys and omit them on output; no recompile required.
Message routing rules:
- The hub only forwards messages with
typeset to"text"to plugins. Other types (typing, reactions, etc.) are not delivered. - Host behavior: The server never blocks its hub on plugin stdin. Each running plugin has a bounded outbound queue (64 messages by default in
plugin/host); if a plugin falls behind, new chat fan-out lines may be dropped (logged server-side). Fan-out is best-effort and at most once per plugin per message (no host retry). Plugins should return quickly fromOnMessageand avoid blocking the stdio read loop. - Plugin replies that omit
type(or set it to anything other than"text") are broadcast to clients but are not re-forwarded to other plugins. This prevents accidental infinite loops. - To opt into plugin-to-plugin chaining, set
Type: "text"on outboundsdk.Messageexplicitly. Use with care: the echo plugin, for example, should not do this or it will loop. - Encrypted messages: The hub does not filter encrypted messages before forwarding to plugins. Plugins receive them with
Encrypted: trueand opaqueContent. Plugins that parseContentshould checkmsg.Encryptedand skip or handle accordingly.
Plugins receive messages and can respond with additional messages:
func (p *MyPlugin) OnMessage(msg sdk.Message) ([]sdk.Message, error) {
// Skip encrypted messages the plugin cannot read
if msg.Encrypted {
return nil, nil
}
// Process incoming message
if strings.HasPrefix(msg.Content, "hello") {
response := sdk.Message{
Sender: "MyBot",
Content: "Hello back!",
CreatedAt: time.Now(),
Channel: msg.Channel, // reply in the same channel
}
return []sdk.Message{response}, nil
}
return nil, nil
}Plugins can register commands that users can invoke:
func (p *MyPlugin) Commands() []sdk.PluginCommand {
return []sdk.PluginCommand{
{
Name: "greet",
Description: "Send a greeting",
Usage: ":greet <name>",
AdminOnly: false,
},
}
}{
"type": "message|command|init|shutdown",
"command": "command_name",
"data": {}
}{
"type": "message|log",
"success": true,
"data": {},
"error": "error message"
}- init: Plugin initialization with config and user list
- message: Incoming chat message
- command: Plugin command execution
- shutdown: Graceful shutdown request
-
Create plugin directory:
mkdir myplugin cd myplugin -
Create plugin.json:
{ "name": "myplugin", "version": "1.0.0", "description": "My first plugin", "author": "Your Name", "license": "MIT", "commands": [] } -
Create main.go:
package main import ( "log" "github.com/Cod-e-Codes/marchat/plugin/sdk" ) type MyPlugin struct { *sdk.BasePlugin } func NewMyPlugin() *MyPlugin { return &MyPlugin{ BasePlugin: sdk.NewBasePlugin("myplugin"), } } func (p *MyPlugin) Init(config sdk.Config) error { return nil } func (p *MyPlugin) OnMessage(msg sdk.Message) ([]sdk.Message, error) { return nil, nil } func (p *MyPlugin) Commands() []sdk.PluginCommand { return nil } func main() { plugin := NewMyPlugin() if err := sdk.RunStdio(plugin, plugin.handleCommand); err != nil { log.Fatalf("plugin exited: %v", err) } } // handleCommand handles PluginRequest type "command" (chat :commands). // init, message, and shutdown are handled by sdk.HandlePluginRequest via RunStdio. func (p *MyPlugin) handleCommand(command string, args []string) sdk.PluginResponse { _ = args return sdk.PluginResponse{ Type: "command", Success: false, Error: "unknown command", } }
-
Build the plugin:
go build -o myplugin main.go
-
Install the plugin:
# Copy to plugin directory cp myplugin /path/to/marchat/plugins/myplugin/ cp plugin.json /path/to/marchat/plugins/myplugin/
Plugins receive configuration during initialization:
type Config struct {
PluginDir string // Plugin directory path
DataDir string // Plugin data directory
Settings map[string]string // Plugin settings
}Plugins can store data in their data directory:
import (
"encoding/json"
"os"
"path/filepath"
)
func (p *MyPlugin) saveData(data interface{}) error {
dataFile := filepath.Join(p.GetConfig().DataDir, "data.json")
b, err := json.Marshal(data)
if err != nil {
return err
}
return os.WriteFile(dataFile, b, 0644)
}Plugins can be installed via:
-
Chat commands:
:install myplugin -
Plugin store:
:store -
Manual installation:
- Copy plugin files to plugin directory
- Restart marchat or use
:plugin enable myplugin
:plugin list- List installed plugins:plugin enable <name>- Enable a plugin:plugin disable <name>- Disable a plugin:plugin uninstall <name>- Uninstall a plugin (admin only):store- Open plugin store:refresh- Refresh plugin store
The plugin store provides a TUI interface for browsing and installing plugins:
- Browse plugins by category, tags, or search
- View plugin details including description, commands, and metadata
- Install plugins with one-click installation
- Manage installed plugins enable/disable/update
Official (paid) plugins require license validation:
- License file:
.licensefile in plugin directory - Cryptographic verification: Ed25519 signature validation
- Offline support: Licenses cached after first validation; the server re-verifies the signature (and that
plugin_namematches the cache key) whenever it reads the cache, and drops invalid cache files
Use the marchat-license CLI tool:
# Generate key pair
marchat-license -action genkey
# Generate license
marchat-license -action generate \
-plugin myplugin \
-customer CUSTOMER123 \
-expires 2024-12-31 \
-private-key <private-key> \
-output myplugin.license
# Validate license
marchat-license -action validate \
-license myplugin.license \
-public-key <public-key>
# Check license status
marchat-license -action check \
-plugin myplugin \
-public-key <public-key>The community registry is a JSON file hosted on GitHub:
[
{
"name": "myplugin",
"version": "1.0.0",
"description": "A community plugin",
"author": "Community Member",
"license": "MIT",
"repository": "https://github.com/user/myplugin",
"download_url": "https://github.com/user/myplugin/releases/latest/download/myplugin.zip",
"checksum": "sha256:...",
"category": "utility",
"tags": ["chat", "utility"],
"commands": [...]
}
]When the registry entry includes checksum, the server validates the SHA-256 hash of the downloaded bytes before extracting the archive. HTTP downloads are capped at 100 MB; oversize or checksum failures abort without writing plugin files. Archive type (.zip, .tar.gz, .tgz, or raw binary) is determined from the URL path, so URLs with query strings still work (for example .../plugin.zip?token=...). Local file:// download URLs are supported for development; on Unix use the three-slash form (file:///path/to/plugin.zip); on Windows both file:///C:/path/to/plugin.zip and file://C:/path/to/plugin.zip work.
- Create plugin following the structure above
- Host plugin on GitHub/GitLab with releases
- Submit PR to the community registry
- Include metadata in registry entry
The default registry URL is:
https://raw.githubusercontent.com/Cod-e-Codes/marchat-plugins/main/registry.json
- Fail gracefully: Never crash the main application
- Use BasePlugin: Extend
sdk.BasePluginfor common functionality - Validate input: Always validate user input and plugin data
- Log appropriately: Use stderr for logging, stdout for responses
- Handle errors: Return meaningful error messages
- Test thoroughly: Test with various inputs and edge cases
- Input validation: Validate all user input
- Resource limits: Don't consume excessive resources
- File operations: Use plugin data directory for file operations
- Network access: Document any network access requirements
- Permissions: Request only necessary permissions
- Async operations: Use goroutines for long-running operations
- Memory usage: Be mindful of memory consumption
- Response time: Respond quickly to avoid blocking the chat
- Caching: Cache frequently accessed data
- Cleanup: Clean up resources on shutdown
A simple echo plugin that repeats messages:
func (p *EchoPlugin) OnMessage(msg sdk.Message) ([]sdk.Message, error) {
if strings.HasPrefix(msg.Content, "echo:") {
response := sdk.Message{
Sender: "EchoBot",
Content: strings.TrimPrefix(msg.Content, "echo:"),
CreatedAt: time.Now(),
}
return []sdk.Message{response}, nil
}
return nil, nil
}A weather plugin that responds to weather queries:
func (p *WeatherPlugin) OnMessage(msg sdk.Message) ([]sdk.Message, error) {
if strings.HasPrefix(msg.Content, "weather:") {
location := strings.TrimPrefix(msg.Content, "weather:")
weather := p.getWeather(location)
response := sdk.Message{
Sender: "WeatherBot",
Content: fmt.Sprintf("Weather in %s: %s", location, weather),
CreatedAt: time.Now(),
}
return []sdk.Message{response}, nil
}
return nil, nil
}- Plugin not loading: Check plugin.json format and binary permissions
- Plugin not responding: Check JSON communication format
- Permission denied: Ensure plugin binary is executable
- License validation failed: Check license file and public key
- Plugin crashes: Check plugin logs in stderr
- Enable debug logging: Set log level to debug
- Check plugin logs: Plugin stderr is logged by marchat
- Test communication: Use test harness for plugin communication
- Validate JSON: Ensure JSON format is correct
- Check permissions: Verify file and directory permissions
- Documentation: Check this README and code comments
- Examples: Review example plugins in
plugin/examples/ - Issues: Report bugs on GitHub
- Discussions: Ask questions in GitHub Discussions
- Community: Join the marchat community
The plugin system is part of marchat and is licensed under the MIT License. Individual plugins may have their own licenses.