Skip to content

Diagnostic Slack

the bot answers its own messages in an endless loop

A channel fills with hundreds of identical bot messages in a few seconds. Slack starts rate-limiting the app, which slows the flood without stopping it, and in the end somebody removes the bot from the channel to make it stop. The cause is one line that was never written: the handler subscribed to message.channels, which delivers every message in the channel, including the one the app posted a moment ago.

Read-only token Python and Node.js Tests included
White signs on a metal rack
Photo by Anna Auza on Unsplash
The short answer

Call auth.test for your bot_id, then read conversations.history?channel=C...&limit=200 and walk the messages in timestamp order looking for runs of consecutive messages authored by you with no human message in between. One or two in a row is normal. Twenty in a row, sub-second apart, is the loop.

The guard is in the handler, not in Slack: ignore any event carrying bot_id, any event whose subtype is bot_message, and any event whose user is your own bot user id. Or subscribe to app_mention instead, which never fires on your own posts.

The problem in plain words

This is the classic first-week Slack bug, and it is unusual in being both catastrophic and completely unambiguous once you look for it. There is no interpretation required: either there is a run of thirty self-authored messages in the channel or there is not.

What makes it worse than it looks is that rate limiting does not save you. Slack throttles chat.postMessage to roughly one message per second per channel, so the loop keeps running, one message a second, indefinitely. It will still be going tomorrow. Meanwhile every one of those posts is another event delivered to your handler, so the app's own inbound queue grows at the same rate, and any downstream side effect the handler performs happens once per iteration.

Human postsone messageBot replieschat.postMessageSlack deliversreplyto your ownhandlerHandler repliesagainno bot_id guardChannel floodsuntil someoneremoves the botevery reply is a new event
message.channels delivers every message in the channel, including the ones your app just posted. A handler matching on text alone cannot tell the difference.

Why it happens

message.channels is not filtered for you. The subscription means “every message in every public channel this app is in”, and your own messages are messages. Slack marks them — app-authored posts carry bot_id and app_id, and legacy senders also carry subtype: "bot_message" — but a handler that branches on event.text alone never looks at any of that.

Bolt's two entry points behave differently. app.message() in Bolt already skips messages with a bot_message subtype, which is exactly enough to make developers believe they are protected. It does not skip a modern app-authored message that carries bot_id without that subtype. app.event('app_mention') never fires on your own messages at all, which is why the mention path is the safe one.

A slow loop looks like a feature. If the handler adds a delay, or the reply is only sent under some condition, the run is spaced out over minutes and reads as a chatty integration rather than a bug. Length alone is not the signal; length combined with spacing is.

Another app's bot messages are not yours. Filtering on “any message with a bot_id” finds every integration in the channel and reports a busy alerts channel as a loop. The comparison has to be against your own bot_id from auth.test.

The fix, as a flow

The script walks each channel in time order and measures runs of consecutive self-authored messages, because a run broken by a human is a conversation and a run that is never broken is a loop.

Consecutive self-authored runsmeasured per channelRuns of onereplying to humansShort runs, slowa batch or a threadLong runs, seconds aparta poster, not a loopLong runs, sub-secondthe handler hears itself
A digest job posting twelve messages in a row is not a loop. Reporting it as one is how this check gets switched off.

How to fix it

Get your own identity first

auth.test returns both bot_id and user_id. You need both: history items from a modern app carry bot_id, while messages posted with a user token carry only user. Matching on either one, and on nothing else, is what keeps other apps out of the result.

Read history in timestamp order

conversations.history returns newest first. Sort ascending before you look for runs, because a run is a property of the order the messages were posted in, and reading the array as it arrives measures the loop backwards.

Measure runs, not counts

A bot that posted 180 of the last 200 messages in an alerts channel is doing its job. A bot that posted 30 consecutively, with no human message anywhere in the run, is talking to itself. Track the longest run per channel and the gaps inside it.

Separate a loop from a batch

A digest job that posts twelve messages in a row at startup is not a loop; a run whose internal gaps are all under a couple of seconds is. Report the slow long run as its own state so that the fast one keeps its meaning — a check that cries wolf on the nightly digest gets switched off within a week.

Add the guard, then prefer app_mention

In the handler, return early when event.bot_id is present, when event.subtype === "bot_message", or when event.user equals your bot user id. Better still, subscribe to app_mention for anything conversational: it only fires when a human addresses the app, so the loop is structurally impossible.

