26 KiB
Design: Mattermore — Mattermost AI Chat Agent for Ollama
Status: Draft / Pre-implementation
Last updated: 2026-06-16
Assumed direction: AI chat agent plugin connecting to a remote Ollama instance.
Open questions are markedTODO(design):throughout. This is a living template — change assumptions as the product direction firms up.
1. Purpose & Scope
1.1 What It Is
Mattermore is a Mattermost (Community Edition) plugin that lets users chat
with remote LLMs through an Ollama backend. Users address the bot by
@-mentioning it in any channel (e.g. @mattermore write a poem). The
bot responds only when addressed and disengages naturally — it never
reads every message in the room. A slash command (/ai) remains available
as an alternative entry point.
1.2 What It Is Not (Explicit Out-of-Scope)
| Out of scope | Rationale |
|---|---|
| Hosting its own LLM inference | Ollama is the sole backend; the plugin is a thin proxy |
| Multi-backend abstraction (OpenAI, Anthropic, etc.) | If needed later, abstract behind an LLMProvider interface — not now |
| Training / fine-tuning | Ollama handles that |
| Channel whisper mode — replying to every message in a channel | The agent only activates on @-mention or thread reply. It never reads unprompted messages. |
1.3 Target Audience
- Teams self-hosting Mattermost who want AI assistant access without data leaving their infrastructure
- Users comfortable with @-mentioning a bot the same way they @-mention a human teammate
2. Architecture Overview
2.1 Component Diagram (text)
┌──────────────────────────────────────────────────────────────┐
│ Mattermost Server (Community Edition) │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Mattermore Plugin │ │
│ │ │ │
│ │ ┌──────────────────┐ ┌────────────────────────┐ │ │
│ │ │ MessageHasBeen │──▶│ Plugin (Go) │ │ │
│ │ │ Posted (primary) │ │ │ │ │
│ │ └──────────────────┘ │ ┌──────────────────┐ │ │ │
│ │ ┌──────────────────┐ │ │ OllamaClient │──┼───┼───┼──▶ Ollama
│ │ │ SlashCommand │──▶│ └──────────────────┘ │ │ │ (remote)
│ │ │ /ai (alt.) │ │ ┌──────────────────┐ │ │ │
│ │ └──────────────────┘ │ │ ConversationStore│ │ │ │
│ │ ┌──────────────────┐ │ └──────────────────┘ │ │ │
│ │ │ EngagementEngine │ │ ┌──────────────────┐ │ │ │
│ │ │ wake / sleep │──▶│ │ RateLimiter │ │ │ │
│ │ └──────────────────┘ │ └──────────────────┘ │ │ │
│ │ ┌──────────────────┐ │ ┌──────────────────┐ │ │ │
│ │ │ Bot Account │ │ │ Config (KV) │ │ │ │
│ │ │ (auto-created) │ │ └──────────────────┘ │ │ │
│ │ └──────────────────┘ │ │ │ │
│ │ ┌──────────────────┐ │ │ │ │
│ │ │ Webapp (none) │ │ │ │ │
│ │ └──────────────────┘ │ │ │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
2.2 Request Flow (happy path)
User types "@mattermore write a release note for v2.1"
│
▼
Mattermore MessageHasBeenPosted fires
│
▼
EngagementEngine.Wake(userId, channelId) → mark session Active
│
▼
@-mention detected → strip "@mattermore " prefix from prompt
│
▼
RateLimiter.Allow(userId) → 200 or 429
│
▼
ConversationStore.GetSession(userId, channelId, threadId) → previous messages for context
│
▼
OllamaClient.ChatCompletion(messages, stream=true, model=defaultModel)
│
▼
Ollama returns ndjson stream → plugin creates root post with live-updating thread
│
▼
Post created in channel → user sees response in thread
│
▼
ConversationStore.Append(userId, channelId, threadId, userMsg, assistantMsg)
Thread Reply Flow (continues the conversation)
User replies in thread → "Can you make it more formal?"
│
▼
Mattermore MessageHasBeenPosted fires (same thread as bot post)
│
▼
EngagementEngine.IsActive(userId, threadId) → true
│
▼
Not an @-mention, but active thread → treat as continuation
│
▼
ConversationStore.GetSession(userId, channelId, threadId) → full context so far
│
▼
OllamaClient.ChatCompletion(...) → stream response into same thread
│
▼
ConversationStore.Append(userId, channelId, userMsg, assistantMsg)
2.3 Error Flow
| Failure point | Behaviour |
|---|---|
| Ollama unreachable | Ephemeral post: "Ollama at unreachable. Check configuration." |
| Rate limit hit | Ephemeral post: "Rate limit exceeded. Try again in N seconds." |
| Invalid model name | Ephemeral post: "Model 'xyz' not found on Ollama server." |
| Timeout (>30 s) | Abort stream, post partial response + "Response truncated (timeout)." |
| Input too long | Reject with context window limit message; suggest @mattermore new to reset. |
3. Plugin Manifest & Identity
3.1 plugin.json
{
"id": "com.forkless.mattermore",
"name": "Mattermore",
"description": "AI chat agent powered by Ollama.",
"version": "0.1.0",
"server": {
"executables": {
"linux-amd64": "server/dist/plugin-linux-amd64",
"linux-arm64": "server/dist/plugin-linux-arm64",
"darwin-amd64": "server/dist/plugin-darwin-amd64"
}
},
"webapp": {},
"settings_schema": {
"header": "Configure the connection to your Ollama instance.",
"footer": "",
"settings": [
{
"key": "OllamaURL",
"display_name": "Ollama Server URL",
"type": "text",
"help_text": "Base URL of the Ollama API, e.g. http://10.0.0.5:11434",
"placeholder": "http://localhost:11434",
"default": "http://localhost:11434"
},
{
"key": "DefaultModel",
"display_name": "Default Model",
"type": "text",
"help_text": "Model name to use when none is specified.",
"placeholder": "llama3:latest",
"default": "llama3:latest"
},
{
"key": "SystemPrompt",
"display_name": "System Prompt",
"type": "text",
"help_text": "System prompt prepended to every conversation.",
"placeholder": "You are a helpful assistant.",
"default": "You are a helpful assistant."
},
{
"key": "MaxTokens",
"display_name": "Max Response Tokens",
"type": "number",
"help_text": "Maximum tokens per response (0 = model default).",
"default": 2048
},
{
"key": "RateLimitPerMinute",
"display_name": "Rate Limit (requests/minute/user)",
"type": "number",
"help_text": "Maximum requests per minute per user. 0 = unlimited.",
"default": 10
},
{
"key": "AllowedUserIDs",
"display_name": "Allowed User IDs (comma-separated)",
"type": "text",
"help_text": "Restrict to specific user IDs. Empty = all users.",
"default": ""
},
{
"key": "StopWords",
"display_name": "Stop Words (comma-separated)",
"type": "text",
"help_text": "When the user says one of these, the bot responds briefly and disengages.",
"placeholder": "thanks, thank you, bye, goodbye, that's all, done",
"default": "thanks, thank you, bye, goodbye, see you, that's all, that's it, done, all done, stop, quit, end"
}
]
}
}
TODO(design): Should AllowedUserIDs be a team/channel whitelist instead?
For now, keep user-level granularity — easy to widen later.
3.2 Versioning
Follow release-workflow skill: v0.1.0, v0.2.0, etc. Bump PATCH for
bugs, MINOR for features. MAJOR after 1.0 for breaking config schema changes.
4. Server-Side Components
4.1 Plugin Lifecycle Hooks
| Hook | Purpose |
|---|---|
OnActivate() |
Register slash command, create bot account via API.CreateBot(), initialise EngagementEngine |
OnConfigurationChange() |
Re-read System Console settings, validate OllamaURL, rebuild clients, rebuild stop-word trie |
MessageHasBeenPosted() |
Primary entry point — detect @-mentions and thread continuations, delegate to EngagementEngine |
ExecuteCommand() |
Alternative entry point — intercept /ai and subcommands |
OnDeactivate() |
Clean up goroutines, deactivate bot account, close idle HTTP connections |
4.2 Interaction Model
The plugin has two entry points, but @-mention is the primary UX.
4.2.1 @-mention (primary)
User types @mattermore <prompt> in any channel. The plugin:
- Detects the @-mention via
MessageHasBeenPosted→model.Post.Mentions - Strips the bot username from the prompt
- Wakes the
EngagementEnginefor this user+thread - Creates a thread root post with the streamed response
- Monitors thread replies for continuation — as long as the user keeps replying in the thread, the bot auto-responds (no @-mention needed for follow-ups in the same thread)
When the bot does NOT respond:
- Messages without
@mattermorewhile the EngagementEngine is Sleeping - Messages in threads the bot didn't start
- Messages from other bot accounts (infinite-loop guard)
4.2.2 Slash command (alternative)
/ai <prompt> works as a fallback for users who prefer it or when
@-mention is inconvenient. Behaviour is identical — creates a thread,
activates the EngagementEngine, continues on thread replies.
Full syntax:
/ai <prompt>
— Send a prompt using the default model (current session context).
/ai model <name> <prompt>
— Send a prompt using a specific model.
/ai new
— Reset conversation context for the current user+channel.
/ai model list
— List available models from the Ollama server.
/ai help
— Show usage help.
4.3 OllamaClient
Interface (in server/ollama/client.go):
type Client interface {
ChatCompletion(ctx context.Context, req *ChatRequest) (<-chan ChatStreamEvent, error)
ListModels(ctx context.Context) ([]Model, error)
Ping(ctx context.Context) error
}
type ChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Stream bool `json:"stream"`
Options map[string]any `json:"options,omitempty"`
}
type ChatStreamEvent struct {
Token string // delta content
Done bool
Error error
}
TODO(design): Should we use Ollama's /api/chat (chat format) or
/api/generate (raw prompt)? /api/chat is preferred — it maps cleanly to
the conversational UX and supports the messages array natively.
4.4 ConversationStore
Store session context in Mattermost's KV store (plugin's API.KVSet /
KVGet).
| Key pattern | Value | TTL |
|---|---|---|
ctx_{userID}_{channelID} |
[]Message (last N turns) |
1 hour |
ctx_{userID}_{channelID}_model |
Model name override | 1 hour |
TODO(design):
- How many turns to keep? Proposed: last 10 messages (5 user + 5 assistant).
- TTL of 1 hour — resets on activity. Configurable?
- Should context be channel-scoped or user-global? Channel-scoped means a user gets different context in different channels.
4.5 RateLimiter
Token-bucket per user. Configurable requests/minute from System Console. Resets on config change. Stored in memory, not KV (ephemeral state).
4.6 Engagement Lifecycle
The EngagementEngine is the core mechanism that prevents the bot from replying to everything in the channel. It manages whether the bot should respond to a given message.
States
| State | Behaviour | Entered when |
|---|---|---|
| Sleeping | Ignores all messages | Agent started, disengage triggered, timeout expired |
| Active | Responds to @-mentions and thread replies in the active session | User @-mentions bot or sends /ai |
| Disengaging | Responds to current message with a closing note, then sleeps | Stop word detected or user sends stop command |
┌─────────────────┐
│ Sleeping │
└────────┬────────┘
│ @-mention or /ai
▼
┌─────────────────┐
┌──────│ Active │◄──── thread reply continues
│ └────────┬────────┘
│ │ stop word / stop command / timeout
│ ▼
│ ┌─────────────────┐
│ │ Disengaging │── responds once, then...
│ └────────┬────────┘
│ │
└───────────────┘
Disengagement Triggers
| Trigger | Example | Behaviour |
|---|---|---|
| Stop word | "thanks", "thank you", "bye", "goodbye", "that's all", "that's it" | Bot responds with a brief closing message, then sleeps |
| Stop command | @mattermore stop |
Bot sleeps immediately, no closing message |
| Thread timeout | No reply in thread for 15 minutes | Bot sleeps silently — next @-mention starts a fresh session |
| Model EOS signal | Model outputs [EOS] naturally |
Same as stop word — clean disengagement from the AI's side |
Stop word matching
Stop words are configurable via System Console (StopWords). The plugin
builds a case-insensitive trie at config load. Matching is done on the
user's raw prompt (after stripping the @-mention), before sending to
Ollama — this avoids the latency and cost of a round-trip just to detect
a goodbye.
The system prompt also instructs the model to append [EOS] when it
believes the conversation is naturally complete. The EngagementEngine
watches the response stream for this token as an additional trigger.
Thread timeout
15 minutes of inactivity in the thread → bot sleeps. The ConversationStore
TTL naturally handles cleanup. On wake, a new thread is created rather than
resuming the old one.
TODO(design): Should the timeout be configurable via System Console?
Proposed: yes — EngagementTimeoutMinutes setting, default 15.
Infinite-loop guard
The plugin never processes messages from:
- Its own bot account (checked via
post.UserId == botUserID) - Any other bot account (
post.UserIdcross-referenced againstmodel.Bot)
5. Ollama Integration
5.1 API Mapping
| Plugin Action | Ollama Endpoint | Method |
|---|---|---|
| Chat completion (streaming) | /api/chat |
POST |
| List models | /api/tags |
GET |
| Health check | /api/tags (or HEAD /) |
GET |
| Pull model | /api/pull |
POST |
TODO(design): Should the plugin auto-pull a model if it's not present?
Risk: user mistypes name → long pull. Proposed: return error with list of
available models instead.
5.2 Streaming Strategy
- Send POST to
/api/chatwith"stream": true. - Read ndjson response body line by line (each line is
{"message":{"role":"assistant","content":"..."},"done":false}). - On each
done:falseevent, update the thread root post viaAPI.UpdatePost()with accumulated content. - On
done:true, finalise the post with full content, usage stats (token count, duration).
TODO(design): Post-update on every token may be too chatty. Proposed:
batch updates every ~200 ms or every 3 tokens, whichever comes first.
5.3 Context Window Management
- Token counting: use Ollama's
num_ctxparameter or a Go tokenizer (tiktoken-go). - If input exceeds configured limit (default: 4096 tokens), trim oldest messages until within limit, or reject if a single message overflows.
- Display a warning: "Context window trimmed (oldest messages removed)."
TODO(design): Should the user be able to set num_ctx per-request?
@mattermore --context 8192 ...
5.4 Model Selection Precedence
- Per-request:
@mattermore model gemma3:12b write a poem - Session override:
/ai model gemma3:12b(stored in KV) - Default from System Console:
DefaultModel
6. Configuration
6.1 System Console Settings
Defined in plugin.json settings_schema (see §3.1). The System Console
provides the UI; the plugin reads via OnConfigurationChange().
6.2 Dynamic Config Reload
OnConfigurationChange() is called on every save. The plugin must:
- Re-read the config struct from
API.GetConfig()/API.GetPluginConfig(). - Validate
OllamaURL(parse as URL, reject non-HTTP(S) schemes). - If URL changed, create a new
http.Client(with configurable timeout). - If
RateLimitPerMinutechanged, rebuild the token buckets. - If
StopWordschanged, rebuild the stop-word trie. - Log the change at debug level.
7. Data Flow — Detailed
7.1 Full Lifecycle
Step User / System Component
──── ────────────────────────────── ──────────────────
1 User types "@mattermore explain TCP" Mattermost webapp
2 MessageHasBeenPosted fires Mattermore plugin
3 EngagementEngine.Wake() EngagementEngine
3a Check not a bot post (loop guard)
3b Check @-mention or active thread
3c Transition to Active
4 Strip "@mattermore " → "explain TCP"
5 Check stop-word trie (no match)
6 RateLimiter.Allow(userId) RateLimiter
7 ConversationStore.GetSession(...) ConversationStore
8 Build []ChatMessage (system + history + new prompt)
9 OllamaClient.ChatCompletion(...) OllamaClient
10 Ollama streams ndjson tokens Ollama (remote)
11 Plugin accumulates tokens, updates Mattermore plugin
thread post every ~200ms
12 On done:true → finalise post Mattermore plugin
13 Check response for [EOS] signal EngagementEngine
14 ConversationStore.Append(...) ConversationStore
7.2 Disengagement Flow
Step User / System Component
──── ────────────────────────────── ──────────────────
1 User replies "thanks that's all"
2 MessageHasBeenPosted fires
3 EngagementEngine.IsActive → true
4 Strip @-mention if present
5 Stop-word trie matches "that's all" StopWordTrie
6 EngagementEngine.Transition(Disengaging)
7 OllamaClient.ChatCompletion(lastMsg) (brief closing response)
8 Post closing message in thread
9 EngagementEngine.Transition(Sleeping)
7.3 Thread Participation
Thread replies continue the conversation without requiring a new @-mention.
The EngagementEngine tracks which threads it owns (via ownerThread_{threadID}
in KV). For posts in owned threads:
- If the EngagementEngine is Active for this user+thread → respond
- If Sleeping → respond once to wake, then continue
- If Disengaging → respond with closing note, then sleep
8. Security Model
8.1 Input Sanitization
- Strip control characters / zero-width Unicode from prompts before sending to Ollama.
- Limit prompt length to 4096 characters (configurable via System Console).
TODO(design):Should we block prompts that look like prompt injections? ("Ignore previous instructions...") Hard to do reliably — document risk instead.
8.2 Output Safety
- Cap response tokens in the Ollama request (
options.num_predict). TODO(design):Add a "Report this response" feedback mechanism? Defer.
8.3 Network Security
OllamaURLmust start withhttp://orhttps://— validate at config load.- Reject internal IP ranges unless explicitly enabled by a
AllowPrivateNetworkstoggle (default: off for production). - TLS verify enabled by default; optional
SkipTLSVerifytoggle (logged as a warning).
8.4 Authorization
- If
AllowedUserIDsis non-empty, reject requests from other users with a "not authorized" ephemeral message. - All plugin API calls are already scoped to authenticated Mattermost users.
8.5 Rate Limiting
- Per-user token bucket, replenish rate set via System Console.
- Default: 10 requests/minute. 0 = unlimited (logged as a warning).
- Burst: 1 (enforce smooth spacing).
9. Observability
9.1 Logging
| Level | When |
|---|---|
ERROR |
Ollama unreachable, config validation failure, KV store error |
WARN |
Rate limit hit, context window trimmed, TLS skipped |
INFO |
Plugin activated/deactivated, config changed, first request |
DEBUG |
Every request/response round-trip (with userID, model, token count, latency, engagement state) |
9.2 Metrics (future)
TODO(design): Expose Prometheus-style counters via a /metrics endpoint
if the Mattermost server has plugin metrics support:
mattermore_requests_total{user,model,status}mattermore_latency_secondsmattermore_tokens_total{direction="input|output"}mattermore_rate_limit_hits_total
9.3 Error Reporting
Ephemeral posts with user-facing messages (not raw Go errors). Internal
errors logged at ERROR with stack trace.
10. Open Design Decisions
These are the unresolved questions that need answers before v0.1.0 ships.
| # | Question | Proposed | Alternatives | Decided? |
|---|---|---|---|---|
| D1 | Reply style | Thread posts (not ephemeral) — community sees the interaction | Ephemeral, channel-wide | ✅ → DECISIONS.md |
| D2 | Context scope | Per-channel per-user — each channel gets its own conversation | Per-user global, per-channel only, no context | ❌ |
| D3 | Context turns | Last 5 exchanges (10 messages) | Last N tokens, everything in TTL, sliding window | ❌ |
| D4 | Webapp UI | None — @-mention + thread replies cover all interaction | Channel header button, RHS panel | ✅ → DECISIONS.md |
| D5 | Model governance | All models available | Admin-restricted model list, least-privilege Ollama API key | ❌ |
| D6 | Thread replies | Core feature — the EngagementEngine auto-follows thread replies | Slash-command-only, ephemeral | ✅ → DECISIONS.md |
| D7 | num_ctx per request |
Via --context N flag |
System Console default only | ❌ |
| D8 | Auto-pull models | No (error + list available) | Yes (convenient but slow) | ❌ |
| D9 | Prompt injection guard | Defer (document risk) | Regex blocklist, LLM-as-judge pre-filter | ❌ |
| D10 | Metrics endpoint | Defer | /metrics via plugin HTTP handler |
❌ |
| D11 | Engagement scope | Per-user+channel | Per-user global, per-channel only | ❌ |
| D12 | Thread timeout | 15 minutes, configurable | Fixed 30min, no timeout | ❌ |
Each decision should be recorded in DECISIONS.md once resolved.
11. Deployment & Lifecycle
11.1 Build
make dist
Produces dist/com.forkless.mattermore-0.1.0.tar.gz containing the server
binaries and plugin.json.
11.2 Upload
Via Mattermost System Console → Plugins → Upload Plugin, or via REST API:
curl -X POST $MM_URL/api/v4/plugins \
-H "Authorization: Bearer $MM_TOKEN" \
-F "plugin=@dist/com.forkless.mattermore-0.1.0.tar.gz"
11.3 Upgrade
- Upload new
.tar.gz— Mattermost replaces the plugin binaries. - Plugin's
OnConfigurationChange()fires (config preserved). - Bot account is re-fetched (not re-created) — idempotent.
- If the KV schema changed, a migration in
OnActivate()handles it.
11.4 Rollback
Re-upload the previous version's .tar.gz. No data migration needed if the
KV key schema hasn't changed. Bot account survives rollback. If it has,
restore from backup or DECISIONS.md records the migration path.
12. Future Considerations (v0.2+)
- Multi-modal inputs — Ollama supports images via
/api/chatwithimagesfield. Could allow image attachment in thread. - Multi-model routing — Different models for different channel categories (e.g. code-review channel uses codellama).
- Tool calling — Ollama supports tool definitions. Could let the plugin fetch data from external APIs (weather, Jira, etc.).
- Streaming into message attachments — Render structured data (tables, JSON, code blocks) with better formatting.
- Conversation export — Allow users to export an AI thread as markdown.