# NousResearch/hermes-agent · Discord free-response channels and auto-threading behavior

## Open now

- 3 issues, 1 PR open on GitHub.
- #15262: fix(discord): free_response_channels now correctly honored — breaks existing workflows that relied on wildcard '*' never matching
- #26058: auto_thread disabled for free_response_channels breaks legitimate use case
- #52766: Discord: Add free_response_thread_channels — no @mention but keep auto-threading
- PR #112573: fix(discord): auto-thread free-response channel messages too

These issues and PRs concern how Discord free-response channels interact with auto-threading, including whether threads should be created, suppressed, or made opt-in, and how reply messages and wildcard channel matching behave in that context.

## TL;DR

- 14 member(s): 3 open issue(s), 2 closed issue(s), 1 open PR(s), 3 merged PR(s), 5 PR(s) closed without merging.
- A fix was merged to main in PR #11629. Verify it addresses your case.
- **Recommended:** read PR #11629 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-11629,pr-25444`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.13`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #15262: fix(discord): free_response_channels now correctly honored — breaks existing workflows that relied on wildcard '*' never matching
- #26058: auto_thread disabled for free_response_channels breaks legitimate use case
- #52766: Discord: Add free_response_thread_channels — no @mention but keep auto-threading

## Closed / duplicate issues

- #9399: Discord: bot silently ignores quote-replies in free_response_channels
- #25310: \[Bug\]: Free-response Discord channels spawn a new thread per message, contradicting documented inline-reply behavior

## Prior & competing PRs