How to check it worked

Re-run after the guard ships. The longest self-authored run in every channel should collapse to one or two.

python3 slack_echo_loop_audit.py --limit 200
# 6 channel(s) checked, longest self-authored run 2, 0 loop(s)

The full code

Two GET methods, auth.test and conversations.history, plus users.conversations to find the channels — channels:read and channels:history cover all three. The two pure functions are the ones that decide the answer: whether a given message is ours, which is the same rule the repair puts in the handler, and whether a run of ours is a loop or a batch.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 20 Slack fixes, free and open source.
slack_echo_loop_audit.py
"""Find Slack channels where the app is replying to its own messages.

Read only. Three GET methods and no writes: a bot token with channels:read and
channels:history is enough. The repair is printed, never performed, because this
token can post into the same channels it is reading.
"""
import argparse
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_echo_loop_audit")

API = "https://slack.com/api"


def is_self(message, identity):
    """True when this message was authored by the app we authenticated as.

    Pure, and deliberately narrow. Matching on "has a bot_id" would flag every
    other integration in the channel and report a busy alerts channel as a loop,
    so the comparison is against our own ids from auth.test. Both are checked
    because a modern app-authored message carries bot_id while a message posted
    with a user token carries only `user`.

    This is the same predicate the repair puts in the event handler.
    """
    bot_id = identity.get("bot_id")
    user_id = identity.get("user_id")
    if bot_id and message.get("bot_id") == bot_id:
        return True
    if user_id and message.get("user") == user_id:
        return True
    return False


def verdict(messages, identity, *, min_run=4, burst=2.0):
    """Classify one channel by its longest run of self-authored messages.

    Pure, so the thresholds are visible and testable rather than buried in a
    request loop. `messages` are history items in any order; they are sorted by
    ts here because a run is a property of posting order.

    Returns (state, detail). Length alone is not the signal: a digest job posting
    a dozen messages in a row is not a loop, so a long run whose internal gaps
    are wider than `burst` seconds gets its own state rather than being reported
    as one.
    """
    ordered = sorted(messages, key=lambda m: float(m.get("ts") or 0))

    best, best_gaps = [], []
    run, gaps = [], []
    for m in ordered:
        if is_self(m, identity):
            if run:
                gaps.append(float(m.get("ts") or 0) - float(run[-1].get("ts") or 0))
            run.append(m)
        else:
            if len(run) > len(best):
                best, best_gaps = run, gaps
            run, gaps = [], []
    if len(run) > len(best):
        best, best_gaps = run, gaps

    n = len(best)
    if n <= 1:
        return ("quiet",
                "longest self-authored run is %d. Every reply is answering "
                "somebody else." % n)

    widest = max(best_gaps) if best_gaps else 0.0

    if n < min_run:
        return ("short-run",
                "%d in a row, %.1fs apart at widest. A threaded reply or a "
                "two-part message, not a loop." % (n, widest))

    if widest >= burst:
        return ("batch",
                "%d in a row but %.1fs apart at widest. That is a poster, not a "
                "loop: a digest or a backlog being drained. Worth confirming it "
                "is deliberate." % (n, widest))

    return ("echo-loop",
            "%d consecutive self-authored messages, none more than %.2fs apart, "
            "with no human message in the run. The handler is hearing itself."
            % (n, widest))


def call(session, method, **params):
    """One Web API read. Slack answers almost every failure with HTTP 200 and
    puts the error in the body, so the body is what gets asserted on."""
    r = session.get("%s/%s" % (API, method), params=params, timeout=30)
    r.raise_for_status()
    body = r.json()
    if not body.get("ok"):
        raise SystemExit("%s: %s (needed=%s provided=%s)"
                         % (method, body.get("error"), body.get("needed"),
                            body.get("provided")))
    return body


def channels(session, explicit):
    if explicit:
        return [{"id": c, "name": c} for c in explicit]
    out, cursor = [], ""
    while True:
        body = call(session, "users.conversations", limit=200,
                    types="public_channel,private_channel", cursor=cursor)
        out.extend(body.get("channels", []))
        cursor = (body.get("response_metadata") or {}).get("next_cursor") or ""
        if not cursor:
            return out


