# NousResearch/hermes-agent · Discord slash commands for approve and deny actions

## Open now

- Nothing open on GitHub.

These items focus on registering /approve and /deny as native Discord slash commands and wiring up related UI elements such as button-based approval interactions.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #4800. Verify it addresses your case.
- **Recommended:** read PR #4800 (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: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: 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.

## Closed / duplicate issues

- #3614: Add Discord slash commands for /approve and /deny

## Prior & competing PRs

- **#4800: fix(discord): register /approve and /deny slash commands, wire up button-based approval UI**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `gateway/platforms/discord.py`, `gateway/run.py`, `tests/gateway/test_discord_document_handling.py`, `tests/gateway/test_discord_free_response.py`, `tests/gateway/test_discord_send.py`, `tests/gateway/test_voice_command.py`
  - fix hunks:
  - `gateway/platforms/discord.py`

    ```diff
    @@ -1617,6 +1617,16 @@ async def slash_voice(interaction: discord.Interaction, mode: str = ""):
             async def slash_update(interaction: discord.Interaction):
                 await self._run_simple_slash(interaction, "/update", "Update initiated~")
     
    +        @tree.command(name="approve", description="Approve a pending dangerous command")
    +        @discord.app_commands.describe(scope="Optional: 'all', 'session', 'always', 'all session', 'all always'")
    +        async def slash_approve(interaction: discord.Interaction, scope: str = ""):
    +            await self._run_simple_slash(interaction, f"/approve {scope}".strip())
    +
    +        @tree.command(name="deny", description="Deny a pending dangerous command")
    +        @discord.app_commands.describe(scope="Optional: 'all' to deny all pending commands")
    +        async def slash_deny(interaction: discord.Interaction, scope: str = ""):
    +            await self._run_simple_slash(interaction, f"/deny {scope}".strip())
    +
             @tree.command(name="thread", description="Create a new thread and start a Hermes session in it")
             @discord.app_commands.describe(
                 name="Thread name",
    ```

    ```diff
    @@ -1860,33 +1870,41 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
                 return None
     
         async def send_exec_approval(
    -        self, chat_id: str, command: str, approval_id: str
    +        self, chat_id: str, command: str, session_key: str,
    +        description: str = "dangerous command",
    +        metadata: Optional[dict] = None,
         ) -> SendResult:
             """
             Send a button-based exec approval prompt for a dangerous command.
     
    -        Returns SendResult. The approval is resolved when a user clicks a button.
    +        The buttons call ``resolve_gateway_approval()`` to unblock the waiting
    +        agent thread — this replaces the text-based ``/approve`` flow on Discord.
             """
             if not self._client or not DISCORD_AVAILABLE:
                 return SendResult(success=False, error="Not connected")
     
             try:
    -            channel = self._client.get_channel(int(chat_id))
    +            # Resolve channel — use thread_id from metadata if present
    +            target_id = chat_id
    +            if metadata and metadata.get("thread_id"):
    +                target_id = metadata["thread_id"]
    +
    +            channel = self._client.get_channel(int(target_id))
                 if not channel:
    -                channel = await self._client.fetch_channel(int(chat_id))
    +                channel = await self._client.fetch_channel(int(target_id))
     
                 # Discord embed description limit is 4096; show full command up to that
                 max_desc = 4088
                 cmd_display = command if len(command) <= max_desc else command[: max_desc - 3] + "..."
                 embed = discord.Embed(
    -                title="Command Approval Required",
    +                title="⚠️ Command Approval Required",
                     description=f"```\n{cmd_display}\n```",
                     color=discord.Color.orange(),
                 )
    -            embed.set_footer(text=f"Approval ID: {approval_id}")
    +            embed.add_field(name="Reason", value=description, inline=False)
     
                 view = ExecApprovalView(
    -                approval_id=approval_id,
    +                session_key=session_key,
                     allowed_user_ids=self._allowed_user_ids,
                 )
     
    ```

    ```diff
    @@ -2219,13 +2237,15 @@ class ExecApprovalView(discord.ui.View):
             """
             Interactive button view for exec approval of dangerous commands.
     
    -        Shows three buttons: Allow Once (green), Always Allow (blue), Deny (red).
    -        Only users in the allowed list can click. The view times out after 5 minutes.
    +        Shows four buttons: Allow Once, Allow Session, Always Allow, Deny.
    +        Clicking a button calls ``resolve_gateway_approval()`` to unblock the
    +        waiting agent thread — the same mechanism as the text ``/approve`` flow.
    +        Only users in the allowed list can click.  Times out after 5 minutes.
             """
     
    -        def __init__(self, approval_id: str, allowed_user_ids: set):
    +        def __init__(self, session_key: str, allowed_user_ids: set):
                 super().__init__(timeout=300)  # 5-minute timeout
    -            self.approval_id = approval_id
    +            self.session_key = session_key
                 self.allowed_user_ids = allowed_user_ids
                 self.resolved = False
     
    ```

    ```diff
    @@ -2236,9 +2256,10 @@ def _check_auth(self, interaction: discord.Interaction) -> bool:
                 return str(interaction.user.id) in self.allowed_user_ids
     
             async def _resolve(
    -            self, interaction: discord.Interaction, action: str, color: discord.Color
    +            self, interaction: discord.Interaction, choice: str,
    +            color: discord.Color, label: str,
             ):
    -            """Resolve the approval and update the message."""
    +            """Resolve the approval via the gateway approval queue and update the embed."""
                 if self.resolved:
                     await interaction.response.send_message(
                         "This approval has already been resolved~", ephemeral=True
    ```

    ```diff
    @@ -2257,7 +2278,7 @@ async def _resolve(
                 embed = interaction.message.embeds[0] if interaction.message.embeds else None
                 if embed:
                     embed.color = color
    -                embed.set_footer(text=f"{action} by {interaction.user.display_name}")
    +                embed.set_footer(text=f"{label} by {interaction.user.display_name}")
     
                 # Disable all buttons
                 for child in self.children:
    ```

    ```diff
    @@ -2265,33 +2286,40 @@ async def _resolve(
     
                 await interaction.response.edit_message(embed=embed, view=self)
     
    -            # Store the approval decision
    +            # Unblock the waiting agent thread via the gateway approval queue
                 try:
    -                from tools.approval import approve_permanent
    -                if action == "allow_once":
    -                    pass  # One-time approval handled by gateway
    -                elif action == "allow_always":
    -                    approve_permanent(self.approval_id)
    -            except ImportError:
    -                pass
    +                from tools.approval import resolve_gateway_approval
    +                count = resolve_gateway_approval(self.session_key, choice)
    +                logger.info(
    +                    "Discord button resolved %d approval(s) for session %s (choice=%s, user=%s)",
    +                    count, self.session_key, choice, interaction.user.display_name,
    +                )
    +            except Exception as exc:
    +                logger.error("Failed to resolve gateway approval from button: %s", exc)
     
             @discord.ui.button(label="Allow Once", style=discord.ButtonStyle.green)
             async def allow_once(
                 self, interaction: discord.Interaction, button: discord.ui.Button
             ):
    -            await self._resolve(interaction, "allow_once", discord.Color.green())
    +            await self._resolve(interaction, "once", discord.Color.green(), "Approved once")
    +
    +        @discord.ui.button(label="Allow Session", style=discord.ButtonStyle.grey)
    +        async def allow_session(
    +            self, interaction: discord.Interaction, button: discord.ui.Button
    +        ):
    +            await self._resolve(interaction, "session", discord.Color.blue(), "Approved for session")
     
             @discord.ui.button(label="Always Allow", style=discord.ButtonStyle.blurple)
             async def allow_always(
                 self, interaction: discord.Interaction, button: discord.ui.Button
             ):
    -            await self._resolve(interaction, "allow_always", discord.Color.blue())
    +            await self._resolve(interaction, "always", discord.Color.purple(), "Approved permanently")
     
             @discord.ui.button(label="Deny", style=discord.ButtonStyle.red)
             async def deny(
                 self, interaction: discord.Interaction, button: discord.ui.Button
             ):
    -            await self._resolve(interaction, "deny", discord.Color.red())
    +            await self._resolve(interaction, "deny", discord.Color.red(), "Denied")
     
             async def on_timeout(self):
                 """Handle view timeout -- disable buttons and mark as expired."""
    ```
  - `gateway/run.py`

    ```diff
    @@ -5829,10 +5829,39 @@ def _bg_review_send(message: str) -> None:
                 from tools.approval import register_gateway_notify, unregister_gateway_notify
     
                 def _approval_notify_sync(approval_data: dict) -> None:
    -                """Send the approval request to the user from the agent thread."""
    +                """Send the approval request to the user from the agent thread.
    +
    +                If the adapter supports interactive button-based approvals
    +                (e.g. Discord's ``send_exec_approval``), use that for a richer
    +                UX.  Otherwise fall back to a plain text message with
    +                ``/approve`` instructions.
    +                """
                     cmd = approval_data.get("command", "")
    -                cmd_preview = cmd[:200] + "..." if len(cmd) > 200 else cmd
                     desc = approval_data.get("description", "dangerous command")
    +
    +                # Prefer button-based approval when the adapter supports it.
    +                # Check the *class* for the method, not the instance — avoids
    +                # false positives from MagicMock auto-attribute creation in tests.
    +                if getattr(type(_status_adapter), "send_exec_approval", None) is not None:
    +                    try:
    +                        asyncio.run_coroutine_threadsafe(
    +                            _status_adapter.send_exec_approval(
    +                                chat_id=_status_chat_id,
    +                                command=cmd,
    +                                session_key=_approval_session_key,
    +                                description=desc,
    +                                metadata=_status_thread_metadata,
    +                            ),
    +                            _loop_for_step,
    +                        ).result(timeout=15)
    +                        return
    +                    except Exception as _e:
    +                        logger.warning(
    +                            "Button-based approval failed, falling back to text: %s", _e
    +                        )
    +
    +                # Fallback: plain text approval prompt
    +                cmd_preview = cmd[:200] + "..." if len(cmd) > 200 else cmd
                     msg = (
                         f"⚠️ **Dangerous command requires approval:**\n"
                         f"```\n{cmd_preview}\n```\n"
    ```
  - `tests/gateway/test_discord_document_handling.py`

    ```diff
    @@ -34,8 +34,8 @@ def _ensure_discord_mock():
         discord_mod.Thread = type("Thread", (), {})
         discord_mod.ForumChannel = type("ForumChannel", (), {})
         discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
    -    discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3)
    -    discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4)
    +    discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3)
    +    discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)
         discord_mod.Interaction = object
         discord_mod.Embed = MagicMock
         discord_mod.app_commands = SimpleNamespace(
    ```
  - `tests/gateway/test_discord_free_response.py`

    ```diff
    @@ -23,8 +23,8 @@ def _ensure_discord_mock():
         discord_mod.Thread = type("Thread", (), {})
         discord_mod.ForumChannel = type("ForumChannel", (), {})
         discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
    -    discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3)
    -    discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4)
    +    discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3)
    +    discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)
         discord_mod.Interaction = object
         discord_mod.Embed = MagicMock
         discord_mod.app_commands = SimpleNamespace(
    ```
  - `tests/gateway/test_discord_send.py`

    ```diff
    @@ -19,8 +19,8 @@ def _ensure_discord_mock():
         discord_mod.Thread = type("Thread", (), {})
         discord_mod.ForumChannel = type("ForumChannel", (), {})
         discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
    -    discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3)
    -    discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4)
    +    discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3)
    +    discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)
         discord_mod.Interaction = object
         discord_mod.Embed = MagicMock
         discord_mod.app_commands = SimpleNamespace(
    ```
  - `tests/gateway/test_voice_command.py`

    ```diff
    @@ -25,8 +25,8 @@ def _ensure_discord_mock():
         discord_mod.Thread = type("Thread", (), {})
         discord_mod.ForumChannel = type("ForumChannel", (), {})
         discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
    -    discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3)
    -    discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4)
    +    discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3)
    +    discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)
         discord_mod.Interaction = object
         discord_mod.Embed = MagicMock
         discord_mod.app_commands = SimpleNamespace(
    ```
- **#3620: feat(discord): register /approve and /deny as native Discord slash commands**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#4800`

## Fix status

- Merged to main in PR #4800 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-03`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `gateway`
