Skip to content

Diagnostic Slack

Classic Slack app: the scope list says bot, client, read

You go to add one scope. The OAuth & Permissions page does not offer the scope picker you have seen in every tutorial, the code references chat:write:bot which the documentation says does not exist, and the header on every API response reads bot,client,identify,post,read. Nothing is broken yet. What is broken is the assumption that this app can be fixed by adding something to it.

Read-only token Python and Node.js Tests included
A calculator sitting on top of a desk next to a laptop
Photo by Mehdi Mirzaie on Unsplash
The short answer

Apps created before the 2020 granular-permissions migration hold coarse scopes: bare words like bot, client, read, post, identify, each granting a broad slice of the workspace. Granular apps hold namespaced scopes like channels:read and chat:write. The two vocabularies cannot be mixed, and Slack does not upgrade a classic app in place.

So the diagnosis is a vocabulary check, not an error check. The script below makes one auth.test call and reads the grant out of the X-OAuth-Scopes response header, then sorts every scope into three groups: coarse names from the old model, intermediate names the migration retired, and current granular names. A single bare word that is not incoming-webhook or commands settles it. The repair it prints is a new app and a cutover, because there is no other one.

The problem in plain words

The confusing part is that a classic app works. It authenticates, it posts, it reads channels, and it has probably done so for years. The only symptoms are negative ones: a scope you cannot add, a picker that is not there, a documentation page that describes a scope your token does not have and cannot get.

The second symptom is the retired middle generation. Between the coarse model and today's model there was a set of qualified write scopes — chat:write:bot and chat:write:user most visibly — that let you say who a message was posted as. The migration collapsed them into chat:write plus chat:write.customize for the case where you override the display name or icon. Code that still names the old ones is not going to fail loudly; it is going to fail at install time, in a redirect, with a scope error that nobody reads.

And the third is that classic apps still have the RTM API. That is why they survive: RTM was retired for new apps, so any app still speaking it is by definition classic, and any team that tried to move to the Events API discovered they could not simply add the scopes. The app has to be rebuilt. That is a project, so it gets postponed, and the postponement is invisible until the day someone needs a feature that only exists on the granular side.

App createdpre-2020coarse scopesgrantedA feature needsa scopechannels:historyNo scope pickerwrong permissionmodelReinstallattemptedsame grant returnsWork postponeda rebuild, not anedit
Nothing here throws. The app works exactly as it has for years, and the only symptom is a button that is not on the page and a scope name the documentation no longer knows.

Why it happens

The grant is in a header, not the body. Every authenticated Web API response carries X-OAuth-Scopes with the complete list of what this token holds. You do not need a failing call to see it, and you do not need admin access to the app configuration page. One auth.test is enough, and auth.test itself requires no scopes.

A colon is the tell. Granular scopes are namespaced: channels:read, files:write, users:read.email. Coarse scopes are bare words. There are exactly two bare names in the granular world — incoming-webhook and commands — so a bare scope that is neither of those is a classic app with no further analysis required.

The two models cannot coexist. This is what makes the finding terminal rather than a to-do. Slack will not let a classic app request a granular scope, so the usual repair for a permissions problem — add the scope, reinstall, replace the token — has no effect here. Reporting "add channels:history" to a classic app is worse than reporting nothing, because someone will spend an afternoon looking for the button.

A retired scope name is a different, smaller finding. An app holding chat:write:bot is granular-era but was minted before the collapse. That one is fixable in place: change the requested scope to chat:write, reinstall, done. Lumping it in with the classic finding sends a team on a rebuild they do not need, so the script keeps the two apart.

Migration means two apps running at once. There is no in-place upgrade, so the honest plan is: create the new app from a manifest, install it alongside the old one, move traffic, then uninstall the classic app. Both apps in the same workspace at the same time is the point — it is what makes the cutover reversible.

The fix, as a flow

The whole diagnosis is grammar. A scope with a colon belongs to the current model, a bare word to the one before it, and the two cannot sit on the same app. So the script sorts the granted list into three vocabularies and reports which era it is standing in, because the usual repair of adding a scope has nowhere to land.