def history(session, channel_id, limit):
    body = call(session, "conversations.history", channel=channel_id,
                limit=min(200, limit))
    return body.get("messages", [])


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--channel", action="append", default=[],
                    help="channel id to read; repeatable. Default: every channel "
                         "the bot is a member of")
    ap.add_argument("--limit", type=int, default=200,
                    help="messages to read per channel")
    ap.add_argument("--min-run", type=int, default=4,
                    help="runs shorter than this are never reported as a loop")
    ap.add_argument("--burst", type=float, default=2.0,
                    help="seconds; a run spaced wider than this is a batch")
    args = ap.parse_args()

    token = os.environ.get("SLACK_BOT_TOKEN")
    if not token:
        log.error("set SLACK_BOT_TOKEN (a bot token with channels:read and "
                  "channels:history is enough)")
        return 2

    session = requests.Session()
    session.headers.update({"Authorization": "Bearer " + token})

    me = call(session, "auth.test")
    identity = {"bot_id": me.get("bot_id"), "user_id": me.get("user_id")}
    log.info("authenticated as %s (bot_id=%s) in %s",
             me.get("user"), identity["bot_id"], me.get("team"))

    targets = channels(session, args.channel)
    if not targets:
        log.info("the bot is not a member of any conversation")
        return 0

    loops = longest = 0
    for ch in targets:
        messages = history(session, ch["id"], args.limit)
        state, detail = verdict(messages, identity,
                                min_run=args.min_run, burst=args.burst)
        name = ch.get("name", ch["id"])
        if state in ("quiet", "short-run"):
            log.info("%-10s #%s  %s", state, name, detail)
            continue
        if state == "batch":
            log.info("%-10s #%s  %s", state, name, detail)
            continue
        loops += 1
        log.warning("%-10s #%s  %s", state, name, detail)
        log.warning("  repair: in the handler, return early when event.bot_id is "
                    "set, when event.subtype is bot_message, or when event.user "
                    "== %s.", identity["user_id"])
        log.warning("  better: subscribe to app_mention instead of "
                    "message.channels so your own posts never reach the handler.")

    log.info("%d channel(s) checked, %d loop(s)", len(targets), loops)
    return 1 if loops else 0


if __name__ == "__main__":
    sys.exit(main())
slack-echo-loop-audit.mjs
/**
 * Find Slack channels where the app is replying to its own messages.
 *
 * Read only. Three GET methods and no writes: a bot token with channels:read
 * and channels:history is enough. The repair is printed, never performed.
 */
const API = 'https://slack.com/api';

/**
 * True when this message was authored by the app we authenticated as.
 *
 * Pure, and deliberately narrow: matching on "has a bot_id" would flag every
 * other integration in the channel. Both ids are checked because a modern
 * app-authored message carries bot_id while one posted with a user token
 * carries only `user`. This is the same predicate the repair puts in the
 * event handler.
 */
export function isSelf(message, identity) {
  if (identity.bot_id && message.bot_id === identity.bot_id) return true;
  if (identity.user_id && message.user === identity.user_id) return true;
  return false;
}

/**
 * Classify one channel by its longest run of self-authored messages.
 *
 * Pure, so the thresholds are visible and testable. Length alone is not the
 * signal: a digest job posting a dozen messages in a row is not a loop, so a
 * long run with wide internal gaps gets its own state.
 */
export function verdict(messages, identity, { minRun = 4, burst = 2.0 } = {}) {
  const ordered = [...messages].sort((a, b) => Number(a.ts ?? 0) - Number(b.ts ?? 0));

  let best = [];
  let bestGaps = [];
  let run = [];
  let gaps = [];
  for (const m of ordered) {
    if (isSelf(m, identity)) {
      if (run.length) gaps.push(Number(m.ts ?? 0) - Number(run[run.length - 1].ts ?? 0));
      run.push(m);
    } else {
      if (run.length > best.length) { best = run; bestGaps = gaps; }
      run = [];
      gaps = [];
    }
  }
  if (run.length > best.length) { best = run; bestGaps = gaps; }

  const n = best.length;
  if (n <= 1) {
    return ['quiet',
      `longest self-authored run is ${n}. Every reply is answering somebody else.`];
  }

  const widest = bestGaps.length ? Math.max(...bestGaps) : 0;

  if (n < minRun) {
    return ['short-run',
      `${n} in a row, ${widest.toFixed(1)}s apart at widest. A threaded reply ` +
      'or a two-part message, not a loop.'];
  }

  if (widest >= burst) {
    return ['batch',
      `${n} in a row but ${widest.toFixed(1)}s apart at widest. That is a ` +
      'poster, not a loop: a digest or a backlog being drained. Worth ' +
      'confirming it is deliberate.'];
  }

  return ['echo-loop',
    `${n} consecutive self-authored messages, none more than ` +
    `${widest.toFixed(2)}s apart, with no human message in the run. The ` +
    'handler is hearing itself.'];
}