- **#11629: fix(discord): auto-thread cluster — free-response + reply + fallback + mention-name-strip**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/discord.py`, `scripts/release.py`, `tests/gateway/test_discord_free_response.py`, `tests/gateway/test_discord_slash_commands.py`
  - fix hunks:
  - `gateway/platforms/discord.py`

    ```diff
    @@ -2285,6 +2285,26 @@ def _resolve_channel_prompt(self, channel_id: str, parent_id: str | None = None)
             from gateway.platforms.base import resolve_channel_prompt
             return resolve_channel_prompt(self.config.extra, channel_id, parent_id)
     
    +    def _discord_require_mention(self) -> bool:
    +        """Return whether Discord channel messages require a bot mention."""
    +        configured = self.config.extra.get("require_mention")
    +        if configured is not None:
    +            if isinstance(configured, str):
    +                return configured.lower() not in ("false", "0", "no", "off")
    +            return bool(configured)
    +        return os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off")
    +
    +    def _discord_free_response_channels(self) -> set:
    +        """Return Discord channel IDs where no bot mention is required."""
    +        raw = self.config.extra.get("free_response_channels")
    +        if raw is None:
    +            raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "")
    +        if isinstance(raw, list):
    +            return {str(part).strip() for part in raw if str(part).strip()}
    +        if isinstance(raw, str) and raw.strip():
    +            return {part.strip() for part in raw.split(",") if part.strip()}
    +        return set()
    +
         def _thread_parent_channel(self, channel: Any) -> Any:
             """Return the parent text channel when invoked from a thread."""
             return getattr(channel, "parent", None) or channel
    ```

    ```diff
    @@ -2387,8 +2407,15 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
     
             Returns the created thread object, or ``None`` on failure.
             """
    -        # Build a short thread name from the message
    +        # Build a short thread name from the message. Strip Discord mention
    +        # syntax (users / roles / channels) so thread titles don't end up
    +        # showing raw <@id>, <@&id>, or <#id> markers — the ID isn't
    +        # meaningful to humans glancing at the thread list (#6336).
             content = (message.content or "").strip()
    +        # <@123>, <@!123>, <@&123>, <#123> — collapse to empty; normalize spaces.
    +        content = re.sub(r"<@[!&]?\d+>", "", content)
    +        content = re.sub(r"<#\d+>", "", content)
    +        content = re.sub(r"\s+", " ", content).strip()
             thread_name = content[:80] if content else "Hermes"
             if len(content) > 80:
                 thread_name = thread_name[:77] + "..."
    ```

    ```diff
    @@ -2396,9 +2423,25 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
             try:
                 thread = await message.create_thread(name=thread_name, auto_archive_duration=1440)
                 return thread
    -        except Exception as e:
    -            logger.warning("[%s] Auto-thread creation failed: %s", self.name, e)
    -            return None
    +        except Exception as direct_error:
    +            display_name = getattr(getattr(message, "author", None), "display_name", None) or "unknown user"
    +            reason = f"Auto-threaded from mention by {display_name}"
    +            try:
    +                seed_msg = await message.channel.send(f"\U0001f9f5 Thread created by Hermes: **{thread_name}**")
    +                thread = await seed_msg.create_thread(
    +                    name=thread_name,
    +                    auto_archive_duration=1440,
    +                    reason=reason,
    +                )
    +                return thread
    +            except Exception as fallback_error:
    +                logger.warning(
    +                    "[%s] Auto-thread creation failed. Direct error: %s. Fallback error: %s",
    +                    self.name,
    +                    direct_error,
    +                    fallback_error,
    +                )
    +                return None
     
         async def send_exec_approval(
             self, chat_id: str, command: str, session_key: str,
    ```

    ```diff
    @@ -2745,12 +2788,11 @@ async def _handle_message(self, message: DiscordMessage) -> None:
                     logger.debug("[%s] Ignoring message in ignored channel: %s", self.name, channel_ids)
                     return
     
    -            free_channels_raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "")
    -            free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()}
    +            free_channels = self._discord_free_response_channels()
                 if parent_channel_id:
                     channel_ids.add(parent_channel_id)
     
    -            require_mention = os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no")
    +            require_mention = self._discord_require_mention()
                 # Voice-linked text channels act as free-response while voice is active.
                 # Only the exact bound channel gets the exemption, not sibling threads.
                 voice_linked_ids = {str(ch_id) for ch_id in self._voice_text_channels.values()}
    ```

    ```diff
    @@ -2778,9 +2820,10 @@ async def _handle_message(self, message: DiscordMessage) -> None:
             if not is_thread and not isinstance(message.channel, discord.DMChannel):
                 no_thread_channels_raw = os.getenv("DISCORD_NO_THREAD_CHANNELS", "")
                 no_thread_channels = {ch.strip() for ch in no_thread_channels_raw.split(",") if ch.strip()}
    -            skip_thread = bool(channel_ids & no_thread_channels)
    +            skip_thread = bool(channel_ids & no_thread_channels) or is_free_channel
                 auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in ("true", "1", "yes")
    -            if auto_thread and not skip_thread and not is_voice_linked_channel:
    +            is_reply_message = getattr(message, "type", None) == discord.MessageType.reply
    +            if auto_thread and not skip_thread and not is_voice_linked_channel and not is_reply_message:
                     thread = await self._auto_create_thread(message)
                     if thread:
                         is_thread = True
    ```
  - `scripts/release.py`

    ```diff
    @@ -238,6 +238,8 @@
         "[redacted:email]": "malaiwah",
         "[redacted:email]": "gnanam1990",
         "[redacted:email]": "0xyg3n",
    +    "[redacted:email]": "Hypn0sis",
    +    "[redacted:email]": "OwenYWT",
         "[redacted:email]": "v1k22",
         "[redacted:email]": "RucchiZ",
         "[redacted:email]": "LehaoLin",
    ```
  - `tests/gateway/test_discord_free_response.py`

    ```diff
    @@ -96,7 +96,7 @@ def adapter(monkeypatch):
         return adapter
     
     
    -def make_message(*, channel, content: str, mentions=None):
    +def make_message(*, channel, content: str, mentions=None, msg_type=None):
         author = SimpleNamespace(id=42, display_name="Jezza", name="Jezza")
         return SimpleNamespace(
             id=123,
    ```

    ```diff
    @@ -107,6 +107,7 @@ def make_message(*, channel, content: str, mentions=None):
             created_at=datetime.now(timezone.utc),
             channel=channel,
             author=author,
    +        type=msg_type if msg_type is not None else discord_platform.discord.MessageType.default,
         )
     
     
    ```

    ```diff
    @@ -204,6 +205,21 @@ async def test_discord_free_response_channel_overrides_mention_requirement(adapt
         assert event.text == "allowed without mention"
     
     
    [redacted:email]
    +async def test_discord_free_response_channel_can_come_from_config_extra(adapter, monkeypatch):
    +    monkeypatch.delenv("DISCORD_REQUIRE_MENTION", raising=False)
    +    monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
    +    adapter.config.extra["free_response_channels"] = ["789", "999"]
    +
    +    message = make_message(channel=FakeTextChannel(channel_id=789), content="allowed from config")
    +
    +    await adapter._handle_message(message)
    +
    +    adapter.handle_message.assert_awaited_once()
    +    event = adapter.handle_message.await_args.args[0]
    +    assert event.text == "allowed from config"
    +
    +
     @pytest.mark.asyncio
     async def test_discord_forum_parent_in_free_response_list_allows_forum_thread(adapter, monkeypatch):
         monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
    ```

    ```diff
    @@ -276,6 +292,31 @@ async def test_discord_auto_thread_enabled_by_default(adapter, monkeypatch):
         assert event.source.thread_id == "999"
     
     
    [redacted:email]
    +async def test_discord_reply_message_skips_auto_thread(adapter, monkeypatch):
    +    """Quote-replies should stay in-channel instead of trying to create a thread."""
    +    monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
    +    monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
    +    monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "123")
    +
    +    adapter._auto_create_thread = AsyncMock()
    +
    +    message = make_message(
    +        channel=FakeTextChannel(channel_id=123),
    +        content="reply without mention",
    +        msg_type=discord_platform.discord.MessageType.reply,
    +    )
    +
    +    await adapter._handle_message(message)
    +
    +    adapter._auto_create_thread.assert_not_awaited()
    +    adapter.handle_message.assert_awaited_once()
    +    event = adapter.handle_message.await_args.args[0]
    +    assert event.text == "reply without mention"
    +    assert event.source.chat_id == "123"
    +    assert event.source.chat_type == "group"
    +
    +
     @pytest.mark.asyncio
     async def test_discord_auto_thread_can_be_disabled(adapter, monkeypatch):
         """Setting auto_thread to false skips thread creation."""
    ```

    ```diff
    @@ -385,6 +426,33 @@ async def test_discord_voice_linked_channel_skips_mention_requirement_and_auto_t
         assert event.source.chat_type == "group"
     
     
    [redacted:email]
    +async def test_discord_free_channel_skips_auto_thread(adapter, monkeypatch):
    +    """Free-response channels must NOT auto-create threads — bot replies inline.
    +
    +    Without this, every message in a free-response channel would spin off a
    +    thread (since the channel bypasses the @mention gate), defeating the
    +    lightweight-chat purpose of free-response mode.
    +    """
    +    monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
    +    monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "789")
    +    monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)  # default true
    +
    +    adapter._auto_create_thread = AsyncMock()
    +
    +    message = make_message(
    +        channel=FakeTextChannel(channel_id=789),
    +        content="free chat message",
    +    )
    +
    +    await adapter._handle_message(message)
    +
    +    adapter._auto_create_thread.assert_not_awaited()
    +    adapter.handle_message.assert_awaited_once()
    +    event = adapter.handle_message.await_args.args[0]
    +    assert event.source.chat_type == "group"
    +
    +
     @pytest.mark.asyncio
     async def test_discord_voice_linked_parent_thread_still_requires_mention(adapter, monkeypatch):
         """Threads under a voice-linked channel should still require @mention."""
    ```
  - `tests/gateway/test_discord_slash_commands.py`

    ```diff
    @@ -401,6 +401,8 @@ async def test_auto_create_thread_uses_message_content_as_name(adapter):
         message = SimpleNamespace(
             content="Hello world, how are you?",
             create_thread=AsyncMock(return_value=thread),
    +        channel=SimpleNamespace(send=AsyncMock()),
    +        author=SimpleNamespace(display_name="Jezza"),
         )
     
         result = await adapter._auto_create_thread(message)
    ```

    ```diff
    @@ -412,6 +414,48 @@ async def test_auto_create_thread_uses_message_content_as_name(adapter):
         assert call_kwargs["auto_archive_duration"] == 1440
     
     
    [redacted:email]
    +async def test_auto_create_thread_strips_mention_syntax_from_name(adapter):
    +    """Thread names must not contain raw <@id>, <@&id>, or <#id> markers.
    +
    +    Regression guard for #6336 — previously a message like
    +    ``<@&1490963422786093149> help`` would spawn a thread literally
    +    named ``<@&1490963422786093149> help``.
    +    """
    +    thread = SimpleNamespace(id=999, name="help")
    +    message = SimpleNamespace(
    +        content="<@&1490963422786093149> <@555> please help <#123>",
    +        create_thread=AsyncMock(return_value=thread),
    +        channel=SimpleNamespace(send=AsyncMock()),
    +        author=SimpleNamespace(display_name="Jezza"),
    +    )
    +
    +    await adapter._auto_create_thread(message)
    +
    +    name = message.create_thread.await_args[1]["name"]
    +    assert "<@" not in name, f"role/user mention leaked: {name!r}"
    +    assert "<#" not in name, f"channel mention leaked: {name!r}"
    +    assert name == "please help"
    +
    +
    [redacted:email]
    +async def test_auto_create_thread_falls_back_to_hermes_when_only_mentions(adapter):
    +    """If a message contains only mention syntax, the stripped content is
    +    empty — fall back to the 'Hermes' default rather than ''."""
    +    thread = SimpleNamespace(id=999, name="Hermes")
    +    message = SimpleNamespace(
    +        content="<@&1490963422786093149>",
    +        create_thread=AsyncMock(return_value=thread),
    +        channel=SimpleNamespace(send=AsyncMock()),
    +        author=SimpleNamespace(display_name="Jezza"),
    +    )
    +
    +    await adapter._auto_create_thread(message)
    +
    +    name = message.create_thread.await_args[1]["name"]
    +    assert name == "Hermes"
    +
    +
     @pytest.mark.asyncio
     async def test_auto_create_thread_truncates_long_names(adapter):
         long_text = "a" * 200
    ```

    ```diff
    @@ -419,6 +463,8 @@ async def test_auto_create_thread_truncates_long_names(adapter):
         message = SimpleNamespace(
             content=long_text,
             create_thread=AsyncMock(return_value=thread),
    +        channel=SimpleNamespace(send=AsyncMock()),
    +        author=SimpleNamespace(display_name="Jezza"),
         )
     
         result = await adapter._auto_create_thread(message)
    ```

    ```diff
    @@ -430,10 +476,33 @@ async def test_auto_create_thread_truncates_long_names(adapter):
     
     
     @pytest.mark.asyncio
    -async def test_auto_create_thread_returns_none_on_failure(adapter):
    +async def test_auto_create_thread_falls_back_to_seed_message(adapter):
    +    thread = SimpleNamespace(id=555, name="Hello")
    +    seed_message = SimpleNamespace(create_thread=AsyncMock(return_value=thread))
    +    message = SimpleNamespace(
    +        content="Hello",
    +        create_thread=AsyncMock(side_effect=RuntimeError("no perms")),
    +        channel=SimpleNamespace(send=AsyncMock(return_value=seed_message)),
    +        author=SimpleNamespace(display_name="Jezza"),
    +    )
    +
    +    result = await adapter._auto_create_thread(message)
    +    assert result is thread
    +    message.channel.send.assert_awaited_once_with("🧵 Thread created by Hermes: **Hello**")
    +    seed_message.create_thread.assert_awaited_once_with(
    +        name="Hello",
    +        auto_archive_duration=1440,
    +        reason="Auto-threaded from mention by Jezza",
    +    )
    +
    +
    [redacted:email]
    +async def test_auto_create_thread_returns_none_when_direct_and_fallback_fail(adapter):
         message = SimpleNamespace(
             content="Hello",
             create_thread=AsyncMock(side_effect=RuntimeError("no perms")),
    +        channel=SimpleNamespace(send=AsyncMock(side_effect=RuntimeError("send failed"))),
    +        author=SimpleNamespace(display_name="Jezza"),
         )
     
         result = await adapter._auto_create_thread(message)
    ```
