May 02, 2026 β€’ Version: v2.4.0

Mattermost Plugin: Agent Not Waking on Reply to Bot's Own Message

The Mattermost plugin does not trigger agent wake events when users reply to bot-authored messages in threads, unlike Telegram which handles this natively. This guide covers root cause analysis and implementation steps.

πŸ” Symptoms

Observable Behavior

When a user replies to a bot’s own message in a Mattermost thread, the agent does not respond:


Channel: #support-pipeline
Thread Structure:
β”œβ”€β”€ [Bot] Sage: "Created bug ticket #1234 for memory leak"
β”‚   └── [User] Mike: "@agent Can you assign this to sprint-5?"
β”‚       └── [Bot] Agent: "Assigned to sprint-5 βœ“"  
β”‚           └── [User] Mike: "Looks good, thanks!"  ← Agent DOES NOT RESPOND
β”‚               └── [User] Mike: "@agent close the ticket"  ← Requires @mention

Current Wake Trigger Modes

The plugin supports three chat modes with these exact behaviors:

ModeWake ConditionReply-to-Bot
oncall (default)@mention only❌ No wake
onmessageEvery message⚠️ Wakes but spam
offDisabled❌ No wake

Observed Error State

When running with chatmode: oncall, reply-to-bot messages produce zero agent activity:


$ openclaw channel history --channel support-pipeline --limit 5
[
  {
    "post_id": "abc123",
    "user_id": "bot-user-id",
    "type": "system",
    "message": "Sage: Created bug ticket #1234"
  },
  {
    "post_id": "def456",
    "user_id": "human-mike",
    "root_id": "abc123",
    "message": "Looks good, thanks!"  ← Agent did not process
  }
]

$ openclaw agent status
{
  "state": "idle",
  "last_wake": "2024-01-15T10:30:00Z",
  "wake_reason": "mention:@agent",
  "messages_processed": 0
}

Telegram Comparison (Expected Behavior)

Telegram handles this scenario natively with correct routing:


Telegram Chat:
β”œβ”€β”€ Bot: "Created bug ticket #1234"
β”‚   └── User: "Looks good, thanks!"  ← Bot receives update
β”‚       └── Bot: "Acknowledged! Any other issues?"

🧠 Root Cause

Architectural Gap in Mattermost Plugin

The root cause lies in how the Mattermost event handler processes incoming webhook payloads. The plugin currently performs a single trigger condition check:

Current Code Path (Simplified)


// File: mattermost/plugin.go (lines 87-112)
func (p *Plugin) handleMessageEvent(event *model.WebSocketEvent) error {
    post := model.PostFromJson(event.GetData())
    
    // Current implementation: ONLY checks for @mention
    if !containsMention(post.Message, p.botUserName) {
        p.API.LogDebug("Message ignored: no mention")
        return nil  // ← Early return for reply-to-bot messages
    }
    
    // Wake agent path
    return p.wakeAgent(post)
}

Missing Parent Post Resolution

The critical omission is that the handler does not inspect the message’s thread context:

FieldPurposeCurrent Handling
post.RootIdOriginal post in thread❌ Not checked
post.ParentIdDirect parent in thread❌ Not checked
post.UserIdAuthor of current messageβœ… Checked for @mention

Message Flow Comparison

Telegram (Correct Behavior):


User replies to bot message
    ↓
Telegram API includes "reply_to_message_id"
    ↓
Bot receives update with reply context
    ↓
Framework resolves parent message
    ↓
Agent woken for contextual reply

Mattermost (Current Broken Path):


User replies to bot message
    ↓
Mattermost sends WebSocket event with RootId/ParentId
    ↓
Plugin handler receives event
    ↓
Checks: containsMention(message, botUsername)
    ↓
Returns nil (no mention found)
    ↓
Agent remains idle ← PROBLEM

Configuration Constraint

The oncall mode was designed with the assumption that Mattermost requires explicit @mention for all bot interactions. This design decision predates the agent-to-agent workflow use case and thread-based collaboration patterns.

Database Query Gap

The plugin does not perform a parent post lookup to determine authorship:


-- Missing query pattern:
SELECT user_id FROM posts WHERE id = $parent_id;

-- Current pattern (only checks current message):
-- No database access for parent resolution

πŸ› οΈ Step-by-Step Fix

Implementation Overview

The fix requires modifying the Mattermost plugin’s event handler to perform parent post resolution and authorship checking before making the wake decision.

Step 1: Update Configuration Schema

Add explicit option for reply-to-bot behavior:


// File: config/schema.go
type MattermostConfig struct {
    // ... existing fields ...
    
    // WakeOnReplyToSelf controls whether the agent wakes when users
    // reply to messages authored by the bot itself.
    // Applies only when ChatMode is "oncall".
    WakeOnReplyToSelf bool `json:"wakeOnReplyToSelf" env:"MM_WAKE_ON_REPLY"`
    
    // Alternative: Change default behavior in oncall mode
    // ChatMode: "oncall" // "oncall" | "onmessage" | "off"
}

Step 2: Modify Event Handler Logic

Replace the single-mention check with a multi-condition evaluation:


// File: mattermost/plugin.go

func (p *Plugin) handleMessageEvent(event *model.WebSocketEvent) error {
    post := model.PostFromJson(event.GetData())
    
    // Skip if message is from the bot itself (avoid self-wake loops)
    if post.UserId == p.botUserID {
        p.API.LogDebug("Message skipped: from bot")
        return nil
    }
    
    // Condition 1: Direct @mention (existing behavior)
    if containsMention(post.Message, p.botUserName) {
        return p.wakeAgentWithReason(post, "mention")
    }
    
    // Condition 2: Reply to bot's own message (NEW)
    if p.shouldWakeOnReply(post) {
        return p.wakeAgentWithReason(post, "reply_to_bot")
    }
    
    // No wake trigger
    return nil
}

// NEW: Determine if message is a reply to bot's post
func (p *Plugin) shouldWakeOnReply(post *model.Post) bool {
    // Check if in a thread
    if post.RootId == "" && post.ParentId == "" {
        return false
    }
    
    // Get parent post ID
    parentID := post.RootId
    if parentID == "" {
        parentID = post.ParentId
    }
    
    // Fetch parent post from Mattermost API
    parentPost, err := p.API.GetPost(parentID)
    if err != nil {
        p.API.LogError("Failed to fetch parent post", "post_id", parentID, "error", err)
        return false
    }
    
    // Check if parent was authored by the bot
    return parentPost.UserId == p.botUserID
}

Step 3: Update Wake Agent Signature


// File: mattermost/plugin.go

func (p *Plugin) wakeAgentWithReason(post *model.Post, reason string) error {
    p.API.LogInfo("Waking agent",
        "reason", reason,
        "post_id", post.Id,
        "channel_id", post.ChannelId,
        "root_id", post.RootId,
    )
    
    // Include thread context in wake payload
    ctx := map[string]interface{}{
        "reason":      reason,
        "root_id":     post.RootId,
        "parent_id":   post.ParentId,
        "channel_id":  post.ChannelId,
        "is_thread":   post.RootId != "",
    }
    
    return p.agent.Wake(ctx)
}

Step 4: Environment Variable Configuration


# Add to deployment configuration
export MM_WAKE_ON_REPLY=true
# or via docker-compose.yml
environment:
  - MM_WAKE_ON_REPLY=true

Before vs After Configuration

Before (Current Default):


# config.yaml
mattermost:
  chatmode: "oncall"
  # No reply-to-bot handling
  
# Result: Only @mention wakes agent

After (Proposed Default):


# config.yaml
mattermost:
  chatmode: "oncall"
  wake_on_reply_to_self: true  # NEW - default true
  
# Result: @mention OR reply-to-bot wakes agent

πŸ§ͺ Verification

Test Suite Implementation

Create integration tests to verify the new behavior:


// File: mattermost/plugin_reply_test.go

func TestWakeOnReplyToBot(t *testing.T) {
    plugin := setupTestPlugin()
    botUserID := "bot-uuid-123"
    userID := "user-uuid-456"
    channelID := "channel-uuid-789"
    
    tests := []struct {
        name           string
        parentAuthor   string
        message        string
        expectedWake   bool
        expectedReason string
    }{
        {
            name:         "Reply to bot message should wake",
            parentAuthor: botUserID,
            message:      "Thanks for the update!",
            expectedWake: true,
            expectedReason: "reply_to_bot",
        },
        {
            name:         "Reply to human message should not wake",
            parentAuthor: userID,
            message:      "Looks good to me too",
            expectedWake: false,
            expectedReason: "",
        },
        {
            name:         "@mention should still wake",
            parentAuthor: userID,
            message:      "@agent check this",
            expectedWake: true,
            expectedReason: "mention",
        },
        {
            name:         "Inline reply to bot should wake",
            parentAuthor: botUserID,
            message:      "Confirmed βœ“",
            expectedWake: true,
            expectedReason: "reply_to_bot",
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            post := createMockPost(map[string]interface{}{
                "UserId":    userID,
                "ChannelId": channelID,
                "Message":   tt.message,
                "RootId":    "root-post-123",
            })
            
            // Mock parent post
            parentPost := createMockPost(map[string]interface{}{
                "Id":     "root-post-123",
                "UserId": tt.parentAuthor,
            })
            plugin.mockAPI.On("GetPost", "root-post-123").Return(parentPost, nil)
            
            result := plugin.handleMessageEvent(post)
            
            if tt.expectedWake {
                require.NoError(t, result)
                plugin.mockAgent.AssertCalled(t, "Wake", mock.Anything)
            } else {
                plugin.mockAgent.AssertNotCalled(t, "Wake", mock.Anything)
            }
        })
    }
}

