# Slack Connections (Per-Workflow and Per-Project Slack Apps) Each workflow either has its own Slack bot (a Slack app with its own bot token + app token pair), uses one of its owner's other bots, or uses the shared bot (the platform default app). Crew projects work the same way. This document describes who answers, the registry, the multi-listener runtime, and the ownership rules. ## Who answers: three options The Slack tab asks "Who answers for this workflow in Slack?": | | Its own bot | One of my bots | Shared bot | |---|---|---|---| | Which app | A connection scoped to this workflow or crew project (`workspace_path` set) | A connection scoped to another workflow or crew the user manages | The default connection, or any unscoped one | | Which workflow runs | Its own, in any channel it is invited to | This one in the channels picked here; the bot's own workflow everywhere else | The channel route (channel ID → workflow/project) | | Channel routes | None needed | Stored on that bot (`channel_routes`) | Stored in the shared bot config (`allowed_channels`) | | Managed by | The workflow/project owner | The bot's manager, who must also be able to write this workflow | Admin (Access → Slack) for the app; owners add channels | | Admin needed | No | No | Yes, to set the bot up | "One of my bots" exists because one Slack app can only be connected once (`slackTokensInUse`): to answer for workflow B with the app workflow A already owns, the owner adds channel routes to A's bot instead of connecting the app twice. - Inbound rule (`services.ResolveSlackRoute(ctx, connection, channel, …)`): a message's arrival app decides. A dedicated (scoped) app answers for the destination its own `channel_routes` names for the channel, else for its own destination (`StreamingAPI.dedicatedSlackRoute`, Run mode). A shared app follows the platform channel route. A dedicated app whose destination (own or routed) no longer resolves gets the revoked sentinel and refuses loudly; it never falls back to another workflow. - One channel maps to one destination per app. Another app in the same channel keeps its own rule. - Crew bots: the owner comes from the project's `_users//` path and the conversation key from its `product.json`, re-checked through `resolveProductConversationBinding` like a saved route. - The connection's scope is the grant: only the destination's owner (or an admin) can create a scoped connection. A dedicated app authorizes its own traffic like a saved route, independent of the platform switch. - The same rule runs everywhere a route is re-checked: inbound routing, `revalidateExecutionPrincipal` (every tool call), `send_slack_message` and the Slack CLI tool (`slackRouteForConnection` / `slackToolRoute`). Non-bot sessions (a workflow run posting to a channel) only ever use channel routes. - Sessions are per (thread, app): `ThreadID.Key()` appends `#` for non-default connections, so two bots in one thread keep separate conversations and each replies as itself. Default-connection keys and durable bindings keep the legacy three-part form; a non-default thread falls back to its legacy binding when the route key matches. - Several bots in one thread: a plain (untagged) reply reaches none of them (`threadHasOtherBot`, in memory and via durable bindings); users @mention the bot they mean. A message tagging another bot stays silent on this bot (the colleague-tag guard). ## Model ```text slack-config.json connections[]: [{ id, display_name, bot_token, app_token, enabled, workspace_path, profile_id, channel_routes: { "C123": { workspace_path, profile_id, added_by } } }] default_connection_id: "slack_001" workflow.json capabilities (workflows) slack_connection_id: "slack_abc123" | "" (= inherit default) project runtime manifest capabilities (crew projects) slack_connection_id: "slack_abc123" | "" (= inherit default) ``` - One connection = one Slack app = one Socket Mode websocket. Tokens are encrypted at rest (`operator:slack` AAD) and masked on every read. - `workspace_path` scopes a connection to its owning workflow or product project (`profile_id` names the agent profile for product scopes). Empty means platform-managed. A lone scoped connection never auto-becomes the default; inheriting platform traffic is an explicit admin decision. - `channel_routes` only applies to scoped connections. Normalization drops routes on unscoped connections, routes with no destination, and routes to the connection's own destination. Changing a connection's scope (admin only) clears its routes: they were granted by the old scope's owner. - Pre-registry single-credential configs migrate on load into an unscoped `slack_001` "Default" connection. Legacy save shapes fold onto the default connection. - An unknown, incomplete, or disabled selection fails the send. It never falls back to another identity: delivering from an unintended Slack app is worse than not delivering. ## Runtime ```text Socket Mode listeners (one per enabled connection) | inbound tagged with connection ID v SlackService root (serves default) + children (one per extra connection) | BotConnector / NotificationConnector v BotConversationManager (connection rides ThreadID / BotIncomingMessage) ``` - The root instance (returned by `GetSlackService`, registered with the `BotConversationManager`) serves the default connection itself and fans out to one child `SlackService` per additional enabled connection. - Children are owned by the root: created, token-synced, and stopped by `reconcileChildren`, which runs on every config reload. Children never touch the config file. - `ThreadID.ConnectionID` (part of `Key()` for non-default connections; see "Who answers") carries the arrival connection through sessions, replies, reactions, button interactions, and history reads. Reactions and channel names resolve through the optional `connectionScopedConnector` interface; other platforms keep the legacy channel-only behavior. - Resolution order for a send: live bot execution's arrival connection, else the route target's selection (workflow manifest or project runtime manifest), else the platform default. ## Ownership | Action | Who | |---|---| | Create/update/delete/test a workflow-scoped connection | Owner of that workflow (create requires the scope; scope changes are admin-only) | | Create/update/delete/test a project-scoped connection | Owner of that product project (same scope rules as workflows) | | Create/update the default or any unscoped connection | Platform admin | | Change the default connection | Platform admin | | Flip the global Enable switch / bot mode | Platform admin (one-time platform step) | | Select an app on a workflow (`slack_connection_id`) | Anyone who can edit the manifest; unknown IDs fail validation. Saving a workflow's own bot selects it automatically. | | Select an app on a project (`slack_connection_id`) | Product owner (reads need product access); unknown IDs fail validation | | Delete a connection | Blocked while it is the default or still selected by any workflow or project | | Add a channel route on a scoped connection ("One of my bots") | Someone who manages the connection (owner of its workflow/crew, or admin) **and** can write the destination (workflow Owner/Editor, or owner of the crew project) | | Remove a channel route | The same two checks; if the destination no longer exists, managing the connection is enough | | List "bots I can use" (`GET …/slack/connections/mine`) | Returns only scoped connections the caller manages, with their routes and no tokens | Bot-route principals can never manage connections. WhatsApp turns are not bot-route principals: they run as the paired owner (`bot_owner` — the bot is linked as the owner's number, so there is no channel grant to hold) and pass these gates as the owner would. All API responses carry masked tokens only. ## Bot principals (`bot_route` vs `bot_owner`) - `bot_route` is stamped on Slack turns only, and only when the request carries a channel grant (`applyBotRouteClaims` in `agent_go/cmd/server/bot_session_starter.go`). It is a synthetic route identity: the Slack sender gets no authority beyond the grant, and `revalidateExecutionPrincipal` re-checks the live route config on every tool call (connector enabled, route present, explicit grant, destination unchanged, sender email allowed). Execution is pinned to `run` access. - `bot_owner` is stamped on WhatsApp turns. The sender was authenticated at message ingress (pairing ownership, link codes, per-slug workflow access), so the turn executes as the paired owner on the owner's data with nothing further to revalidate — the same standing as the owner's own app turns. - The shared tool boundary (`bindToolExecutionContext` in `agent_go/cmd/server/tool_execution_context.go`) admits exactly these two principals on bot-marked sessions. Anything else bound there is rejected as `session origin changed`: before `bot_owner` existed, the guard admitted only `bot_route`, which silently broke every tool call in every WhatsApp turn. ## Key files | Area | File | |---|---| | Registry model, normalize, migrate, encrypt | `agent_go/cmd/server/services/slack_connections.go` | | Runtime mux, children, routing | `agent_go/cmd/server/services/slack_service.go` | | Connection threading, scoped helpers | `agent_go/cmd/server/services/bot_connector.go` | | Connections API + permission gates | `agent_go/cmd/server/slack_connection_routes.go` | | "One of my bots" API (`/mine`, `/{id}/channel-routes/{channel}`) | `agent_go/cmd/server/slack_connection_channel_routes.go` | | Manifest selection + validation | `agent_go/cmd/server/workflow_manifest*.go` | | Project selection + product ownership | `agent_go/cmd/server/slack_connection_routes.go` (`productSlackConnectionID`, `requireProductSlackScopeOwner`) | | Own-bot routing rule | `agent_go/cmd/server/services/slack_dedicated_route.go`, `agent_go/cmd/server/slack_dedicated_route.go` | | Tool owner branches | `agent_go/cmd/server/slack_bot_tools.go` | | Slack tab (own bot, one of my bots, shared bot) | `frontend/src/components/workflow/bots/SlackSetup.tsx`, `useWorkflowBots.ts` | | Admin shared bot + bot list (Access → Slack) | `frontend/src/components/admin/SlackAdminPanel.tsx` | | Agent guidance | `agent_go/cmd/server/guidance/templates/system/slack-bot-routing.md` |