- **#25444: fix(discord): keep free-response channels inline**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/discord.py`, `tests/gateway/test_discord_free_response.py`
  - fix hunks:
  - `gateway/platforms/discord.py`

    ```diff
    @@ -4223,7 +4223,7 @@ async def _handle_message(self, message: DiscordMessage) -> None:
             if not is_thread and not isinstance(message.channel, discord.DMChannel):
                 no_thread_channels_raw = os.getenv("DISCORD_NO_THREAD_CHANNELS", "")
                 no_thread_channels = {ch.strip() for ch in no_thread_channels_raw.split(",") if ch.strip()}
    -            skip_thread = bool(channel_ids & no_thread_channels)
    +            skip_thread = bool(channel_ids & no_thread_channels) or is_free_channel
                 auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in {"true", "1", "yes"}
                 is_reply_message = getattr(message, "type", None) == discord.MessageType.reply
                 if auto_thread and not skip_thread and not is_voice_linked_channel and not is_reply_message:
    ```
  - `tests/gateway/test_discord_free_response.py`

    ```diff
    @@ -446,6 +446,37 @@ async def test_discord_voice_linked_channel_skips_mention_requirement_and_auto_t
         assert event.source.chat_type == "group"
     
     
    [redacted:email]
    +async def test_discord_free_response_channel_skips_auto_thread(adapter, monkeypatch):
    +    """Free-response channels should reply inline, never spawn a new thread.
    +
    +    Without this, every message in a free-response channel would auto-create
    +    a fresh thread (since the channel bypasses the @mention gate, every
    +    message looks like a fresh trigger).  That turns a "lightweight chat"
    +    channel into a thread-spawning machine — see the docs at
    +    website/docs/user-guide/messaging/discord.md which already describe
    +    this as the intended behavior.
    +    """
    +    monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
    +    monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "789")
    +    monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)  # default true
    +
    +    adapter._auto_create_thread = AsyncMock()
    +
    +    message = make_message(
    +        channel=FakeTextChannel(channel_id=789),
    +        content="casual chat in free-response channel",
    +    )
    +
    +    await adapter._handle_message(message)
    +
    +    adapter._auto_create_thread.assert_not_awaited()
    +    adapter.handle_message.assert_awaited_once()
    +    event = adapter.handle_message.await_args.args[0]
    +    assert event.text == "casual chat in free-response channel"
    +    assert event.source.chat_type == "group"
    +
    +
     
     
     @pytest.mark.asyncio
    ```
