Skip to content

Diagnostic Slack

A slash command your code handles was never registered

The handler is written. It has a unit test. Somebody reviewed it, it went out in the release, and the release notes said you can now type /deploy in any channel.

Type /deploy in a channel and Slack says /deploy is not a valid command, or quietly autocompletes to something else entirely, or does nothing at all. There is no request in your access log, no error in your application log, and no event anywhere. Slack never contacted you, because as far as Slack is concerned this app has no such command — and it never will until somebody declares it in the app configuration and reinstalls.

Read-only token Python and Node.js Tests included
A calculator, pen, and money on a table
Photo by Sasun Bughdaryan on Unsplash
The short answer

Slash commands are declared in app configuration, not in code. A handler registered in Bolt is a routing rule for a request that has to arrive from somewhere, and the only thing that makes Slack send it is an entry under Slash Commands — or, in a manifest, under features.slash_commands — carrying a name, a Request URL and a description. Without one, the handler is dead code that nothing will ever call.

Two more things follow from that split, and both bite. Adding a command requires reinstalling the app, so declaring it and stopping halfway leaves the same symptom you started with. And command names are workspace-unique: Slack's own commands and every other installed app compete for the same namespace, so a generic /deploy may simply belong to somebody else in that workspace, and no amount of configuration on your side will take it back.

The check is a set difference, run in both directions. Export features.slash_commands, scan your source for the commands it handles, and print the two gaps. Handled but not registered is this note: the user gets “not a valid command”. Registered but not handled is the mirror image and is worse, because the request does arrive: Slack sends it, your app has no route for it, and the user sees dispatch_failed.

The problem in plain words

The reason this survives to production is that nothing fails. There is no exception, no warning at startup, and no signal from Slack. A Bolt app with a handler for a command that does not exist boots exactly like one with a handler that does, serves every other route perfectly, and reports itself healthy. The bug is an absence in a web form.

It is also unusually easy to do halfway. A command is added in the console; the person adding it moves on without reinstalling, and reinstalling is not optional here. A command is added to the manifest in the repository, and the manifest is never deployed, so the file that describes the app and the app itself disagree. A command works in the development app, because that is where the console was open, and the production app — a separate Slack app, since Slack has no notion of environments — never got the same edit.

The opposite direction fails differently and more visibly. A command left registered after its handler was removed, or renamed in code and not in configuration, produces a live entry in the Slack autocomplete that anybody can type. Slack sends the request, your app returns a 404 for the route or throws before acknowledging, and the user gets dispatch_failed in the channel with your app's name attached to it. Nobody files a bug for a command they did not know existed; they just conclude the app is broken.

Then there is the namespace, which is not yours. Command names are unique per workspace across every installed app plus Slack's own built-ins, so /remind, /invite and /status are gone before you start, and /deploy is a coin toss in any workspace with more than a handful of apps. What makes this one hard is that it is genuinely invisible to the API: no read method tells you which other app owns a name in a customer's workspace. All a script can do is tell you when you are gambling.

A handler iswrittenand reviewed andshippedSomebody typesitin a real channelSlack has noroutenothing wasdeclaredNo request ismadeyour access log isemptyNot a validcommandand the service ishealthy
The handler exists, is tested and is deployed. Slack has never heard of the name, so there is no request, no log line and nothing at all for the application to report.

Why it happens

The diff has to run in both directions, because the two gaps are different bugs with different victims. A handler with no registration is invisible: nobody can trigger it, and it costs you only the feature. A registration with no handler is public: it appears in autocomplete, it can be typed by anyone, and it answers with an error that carries your app's name. Reporting one and not the other leaves half the problem in place.

The handled set should come from the source, not from a list somebody typed. The whole failure is a divergence between two artefacts, and asking a human to enumerate one of them reintroduces the divergence into the check. So the script scans the files you point it at for .command(...) registrations and builds the set from what is actually in the code.

Handlers registered by pattern have to be counted and not guessed at. Bolt lets you route a command with a regular expression. Those cannot be diffed against a name, so the script counts them separately and says so, rather than reporting a registered command as unhandled when a pattern quietly covers it. A check that produces confident false positives gets switched off.

Names are normalised before they are compared, because that is where the near misses hide. deploy, /deploy and /Deploy are the same command written three ways across a manifest, a console form and a source file. Comparing raw strings would report a command as both unregistered and unhandled at the same time, which is the least useful output available.

The namespace is a blind spot and the script says so out loud. There is no read method that reports which app owns /deploy in a workspace you do not administer. What a script can do is flag a name that Slack itself owns, and flag a name generic enough to be somebody else's, and recommend a prefix. Pretending to more certainty than that would be a lie about the API.

Nothing here is a probe. There is no way to test a slash command from a script without typing it into a workspace, which posts, which is a write. Every finding comes from the manifest and from your own files.