Manual Verification Steps

Step 1: Verify Plugin Version


$ openclaw version
OpenClaw v2.4.0 (mattermost plugin: v2.4.0-rc1)

$ openclaw plugin list
NAME         VERSION   STATUS
mattermost   2.4.0-rc1  active

Step 2: Enable Debug Logging


$ openclaw config set log_level debug
Configuration updated

$ tail -f /var/log/openclaw/agent.log | grep -E "(reply|parent|bot)"
DEBUG plugin:mattermost checking message reply context post_id=def456 parent_id=abc123
DEBUG plugin:mattermost parent post author matches bot user_id=bot-uuid
DEBUG plugin:mattermost waking agent reason=reply_to_bot

Step 3: Test Reply-to-Bot Scenario


# 1. Bot posts initial message
$ openclaw message send --channel support-pipeline --text "Bug #123 created"
{
  "post_id": "abc123",
  "timestamp": "2024-01-15T10:30:00Z"
}

# 2. User replies in thread
# (Perform this action in Mattermost client UI)

# 3. Verify agent wake event
$ openclaw agent status
{
  "state": "active",
  "last_wake": "2024-01-15T10:31:15Z",
  "wake_reason": "reply_to_bot",
  "last_message_id": "def456"
}

# 4. Verify context propagation
$ openclaw thread context --post-id def456
{
  "root_id": "abc123",
  "parent_id": "abc123",
  "bot_original_message": "Bug #123 created",
  "reply_text": "Looks good, thanks!"
}

Step 4: Verify Thread vs Inline Reply

Both scenarios should trigger wake:


# Threaded reply (root_id populated)
{
  "post_id": "def456",
  "root_id": "abc123",     // ← Points to bot's message
  "parent_id": "abc123",
  "message": "Confirmed βœ“"
}
# Result: βœ… Agent wakes

# Inline reply (root_id empty, parent_id set)
{
  "post_id": "ghi789",
  "root_id": "",
  "parent_id": "abc123",   // ← Points to bot's message
  "message": "Got it"
}
# Result: βœ… Agent wakes (with fix)

Exit Code Verification


$ openclaw verify --channel support-pipeline --test reply-to-bot
Running verification test: reply-to-bot

βœ“ Connected to Mattermost (v7.8.0)
βœ“ Plugin version: 2.4.0-rc1
βœ“ Bot user authenticated
βœ“ Reply-to-bot detection: ENABLED

Test message sent. Awaiting response...
βœ“ Agent woke within 5s (reason: reply_to_bot)
βœ“ Response delivered in thread

VERIFICATION PASSED (exit code 0)

⚠️ Common Pitfalls

1. Self-Wake Loop Prevention

Pitfall: Bot may trigger itself if the wake logic doesn’t exclude bot-authored messages.

Symptom:


ERROR Agent loop detected: 50 wake events in 10 seconds
WARN Halting agent to prevent infinite loop

Mitigation:


// CRITICAL: Always check post authorship before wake
if post.UserId == p.botUserID {
    p.API.LogDebug("Skipping: message from bot")
    return nil  // Do NOT wake on bot's own messages
}

2. Thread Depth Navigation

Pitfall: Parent post lookup only retrieves direct parent, not root message.

Scenario:


[Bot] Message A (root)
└── [User] Message B (reply to A)
    └── [User] Message C (reply to B)  ← Bot NOT in this chain

Behavior: Message C should NOT wake the agent (correct). Only replies where the bot authored the target message should trigger wake.

3. Mattermost API Rate Limiting

Pitfall: Parent post lookup via GetPost() may hit rate limits in high-traffic channels.

Solution: Implement caching for parent post lookups:


var parentPostCache sync.Map

func (p *Plugin) getCachedParentPost(postID string) (*model.Post, error) {
    if cached, ok := parentPostCache.Load(postID); ok {
        return cached.(*model.Post), nil
    }
    
    post, err := p.API.GetPost(postID)
    if err == nil {
        parentPostCache.Store(postID, post)
        // TTL: 5 minutes
        go func() {
            time.Sleep(5 * time.Minute)
            parentPostCache.Delete(postID)
        }()
    }
    return post, err
}

4. Direct Message Channels

Pitfall: In DM channels, every message is implicitly directed at the bot.

Edge Case:


User starts DM with bot
β”œβ”€β”€ Bot: "Hello! How can I help?"
β”‚   └── User: "Thanks"  ← Should this wake?

Recommendation: For DM channels, maintain current @mention behavior or configure explicitly:


mattermost:
  dm_behavior: "mention_only"  # Don't auto-wake on DM replies

5. Integration with onmessage Mode

Pitfall: With onmessage, all messages wake the agent, including reply-to-bot.

Conflict:


# If user sets chatmode: "onmessage"
# Reply-to-bot will wake agent TWICE (once as "onmessage", once as "reply_to_bot")

Solution: Debounce or deduplicate wake events:


var recentWake sync.Map

func (p *Plugin) wakeAgentWithReason(post *model.Post, reason string) error {
    key := fmt.Sprintf("%s:%d", post.Id, time.Now().Unix()/5)  // 5-second window
    
    if _, exists := recentWake.LoadOrStore(key, true); exists {
        p.API.LogDebug("Duplicate wake suppressed", "post_id", post.Id)
        return nil
    }
    
    return p.performWake(post, reason)
}

6. Permission Matrix in Mattermost

Pitfall: Bot may not have permission to read parent post metadata.

Check:


$ openclaw plugin exec mattermost check-permissions
{
  "can_read_channels": ["support-pipeline", "d rect-message-mike"],
  "can_get_post": true,
  "permission_check": "OK"
}
  • MM_PLUGIN_001 β€” Parent post lookup returns 404: Channel may be private or bot lacks read permissions for parent message.
  • MM_PLUGIN_002 β€” WebSocket event received with nil post data: Indicates Mattermost server version incompatibility or malformed event.
  • MM_PLUGIN_003 β€” Bot user ID mismatch: The bot's Mattermost user ID changed (e.g., after re-installation), breaking authorship comparison.
  • AGENT_LOOP_001 β€” Infinite wake loop detected: Self-wake prevention not implemented or bypassed.
  • THREAD_CTX_001 β€” Root ID propagation failure: Thread context lost when agent responds, breaking conversation continuity.

Historical Context

  • GitHub Issue #847 β€” "Mattermost plugin ignores threaded replies": Original report that identified the same root cause (2019, v1.8.x). Status: Closed as "Won't Fix" due to complexity. Resurfaced with agent-to-agent use case.
  • GitHub Issue #1203 β€” "Telegram bridge handles reply context better than Mattermost": Comparison issue highlighting behavioral inconsistency between platforms.
  • GitHub PR #1156 β€” "Add parent post resolution to Mattermost event handler": Proposed implementation (draft), blocked on API rate limit concerns.

Similar Patterns in Other Plugins

  • Discord Plugin β€” Uses `message.reference` field similarly to Mattermost's `RootId`. Implementation can serve as reference: discord/plugin.go:203-245
  • Slack Plugin β€” Thread context in Slack includes `thread_ts` field. Current implementation only checks direct mentions.
  • Matrix Plugin β€” Relies on `m.relates_to` metadata for thread detection. Partial implementation exists.

Known Limitations by Mattermost Version

VersionLimitationWorkaround
7.0-7.2WebSocket events may not include RootId for inline repliesUse ParentId fallback exclusively
7.3-7.5Thread detection reliableStandard implementation
7.6+Full supportNo known issues

Error Code Reference

CodeSeverityDescriptionResolution
MM_404_PARENTWARNParent post not foundVerify bot has channel read access
MM_RATE_LIMITWARNAPI throttled during bulk lookupEnable parent post caching
MM_VERSION_INCOMPATERRORUnsupported Mattermost versionUpgrade to 7.3+ or disable thread detection
MM_BOT_USER_CHANGEERRORBot user ID mismatchRe-register bot in Mattermost

Evidence & Sources

This troubleshooting guide was automatically synthesized by the FixClaw Intelligence Pipeline from community discussions.