X-OAuth-Scopes, one callthe grant as grantedA bare word like botno scope can be addedchat:write:botcollapsed, fixable in placecommands, incoming-webhookgranular without a colonNo header at allthe call never authenticatedAll namespacedcurrent model, nothing to do
A stale write scope and a coarse scope both read as wrong scopes and lead opposite ways: one is an afternoon, the other is a second app and an RTM rewrite.

How to fix it

Read the header rather than the settings page

You may not have admin access to the app configuration, and the page does not show the grant as a list anyway. One auth.test with the deployed token returns the complete granted scope set in X-OAuth-Scopes, which is the ground truth: it is what the token actually holds, not what the manifest asked for.

Sort the grant into three vocabularies

Coarse, retired, and current. The classification is mechanical and needs no network, which is why it is the part the tests cover: a bare word that is not incoming-webhook or commands goes in the coarse bucket, a name in the retired table goes in the retired bucket, and everything else is current.

Treat one coarse scope as the whole answer

You do not need a majority. Because the models cannot be mixed, a single bot or read in the grant means the installation is classic and every granular scope in the same list is a reporting artefact rather than a real grant. Stop analysing and print the migration.

Separate the write scopes that were merely collapsed

chat:write:bot and chat:write:user became chat:write; files:write:user became files:write. If those are the only stale names, the app is granular and the repair is one edit to the requested scope list plus a reinstall. Add chat:write.customize only if the code overrides username or icon_emoji.

Plan a second app, not a reinstall

Write the new app's manifest with the granular scopes the code actually needs, install it into the same workspace, point a copy of the service at the new token, and verify. Only then uninstall the classic app. Nothing about this is reversible if you uninstall first.

Budget for the RTM rewrite in the same change

If the app is on RTM it has to move to the Events API or Socket Mode as part of the migration, because the new granular app will not have RTM. That is usually the largest part of the work and the reason the migration keeps slipping, so it belongs in the estimate rather than in the surprise.

How to check it worked

After the cutover, run the same script against the new token. The grant should classify as granular with no coarse and no retired names, and the verdict line should be the only one the script prints.

SLACK_BOT_TOKEN=xoxb-... python3 slack_classic_scope_audit.py
# granular  12 granular scope(s), no coarse or retired names

The full code

One GET, and the answer arrives in a response header rather than in the body — which is unusual enough that the fetch returns both. Three pure functions do the actual work: grant_buckets sorts the raw header into the three vocabularies, classify_grant names the era, and migration_verdict crosses that with what auth.test said about the credential, because a scope list means nothing if the token behind it is dead.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 108 Slack fixes, free and open source.
slack_classic_scope_audit.py
"""Decide which Slack permission era an installed app belongs to.

Read only. One GET, and the finding is in the response header rather than the
body: X-OAuth-Scopes carries the complete grant. The repair for a classic app is
a second app, so the script prints the migration rather than suggesting a
reinstall that cannot work.
"""
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_classic_scope_audit")

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

# The only two granular scopes that legitimately carry no colon. Any other bare
# word in the grant is a coarse scope from the pre-2020 model.
GRANULAR_BARE = frozenset({"incoming-webhook", "commands"})

# Intermediate scopes the granular migration retired, and what replaced them.
RETIRED = {
    "chat:write:bot": "chat:write",
    "chat:write:user": "chat:write",
    "files:write:user": "files:write",
}


def grant_buckets(header):
    """Sort a raw X-OAuth-Scopes header into three vocabularies. Pure.

    Returns {"coarse": [...], "retired": [...], "granular": [...]}.
    """
    scopes = [s.strip() for s in str(header or "").split(",") if s.strip()]
    buckets = {"coarse": [], "retired": [], "granular": []}
    for scope in scopes:
        if scope in RETIRED:
            buckets["retired"].append(scope)
        elif ":" not in scope and scope not in GRANULAR_BARE:
            buckets["coarse"].append(scope)
        else:
            buckets["granular"].append(scope)
    return buckets