async function call(token, method, params = {}) {
  const url = new URL(`${API}/${method}`);
  for (const [k, v] of Object.entries(params)) {
    if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, v);
  }
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`${res.status} from ${method}`);
  const body = await res.json();
  // Slack answers almost every failure with HTTP 200 and puts the error in the
  // body, so the body is what gets asserted on.
  if (!body.ok) {
    throw new Error(`${method}: ${body.error} (needed=${body.needed} ` +
                    `provided=${body.provided})`);
  }
  return body;
}

async function channels(token, explicit) {
  if (explicit.length) return explicit.map((id) => ({ id, name: id }));
  const out = [];
  let cursor = '';
  for (;;) {
    const body = await call(token, 'users.conversations',
      { limit: 200, types: 'public_channel,private_channel', cursor });
    out.push(...(body.channels ?? []));
    cursor = body.response_metadata?.next_cursor ?? '';
    if (!cursor) return out;
  }
}

async function main() {
  const token = process.env.SLACK_BOT_TOKEN;
  if (!token) {
    console.error('set SLACK_BOT_TOKEN (channels:read and channels:history)');
    process.exitCode = 2;
    return;
  }

  const explicit = process.argv.slice(2).filter((a) => !a.startsWith('-'));

  const me = await call(token, 'auth.test');
  const identity = { bot_id: me.bot_id, user_id: me.user_id };
  console.log(`authenticated as ${me.user} (bot_id=${me.bot_id}) in ${me.team}`);

  const targets = await channels(token, explicit);
  if (targets.length === 0) {
    console.log('the bot is not a member of any conversation');
    return;
  }

  let loops = 0;
  for (const ch of targets) {
    const body = await call(token, 'conversations.history',
      { channel: ch.id, limit: 200 });
    const [state, detail] = verdict(body.messages ?? [], identity);
    const name = ch.name ?? ch.id;
    if (state !== 'echo-loop') {
      console.log(`${state.padEnd(10)} #${name}  ${detail}`);
      continue;
    }
    loops += 1;
    console.warn(`${state.padEnd(10)} #${name}  ${detail}`);
    console.warn('  repair: in the handler, return early when event.bot_id is ' +
                 `set, when event.subtype is bot_message, or when event.user == ${identity.user_id}.`);
    console.warn('  better: subscribe to app_mention instead of ' +
                 'message.channels so your own posts never reach the handler.');
  }

  console.log(`${targets.length} channel(s) checked, ${loops} loop(s)`);
  process.exitCode = loops ? 1 : 0;
}

// Only run when invoked directly. The test file imports this module, and
// without the guard main() would run there too, fail on the missing token, and
// set a non-zero exit code that fails the whole test file even as every test
// passes.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The two tests that keep this check usable are the negatives. Another app's bot_id must not count as ours, or every alerts channel in the workspace is a loop; and a long run posted slowly must come back as batch, or the nightly digest gets reported every night until somebody deletes the cron entry for the audit.

test_slack_echo_loop_audit.py
from slack_echo_loop_audit import is_self, verdict

ME = {"bot_id": "B111", "user_id": "U111"}


def msg(ts, *, bot=None, user=None):
    m = {"ts": str(ts)}
    if bot:
        m["bot_id"] = bot
    if user:
        m["user"] = user
    return m


def test_another_apps_bot_message_is_not_ours():
    assert is_self(msg(1, bot="B999"), ME) is False


def test_our_bot_id_and_our_user_id_both_count():
    assert is_self(msg(1, bot="B111"), ME) is True
    assert is_self(msg(2, user="U111"), ME) is True


def test_replies_interleaved_with_humans_are_quiet():
    messages = [msg(1, user="U777"), msg(2, bot="B111"),
                msg(3, user="U777"), msg(4, bot="B111")]
    state, _ = verdict(messages, ME)
    assert state == "quiet"


