Architecture Note: Building a Minimal Mattermost Plugin with Slash Commands and REST Endpoints
A practical architecture note for extending Mattermost with a lightweight Go plugin that registers a slash command, exposes a REST endpoint, and persists user settings without direct database access.
06 Aug 2026, 15:39 UTC

The Problem: Extending Mattermost Without Forking the Server
You need a custom slash command and a REST endpoint in Mattermost, but the feature is small enough that running a separate microservice feels like overkill. The practical takeaway is that a single Go module using the official plugin API handles this workload until you need cross-team data sharing or direct database writes.
Requirements and Scope
Before writing code, define the minimum contract:
- Register a slash command that users can invoke from any channel.
- Expose a REST endpoint for programmatic access.
- Persist user-specific settings using the plugin key/value store.
All three must run inside the plugin's Go runtime. Anything outside this scope—such as scheduled jobs that scan every team's channels—signals that the design is outgrowing the plugin sandbox.
The Smallest Suitable Design
Use one Go module. The plugin binary is a single executable that Mattermost loads into its own process. You need a plugin struct, a command handler, an HTTP handler, and a settings type. Mattermost's plugin API has evolved across server versions; verify method signatures against the documentation for your target release.
Plugin Entry Point
The plugin struct embeds the base plugin type and overrides activation hooks. During activation, register the command and health check. The following illustrates the structure; exact signatures depend on the plugin API version:
package main
import (
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)
type Settings struct {
ExternalURL string `json:"external_url"`
CacheTTL int `json:"cache_ttl"`
}
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) OnActivate() error {
cmd := &model.Command{
Trigger: "lookup",
DisplayName: "External Lookup",
Description: "Query an external service",
AutoComplete: true,
AutoCompleteHint: "[query]",
}
if err := p.API.RegisterCommand(cmd); err != nil {
return err
}
return p.API.RegisterHealthCheck(func() error {
// Return nil when the plugin can serve requests.
return nil
})
}
func (p *MyPlugin) OnDeactivate() error {
// Flush or release resources here.
return nil
}
Command and REST Handlers
The command handler receives user input, loads settings from the key/value store, and returns an ephemeral message—visible only to the invoking user. The HTTP handler serves the REST endpoint. Both share the same settings struct and external client.
func (p *MyPlugin) ExecuteCommand(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
settings, err := p.loadSettings()
if err != nil {
return &model.CommandResponse{Text: "Failed to load settings."}, nil
}
result, err := p.queryExternal(settings.ExternalURL, args.Command)
if err != nil {
// Fall back to cached value or return a clear error.
return &model.CommandResponse{Text: "External service unavailable."}, nil
}
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: result,
}, nil
}
func (p *MyPlugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/settings":
p.handleSettings(w, r)
default:
http.NotFound(w, r)
}
}
Persisting User Settings
Serialize the settings struct to JSON and store it with the plugin key/value store. The store is scoped to the plugin, so keys do not collide with other plugins. Use p.API.KVSet and p.API.KVGet rather than writing to disk.
func (p *MyPlugin) loadSettings() (*Settings, error) {
data, appErr := p.API.KVGet("settings")
if appErr != nil {
return nil, appErr
}
if data == nil {
return &Settings{}, nil
}
var s Settings
if err := json.Unmarshal(data, &s); err != nil {
return nil, err
}
return &s, nil
}
Trust and Data Boundaries
The plugin runs in a sandboxed Go process. It cannot open the Mattermost database directly or read arbitrary files from the server host. Every interaction with the server goes through the plugin API. This isolation protects the server from plugin bugs, but it also means you cannot run SQL queries or scan the filesystem from inside the plugin.
Treat the key/value store as untrusted storage. Do not save passwords or tokens in plain text. Use p.API.GetSecret to retrieve secrets injected through the Mattermost configuration, or encrypt values before calling KVSet. Hard-coding external URLs is another risk: read them from environment variables or the plugin configuration so operators can change endpoints without rebuilding the binary.
Operational Checks
Register a health check during activation so the admin console can report plugin status. The check should verify that the plugin can reach its external dependency or that its cache is warm.
To inspect the REST endpoint, run curl from a machine that can reach the Mattermost server. Replace MATTERMOST_URL and PLUGIN_ID with your values:
curl -H "Authorization: Bearer USER_ACCESS_TOKEN" \
https://MATTERMOST_URL/plugins/PLUGIN_ID/api/v1/settings
Expected check: a 200 response with JSON settings. If you receive 404, confirm the route is registered and the plugin ID in the path matches the deployed plugin. If you receive 401, the token is missing or expired.
Graceful shutdown matters because the plugin process may be stopped during server updates. Use OnDeactivate to flush in-memory caches or close external connections. Mattermost calls this hook before terminating the plugin process.
Failure Modes and Mitigations
| Failure | Mitigation |
|---|---|
| External API is unreachable | Return the last cached value from the key/value store, or an ephemeral error telling the user the service is down. |
| Malformed command arguments | Validate and sanitize input before use; return a clear usage message rather than a stack trace. |
| Settings are corrupted in the store | Fail safe by returning defaults and logging the decode error. |
| Plugin panics during a request | The Mattermost server isolates the plugin process; the rest of the server stays available. |
Conditions That Would Change the Design
Two conditions push the feature out of the plugin sandbox:
- Cross-team data sharing. The plugin key/value store is isolated per plugin, but it is not a query engine. If your feature must aggregate data across teams or channels, move the logic to a separate microservice with its own database and expose it through the plugin's REST endpoint or a webhook.
- Direct database writes. Plugins cannot execute SQL against the Mattermost database. If the feature requires new tables or transactional writes to core tables, implement a service that talks to the database directly and let the plugin act as a thin client.
Verification Steps
- Deploy the plugin to a non-production Mattermost instance and confirm the slash command appears in the autocomplete list.
- Invoke the command with valid and invalid arguments; verify the response is ephemeral and errors are readable.
- Call the REST endpoint with curl using a user access token; confirm the status code and payload.
- Open the admin console and check that the plugin health check reports healthy.
- Change a setting through the REST endpoint, restart the Mattermost server, and confirm the setting persists by reading it back.
- Block network access to the external service and confirm the plugin falls back to cached data or returns the expected error.
These checks validate the boundaries without requiring a full production rollout.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.