def classify_grant(header):
    """Name the permission era this grant belongs to. Pure. -> (state, detail)"""
    if not str(header or "").strip():
        return ("no-scope-header",
                "the response carried no X-OAuth-Scopes header, so the grant is "
                "unknown. Slack sets it on every authenticated Web API response, "
                "so its absence usually means the call never authenticated.")

    buckets = grant_buckets(header)
    if buckets["coarse"]:
        return ("classic",
                "coarse scope(s) %s. Those belong to the pre-2020 model, which "
                "cannot be mixed with granular scopes, so nothing can be added "
                "to this app." % ", ".join(sorted(buckets["coarse"])))
    if buckets["retired"]:
        return ("retired-intermediate",
                "granular vocabulary, but %s no longer exists. The requested "
                "scope list predates the collapse of the qualified write scopes."
                % ", ".join(sorted(buckets["retired"])))
    return ("granular",
            "%d granular scope(s), no coarse or retired names"
            % len(buckets["granular"]))


def migration_verdict(identity, state):
    """Cross the era with what auth.test said about the token. Pure.

    A scope list read off a credential that does not authenticate is not
    evidence of anything, so the credential is resolved first.
    """
    if identity.get("ok") is not True:
        return ("credential-unusable",
                "auth.test answered error=%s, so nothing can be concluded from "
                "the header on that response. Resolve the credential before "
                "reading its grant." % (identity.get("error") or "<no error field>"))

    if state == "no-scope-header":
        return ("not-assessed",
                "the call authenticated but no grant came back with it. Report "
                "this as unknown rather than as granular.")

    if state == "classic":
        if identity.get("bot_id"):
            return ("classic-bot-install",
                    "a classic bot install. It still has RTM, which is usually "
                    "why it is still here, and it cannot be given a single "
                    "granular scope. Build a new app and cut over.")
        return ("classic-user-install",
                "a classic install on a user token: it acts as the person who "
                "installed it and dies with their account. Rebuild as a granular "
                "app with bot scopes rather than porting the user token across.")

    if state == "retired-intermediate":
        return ("granular-with-dead-names",
                "granular app, stale scope names. This one is fixable in place: "
                "edit the requested scopes, reinstall, replace the token. No new "
                "app is needed.")

    return ("granular", "current permission model, nothing to migrate")


def auth_test(session, token):
    """One GET. Returns (body, grant) because the finding is in the header."""
    r = session.get(API + "auth.test",
                    headers={"Authorization": "Bearer " + token}, timeout=30)
    try:
        body = r.json()
    except ValueError:
        body = {"ok": False, "error": "unparseable_body"}
    return body, r.headers.get("x-oauth-scopes")


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
                    help="environment variable holding the token to inspect")
    args = ap.parse_args()

    token = os.environ.get(args.token_env)
    if not token:
        log.error("%s is not set; nothing to inspect", args.token_env)
        return 2

    body, header = auth_test(requests.Session(), token)
    state, detail = classify_grant(header)
    verdict, why = migration_verdict(body, state)
    buckets = grant_buckets(header)

    log.info("%-26s %s", verdict, why)
    log.info("  grant: %s", detail)

    if verdict == "granular":
        return 0

    if buckets["coarse"]:
        log.warning("  coarse: %s", ", ".join(sorted(buckets["coarse"])))
        log.warning("  repair: create a new app from a manifest with granular "
                    "scopes, install it alongside this one, move traffic, then "
                    "uninstall the classic app. There is no in-place upgrade.")
        log.warning("  repair: budget for the RTM rewrite; the new app will not "
                    "have RTM, so the code moves to the Events API or Socket Mode.")
    for scope in sorted(buckets["retired"]):
        log.warning("  retired: %s is now %s", scope, RETIRED[scope])
    if buckets["retired"]:
        log.warning("  repair: swap the names above in the requested scope list "
                    "and reinstall. Add chat:write.customize only if the code "
                    "overrides username or icon_emoji.")

    return 1


if __name__ == "__main__":
    sys.exit(main())