def test_a_fast_unbroken_run_is_the_loop():
    messages = [msg(1000 + i * 0.3, bot="B111") for i in range(12)]
    state, detail = verdict(messages, ME)
    assert state == "echo-loop"
    assert "12" in detail


def test_a_slow_long_run_is_a_batch_not_a_loop():
    # A digest posting one message every five seconds. Reporting this is how
    # the check gets switched off.
    messages = [msg(1000 + i * 5.0, bot="B111") for i in range(12)]
    state, _ = verdict(messages, ME)
    assert state == "batch"


def test_history_arriving_newest_first_is_still_measured_correctly():
    newest_first = [msg(1000 + i * 0.3, bot="B111") for i in range(9)][::-1]
    assert verdict(newest_first, ME)[0] == "echo-loop"


def test_two_in_a_row_is_a_short_run():
    messages = [msg(1, user="U777"), msg(2, bot="B111"), msg(2.4, bot="B111")]
    state, _ = verdict(messages, ME)
    assert state == "short-run"
slack-echo-loop-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isSelf, verdict } from './slack-echo-loop-audit.mjs';

const ME = { bot_id: 'B111', user_id: 'U111' };

const msg = (ts, { bot, user } = {}) => {
  const m = { ts: String(ts) };
  if (bot) m.bot_id = bot;
  if (user) m.user = user;
  return m;
};

test('another app bot message is not ours', () => {
  assert.equal(isSelf(msg(1, { bot: 'B999' }), ME), false);
});

test('our bot id and our user id both count', () => {
  assert.equal(isSelf(msg(1, { bot: 'B111' }), ME), true);
  assert.equal(isSelf(msg(2, { user: 'U111' }), ME), true);
});

test('replies interleaved with humans are quiet', () => {
  const messages = [msg(1, { user: 'U777' }), msg(2, { bot: 'B111' }),
    msg(3, { user: 'U777' }), msg(4, { bot: 'B111' })];
  assert.equal(verdict(messages, ME)[0], 'quiet');
});

test('a fast unbroken run is the loop', () => {
  const messages = Array.from({ length: 12 }, (_, i) => msg(1000 + i * 0.3, { bot: 'B111' }));
  const [state, detail] = verdict(messages, ME);
  assert.equal(state, 'echo-loop');
  assert.match(detail, /12/);
});

test('a slow long run is a batch, not a loop', () => {
  const messages = Array.from({ length: 12 }, (_, i) => msg(1000 + i * 5, { bot: 'B111' }));
  assert.equal(verdict(messages, ME)[0], 'batch');
});

test('history arriving newest first is still measured correctly', () => {
  const messages = Array.from({ length: 9 }, (_, i) => msg(1000 + i * 0.3, { bot: 'B111' })).reverse();
  assert.equal(verdict(messages, ME)[0], 'echo-loop');
});

test('two in a row is a short run', () => {
  const messages = [msg(1, { user: 'U777' }), msg(2, { bot: 'B111' }), msg(2.4, { bot: 'B111' })];
  assert.equal(verdict(messages, ME)[0], 'short-run');
});

FAQ

Why does my bot receive its own messages at all?

Because message.channels means every message posted in a channel the app is in, and your app's posts are messages in that channel. Slack marks them with bot_id and app_id so you can filter, but it does not filter for you. There is no subscription setting that excludes your own posts.

Doesn't Bolt already protect me from this?

Partly, which is the trap. Bolt's app.message() skips messages whose subtype is bot_message, so a legacy-shaped post is filtered. A modern app-authored message can carry bot_id without that subtype and still reach your handler. app.event('app_mention') is the one that never fires on your own posts.

Rate limiting will stop the loop, won't it?

No. chat.postMessage is throttled to roughly one message per second per channel, so the loop slows to one message a second and keeps going. It does not terminate on its own; somebody has to deploy the guard or remove the bot from the channel.

How does the script tell a loop from a bot that just posts a lot?

It measures runs of consecutive self-authored messages with no human message in between, and then the gaps inside the longest run. Twelve in a row five seconds apart is a digest and gets reported as a batch. Twelve in a row a third of a second apart is a handler answering itself.

Can it check threads too?

The same shape works on conversations.replies for a thread_ts, and threaded echo loops do happen when a handler replies in-thread to any message in the thread. The channel-level check finds the expensive case first, because a loop in the main channel is the one that pages everybody.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.