The fix, as a flow

The red arrow here is the first one, and it is the earliest failure in the batch: Slack has no route for the name, so nothing is ever sent and no request exists to go wrong. Every box after it is an absence, which is why the chain ends in a healthy service rather than in an error. The fix branch is the only one in the batch that sorts a set difference, and it deliberately reads in both directions, because the mirror image of a dead handler is a live command that anybody in the workspace can type.

Declared beside handledafter the names are normalisedHandled, never declareddead code and a puzzled userDeclared, never handledanyone typing it gets an errorA name Slack ownsunavailable in every workspaceMatched by a patterncounted, and never guessed atBoth lists agreeand the app was reinstalled
Two lists, two gaps, two different victims. One costs you a feature nobody can reach; the other hands strangers an error with your app name on it. Reporting only the first leaves half the fault standing.

How to fix it

Export the registered commands

apps.manifest.export returns features.slash_commands, each entry with command, url, description, usage_hint and should_escape. It needs an app configuration token. Without one, pass --registered with the names from the Slash Commands page and the diff still runs.

Build the handled set from the code itself

commands_in_source reads the files you name and returns the command literals it finds in .command(...) registrations, plus a count of pattern-based handlers it deliberately will not guess about. Building this set from source rather than from a list somebody typed is the point: the failure is a divergence between two artefacts, and typing one of them out by hand recreates it inside the check.

Normalise before comparing

normalise_command settles the leading slash and the case, and reports no-slash, uppercase, has-space, too-long and invalid-char along the way. Without this step the same command written three ways in three places is reported as three findings, none of them true.

Diff in both directions and read both answers

command_diff returns the dead handlers, the unhandled registrations and the matched set. The first list is the one users report as “not a valid command”. The second is the one that answers strangers with dispatch_failed, and it is usually the one nobody knew about.

Look at the names you are about to gamble on

name_risk answers reserved, generic, namespaced or invalid. Slack's own commands are unavailable everywhere; a bare English verb is available until the workspace installs one more app. A prefix is not decoration, it is the only defence, and there is no API that will warn you before a customer collides.

Register, then reinstall, then check the registrations you already have

registration_gaps reads each registered entry for a missing Request URL, a plain http URL or a missing description. Then reinstall: a new command does not take effect until the app is reinstalled, which is the step that turns “I added it and nothing changed” into a working command.

How to check it worked

Register the missing command, reinstall, and run it again. Both gaps should be empty and matched should hold everything your app answers.

python3 slack_slash_commands.py --app-id A05NW7XQ1 --source app/commands.py,app/legacy.py
# manifest   ok             3 registered command(s) for A05NW7XQ1
# source     2 file(s)      3 handled command(s), 1 pattern handler(s) not diffed
# dead       /deploy        handled in your source and registered nowhere; users get
#                           "/deploy is not a valid command"
# unhandled  /oldsync       registered with a Request URL and handled by nothing;
#                           whoever types it gets dispatch_failed from your app
# matched    /acme-status, /acme-whoami
# risk       /deploy        generic     a bare name in a namespace shared with every
#                           other installed app; consider /acme-deploy
# risk       /oldsync       generic     ...
# gap        /oldsync       no-description   Slack shows this in the autocomplete
# verdict    2 finding(s)
#   repair: declare /deploy under features.slash_commands with the production
#           Request URL, then reinstall the app; new commands need a reinstall
#   repair: remove /oldsync from the app configuration, or add a handler for it
#   note:   no read method reports which other app owns a name in a workspace

The full code

One optional network call and one local read. normalise_command exists so that the same command written three ways in three places stops being three findings. commands_in_source builds the handled set out of the code rather than out of somebody's memory, and counts the pattern-based handlers separately instead of guessing about them. command_diff is the whole check, run in both directions. name_risk and registration_gaps cover the two things that are true of a command even when the diff is clean.

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_slash_commands.py
"""Diff the slash commands your app registers against the ones it handles.

Read only. At most one GET is made, to apps.manifest.export with an app
configuration token; everything else is your own source, read from disk. There
is no way to test a slash command from a script without typing it into a
workspace, which would be a write, so nothing here probes anything.

The one thing this cannot see is the rest of the namespace. Command names are
unique per workspace across every installed app, and no read method reports
which app owns a name in a workspace you do not administer. The script flags
the names worth worrying about and is honest that it cannot settle them.
"""
import argparse
import logging
import os
import re
import sys

import requests

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

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

# Slack's own commands. These are unavailable in every workspace, everywhere,
# and no configuration on your side will take one back.
SLACK_BUILTINS = frozenset({
    "/active", "/away", "/archive", "/call", "/collapse", "/dm", "/dnd", "/expand",
    "/feed", "/feedback", "/giphy", "/help", "/invite", "/join", "/leave", "/me",
    "/msg", "/mute", "/open", "/prefs", "/remind", "/remove", "/rename", "/search",
    "/shortcuts", "/shrug", "/star", "/status", "/topic", "/who",
})