slack-classic-scope-audit.mjs
/**
 * Decide which Slack permission era an installed app belongs to.
 *
 * Read only. One GET, and the finding is in the response header rather than the
 * body: X-OAuth-Scopes carries the complete grant. The repair for a classic app
 * is a second app, so the script prints the migration rather than suggesting a
 * reinstall that cannot work.
 */
const API = 'https://slack.com/api/';

// The only two granular scopes that legitimately carry no colon. Any other bare
// word in the grant is a coarse scope from the pre-2020 model.
const GRANULAR_BARE = new Set(['incoming-webhook', 'commands']);

// Intermediate scopes the granular migration retired, and what replaced them.
export const RETIRED = {
  'chat:write:bot': 'chat:write',
  'chat:write:user': 'chat:write',
  'files:write:user': 'files:write',
};

/**
 * Sort a raw X-OAuth-Scopes header into three vocabularies. Pure.
 */
export function grantBuckets(header) {
  const scopes = String(header ?? '').split(',').map((s) => s.trim()).filter(Boolean);
  const buckets = { coarse: [], retired: [], granular: [] };
  for (const scope of scopes) {
    if (Object.prototype.hasOwnProperty.call(RETIRED, scope)) buckets.retired.push(scope);
    else if (!scope.includes(':') && !GRANULAR_BARE.has(scope)) buckets.coarse.push(scope);
    else buckets.granular.push(scope);
  }
  return buckets;
}

/**
 * Name the permission era this grant belongs to. Pure.
 */
export function classifyGrant(header) {
  if (!String(header ?? '').trim()) {
    return ['no-scope-header',
      'the response carried no X-OAuth-Scopes header, so the grant is unknown. ' +
      'Slack sets it on every authenticated Web API response, so its absence ' +
      'usually means the call never authenticated.'];
  }

  const buckets = grantBuckets(header);
  if (buckets.coarse.length) {
    return ['classic',
      `coarse scope(s) ${[...buckets.coarse].sort().join(', ')}. Those belong to ` +
      'the pre-2020 model, which cannot be mixed with granular scopes, so nothing ' +
      'can be added to this app.'];
  }
  if (buckets.retired.length) {
    return ['retired-intermediate',
      `granular vocabulary, but ${[...buckets.retired].sort().join(', ')} no longer ` +
      'exists. The requested scope list predates the collapse of the qualified ' +
      'write scopes.'];
  }
  return ['granular',
    `${buckets.granular.length} granular scope(s), no coarse or retired names`];
}

/**
 * Cross the era with what auth.test said about the token. Pure.
 * A scope list read off a credential that does not authenticate is not evidence
 * of anything, so the credential is resolved first.
 */
export function migrationVerdict(identity, state) {
  if (identity?.ok !== true) {
    return ['credential-unusable',
      `auth.test answered error=${identity?.error ?? '<no error field>'}, so ` +
      'nothing can be concluded from the header on that response. Resolve the ' +
      'credential before reading its grant.'];
  }

  if (state === 'no-scope-header') {
    return ['not-assessed',
      'the call authenticated but no grant came back with it. Report this as ' +
      'unknown rather than as granular.'];
  }

  if (state === 'classic') {
    if (identity.bot_id) {
      return ['classic-bot-install',
        'a classic bot install. It still has RTM, which is usually why it is ' +
        'still here, and it cannot be given a single granular scope. Build a new ' +
        'app and cut over.'];
    }
    return ['classic-user-install',
      'a classic install on a user token: it acts as the person who installed it ' +
      'and dies with their account. Rebuild as a granular app with bot scopes ' +
      'rather than porting the user token across.'];
  }

  if (state === 'retired-intermediate') {
    return ['granular-with-dead-names',
      'granular app, stale scope names. This one is fixable in place: edit the ' +
      'requested scopes, reinstall, replace the token. No new app is needed.'];
  }

  return ['granular', 'current permission model, nothing to migrate'];
}

