diff --git a/src/backend/adapters/message_router.py b/src/backend/adapters/message_router.py
index c6a78737c..a8a61403a 100644
--- a/src/backend/adapters/message_router.py
+++ b/src/backend/adapters/message_router.py
@@ -784,6 +784,20 @@ async def _resolve_agent_and_token(
(the caller short-circuits). Both return silently — there is no token to
reply with on the no-token path.
"""
+ # ent#222: if the event was received by an agent's DEDICATED Slack bot,
+ # that agent wins over the channel binding and its own bot token is used
+ # for the reply. Inert for non-Slack / OSS-only (no resolver registered).
+ from adapters import per_agent_bot
+ pab_agent = per_agent_bot.resolve_from_message(message)
+ if pab_agent:
+ pab_token = per_agent_bot.get_token(pab_agent)
+ if pab_token:
+ logger.debug(f"[ROUTER:{channel}] per-agent bot → agent {pab_agent}")
+ return pab_agent, pab_token
+ # Binding without a usable token → fall through to channel routing.
+ logger.warning(f"[ROUTER:{channel}] per-agent bot for {pab_agent} has no token; "
+ "falling back to channel routing")
+
agent_name = await adapter.get_agent_name(message)
logger.debug(f"[ROUTER:{channel}] Step 1 - resolved agent: {agent_name}")
if not agent_name:
diff --git a/src/backend/adapters/per_agent_bot.py b/src/backend/adapters/per_agent_bot.py
new file mode 100644
index 000000000..bd46987f7
--- /dev/null
+++ b/src/backend/adapters/per_agent_bot.py
@@ -0,0 +1,75 @@
+"""Per-agent bot resolution seam (ent#222) — the OSS half of per-agent Slack bots.
+
+Edition-agnostic and **inert by default**: with no resolver registered (OSS-only
+builds, or before the enterprise module loads) every function here is a no-op and
+channel routing is byte-for-byte unchanged. The private ``slack_per_agent_bots``
+enterprise module registers a resolver + token provider at startup; from then on
+an inbound Slack event received by an agent's *dedicated* bot resolves to that
+agent (over the channel binding) and replies through that bot's own token.
+
+Mirrors the connector-seam pattern (#118): the OSS code owns the seam, the
+enterprise module owns the policy. Both hooks fail **open** — any error falls
+back to normal channel routing, so a bug here can never take Slack offline.
+"""
+from __future__ import annotations
+
+import logging
+from typing import Callable, Optional
+
+logger = logging.getLogger(__name__)
+
+# (team_id, bot_user_id, app_id) -> agent_name | None
+_resolver: Optional[Callable[[Optional[str], Optional[str], Optional[str]], Optional[str]]] = None
+# agent_name -> per-agent bot token | None
+_token_provider: Optional[Callable[[str], Optional[str]]] = None
+
+
+def set_resolver(fn: Optional[Callable]) -> None:
+ """Install the enterprise resolver. ``None`` disables (returns to inert)."""
+ global _resolver
+ _resolver = fn
+
+
+def set_token_provider(fn: Optional[Callable]) -> None:
+ global _token_provider
+ _token_provider = fn
+
+
+def is_active() -> bool:
+ return _resolver is not None
+
+
+def resolve_from_message(message) -> Optional[str]:
+ """The agent whose dedicated bot RECEIVED this event, or None.
+
+ Reads only the receiving-bot identity the Slack adapter stamps into
+ ``message.metadata`` (``slack_team_id`` + ``slack_recipient_bot_user_id`` /
+ ``slack_recipient_app_id``). Non-Slack messages carry none of these, so this
+ is inert for every other channel. Fail-open: any error → None → normal
+ channel routing.
+ """
+ if _resolver is None:
+ return None
+ md = getattr(message, "metadata", None) or {}
+ team_id = md.get("slack_team_id")
+ bot_user_id = md.get("slack_recipient_bot_user_id")
+ app_id = md.get("slack_recipient_app_id")
+ if not team_id or not (bot_user_id or app_id):
+ return None
+ try:
+ return _resolver(team_id, bot_user_id, app_id)
+ except Exception as e: # noqa: BLE001 — never break routing
+ logger.warning("per-agent bot resolver raised; falling back to channel routing: %s", e)
+ return None
+
+
+def get_token(agent_name: str) -> Optional[str]:
+ """The dedicated bot token for a per-agent-routed reply, or None to fall back
+ to the workspace token. Fail-open."""
+ if _token_provider is None or not agent_name:
+ return None
+ try:
+ return _token_provider(agent_name)
+ except Exception as e: # noqa: BLE001
+ logger.warning("per-agent bot token provider raised; falling back: %s", e)
+ return None
diff --git a/src/backend/adapters/slack_adapter.py b/src/backend/adapters/slack_adapter.py
index 2a3570307..f3ef854dd 100644
--- a/src/backend/adapters/slack_adapter.py
+++ b/src/backend/adapters/slack_adapter.py
@@ -109,19 +109,40 @@ def parse_message(self, raw_event: dict) -> Optional[NormalizedMessage]:
team_id = raw_event.get("team_id")
event_type = event.get("type")
+ message = None
# Handle DM messages
if event_type == "message" and event.get("channel_type") == "im":
- return self._parse_dm(event, team_id)
-
+ message = self._parse_dm(event, team_id)
# Handle @mentions in channels
- if event_type == "app_mention":
- return self._parse_mention(event, team_id)
-
+ elif event_type == "app_mention":
+ message = self._parse_mention(event, team_id)
# Handle thread replies in bot channels (no @mention needed)
- if event_type == "message" and event.get("thread_ts"):
- return self._parse_thread_reply(event, team_id)
+ elif event_type == "message" and event.get("thread_ts"):
+ message = self._parse_thread_reply(event, team_id)
- return None
+ if message is None:
+ return None
+ return self._stamp_recipient_bot(message, raw_event, team_id)
+
+ def _stamp_recipient_bot(self, message, raw_event: dict, team_id):
+ """ent#222: record WHICH bot received this event so the router can route a
+ per-agent dedicated bot (over the channel binding). ``authorizations[0]
+ .user_id`` is the bot the event was delivered to; ``api_app_id`` its app.
+ Additive metadata — inert unless a per-agent resolver is registered."""
+ auths = raw_event.get("authorizations") or []
+ recipient_bot = (
+ auths[0].get("user_id") if auths and isinstance(auths[0], dict) else None
+ )
+ md = dict(getattr(message, "metadata", None) or {})
+ md.update({
+ "slack_team_id": team_id,
+ "slack_recipient_bot_user_id": recipient_bot,
+ "slack_recipient_app_id": raw_event.get("api_app_id"),
+ })
+ try:
+ return message.model_copy(update={"metadata": md})
+ except Exception: # noqa: BLE001 — never fail parsing over metadata
+ return message
def format_response(self, text: str) -> str:
"""Convert standard markdown to Slack mrkdwn format.
diff --git a/src/frontend/src/components/SharingPanel.vue b/src/frontend/src/components/SharingPanel.vue
index b83073287..cfe7ec5ff 100644
--- a/src/frontend/src/components/SharingPanel.vue
+++ b/src/frontend/src/components/SharingPanel.vue
@@ -169,6 +169,8 @@
:derive-status="slackStatus"
>
+
+
+
+
+
+
+
+ Dedicated Slack bot
+
+
+ Give this agent its own Slack bot identity — its own name and avatar,
+ directly DM-able and @mention-able, alongside other agents
+ in the same channel.
+