# Slack's ceiling for a command name, the slash included.
MAX_NAME = 32

# Two patterns rather than one: the literal registrations can be diffed by
# name, and the rest are pattern handlers that cannot. Counting them separately
# is what stops the script reporting a registered command as unhandled because
# a regular expression quietly covers it.
CMD_CALL = re.compile(r"""\.command\(""")
CMD_LITERAL = re.compile(r"""\.command\(\s*["']([^"']+)["']""")

ALLOWED = re.compile(r"^[a-z0-9_-]+$")


def normalise_command(name):
    """Settle the slash and the case so two spellings compare equal. Pure.

    Returns (canonical, notes). Without this, the same command written as
    deploy in a manifest, /deploy in a console and /Deploy in a source file is
    reported as three separate findings, none of which is real.
    """
    text = str(name or "").strip()
    notes = []
    if not text:
        return ("", ["empty"])
    if not text.startswith("/"):
        notes.append("no-slash")
        text = "/" + text
    body = text[1:]
    if body != body.lower():
        notes.append("uppercase")
        body = body.lower()
    if " " in body or "\t" in body:
        notes.append("has-space")
        body = body.split()[0] if body.split() else ""
    if len(body) + 1 > MAX_NAME:
        notes.append("too-long")
    if body and not ALLOWED.match(body):
        notes.append("invalid-char")
    return ("/" + body if body else "", notes)


def commands_in_source(text):
    """Which commands does this source register? Pure.

    Returns (names, dynamic). names is the sorted set of canonical command
    literals; dynamic counts the .command(...) registrations whose argument is
    not a string literal - a regular expression, or a name held in a variable.
    Those cannot be diffed against a list of names, and pretending otherwise
    would produce confident false positives.
    """
    body = str(text or "")
    names = set()
    for match in CMD_LITERAL.finditer(body):
        canonical, _notes = normalise_command(match.group(1))
        if canonical:
            names.add(canonical)
    dynamic = len(CMD_CALL.findall(body)) - len(CMD_LITERAL.findall(body))
    return (sorted(names), max(0, dynamic))


def command_diff(registered, handled):
    """The two gaps, and the overlap. Pure.

    Returns (dead_handlers, unhandled_registrations, matched), all sorted lists
    of canonical names.

      dead_handlers            handled in code, registered nowhere. Users get
                               "not a valid command" and your handler is dead.
      unhandled_registrations  registered and handled by nothing. Slack sends
                               the request, your app has no route, and the user
                               sees dispatch_failed with your app name on it.
    """
    reg = {normalise_command(n)[0] for n in (registered or [])}
    hand = {normalise_command(n)[0] for n in (handled or [])}
    reg.discard("")
    hand.discard("")
    return (sorted(hand - reg), sorted(reg - hand), sorted(reg & hand))


def name_risk(name):
    """How likely is this name to be somebody else's? Pure.

    Returns (risk, detail). This is the honest limit of the check: the API
    cannot tell you who owns a name in a workspace you do not administer, so
    the script grades the gamble instead of pretending to settle it.
    """
    canonical, notes = normalise_command(name)
    if not canonical:
        return ("invalid", "an empty command name")
    bad = [n for n in notes if n in ("has-space", "too-long", "invalid-char")]
    if bad:
        return ("invalid", "Slack will not accept this name: %s" % ", ".join(bad))
    if canonical in SLACK_BUILTINS:
        return ("reserved", "%s is one of Slack's own commands and is unavailable in "
                            "every workspace" % canonical)
    body = canonical[1:]
    if "-" in body or "_" in body:
        return ("namespaced", "the name carries a prefix, which is the only defence "
                              "against a collision")
    return ("generic", "a bare name in a namespace shared with every other installed "
                       "app; consider a prefix such as /acme-%s" % body)


def registration_gaps(entry):
    """What is missing from one registered command? Pure.

    Returns a list of (code, detail). The Request URL's health beyond the
    scheme is a separate question with a separate check; this one only reports
    that the URL is absent or plainly wrong.
    """
    command = str((entry or {}).get("command") or "")
    url = str((entry or {}).get("url") or "").strip()
    out = []
    if not url:
        out.append(("no-url", "no Request URL, so Slack has nowhere to send %s"
                    % (command or "this command")))
    elif url.lower().startswith("http://"):
        out.append(("insecure-url", "the Request URL is plain http; the payload "
                                    "carries a signing header and a user's text"))
    if not str((entry or {}).get("description") or "").strip():
        out.append(("no-description", "Slack shows the description in the autocomplete "
                                      "and requires one"))
    return out