async function authTest(token) {
  const res = await fetch(`${API}auth.test`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  let body;
  try {
    body = await res.json();
  } catch {
    body = { ok: false, error: 'unparseable_body' };
  }
  return [body, res.headers.get('x-oauth-scopes')];
}

function arg(args, name, fallback = null) {
  const i = args.indexOf(name);
  return i === -1 ? fallback : args[i + 1];
}

async function main() {
  const tokenEnv = arg(process.argv.slice(2), '--token-env', 'SLACK_BOT_TOKEN');
  const token = process.env[tokenEnv];
  if (!token) {
    console.error(`${tokenEnv} is not set; nothing to inspect`);
    process.exitCode = 2;
    return;
  }

  const [body, header] = await authTest(token);
  const [state, detail] = classifyGrant(header);
  const [verdict, why] = migrationVerdict(body, state);
  const buckets = grantBuckets(header);

  console.log(`${verdict.padEnd(26)} ${why}`);
  console.log(`  grant: ${detail}`);

  if (verdict === 'granular') {
    process.exitCode = 0;
    return;
  }

  if (buckets.coarse.length) {
    console.warn(`  coarse: ${[...buckets.coarse].sort().join(', ')}`);
    console.warn('  repair: create a new app from a manifest with granular scopes, ' +
                 'install it alongside this one, move traffic, then uninstall the ' +
                 'classic app. There is no in-place upgrade.');
    console.warn('  repair: budget for the RTM rewrite; the new app will not have ' +
                 'RTM, so the code moves to the Events API or Socket Mode.');
  }
  for (const scope of [...buckets.retired].sort()) {
    console.warn(`  retired: ${scope} is now ${RETIRED[scope]}`);
  }
  if (buckets.retired.length) {
    console.warn('  repair: swap the names above in the requested scope list and ' +
                 'reinstall. Add chat:write.customize only if the code overrides ' +
                 'username or icon_emoji.');
  }

  process.exitCode = 1;
}

// Only run when invoked directly, so importing this module in the tests does not
// reach for a token that is not there.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The two cases worth pinning are the ones that look alike and lead opposite ways: a grant containing bot is a rebuild, and a grant containing only chat:write:bot is a one-line edit. Both are “your scopes are wrong”; only one of them is a project. The rest of the suite defends the bare-word rule against its two legitimate exceptions, because flagging commands as classic would condemn a healthy app.

test_slack_classic_scope_audit.py
from slack_classic_scope_audit import classify_grant, grant_buckets, migration_verdict


def test_a_bare_coarse_scope_makes_the_install_classic():
    state, detail = classify_grant("bot,client,identify,post,read")
    assert state == "classic"
    assert "cannot be mixed" in detail


def test_incoming_webhook_and_commands_are_not_coarse():
    state, _ = classify_grant("commands,incoming-webhook,chat:write,channels:read")
    assert state == "granular"


def test_a_retired_write_scope_is_a_separate_smaller_finding():
    state, detail = classify_grant("channels:read,chat:write:bot")
    assert state == "retired-intermediate"
    assert "chat:write:bot" in detail


def test_coarse_wins_over_retired_because_it_is_terminal():
    assert classify_grant("bot,chat:write:bot")[0] == "classic"


def test_an_absent_header_is_reported_as_unknown_not_as_granular():
    assert classify_grant(None)[0] == "no-scope-header"
    assert classify_grant("  ")[0] == "no-scope-header"


def test_buckets_split_the_three_vocabularies():
    buckets = grant_buckets("read, chat:write:user , channels:read,commands")
    assert buckets["coarse"] == ["read"]
    assert buckets["retired"] == ["chat:write:user"]
    assert sorted(buckets["granular"]) == ["channels:read", "commands"]


def test_a_classic_bot_install_is_named_as_a_rebuild():
    verdict, detail = migration_verdict({"ok": True, "bot_id": "B1"}, "classic")
    assert verdict == "classic-bot-install"
    assert "new app" in detail


def test_a_classic_user_install_is_a_different_verdict():
    verdict, _ = migration_verdict({"ok": True, "user_id": "U1"}, "classic")
    assert verdict == "classic-user-install"


def test_stale_names_alone_are_fixable_in_place():
    verdict, detail = migration_verdict({"ok": True, "bot_id": "B1"},
                                        "retired-intermediate")
    assert verdict == "granular-with-dead-names"
    assert "No new app" in detail


def test_a_dead_credential_stops_the_analysis_before_the_scopes():
    verdict, detail = migration_verdict({"ok": False, "error": "invalid_auth"},
                                        "classic")
    assert verdict == "credential-unusable"
    assert "invalid_auth" in detail
slack-classic-scope-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classifyGrant, grantBuckets, migrationVerdict } from './slack-classic-scope-audit.mjs';

