Summary
The Slack channel adapter ignores every event whose bot_id is non-empty, including events where another bot @-mentions the forge-agent. This blocks legitimate integration patterns: a scheduled bot, monitoring alert, CI/CD bot, or workflow that posts "<@FORGE_AGENT> please summarize this incident" will never be seen by the agent, even though a human posting the same text triggers a response.
The filter is hardcoded — there is no way to admit specific bot IDs. A correct fix also has to handle the inverse case: the agent must never respond to its own messages, even when the allowlist is wide open.
Root cause
forge-plugins/channels/slack/slack.go:328-331:
// Skip bot messages.
if payload.Event.BotID != "" {
continue
}
This runs before any mention-matching logic. Slack sets bot_id on every message authored by an app, including the message event and the app_mention event Slack delivers when a bot mentions another bot. So the agent is doubly insulated:
| Event type Slack delivers |
Filter that drops it |
message with bot_id != "" |
BotID != "" check at slack.go:329 |
app_mention with bot_id != "" |
Same BotID != "" check at slack.go:329 (runs before the Type == "app_mention" skip at slack.go:341) |
There is no allowlist or escape hatch, and equally importantly, no self-id guard — if the filter were simply lifted, the agent could respond to its own messages and hot-loop.
Reproduction
- Configure a forge-agent with the Slack adapter and confirm a human @-mention triggers a response in a channel.
- From a second Slack app (Workflow Builder, a scheduler bot, a monitoring bot, anything with
chat:write), post "<@FORGE_AGENT_USER_ID> ping" to the same channel.
- Observed: no reaction, no response, no log line — the event is silently dropped at
slack.go:329.
- Expected (or at least configurable): the agent recognizes the mention and responds the same way it would for a human.
Why the filter exists, and what it overshoots
Filtering bot messages by default is correct — bot-to-bot loops are a real risk (two agents @-mentioning each other can hot-loop a workspace, and the agent talking to itself is just as bad). But the current implementation is binary: all bot messages are dropped. Legitimate cases the binary filter blocks:
- A cron / scheduler bot routing daily standup prompts to the agent.
- A monitoring tool (PagerDuty, Datadog, custom) tagging the agent on an incident summary.
- A workflow builder step that asks the agent to draft a response.
- A CI/CD bot asking the agent to comment on a release.
In all of these the human set up the integration deliberately, and the loop risk is well-defined (one bot, known ID).
Proposed fix
Two pieces, both inside the Slack adapter:
1. Capture the agent's own bot_id at startup. resolveBotID already calls auth.test to discover user_id. The same response payload includes bot_id — parse it and store it on Plugin (e.g. p.ownBotID). This is the self-id guard.
2. Replace the unconditional skip with an allowlist + self-id check. A new optional setting on the channel YAML controls which other bots are admitted:
# slack-config.yaml
adapter: slack
settings:
app_token_env: SLACK_APP_TOKEN
bot_token_env: SLACK_BOT_TOKEN
allow_bot_ids: B0123ABC,B0456DEF # comma-separated; default empty = current behavior
Sketch:
botID := payload.Event.BotID
if botID != "" {
// Hard rule, always on: never respond to our own messages, even if
// the allowlist would otherwise admit them. Prevents self-mention loops.
if botID == p.ownBotID {
continue
}
// Allowlist gate for every other bot.
if !p.allowBotIDs[botID] {
continue
}
}
The mention-matching block at slack.go:346-361 already requires <@botUserID> in the text, so an admitted bot still has to explicitly tag the agent — bots in the allowlist that chatter without tagging won't trigger responses.
Why bot_id rather than app_id or app name
Slack bot_id is stable per-bot-user within a workspace and visible in the same event payload (payload.event.bot_id) without an extra API call. app_id is also available but resolving an app name → app_id requires a separate apps.info call. For an MVP, bot_id is the lowest-friction key. Documentation would point operators at the easiest way to discover a bot's ID (Slack admin → Manage apps → the app's Bot User OAuth page).
Loop-safety
Three safeguards keep bot-to-bot and self-loops bounded even after admitting some bot mentions:
- Self-id guard (unconditional).
payload.Event.BotID == p.ownBotID short-circuits before the allowlist is consulted. Even an allow_bot_ids: "*" style wildcard (if ever added) would still exclude the agent itself. This is the non-negotiable rule.
- The allowlist itself. Bots not on it remain ignored. So an inadvertent loop with another bot requires the operator to explicitly add a misbehaving bot.
- Mention requirement. Even admitted bots must include
<@FORGE_AGENT> in the text. A bot posting general chatter without tagging the agent does not trigger a response.
Worth considering as a follow-up (not blocking): a configurable rate limit (max_bot_invocations_per_minute) for messages whose bot_id is non-empty — useful if a scheduler bot misfires.
Affected files
forge-plugins/channels/slack/slack.go:328-331 — the binary bot_id filter to replace with allowlist-aware admission plus self-id guard
forge-plugins/channels/slack/slack.go:71-103 — resolveBotID extend the auth.test response parsing to capture bot_id (the agent's own) alongside user_id
forge-plugins/channels/slack/slack.go:339-343 — app_mention dedup skip; revisit if the allowlist needs to apply to the app_mention path differently
forge-plugins/channels/slack/slack.go (type definition near bot_id JSON tag) — the existing BotID string json:"bot_id"` field already exists; no new event field is needed
forge-cli/templates/init/slack-config.yaml.tmpl — document the new optional setting in the template
docs/core-concepts/channels.md — operator docs describing the loop-safety trade-off, how to find a bot's bot_id, and that the agent's own messages are always ignored
Acceptance criteria
- An operator can list one or more bot IDs in
slack-config.yaml (allow_bot_ids) and have the forge-agent respond to @-mentions from those bots in exactly the same way it responds to human mentions.
- With
allow_bot_ids empty or absent, behavior is identical to today — every bot-authored event is dropped, no surprises.
- The agent never responds to its own messages. Even if its own
bot_id were listed in allow_bot_ids (whether by mistake or future wildcard support), the self-id guard at startup-resolved p.ownBotID short-circuits the event. This rule has no opt-out.
- A bot in the allowlist still only triggers a response when it explicitly @-mentions the forge-agent (
<@FORGE_AGENT_USER_ID> in the message text). Generic chatter from an allowed bot is ignored.
app_mention events authored by allowed bots are honored (since the regular message event is the existing primary path; the implementation must not regress to firing both for admitted bots).
- A clear log line is emitted when an event is dropped for being a non-allowlisted bot or for being the agent itself, so operators can debug "why isn't my CI bot's mention working" — and "why isn't my agent reacting to its own scheduled prompt" — without reading the source.
Context
Surfaced from real usage: forge-agent in a Slack workspace responds to human mentions but is silent when another bot (scheduler / monitoring / workflow) routes a mention to it. The mention-aware filter at slack.go:346-361 is correct; the unconditional BotID != "" skip above it is what's overshooting.
Related: this is a separate concern from #50 (channel env vars in K8s manifests) but in the same Slack adapter file. Fixing both is independent.
Summary
The Slack channel adapter ignores every event whose
bot_idis non-empty, including events where another bot @-mentions the forge-agent. This blocks legitimate integration patterns: a scheduled bot, monitoring alert, CI/CD bot, or workflow that posts"<@FORGE_AGENT> please summarize this incident"will never be seen by the agent, even though a human posting the same text triggers a response.The filter is hardcoded — there is no way to admit specific bot IDs. A correct fix also has to handle the inverse case: the agent must never respond to its own messages, even when the allowlist is wide open.
Root cause
forge-plugins/channels/slack/slack.go:328-331:This runs before any mention-matching logic. Slack sets
bot_idon every message authored by an app, including themessageevent and theapp_mentionevent Slack delivers when a bot mentions another bot. So the agent is doubly insulated:messagewithbot_id != ""BotID != ""check atslack.go:329app_mentionwithbot_id != ""BotID != ""check atslack.go:329(runs before theType == "app_mention"skip atslack.go:341)There is no allowlist or escape hatch, and equally importantly, no self-id guard — if the filter were simply lifted, the agent could respond to its own messages and hot-loop.
Reproduction
chat:write), post"<@FORGE_AGENT_USER_ID> ping"to the same channel.slack.go:329.Why the filter exists, and what it overshoots
Filtering bot messages by default is correct — bot-to-bot loops are a real risk (two agents @-mentioning each other can hot-loop a workspace, and the agent talking to itself is just as bad). But the current implementation is binary: all bot messages are dropped. Legitimate cases the binary filter blocks:
In all of these the human set up the integration deliberately, and the loop risk is well-defined (one bot, known ID).
Proposed fix
Two pieces, both inside the Slack adapter:
1. Capture the agent's own
bot_idat startup.resolveBotIDalready callsauth.testto discoveruser_id. The same response payload includesbot_id— parse it and store it onPlugin(e.g.p.ownBotID). This is the self-id guard.2. Replace the unconditional skip with an allowlist + self-id check. A new optional setting on the channel YAML controls which other bots are admitted:
Sketch:
The mention-matching block at
slack.go:346-361already requires<@botUserID>in the text, so an admitted bot still has to explicitly tag the agent — bots in the allowlist that chatter without tagging won't trigger responses.Why
bot_idrather thanapp_idor app nameSlack
bot_idis stable per-bot-user within a workspace and visible in the same event payload (payload.event.bot_id) without an extra API call.app_idis also available but resolving an app name →app_idrequires a separateapps.infocall. For an MVP,bot_idis the lowest-friction key. Documentation would point operators at the easiest way to discover a bot's ID (Slack admin → Manage apps → the app's Bot User OAuth page).Loop-safety
Three safeguards keep bot-to-bot and self-loops bounded even after admitting some bot mentions:
payload.Event.BotID == p.ownBotIDshort-circuits before the allowlist is consulted. Even anallow_bot_ids: "*"style wildcard (if ever added) would still exclude the agent itself. This is the non-negotiable rule.<@FORGE_AGENT>in the text. A bot posting general chatter without tagging the agent does not trigger a response.Worth considering as a follow-up (not blocking): a configurable rate limit (
max_bot_invocations_per_minute) for messages whosebot_idis non-empty — useful if a scheduler bot misfires.Affected files
forge-plugins/channels/slack/slack.go:328-331— the binarybot_idfilter to replace with allowlist-aware admission plus self-id guardforge-plugins/channels/slack/slack.go:71-103—resolveBotIDextend theauth.testresponse parsing to capturebot_id(the agent's own) alongsideuser_idforge-plugins/channels/slack/slack.go:339-343—app_mentiondedup skip; revisit if the allowlist needs to apply to theapp_mentionpath differentlyforge-plugins/channels/slack/slack.go(type definition nearbot_idJSON tag) — the existingBotID stringjson:"bot_id"` field already exists; no new event field is neededforge-cli/templates/init/slack-config.yaml.tmpl— document the new optional setting in the templatedocs/core-concepts/channels.md— operator docs describing the loop-safety trade-off, how to find a bot'sbot_id, and that the agent's own messages are always ignoredAcceptance criteria
slack-config.yaml(allow_bot_ids) and have the forge-agent respond to @-mentions from those bots in exactly the same way it responds to human mentions.allow_bot_idsempty or absent, behavior is identical to today — every bot-authored event is dropped, no surprises.bot_idwere listed inallow_bot_ids(whether by mistake or future wildcard support), the self-id guard at startup-resolvedp.ownBotIDshort-circuits the event. This rule has no opt-out.<@FORGE_AGENT_USER_ID>in the message text). Generic chatter from an allowed bot is ignored.app_mentionevents authored by allowed bots are honored (since the regularmessageevent is the existing primary path; the implementation must not regress to firing both for admitted bots).Context
Surfaced from real usage: forge-agent in a Slack workspace responds to human mentions but is silent when another bot (scheduler / monitoring / workflow) routes a mention to it. The mention-aware filter at
slack.go:346-361is correct; the unconditionalBotID != ""skip above it is what's overshooting.Related: this is a separate concern from #50 (channel env vars in K8s manifests) but in the same Slack adapter file. Fixing both is independent.