- **#116168: Discord: free-response channels can opt back into auto-threading (#26058, salvage #18455)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cli-config.yaml.example`, `contributors/emails/[redacted:email]`, `hermes_cli/config_defaults.py`, `plugins/platforms/discord/adapter.py`, `tests/gateway/test_discord_free_response.py`, `website/docs/reference/environment-variables.md`, `website/docs/user-guide/configuration.md`, `website/docs/user-guide/messaging/discord.md`, `website/docs/user-guide/multi-profile-gateways.md`
- **#9624: fix(discord): skip auto-threading reply messages**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#11629`, `#9399`, `#9650`
- **#18455: feat(discord): add free_response_auto_thread opt-in**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `096bd01435`, `#116168`, `#116168`, `#116168`
- **#25311: fix(discord): keep free-response channels inline**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `d55754456`, `#25444`
- **#26074: fix(discord): decouple auto_thread from free_response_channels**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`
- **#103990: feat(discord): opt-in auto-thread in free-response channels**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#86351`, `#86351`, `#86351`
- **#112573: fix(discord): auto-thread free-response channel messages too**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#116168`, `#26058`
  - files: `plugins/platforms/discord/adapter.py`, `tests/gateway/test_discord_free_response.py`

## Fix status

- Merged to main in PR #11629 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-09-19`.
- Subsystem: `gateway`