test('a bare coarse scope makes the install classic', () => {
  const [state, detail] = classifyGrant('bot,client,identify,post,read');
  assert.equal(state, 'classic');
  assert.match(detail, /cannot be mixed/);
});

test('incoming-webhook and commands are not coarse', () => {
  assert.equal(classifyGrant('commands,incoming-webhook,chat:write,channels:read')[0],
    'granular');
});

test('a retired write scope is a separate smaller finding', () => {
  const [state, detail] = classifyGrant('channels:read,chat:write:bot');
  assert.equal(state, 'retired-intermediate');
  assert.match(detail, /chat:write:bot/);
});

test('coarse wins over retired because it is terminal', () => {
  assert.equal(classifyGrant('bot,chat:write:bot')[0], 'classic');
});

test('an absent header is reported as unknown not as granular', () => {
  assert.equal(classifyGrant(null)[0], 'no-scope-header');
  assert.equal(classifyGrant('  ')[0], 'no-scope-header');
});

test('buckets split the three vocabularies', () => {
  const buckets = grantBuckets('read, chat:write:user , channels:read,commands');
  assert.deepEqual(buckets.coarse, ['read']);
  assert.deepEqual(buckets.retired, ['chat:write:user']);
  assert.deepEqual([...buckets.granular].sort(), ['channels:read', 'commands']);
});

test('a classic bot install is named as a rebuild', () => {
  const [verdict, detail] = migrationVerdict({ ok: true, bot_id: 'B1' }, 'classic');
  assert.equal(verdict, 'classic-bot-install');
  assert.match(detail, /new app/);
});

test('stale names alone are fixable in place', () => {
  const [verdict, detail] = migrationVerdict({ ok: true, bot_id: 'B1' },
    'retired-intermediate');
  assert.equal(verdict, 'granular-with-dead-names');
  assert.match(detail, /No new app/);
});

test('a dead credential stops the analysis before the scopes', () => {
  const [verdict, detail] = migrationVerdict({ ok: false, error: 'invalid_auth' },
    'classic');
  assert.equal(verdict, 'credential-unusable');
  assert.match(detail, /invalid_auth/);
});

FAQ

Can I convert a classic app to a granular one?

No. Slack has never offered an in-place upgrade, and the two scope models cannot be held by the same app, so a classic app cannot request a granular scope even once. The supported path is a new app installed alongside the old one, a traffic cutover, and then uninstalling the classic app. Everything about the migration is a copy, not an edit.

Is a classic app going to stop working?

It has not been switched off, and existing installations continue to function. What you lose is forward motion: no new scopes, no granular features, and no way to adopt anything Slack ships for granular apps. Treat it as a component that cannot receive changes rather than as an outage waiting to happen.

Why does chat:write:bot appear in tutorials that still rank well?

Because it was correct for years and the pages were never updated. The qualified write scopes were collapsed in the granular migration: chat:write covers posting, and chat:write.customize covers overriding the username or icon_emoji. If your requested scope list still names the old ones the install will fail on the scope, which is a redirect error rather than a runtime one and is easy to miss.

The header lists both bare words and granular scopes. What is that?

Take the bare words as authoritative and report the app as classic. The two models are not mixable, so a list containing both is a reporting artefact rather than a genuinely hybrid grant, and any advice derived from the granular half will send someone looking for a scope picker that is not there.

Does the RTM API have to go at the same time?

Effectively yes, since RTM is only available to classic apps and the replacement app will not have it. Plan the move to the Events API or Socket Mode as part of the same project rather than as a follow-up, because the new app cannot receive events any other way and a half-migrated app has no working event transport at all.

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.