Summary
When a Slack workspace has multiple agents bound to it, DMs to the bot are routed to a single "DM-default" agent. Today there is no way to choose or change which agent that is from the UI or any API — the flag is auto-set on the first agent ever connected to the workspace and is permanent unless an admin edits the SQLite database directly.
Current Behavior
The DM router (adapters/slack_adapter.py:201-240) uses this priority:
- Channel binding (
channel_id → agent) — works for non-DM channel messages
- DM default agent (
is_dm_default = 1) — used when message is a DM
- Single connected agent fallback — works only if exactly one agent in workspace
- Legacy
slack_link_connections lookup
- None
The is_dm_default flag on slack_channel_agents is set automatically in three places (all use is_first = len(agents_in_workspace) == 0):
| Location |
When |
routers/slack.py:186 |
OAuth callback for first agent connected to a workspace |
routers/slack.py:296 |
First agent bound via POST /api/agents/{name}/public-links/{link_id}/slack/connect |
routers/slack.py:474 |
First agent bound via POST /api/agents/{name}/slack/channel |
After that point, no setter exists. SlackChannelPanel.vue:33 only renders (DM default) as a label — no toggle, no dropdown.
Why This Matters
- Most common scenario — owner connects agent A first to test, later adds agent B/C/D. All workspace DMs continue going to agent A even when A isn't the right destination.
- Operational pain — only workaround is
docker exec trinity-backend python3 -c "..." against /data/trinity.db, run by an operator with shell access. There's no audit trail and no operator-facing affordance.
- Unbinding leaves zero defaults —
unbind_slack_agent deletes the row but doesn't promote anyone. Workspace ends up with no DM default; routing falls to step 3, which only works with exactly one remaining agent.
Proposed Scope
1. DB layer (db/slack_channels.py)
Add a single-transaction setter:
def set_slack_dm_default(self, team_id: str, agent_name: str) -> bool:
"""
Make agent_name the DM-default for the workspace.
Returns True if a row was updated, False if agent_name isn't bound there.
"""
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute("BEGIN")
cur.execute(
"UPDATE slack_channel_agents SET is_dm_default = 0 WHERE team_id = ?",
(team_id,),
)
cur.execute(
\"\"\"UPDATE slack_channel_agents SET is_dm_default = 1
WHERE team_id = ? AND agent_name = ?\"\"\",
(team_id, agent_name),
)
changed = cur.rowcount > 0
conn.commit()
return changed
The clear-then-set inside one transaction avoids a transient "two defaults" state.
Also: when unbind_slack_agent removes the current default, promote the oldest-bound remaining agent (ORDER BY created_at ASC LIMIT 1) to keep the workspace addressable.
2. Backend API
New endpoint, owner/admin-only:
PUT /api/agents/{name}/slack/channel/dm-default
200 → {"workspace": "...", "team_id": "...", "previous": "agent-a", "new_default": "agent-b"}
404 → agent not bound in any workspace
403 → caller is not the owner / admin
Audit-logged via platform_audit_service.log with event_type=AGENT_LIFECYCLE, event_action="slack_dm_default_changed", target_id=agent_name, details={"team_id": ..., "previous": ..., "new_default": ...}.
3. MCP tool (optional)
Add set_slack_dm_default(agent_name) to src/mcp-server/src/tools/slack.ts (if a Slack tools module exists; otherwise skip — UI is the primary surface).
4. Frontend
src/frontend/src/components/SlackChannelPanel.vue:
- Replace the read-only
(DM default) label with a dropdown listing all agents in the workspace.
- Selection PUTs the new default; success → toast, refresh.
- Disabled for non-owner / non-admin.
If only one agent is bound, the dropdown is suppressed (no choice to make).
Suggested Tier / Categorization
Acceptance Criteria
Out of Scope
- Per-channel routing changes (that already exists via channel bindings)
- A new "DM router" with rules — just a single default agent
- Migrating away from the
is_dm_default boolean schema
References
- Routing logic:
src/backend/adapters/slack_adapter.py:201
- DB layer:
src/backend/db/slack_channels.py:138-198
- Frontend label:
src/frontend/src/components/SlackChannelPanel.vue:33
- Auto-set sites:
src/backend/routers/slack.py:186, 296, 474
- Local runbook for the DB-direct workaround:
.local/notes/runbook-slack-dm-default-flip.md
Summary
When a Slack workspace has multiple agents bound to it, DMs to the bot are routed to a single "DM-default" agent. Today there is no way to choose or change which agent that is from the UI or any API — the flag is auto-set on the first agent ever connected to the workspace and is permanent unless an admin edits the SQLite database directly.
Current Behavior
The DM router (
adapters/slack_adapter.py:201-240) uses this priority:channel_id → agent) — works for non-DM channel messagesis_dm_default = 1) — used when message is a DMslack_link_connectionslookupThe
is_dm_defaultflag onslack_channel_agentsis set automatically in three places (all useis_first = len(agents_in_workspace) == 0):routers/slack.py:186routers/slack.py:296POST /api/agents/{name}/public-links/{link_id}/slack/connectrouters/slack.py:474POST /api/agents/{name}/slack/channelAfter that point, no setter exists.
SlackChannelPanel.vue:33only renders(DM default)as a label — no toggle, no dropdown.Why This Matters
docker exec trinity-backend python3 -c "..."against/data/trinity.db, run by an operator with shell access. There's no audit trail and no operator-facing affordance.unbind_slack_agentdeletes the row but doesn't promote anyone. Workspace ends up with no DM default; routing falls to step 3, which only works with exactly one remaining agent.Proposed Scope
1. DB layer (
db/slack_channels.py)Add a single-transaction setter:
The clear-then-set inside one transaction avoids a transient "two defaults" state.
Also: when
unbind_slack_agentremoves the current default, promote the oldest-bound remaining agent (ORDER BY created_at ASC LIMIT 1) to keep the workspace addressable.2. Backend API
New endpoint, owner/admin-only:
Audit-logged via
platform_audit_service.logwithevent_type=AGENT_LIFECYCLE,event_action="slack_dm_default_changed",target_id=agent_name,details={"team_id": ..., "previous": ..., "new_default": ...}.3. MCP tool (optional)
Add
set_slack_dm_default(agent_name)tosrc/mcp-server/src/tools/slack.ts(if a Slack tools module exists; otherwise skip — UI is the primary surface).4. Frontend
src/frontend/src/components/SlackChannelPanel.vue:(DM default)label with a dropdown listing all agents in the workspace.If only one agent is bound, the dropdown is suppressed (no choice to make).
Suggested Tier / Categorization
Acceptance Criteria
set_slack_dm_default(team_id, agent_name)with single-tx clear-then-setunbind_slack_agentauto-promotes oldest-bound remaining agent if it removed the defaultPUT /api/agents/{name}/slack/channel/dm-defaultendpoint, owner/admin-gated, audit-loggedSlackChannelPanel.vueshows a dropdown for the DM default; works end-to-endset_slack_dm_defaultis idempotent and exclusive (no two-defaults race)unbind_slack_agentpromotes correctly when removing the defaultOut of Scope
is_dm_defaultboolean schemaReferences
src/backend/adapters/slack_adapter.py:201src/backend/db/slack_channels.py:138-198src/frontend/src/components/SlackChannelPanel.vue:33src/backend/routers/slack.py:186, 296, 474.local/notes/runbook-slack-dm-default-flip.md