def export_manifest(session, token, app_id):
    """One GET, with an app configuration token. Returns the parsed body."""
    r = session.get(API + "apps.manifest.export",
                    headers={"Authorization": "Bearer " + token},
                    params={"app_id": app_id}, timeout=30)
    try:
        return r.json()
    except ValueError:
        return {"ok": False, "error": "unparseable_body"}


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--app-id", default="", help="the app id to export, A...")
    ap.add_argument("--config-token-env", default="SLACK_CONFIG_ACCESS_TOKEN",
                    help="environment variable holding an app configuration token")
    ap.add_argument("--registered", default="",
                    help="comma separated command names, if you have no config token")
    ap.add_argument("--source", default="",
                    help="comma separated source files that register handlers")
    ap.add_argument("--handled", default="",
                    help="comma separated command names your app handles")
    args = ap.parse_args()

    entries = []
    registered = [n.strip() for n in args.registered.split(",") if n.strip()]
    token = os.environ.get(args.config_token_env)
    if args.app_id and token and not registered:
        body = export_manifest(requests.Session(), token, args.app_id)
        if body.get("ok") is not True:
            log.warning("manifest   unavailable    apps.manifest.export answered "
                        "ok: false, error=%s", body.get("error"))
        else:
            features = ((body.get("manifest") or body).get("features") or {})
            entries = list(features.get("slash_commands") or [])
            registered = [str(e.get("command") or "") for e in entries]
            log.info("manifest   ok             %d registered command(s) for %s",
                     len(registered), args.app_id)
    elif not registered:
        log.info("manifest   skipped        set %s, or pass --registered with the "
                 "names from the Slash Commands page", args.config_token_env)

    handled = [n.strip() for n in args.handled.split(",") if n.strip()]
    files = [p.strip() for p in args.source.split(",") if p.strip()]
    dynamic = 0
    for path in files:
        found, patterns = commands_in_source(open(path, encoding="utf-8").read())
        handled.extend(found)
        dynamic += patterns
    if files:
        log.info("source     %d file(s)      %d handled command(s), %d pattern "
                 "handler(s) not diffed", len(files), len(set(handled)), dynamic)

    dead, unhandled, matched = command_diff(registered, handled)
    findings = 0
    for name in dead:
        log.warning("dead       %-14s handled in your source and registered nowhere; "
                    'users get "%s is not a valid command"', name, name)
        findings += 1
    for name in unhandled:
        if dynamic:
            log.info("unhandled  %-14s registered and not matched by a literal "
                     "handler; %d pattern handler(s) may cover it", name, dynamic)
            continue
        log.warning("unhandled  %-14s registered with a Request URL and handled by "
                    "nothing; whoever types it gets dispatch_failed from your app",
                    name)
        findings += 1
    log.info("matched    %s", ", ".join(matched) or "none")

    for name in sorted(set(registered) | set(handled)):
        risk, detail = name_risk(name)
        if risk in ("reserved", "invalid"):
            log.warning("risk       %-14s %-10s %s", normalise_command(name)[0], risk,
                        detail)
            findings += 1
        elif risk == "generic":
            log.info("risk       %-14s %-10s %s", normalise_command(name)[0], risk,
                     detail)

    for entry in entries:
        for code, detail in registration_gaps(entry):
            log.warning("gap        %-14s %-16s %s",
                        normalise_command(entry.get("command"))[0], code, detail)
            findings += 1

    if not findings:
        log.info("verdict    clean          every handler is registered and every "
                 "registration is handled")
        return 0
    log.warning("verdict    %d finding(s)", findings)
    log.warning("  repair: declare the missing command(s) under "
                "features.slash_commands with the production Request URL, then "
                "reinstall the app; new commands need a reinstall")
    log.warning("  repair: remove the registrations nothing handles, or add a handler "
                "for them, so nobody can type one and get dispatch_failed")
    log.warning("  note:   no read method reports which other app owns a name in a "
                "workspace, so a generic name stays a gamble")
    return 1


if __name__ == "__main__":
    sys.exit(main())
slack-slash-commands.mjs
/**
 * Diff the slash commands your app registers against the ones it handles.
 *
 * Read only. At most one GET is made, to apps.manifest.export with an app
 * configuration token; everything else is your own source, read from disk.
 * There is no way to test a slash command from a script without typing it into
 * a workspace, which would be a write, so nothing here probes anything.
 *
 * The one thing this cannot see is the rest of the namespace: no read method
 * reports which app owns a name in a workspace you do not administer.
 */

import { readFileSync } from 'node:fs';

const API = 'https://slack.com/api/';

