Building a Mattermost Plugin to Switch AI Agent Models from the UI
TL;DR
Running 20+ AI agents means "switch this agent to GPT-4o" or "put everyone back on Claude" happens constantly. Editing config files and restarting gateways every time is painful. So I built a custom Mattermost plugin that lets you switch models with one click from the channel header.
Why a Mattermost Plugin?
Our multi-agent system uses Mattermost as the communication backbone. The human operator chats with each agent via DM, and agents coordinate with each other the same way. Mattermost is already the de facto management console.
The requirements for model switching were:
- Change an agent's model from the Mattermost UI — no SSH, no config files
- Changes take effect without restarting the agent's gateway
- Current model visible at a glance
"Build it as a Mattermost plugin" was the obvious answer.
Architecture
[Mattermost UI]
↓ Channel header button click
[Plugin Frontend (React)]
↓ API call
[Plugin Backend (Go)]
↓ Persist to KVStore
↓ (Original plan) Push notification to agent via message bus → Failed
↓ (Current) Agent pulls KV on each session start
[Agent (OpenClaw)]
↓ session_status(model=xxx) to switch
KVStore: Built-in Key-Value Storage
Mattermost plugins come with KVStore — a persistent key-value store backed by the database. No separate SQLite or table migrations needed. Just plugin.API.KVSet(key, value) / KVGet(key).
Model preferences are stored here. Key: model_preference, value as JSON:
{
"model": "anthropic/claude-opus-4-6",
"updated_at": "2026-03-27T13:22:00+09:00",
"updated_by": "linou"
}
Frontend: Button in the Channel Header
Using Mattermost's channel_header_button extension point, an icon button sits in the channel header. Click it to open a modal with a dropdown of available models, with the current one highlighted.
registry.registerChannelHeaderButtonAction(
<ModelSwitcherIcon />,
(channel) => openModelSwitcherModal(channel),
'Switch AI Model'
);
Backend: Go REST API
func (p *Plugin) handleModelGet(w http.ResponseWriter, r *http.Request) {
data, _ := p.API.KVGet("model_preference")
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}
func (p *Plugin) handleModelSet(w http.ResponseWriter, r *http.Request) {
var req ModelRequest
json.NewDecoder(r.Body).Decode(&req)
data, _ := json.Marshal(req)
p.API.KVSet("model_preference", data)
w.WriteHeader(http.StatusOK)
}
Endpoints:
-
GET /plugins/.../api/v1/model— Get current model -
PUT /plugins/.../api/v1/model— Set model
The Hardest Part: The Notification Wall
The original design called for real-time push notifications to agents when the model changes. After writing to KVStore, the plugin would POST to our internal message bus, which would forward to the target agent.
It didn't work. The problem: Docker networking. Mattermost runs inside a container. The message bus runs on a separate host. HTTP from inside the container to the host network was blocked by firewall rules.
[Mattermost Container] --X--> [Host Network: Message Bus:8091]
Setting network_mode: host in Docker Compose would fix it, but I didn't want to break the official Compose configuration. A reverse proxy was another option, but overkill for this.
The Fix: Switch to Pull
I gave up on push (plugin → agent) and switched to pull (agent → plugin).
Each agent fetches the current model setting from the plugin API at the start of every session:
curl -s "http://mattermost-server:8065/plugins/.../api/v1/model" \
-H "Authorization: Bearer <token>"
If the returned model differs from the current session, session_status(model=xxx) handles the switch. This check is codified in each agent's AGENTS.md as a mandatory session startup step.
The result:
- Zero dependency on push infrastructure
- Model switches take effect on the next session start (not instant, but perfectly practical)
- Implementation became drastically simpler
Bonus Feature: RHS File Browser
The same plugin also includes a file browser in Mattermost's Right-Hand Sidebar (RHS). It displays the shared directory structure and lets you browse files without SSHing into a machine.
This uses registerRightHandSidebarComponent to mount a React component in the RHS. The backend serves the directory tree of /mnt/shared/ as a JSON API.
The gotcha here was toggleRHSPlugin. It's a Redux action, not a regular function call:
// ❌ Doesn't work
toggleRHSPlugin();
// ✅ Correct
store.dispatch(toggleRHSPlugin());
Mattermost's plugin frontend documentation is sparse on this. Had to read the source code.
After Three Days of Use
Being able to switch models from the UI turned out to be more convenient than expected. Especially when bouncing between Claude Opus for heavy reasoning tasks and Sonnet for daily conversations — one click does it.
KVStore + pull architecture loses on real-time responsiveness but wins on stability and simplicity. In a multi-agent system, "hard to break" trumps everything.
Key Takeaways
- Mattermost plugins work surprisingly well as agent management UIs
- KVStore is perfect for persisting settings (no external DB needed)
- When push is hard, pull is often the pragmatic choice
-
toggleRHSPluginis a Redux action — must usestore.dispatch()
If you're using Mattermost as your agent infrastructure, custom plugins are worth exploring. The official docs are minimal, but starting from mattermost-plugin-starter-template on GitHub gets you something working in a day.
Top comments (0)