Refine interaction model: @-mention as primary with EngagementEngine lifecycle

This commit is contained in:
2026-06-16 17:29:33 +02:00
parent 8da5dcec9b
commit 2709120cf3
2 changed files with 319 additions and 84 deletions
+80
View File
@@ -71,4 +71,84 @@ not final until v0.1.0 implementation.
---
---
## Why @-mention + Engagement Lifecycle over Slash-Command-Only
**Context:** How does the user interact with the AI agent? Slash commands
(`/ai`) are explicit but unnatural — users must learn a command syntax.
Bot @-mentions match how users already talk to each other in Mattermost.
**Alternatives considered:**
1. **Slash command only** (`/ai <prompt>`) — Explicit, discoverable via
`/ai help`. But every interaction requires the prefix, and thread
continuation is ambiguous.
2. **@-mention only** (`@mattermore <prompt>`) — Natural, familiar UX.
Users already @-mention teammates. Thread replies continue the
conversation without re-mentioning. Risk of accidental triggers.
3. **Channel whisper (reply to everything)** — Simplest for the user but
noisy and invasive. The user explicitly rejected this.
4. **Hybrid: @-mention primary + /ai fallback** — Both work identically.
**Outcome:** Hybrid (#4) chosen because:
- @-mention is the primary, most natural interface
- `/ai` is available for users who prefer it or when @-mention is
inconvenient (e.g. automated scripts, mobile)
- Both paths go through the same `EngagementEngine` — identical behaviour
- Thread replies auto-continue without re-mentioning (D6 resolved — core
feature, not deferred)
**Trade-off:** @-mention requires a bot account (`API.CreateBot()`). This
adds a one-time setup step and a DB row, but the plugin handles creation
and deactivation automatically. The bot account is not a separate service —
it's an identity within the plugin process, so there's no extra deployment
artifact or OAuth management.
## Why Thread Posts Instead of Ephemeral Messages
**Context:** Where does the bot's response appear?
**Alternatives considered:**
1. **Ephemeral post** — Only the requesting user sees it. Clean but
invisible to the rest of the channel/reviewers.
2. **Channel-wide post** — Visible to everyone. Can be noisy if the bot
posts long responses.
3. **Thread post** — Visible to everyone but collapsed into a thread.
Channel stays clean, conversation history is easy to follow. Thread
replies naturally continue the conversation.
**Outcome:** Thread post (#3) chosen because:
- Threads keep the channel tidy
- Community can see and follow AI interactions
- Thread replies map 1:1 to conversation continuation
- Users can collapse/hide threads they don't care about
**Trade-off:** Threads require the user to click into them to see the full
response on some clients. Mitigated by showing a preview snippet in the
channel.
## Why No Webapp UI
**Context:** Should the plugin ship a webapp component (channel header
button, right-hand side panel, custom settings page)?
**Alternatives considered:**
1. **No webapp** — Pure server-side plugin. @-mention + `/ai` + thread
replies covers everything. Simplest to build and maintain.
2. **Channel header button** — Button to open an AI chat panel. Adds
discoverability but duplicates what @-mention already does.
3. **Custom settings page** — Duplicates System Console settings in the
main UI. Adds complexity for marginal benefit.
**Outcome:** No webapp (#1) chosen because:
- @-mention is already discoverable (Mattermost autocompletes)
- Thread replies provide a natural conversation UI
- No frontend to build, bundle, or version
- All configuration stays in System Console where admins expect it
**Trade-off:** Users must know the bot exists to @-mention it. Mitigated by
mentioning it in channel header, onboarding posts, or `/ai help`.
---
*This file follows the template from the `project-docs` skill.*
+239 -84
View File
@@ -13,9 +13,11 @@
### 1.1 What It Is
Mattermore is a Mattermost (Community Edition) plugin that lets users chat
with remote LLMs through an Ollama backend. Users invoke the plugin via a
slash command (e.g. `/ai`) and receive responses — either ephemeral or as
thread replies — streamed directly into the channel.
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)
@@ -24,14 +26,14 @@ thread replies — streamed directly into the channel.
| 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 |
| Native webapp UI beyond slash commands | `TODO(design):` decide if a channel header button or RHS panel adds value |
| Bot accounts | Pure plugin hooks — no separate bot user to manage |
| **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 slash commands
- Users comfortable with @-mentioning a bot the same way they @-mention a
human teammate
---
@@ -40,56 +42,87 @@ thread replies — streamed directly into the channel.
### 2.1 Component Diagram (text)
```
┌─────────────────────────────────────────────────────────┐
│ Mattermost Server (Community Edition) │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Mattermore Plugin │ │
│ │ │ │
│ │ ┌─────────────┐ ┌────────────────────────┐ │ │
│ │ │ SlashCommand │──▶│ Plugin (Go) │ │ │
│ │ │ /ai │ │ │ │ │
│ │ └─────────────┘ │ ┌──────────────────┐ │ │ │
│ │ │ │ OllamaClient │──┼───┼───┼──▶ Ollama API
│ │ ┌─────────────┐ │ └──────────────────┘ │ │ │ (remote)
│ │ │ Config (KV) │ │ ┌──────────────────┐ │ │ │
│ │ └─────────────┘ │ │ ConversationStore │ │ │ │
│ │ │ └──────────────────┘ │ │ │
│ │ ┌─────────────┐ │ ┌──────────────────┐ │ │ │
│ │ │ Webapp (?) │ │ RateLimiter │ │ │ │
│ │ └─────────────┘ │ └──────────────────┘ │ │ │
└──────────────────────────────────────────────────
└─────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────
│ 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 "/ai write a release note for v2.1"
User types "@mattermore write a release note for v2.1"
Mattermost parses slash command → routes to Mattermore plugin
Mattermore MessageHasBeenPosted fires
Plugin.OnConfigurationChange() → reads config (Ollama URL, model, system prompt)
EngagementEngine.Wake(userId, channelId) → mark session Active
Plugin.ExecuteCommand() → parses args, strips command prefix
@-mention detected → strip "@mattermore " prefix from prompt
RateLimiter.Allow(userId) → 200 or 429
ConversationStore.GetSession(userId, channelId) → previous messages for context
ConversationStore.GetSession(userId, channelId, threadId) → previous messages for context
OllamaClient.ChatCompletion(messages, stream=true, model=defaultModel)
Ollama returns ndjson stream → plugin writes ephemeral post with live updates
Ollama returns ndjson stream → plugin creates root post with live-updating thread
Post created → user sees response in channel (ephemeral or thread reply)
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)
@@ -103,7 +136,7 @@ ConversationStore.Append(userId, channelId, userMsg, assistantMsg)
| 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 `/ai new` to reset. |
| Input too long | Reject with context window limit message; suggest `@mattermore new` to reset. |
---
@@ -141,7 +174,7 @@ ConversationStore.Append(userId, channelId, userMsg, assistantMsg)
"key": "DefaultModel",
"display_name": "Default Model",
"type": "text",
"help_text": "Model name to use when none is specified in the command.",
"help_text": "Model name to use when none is specified.",
"placeholder": "llama3.2",
"default": "llama3.2"
},
@@ -173,6 +206,14 @@ ConversationStore.Append(userId, channelId, userMsg, assistantMsg)
"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"
}
]
}
@@ -195,14 +236,40 @@ bugs, MINOR for features. MAJOR after 1.0 for breaking config schema changes.
| Hook | Purpose |
|---|---|
| `OnConfigurationChange()` | Re-read System Console settings, validate OllamaURL, rebuild clients |
| `ExecuteCommand()` | Intercept `/ai` and subcommands (`/ai new`, `/ai model ...`) |
| `OnActivate()` | Register slash command in `OnConfigurationChange()` — idempotent |
| `OnDeactivate()` | Clean up goroutines, close idle HTTP connections |
| `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 Slash Command: `/ai`
### 4.2 Interaction Model
**Syntax:**
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:
1. Detects the @-mention via `MessageHasBeenPosted``model.Post.Mentions`
2. Strips the bot username from the prompt
3. Wakes the `EngagementEngine` for this user+thread
4. Creates a **thread root post** with the streamed response
5. 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 `@mattermore` while 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>
@@ -212,15 +279,11 @@ bugs, MINOR for features. MAJOR after 1.0 for breaking config schema changes.
/ai new
— Reset conversation context for the current user+channel.
/ai model list
— List available models from the Ollama server (ephemeral).
— List available models from the Ollama server.
/ai help
— Show usage help.
```
`TODO(design):` Should `/ai` reply ephemerally (only the caller sees it) or
as a thread post (visible to the channel)? **Proposed:** ephemeral for
privacy, with an opt-in `--public` flag to post to thread.
### 4.3 OllamaClient
Interface (in `server/ollama/client.go`):
@@ -271,6 +334,74 @@ Store session context in Mattermost's KV store (plugin's `API.KVSet` /
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.UserId` cross-referenced against `model.Bot`)
---
## 5. Ollama Integration
@@ -292,10 +423,10 @@ available models instead.
1. Send POST to `/api/chat` with `"stream": true`.
2. Read ndjson response body line by line (each line is `{"message":{"role":"assistant","content":"..."},"done":false}`).
3. On each `done:false` event, update an ephemeral post via
`API.UpdateEphemeralPost()` with accumulated content.
4. On `done:true`, create the final ephemeral (or thread) post with full
content, usage stats (token count, duration).
3. On each `done:false` event, update the thread root post via
`API.UpdatePost()` with accumulated content.
4. 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.
@@ -309,11 +440,11 @@ batch updates every ~200 ms or every 3 tokens, whichever comes first.
- Display a warning: "Context window trimmed (oldest messages removed)."
`TODO(design):` Should the user be able to set `num_ctx` per-request?
`/ai --context 8192 ...`
`@mattermore --context 8192 ...`
### 5.4 Model Selection Precedence
1. Per-request: `/ai model gemma3:12b write a poem`
1. Per-request: `@mattermore model gemma3:12b write a poem`
2. Session override: `/ai model gemma3:12b` (stored in KV)
3. Default from System Console: `DefaultModel`
@@ -333,7 +464,8 @@ provides the UI; the plugin reads via `OnConfigurationChange()`.
2. Validate `OllamaURL` (parse as URL, reject non-HTTP(S) schemes).
3. If URL changed, create a new `http.Client` (with configurable timeout).
4. If `RateLimitPerMinute` changed, rebuild the token buckets.
5. Log the change at debug level.
5. If `StopWords` changed, rebuild the stop-word trie.
6. Log the change at debug level.
---
@@ -344,29 +476,50 @@ provides the UI; the plugin reads via `OnConfigurationChange()`.
```
Step User / System Component
──── ────────────────────────────── ──────────────────
1 User types "/ai explain TCP" Mattermost webapp
2 Mattermost routes to plugin mm-server
3 Plugin.ExecuteCommand() parses args Mattermore plugin
3a If "/ai help" → show help text
3b If "/ai new" → KVStore.Delete(ctx)
3c If "/ai model …" → override model KVStore.Set(model)
3d If "/ai model list" → OllamaClient.ListModels()
4 RateLimiter.Allow(userId) RateLimiter
5 ConversationStore.GetSession(...) ConversationStore
6 Build []ChatMessage (system + history + new prompt)
7 OllamaClient.ChatCompletion(...) OllamaClient
8 Ollama streams ndjson tokens Ollama (remote)
9 Plugin accumulates tokens, updates Mattermore plugin
ephemeral post every ~200ms
10 On done:true → create final post Mattermore plugin
11 ConversationStore.Append(...) ConversationStore
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 Thread Participation
### 7.2 Disengagement Flow
`TODO(design):` If the user replies in a thread where the bot posted, should
the plugin pick it up? This requires a `MessageHasBeenPosted` hook and
thread detection — adds complexity. **Scope:** defer to v0.2+.
```
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
---
@@ -418,7 +571,7 @@ thread detection — adds complexity. **Scope:** defer to v0.2+.
| `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) |
| `DEBUG` | Every request/response round-trip (with userID, model, token count, latency, engagement state) |
### 9.2 Metrics (future)
@@ -442,16 +595,18 @@ These are the unresolved questions that need answers before v0.1.0 ships.
| # | Question | Proposed | Alternatives | Decided? |
|---|---|---|---|---|
| D1 | Reply style | Ephemeral by default, `--public` flag for thread | Always ephemeral, always thread, always channel | ❌ |
| D2 | Context scope | Per-channel per-user | Per-user global, per-channel only, no context | ❌ |
| 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 (slash commands only) | Channel header button, RHS panel, settings page | ❌ |
| 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 | Defer to v0.2 | Auto-reply in thread when user replies to bot post | ❌ |
| 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.
@@ -482,25 +637,25 @@ curl -X POST $MM_URL/api/v4/plugins \
1. Upload new `.tar.gz` — Mattermost replaces the plugin binaries.
2. Plugin's `OnConfigurationChange()` fires (config preserved).
3. If the KV schema changed, a migration in `OnActivate()` handles it.
3. Bot account is re-fetched (not re-created) — idempotent.
4. 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. If it has, restore from backup or
`DECISIONS.md` records the migration path.
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/chat` with
`images` field. Could allow `/ai` with attached image.
`images` field. 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.).
- **Bot account mode** — A separate `mattermost-bot` skill / mode for
always-on presence in channels vs. on-demand slash commands.
- **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.