// Slack's own commands, unavailable in every workspace, everywhere.
export const SLACK_BUILTINS = new Set([
  '/active', '/away', '/archive', '/call', '/collapse', '/dm', '/dnd', '/expand',
  '/feed', '/feedback', '/giphy', '/help', '/invite', '/join', '/leave', '/me',
  '/msg', '/mute', '/open', '/prefs', '/remind', '/remove', '/rename', '/search',
  '/shortcuts', '/shrug', '/star', '/status', '/topic', '/who',
]);

// Slack's ceiling for a command name, the slash included.
export const MAX_NAME = 32;

const CMD_CALL = /\.command\(/g;
const CMD_LITERAL = /\.command\(\s*["']([^"']+)["']/g;
const ALLOWED = /^[a-z0-9_-]+$/;

/** Settle the slash and the case so two spellings compare equal. Pure. */
export function normaliseCommand(name) {
  let text = String(name ?? '').trim();
  const notes = [];
  if (!text) return ['', ['empty']];
  if (!text.startsWith('/')) {
    notes.push('no-slash');
    text = `/${text}`;
  }
  let body = text.slice(1);
  if (body !== body.toLowerCase()) {
    notes.push('uppercase');
    body = body.toLowerCase();
  }
  if (/\s/.test(body)) {
    notes.push('has-space');
    [body] = body.split(/\s+/).filter(Boolean).concat(['']);
  }
  if (body.length + 1 > MAX_NAME) notes.push('too-long');
  if (body && !ALLOWED.test(body)) notes.push('invalid-char');
  return [body ? `/${body}` : '', notes];
}

/**
 * Which commands does this source register? Pure.
 * Returns [names, dynamic]; dynamic counts pattern handlers that cannot be
 * diffed against a name and must not be guessed about.
 */
export function commandsInSource(text) {
  const body = String(text ?? '');
  const names = new Set();
  for (const match of body.matchAll(CMD_LITERAL)) {
    const [canonical] = normaliseCommand(match[1]);
    if (canonical) names.add(canonical);
  }
  const calls = [...body.matchAll(CMD_CALL)].length;
  const literals = [...body.matchAll(CMD_LITERAL)].length;
  return [[...names].sort(), Math.max(0, calls - literals)];
}

/**
 * The two gaps, and the overlap. Pure.
 * Returns [deadHandlers, unhandledRegistrations, matched].
 */
export function commandDiff(registered, handled) {
  const reg = new Set((registered ?? []).map((n) => normaliseCommand(n)[0]));
  const hand = new Set((handled ?? []).map((n) => normaliseCommand(n)[0]));
  reg.delete('');
  hand.delete('');
  const only = (a, b) => [...a].filter((n) => !b.has(n)).sort();
  return [only(hand, reg), only(reg, hand),
    [...reg].filter((n) => hand.has(n)).sort()];
}

/** How likely is this name to be somebody else? Pure. */
export function nameRisk(name) {
  const [canonical, notes] = normaliseCommand(name);
  if (!canonical) return ['invalid', 'an empty command name'];
  const bad = notes.filter((n) => ['has-space', 'too-long', 'invalid-char'].includes(n));
  if (bad.length) {
    return ['invalid', `Slack will not accept this name: ${bad.join(', ')}`];
  }
  if (SLACK_BUILTINS.has(canonical)) {
    return ['reserved', `${canonical} is one of Slack own commands and is unavailable `
      + 'in every workspace'];
  }
  const body = canonical.slice(1);
  if (body.includes('-') || body.includes('_')) {
    return ['namespaced', 'the name carries a prefix, which is the only defence '
      + 'against a collision'];
  }
  return ['generic', 'a bare name in a namespace shared with every other installed '
    + `app; consider a prefix such as /acme-${body}`];
}

/** What is missing from one registered command? Pure. */
export function registrationGaps(entry) {
  const command = String((entry ?? {}).command ?? '');
  const url = String((entry ?? {}).url ?? '').trim();
  const out = [];
  if (!url) {
    out.push(['no-url',
      `no Request URL, so Slack has nowhere to send ${command || 'this command'}`]);
  } else if (url.toLowerCase().startsWith('http://')) {
    out.push(['insecure-url', 'the Request URL is plain http; the payload carries a '
      + 'signing header and a user text']);
  }
  if (!String((entry ?? {}).description ?? '').trim()) {
    out.push(['no-description',
      'Slack shows the description in the autocomplete and requires one']);
  }
  return out;
}

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

async function exportManifest(token, appId) {
  const url = `${API}apps.manifest.export?app_id=${encodeURIComponent(appId)}`;
  const r = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  try {
    return await r.json();
  } catch {
    return { ok: false, error: 'unparseable_body' };
  }
}

async function main() {
  const args = process.argv.slice(2);
  const appId = arg(args, '--app-id');
  const configTokenEnv = arg(args, '--config-token-env', 'SLACK_CONFIG_ACCESS_TOKEN');
  let entries = [];
  let registered = arg(args, '--registered').split(',')
    .map((n) => n.trim()).filter(Boolean);

  const token = process.env[configTokenEnv];
  if (appId && token && !registered.length) {
    const body = await exportManifest(token, appId);
    if (body.ok !== true) {
      console.warn('manifest   unavailable    apps.manifest.export answered ok: false, '
        + `error=${body.error}`);
    } else {
      const features = (body.manifest ?? body).features ?? {};
      entries = [...(features.slash_commands ?? [])];
      registered = entries.map((e) => String(e.command ?? ''));
      console.log(`manifest   ok             ${registered.length} registered `
        + `command(s) for ${appId}`);
    }
  } else if (!registered.length) {
    console.log(`manifest   skipped        set ${configTokenEnv}, or pass --registered `
      + 'with the names from the Slash Commands page');
  }

  const handled = arg(args, '--handled').split(',').map((n) => n.trim()).filter(Boolean);
  const files = arg(args, '--source').split(',').map((p) => p.trim()).filter(Boolean);
  let dynamic = 0;
  for (const path of files) {
    const [found, patterns] = commandsInSource(readFileSync(path, 'utf-8'));
    handled.push(...found);
    dynamic += patterns;
  }
  if (files.length) {
    console.log(`source     ${files.length} file(s)      ${new Set(handled).size} `
      + `handled command(s), ${dynamic} pattern handler(s) not diffed`);
  }

  const [dead, unhandled, matched] = commandDiff(registered, handled);
  let findings = 0;
  for (const name of dead) {
    console.warn(`dead       ${name.padEnd(14)} handled in your source and registered `
      + `nowhere; users get "${name} is not a valid command"`);
    findings += 1;
  }
  for (const name of unhandled) {
    if (dynamic) {
      console.log(`unhandled  ${name.padEnd(14)} registered and not matched by a `
        + `literal handler; ${dynamic} pattern handler(s) may cover it`);
      continue;
    }
    console.warn(`unhandled  ${name.padEnd(14)} registered with a Request URL and `
      + 'handled by nothing; whoever types it gets dispatch_failed from your app');
    findings += 1;
  }
  console.log(`matched    ${matched.join(', ') || 'none'}`);

  for (const name of [...new Set([...registered, ...handled])].sort()) {
    const [risk, detail] = nameRisk(name);
    const canonical = normaliseCommand(name)[0];
    if (risk === 'reserved' || risk === 'invalid') {
      console.warn(`risk       ${canonical.padEnd(14)} ${risk.padEnd(10)} ${detail}`);
      findings += 1;
    } else if (risk === 'generic') {
      console.log(`risk       ${canonical.padEnd(14)} ${risk.padEnd(10)} ${detail}`);
    }
  }

  for (const entry of entries) {
    for (const [code, detail] of registrationGaps(entry)) {
      console.warn(`gap        ${normaliseCommand(entry.command)[0].padEnd(14)} `
        + `${code.padEnd(16)} ${detail}`);
      findings += 1;
    }
  }

  if (!findings) {
    console.log('verdict    clean          every handler is registered and every '
      + 'registration is handled');
    return;
  }
  console.warn(`verdict    ${findings} finding(s)`);
  console.warn('  repair: declare the missing command(s) under features.slash_commands '
    + 'with the production Request URL, then reinstall the app');
  console.warn('  repair: remove the registrations nothing handles, or add a handler '
    + 'for them, so nobody can type one and get dispatch_failed');
  console.warn('  note:   no read method reports which other app owns a name in a '
    + 'workspace, so a generic name stays a gamble');
  process.exitCode = 1;
}

if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The fixtures here are source snippets rather than credentials, because the input to this check is your own code. The assertions worth reading are the ones that stop the diff lying: a pattern-based handler is counted and never named, so a registered command covered by a regular expression is not reported as unhandled; and normalisation is pinned hard, because deploy, /deploy and /Deploy written across a manifest, a console and a source file must collapse to one name before anything is compared.

test_slack_slash_commands.py
from slack_slash_commands import (
    command_diff, commands_in_source, name_risk, normalise_command, registration_gaps,
)


def test_the_leading_slash_is_settled_in_both_directions():
    assert normalise_command("deploy")[0] == "/deploy"
    assert normalise_command("/deploy")[0] == "/deploy"
    assert "no-slash" in normalise_command("deploy")[1]


def test_case_is_folded_and_reported():
    canonical, notes = normalise_command("/Deploy")
    assert canonical == "/deploy"
    assert "uppercase" in notes


def test_a_name_with_a_space_is_cut_and_flagged():
    canonical, notes = normalise_command("/deploy now")
    assert canonical == "/deploy"
    assert "has-space" in notes


def test_a_name_past_the_ceiling_is_flagged():
    assert "too-long" in normalise_command("/" + "a" * 40)[1]


def test_an_empty_name_is_empty_rather_than_a_slash():
    assert normalise_command("")[0] == ""
    assert normalise_command(None)[0] == ""


def test_handlers_are_found_in_both_bolt_dialects():
    src = """
    app.command("/acme-deploy")(handle)
    @app.command('/acme-status')
    def status(ack): ack()
    """
    names, dynamic = commands_in_source(src)
    assert names == ["/acme-deploy", "/acme-status"]
    assert dynamic == 0


def test_a_pattern_handler_is_counted_and_never_named():
    src = "app.command(re.compile(r'^/acme-.*'))(handle)"
    names, dynamic = commands_in_source(src)
    assert names == []
    assert dynamic == 1


def test_source_with_no_handlers_is_empty_rather_than_an_error():
    assert commands_in_source("") == ([], 0)
    assert commands_in_source(None) == ([], 0)


def test_a_handler_with_no_registration_is_the_dead_one():
    dead, unhandled, matched = command_diff(["/acme-status"],
                                            ["/acme-status", "/deploy"])
    assert dead == ["/deploy"]
    assert unhandled == []
    assert matched == ["/acme-status"]


def test_a_registration_with_no_handler_is_the_one_users_can_type():
    dead, unhandled, _matched = command_diff(["/oldsync"], [])
    assert dead == []
    assert unhandled == ["/oldsync"]


def test_the_diff_normalises_before_comparing():
    dead, unhandled, matched = command_diff(["deploy"], ["/Deploy"])
    assert dead == []
    assert unhandled == []
    assert matched == ["/deploy"]


def test_empty_names_do_not_become_a_finding():
    assert command_diff(["", None], ["", None]) == ([], [], [])


def test_a_slack_builtin_is_reserved_everywhere():
    risk, detail = name_risk("/remind")
    assert risk == "reserved"
    assert "every workspace" in detail


def test_a_prefixed_name_is_the_defended_one():
    assert name_risk("/acme-deploy")[0] == "namespaced"
    assert name_risk("/acme_deploy")[0] == "namespaced"


def test_a_bare_name_is_a_gamble_and_the_advice_is_a_prefix():
    risk, detail = name_risk("/deploy")
    assert risk == "generic"
    assert "/acme-deploy" in detail


def test_a_name_slack_will_not_accept_is_invalid_rather_than_generic():
    assert name_risk("/deploy now")[0] == "invalid"
    assert name_risk("")[0] == "invalid"


def test_a_registration_without_a_url_has_nowhere_to_send_anything():
    codes = [c for c, _d in registration_gaps({"command": "/x", "description": "d"})]
    assert codes == ["no-url"]


def test_a_plain_http_request_url_is_reported():
    codes = [c for c, _d in registration_gaps(
        {"command": "/x", "url": "http://example.com/s", "description": "d"})]
    assert codes == ["insecure-url"]


def test_a_missing_description_is_its_own_gap():
    codes = [c for c, _d in registration_gaps(
        {"command": "/x", "url": "https://example.com/s"})]
    assert codes == ["no-description"]


def test_a_complete_registration_has_no_gaps():
    assert registration_gaps({"command": "/x", "url": "https://example.com/s",
                              "description": "does a thing"}) == []
slack-slash-commands.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  commandDiff, commandsInSource, nameRisk, normaliseCommand, registrationGaps,
} from './slack-slash-commands.mjs';

const codes = (entry) => registrationGaps(entry).map(([c]) => c);

test('the leading slash is settled in both directions', () => {
  assert.equal(normaliseCommand('deploy')[0], '/deploy');
  assert.equal(normaliseCommand('/deploy')[0], '/deploy');
  assert.equal(normaliseCommand('deploy')[1].includes('no-slash'), true);
});

test('case is folded and reported', () => {
  const [canonical, notes] = normaliseCommand('/Deploy');
  assert.equal(canonical, '/deploy');
  assert.equal(notes.includes('uppercase'), true);
});

test('a name with a space is cut and flagged', () => {
  const [canonical, notes] = normaliseCommand('/deploy now');
  assert.equal(canonical, '/deploy');
  assert.equal(notes.includes('has-space'), true);
});

test('a name past the ceiling is flagged', () => {
  assert.equal(normaliseCommand(`/${'a'.repeat(40)}`)[1].includes('too-long'), true);
});

test('an empty name is empty rather than a slash', () => {
  assert.equal(normaliseCommand('')[0], '');
  assert.equal(normaliseCommand(null)[0], '');
});

test('handlers are found in both bolt dialects', () => {
  const src = `
    app.command("/acme-deploy", handle);
    app.command('/acme-status', status);
  `;
  const [names, dynamic] = commandsInSource(src);
  assert.deepEqual(names, ['/acme-deploy', '/acme-status']);
  assert.equal(dynamic, 0);
});

test('a pattern handler is counted and never named', () => {
  const [names, dynamic] = commandsInSource('app.command(/^\\/acme-.*/, handle);');
  assert.deepEqual(names, []);
  assert.equal(dynamic, 1);
});

test('source with no handlers is empty rather than an error', () => {
  assert.deepEqual(commandsInSource(''), [[], 0]);
  assert.deepEqual(commandsInSource(null), [[], 0]);
});

test('a handler with no registration is the dead one', () => {
  const [dead, unhandled, matched] = commandDiff(['/acme-status'],
    ['/acme-status', '/deploy']);
  assert.deepEqual(dead, ['/deploy']);
  assert.deepEqual(unhandled, []);
  assert.deepEqual(matched, ['/acme-status']);
});

test('a registration with no handler is the one users can type', () => {
  const [dead, unhandled] = commandDiff(['/oldsync'], []);
  assert.deepEqual(dead, []);
  assert.deepEqual(unhandled, ['/oldsync']);
});

test('the diff normalises before comparing', () => {
  const [dead, unhandled, matched] = commandDiff(['deploy'], ['/Deploy']);
  assert.deepEqual(dead, []);
  assert.deepEqual(unhandled, []);
  assert.deepEqual(matched, ['/deploy']);
});

test('empty names do not become a finding', () => {
  assert.deepEqual(commandDiff(['', null], ['', null]), [[], [], []]);
});

test('a slack builtin is reserved everywhere', () => {
  const [risk, detail] = nameRisk('/remind');
  assert.equal(risk, 'reserved');
  assert.match(detail, /every workspace/);
});

test('a prefixed name is the defended one', () => {
  assert.equal(nameRisk('/acme-deploy')[0], 'namespaced');
  assert.equal(nameRisk('/acme_deploy')[0], 'namespaced');
});

test('a bare name is a gamble and the advice is a prefix', () => {
  const [risk, detail] = nameRisk('/deploy');
  assert.equal(risk, 'generic');
  assert.match(detail, /\/acme-deploy/);
});

test('a name slack will not accept is invalid rather than generic', () => {
  assert.equal(nameRisk('/deploy now')[0], 'invalid');
  assert.equal(nameRisk('')[0], 'invalid');
});

test('a registration without a url has nowhere to send anything', () => {
  assert.deepEqual(codes({ command: '/x', description: 'd' }), ['no-url']);
});

test('a plain http request url is reported', () => {
  assert.deepEqual(codes({ command: '/x', url: 'http://example.com/s',
    description: 'd' }), ['insecure-url']);
});

test('a missing description is its own gap', () => {
  assert.deepEqual(codes({ command: '/x', url: 'https://example.com/s' }),
    ['no-description']);
});

test('a complete registration has no gaps', () => {
  assert.deepEqual(registrationGaps({ command: '/x', url: 'https://example.com/s',
    description: 'does a thing' }), []);
});

FAQ

The handler is in the code and it never fires. Why?

Because a handler is a routing rule for a request that has to arrive, and nothing makes Slack send it except an entry in the app configuration. Bolt will happily register a route for a command that does not exist, boot cleanly, and serve everything else, because from the framework's point of view nothing is wrong. Until the command is declared under Slash Commands, or features.slash_commands in a manifest, Slack has never heard of it and no request is ever made.

I added it in the app configuration and it still says it is not a valid command.

Reinstall the app. A new slash command does not take effect on an existing installation until the app is installed again into that workspace, and this is the step that gets skipped most often, because everything on the configuration page looks correct afterwards. If you distribute the app, every workspace needs the reinstall, which is worth batching with other configuration changes rather than doing one command at a time.

Another app in the workspace already uses the name we want. Can we detect that?

Not from the API, and the script says so rather than pretending. Command names are unique per workspace across every installed app plus Slack's own built-ins, and there is no read method that reports which app owns a name in a workspace you do not administer. What the check can do is tell you when you are gambling: reserved for Slack's own commands, and generic for a bare English word that any other app might claim. The only real defence is a prefix.

What produces dispatch_failed, and how is it different from what we have here?

It is the same divergence read from the other end. dispatch_failed means the command is registered, Slack sent the request to your Request URL, and your app did not answer it usefully: no route for that name, or an unhandled exception before the acknowledgement. That is the second list the diff prints, and it is more public than the first, because a registered command appears in the autocomplete and anyone can type it and see your app's name attached to the error.

Can we run this without an app configuration token?

Partly, and the missing half is the important one. The set of handled commands comes from your own source, which you always have. The set of registered commands comes from the manifest, which needs an app configuration token with app_configurations:read, and a bot token cannot substitute for it. Pass --registered with the names copied from the Slash Commands page and the diff runs identically; it just means the list has been transcribed by a person, which is exactly the kind of step this check exists to remove.

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.