Diagnostic Slack
Socket Mode never connects: the xapp- token is underscoped
The process starts, Bolt announces that it is connecting, and then it retries. Forever. No events arrive, nothing crashes, and the only line in the log that matters is missing_scope from apps.connections.open — a method your read-only audit script is not allowed to call, because opening a connection is a write.
App-level tokens are minted per scope set on the Basic Information page and cannot be edited afterwards. Socket Mode needs connections:write; the multi-install helper apps.event.authorizations.list needs authorizations:read. A token generated for one of those does not carry the other, and there is no method that returns an app-level token's scope list.
So a read-only script cannot ask the question directly — but it can ask the neighbouring one. apps.event.authorizations.list is the only read method that accepts an xapp- credential. Call it with an event_context you invented, and the error is a statement about the token: invalid_event_context means it authenticated and holds an app-level scope, missing_scope means it does not, auth_mismatch means it belongs to a different app. Cross that with settings.socket_mode_enabled from a manifest you export yourself and you have the finding: Socket Mode is on and the credential the app was given cannot support it.
The problem in plain words
Everything about this failure is quiet. Socket Mode does not fail closed with a crash; the client library treats a failed connection as transient and reconnects, so the service stays up, the health check passes, and the app simply never receives an event. On a workspace where the app mostly listens, that can go unnoticed for days.
The configuration mistake behind it is easy to make. The App-Level Tokens dialog asks you to name the token and tick the scopes, and it is entirely reasonable to tick the one you came for. A team that added authorizations:read to support a second customer, generated a fresh token, and updated SLACK_APP_TOKEN has just removed Socket Mode from an app that had it — and the reverse happens just as often. There is no edit button on an existing app-level token; there is only generate and revoke.
What makes it awkward to audit is that the diagnostic call is a write. apps.connections.open mints a single-use WebSocket URL: calling it changes server state, counts against the ten permitted connections, and is exactly the sort of thing a section that promises read-only scripts must not do. So the check has to be an inference, and an inference is only worth running if it is honest about its limits.
Why it happens
An app-level token is not a workspace credential. It carries no workspace scopes, cannot call chat.postMessage or conversations.list, and auth.test refuses it. Two methods accept it and only one of them reads. That is the entire surface available for the inference.
The argument error is the proof. Slack checks the credential before it checks the parameters, so a complaint about the event_context you deliberately made up means everything before that step passed: the token is an app-level token, it belongs to this app, and it holds the app-level scope this method needs. Reading an error as a pass feels wrong the first time and is the most reliable signal here.
One app-level scope missing is evidence about the mint, not proof about the other. missing_scope from the probe says authorizations:read is absent. It does not say connections:write is absent — that scope is genuinely unobservable. What it does say is that this token was minted with a partial scope set, which is the mistake, and since the fix is to regenerate with both boxes ticked, the finding is actionable regardless.
The manifest supplies the other half. settings.socket_mode_enabled tells you whether Socket Mode is the transport at all. Without it, a token that lacks an app-level scope is a curiosity; with it, the same fact is the reason no events arrive. If you cannot export the manifest, the script says so rather than assuming.
Socket Mode off with no request URL is its own finding. An app with neither transport configured receives nothing and has no error to show for it, and that combination falls out of the same matrix for free.
The fix, as a flow
The method that would settle this mints a connection, so the script is not allowed to call it. It asks the neighbouring read method a question with a made-up argument instead, and takes the complaint about that argument as proof the credential got past every check before it.
How to fix it
Do not call the method that opens the connection
apps.connections.open mints a WebSocket URL. That is a write: it consumes one of the ten permitted connections and changes state on Slack's side. An audit that opens a socket to prove a socket can be opened is not an audit, and on a busy app it can displace a live connection.
Probe the read method that takes the same credential
One GET to apps.event.authorizations.list with the xapp- token in the Authorization header and an event_context that is obviously not real. Sending it as a query parameter matters: this method rejects the token if it arrives as a body parameter rather than a header.
Read the argument complaint as a pass
invalid_event_context is the healthy answer. It means the credential cleared and the method got as far as the parameters. missing_scope names the gap, auth_mismatch means the token was minted for a different app, and invalid_auth or not_allowed_token_type means the value is not an app-level token at all.
Export the manifest yourself and hand it over
The script takes a manifest as a local JSON file rather than fetching it, so the check needs no second credential class. Export it once from the app configuration and keep it next to the audit; the two fields it reads are settings.socket_mode_enabled and settings.event_subscriptions.request_url.
Regenerate the token with both scopes ticked
There is no way to add a scope to an existing app-level token. Generate a new one on Basic Information with connections:write and authorizations:read both selected, deploy it as SLACK_APP_TOKEN, restart, and only then revoke the old token from the same page.
State what the probe cannot prove
Write the limitation into the output, not just into the README. The probe demonstrates that one app-level scope is missing and that Socket Mode is enabled; it infers rather than proves that the connection scope is also absent. A reader who understands that will trust the next finding too.
How to check it worked
After regenerating the token, run it again with the same manifest. The probe should come back with the argument complaint and the verdict should be the plausible one, which is the strongest statement this check is allowed to make.
SLACK_APP_TOKEN=xapp-... python3 slack_socket_readiness.py --manifest app-manifest.json
# socket-mode-plausible the token authenticates and holds an app-level scope
The full code
One GET, sent deliberately wrong. Three pure functions carry the reasoning: probe_verdict turns the API's error into a statement about the credential, manifest_settings pulls the two fields that matter out of an exported manifest without caring how the rest of it is shaped, and socket_readiness is the matrix that crosses them — nine outcomes, only three of which are findings.
"""Ask a Slack app-level token what it can do, without minting a connection.
Read only. apps.connections.open mints a WebSocket URL, which is a write, so
this script never calls it. It exercises the one read method that accepts the
same credential class, reads the error as a statement about the token, and
crosses that with a manifest you exported yourself.
"""
import argparse
import json
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_socket_readiness")
API = "https://slack.com/api/"
# Deliberately not a real event_context. The method checks the credential before
# it checks the arguments, so a complaint about this string is the pass.
PROBE_CONTEXT = "read-only-probe-not-a-real-event-context"
FINDINGS = ("socket-mode-cannot-connect", "app-level-scopes-incomplete",
"scope-gap-without-socket-mode", "no-event-transport",
"credential-not-app-level", "token-belongs-elsewhere")
def probe_verdict(body):
"""Read one apps.event.authorizations.list answer as a statement about the
credential rather than about the request. Pure. -> (state, detail)
"""
if body.get("ok") is True:
return ("app-level-scope-present",
"the call answered ok, so the token authenticated and holds "
"authorizations:read.")
error = body.get("error") or "<no error field>"
if error in ("invalid_event_context", "event_context_invalid"):
return ("app-level-scope-present",
"the method refused the event_context we invented, which means "
"it accepted the credential and the scope first. An argument "
"complaint here is the pass.")
if error == "missing_scope":
return ("missing-authorizations-read",
"missing_scope: this app-level token was minted without "
"authorizations:read. App-level scopes are chosen once, at mint "
"time, and cannot be added afterwards.")
if error == "auth_mismatch":
return ("token-from-another-app",
"auth_mismatch: the token is a valid app-level token belonging "
"to a different app. Nothing about this app's configuration is "
"wrong; the wrong secret was deployed.")
if error == "not_allowed_token_type":
return ("wrong-credential-class",
"not_allowed_token_type: the value in the app-level slot is a "
"workspace token. It will never open a socket.")
if error == "invalid_auth":
return ("credential-rejected",
"invalid_auth: the credential was refused outright. Check the "
"prefix and check for whitespace before reading anything else "
"into this.")
if error == "ratelimited":
return ("not-assessed",
"ratelimited: no conclusion is available from this run.")
return ("not-assessed",
"error=%s, which is not one of the answers this probe knows how to "
"read. Resolve it before drawing a conclusion." % error)
def manifest_settings(manifest):
"""Pull the two fields this note needs out of an exported manifest. Pure.
Returns None when no manifest was supplied, so the caller can distinguish
"Socket Mode is off" from "nobody told us".
"""
if not isinstance(manifest, dict):
return None
settings = manifest.get("settings")
settings = settings if isinstance(settings, dict) else {}
subs = settings.get("event_subscriptions")
subs = subs if isinstance(subs, dict) else {}
return {"socket_mode_enabled": bool(settings.get("socket_mode_enabled")),
"request_url": subs.get("request_url") or None}
def socket_readiness(settings, probe_state):
"""Cross the probe with the manifest. Pure. -> (state, detail)"""
if probe_state in ("wrong-credential-class", "credential-rejected"):
return ("credential-not-app-level",
"the value deployed as the app-level token is not one. Fix that "
"before asking anything about Socket Mode.")
if probe_state == "token-from-another-app":
return ("token-belongs-elsewhere",
"the token is well formed and useless here. Take the app-level "
"token from this app's Basic Information page.")
if probe_state == "not-assessed":
return ("not-assessed",
"the probe gave no usable answer, so Socket Mode readiness is "
"unknown. Report it that way rather than as healthy.")
gap = probe_state == "missing-authorizations-read"
if settings is None:
if gap:
return ("app-level-scopes-incomplete",
"the token was minted with a partial app-level scope set. "
"Without a manifest we cannot say whether Socket Mode is the "
"transport, but the token should be regenerated either way.")
return ("app-level-token-live",
"the token authenticates and holds an app-level scope. Supply a "
"manifest to say whether Socket Mode is actually enabled.")
if settings["socket_mode_enabled"]:
if gap:
return ("socket-mode-cannot-connect",
"Socket Mode is enabled and the deployed app-level token was "
"minted without one of the two app-level scopes. That is why "
"the client reconnects forever and no event ever arrives.")
return ("socket-mode-plausible",
"Socket Mode is enabled and the token authenticates with an "
"app-level scope. connections:write cannot be read and cannot be "
"exercised without minting a connection, so this is the "
"strongest read-only evidence available.")
if gap:
return ("scope-gap-without-socket-mode",
"the app-level token is short a scope, but Socket Mode is off so "
"nothing is broken today. Regenerate before turning it on.")
if settings["request_url"]:
return ("http-events-not-socket",
"Socket Mode is off and a request URL is configured, so events "
"arrive over HTTP and the app-level token is not in the path.")
return ("no-event-transport",
"Socket Mode is off and no request URL is configured. The app has no "
"way to receive events at all, and there is no error anywhere that "
"says so.")
def probe(session, token, context=PROBE_CONTEXT):
"""One GET. The event_context goes in the query string and the credential in
the header: this method rejects an app-level token sent as a parameter."""
r = session.get(API + "apps.event.authorizations.list",
headers={"Authorization": "Bearer " + token},
params={"event_context": context}, timeout=30)
try:
return r.json()
except ValueError:
return {"ok": False, "error": "unparseable_body"}
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--token-env", default="SLACK_APP_TOKEN",
help="environment variable holding the xapp- app-level token")
ap.add_argument("--manifest",
help="path to a manifest you exported from the app config")
args = ap.parse_args()
token = os.environ.get(args.token_env)
if not token:
log.error("%s is not set; there is no app-level credential to probe",
args.token_env)
return 2
if not token.startswith("xapp-"):
log.warning("%s does not start with xapp-, so the probe below is very "
"likely to confirm the wrong credential class", args.token_env)
manifest = None
if args.manifest:
manifest = json.loads(open(args.manifest, encoding="utf-8").read())
settings = manifest_settings(manifest)
state, detail = probe_verdict(probe(requests.Session(), token))
verdict, why = socket_readiness(settings, state)
log.info("%-30s %s", verdict, why)
log.info(" probe: %s -- %s", state, detail)
if verdict not in FINDINGS:
return 0
if state == "missing-authorizations-read":
log.warning(" repair: Basic Information -> App-Level Tokens -> Generate. "
"Tick connections:write and authorizations:read together; an "
"existing app-level token cannot have a scope added.")
log.warning(" repair: deploy the new value as %s, restart, and only then "
"revoke the old token from the same page.", args.token_env)
log.warning(" limit: the probe proves one app-level scope is absent. "
"connections:write is unobservable without minting a "
"connection, so its absence here is inference, not proof.")
if verdict == "no-event-transport":
log.warning(" repair: enable Socket Mode, or set a request URL under "
"Event Subscriptions. With neither, no event is delivered.")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Ask a Slack app-level token what it can do, without minting a connection.
*
* Read only. apps.connections.open mints a WebSocket URL, which is a write, so
* this script never calls it. It exercises the one read method that accepts the
* same credential class, reads the error as a statement about the token, and
* crosses that with a manifest you exported yourself.
*/
import { readFile } from 'node:fs/promises';
const API = 'https://slack.com/api/';
// Deliberately not a real event_context. The method checks the credential before
// it checks the arguments, so a complaint about this string is the pass.
const PROBE_CONTEXT = 'read-only-probe-not-a-real-event-context';
const FINDINGS = new Set([
'socket-mode-cannot-connect', 'app-level-scopes-incomplete',
'scope-gap-without-socket-mode', 'no-event-transport',
'credential-not-app-level', 'token-belongs-elsewhere',
]);
/**
* Read one apps.event.authorizations.list answer as a statement about the
* credential rather than about the request. Pure.
*/
export function probeVerdict(body) {
if (body?.ok === true) {
return ['app-level-scope-present',
'the call answered ok, so the token authenticated and holds authorizations:read.'];
}
const error = body?.error ?? '<no error field>';
if (error === 'invalid_event_context' || error === 'event_context_invalid') {
return ['app-level-scope-present',
'the method refused the event_context we invented, which means it accepted ' +
'the credential and the scope first. An argument complaint here is the pass.'];
}
if (error === 'missing_scope') {
return ['missing-authorizations-read',
'missing_scope: this app-level token was minted without authorizations:read. ' +
'App-level scopes are chosen once, at mint time, and cannot be added afterwards.'];
}
if (error === 'auth_mismatch') {
return ['token-from-another-app',
'auth_mismatch: the token is a valid app-level token belonging to a different ' +
'app. Nothing about this app is wrong; the wrong secret was deployed.'];
}
if (error === 'not_allowed_token_type') {
return ['wrong-credential-class',
'not_allowed_token_type: the value in the app-level slot is a workspace ' +
'token. It will never open a socket.'];
}
if (error === 'invalid_auth') {
return ['credential-rejected',
'invalid_auth: the credential was refused outright. Check the prefix and ' +
'check for whitespace before reading anything else into this.'];
}
if (error === 'ratelimited') {
return ['not-assessed', 'ratelimited: no conclusion is available from this run.'];
}
return ['not-assessed',
`error=${error}, which is not one of the answers this probe knows how to read. ` +
'Resolve it before drawing a conclusion.'];
}
/**
* Pull the two fields this note needs out of an exported manifest. Pure.
* Returns null when no manifest was supplied, so the caller can distinguish
* "Socket Mode is off" from "nobody told us".
*/
export function manifestSettings(manifest) {
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return null;
const settings = (manifest.settings && typeof manifest.settings === 'object')
? manifest.settings : {};
const subs = (settings.event_subscriptions && typeof settings.event_subscriptions === 'object')
? settings.event_subscriptions : {};
return {
socket_mode_enabled: Boolean(settings.socket_mode_enabled),
request_url: subs.request_url || null,
};
}
/**
* Cross the probe with the manifest. Pure.
*/
export function socketReadiness(settings, probeState) {
if (probeState === 'wrong-credential-class' || probeState === 'credential-rejected') {
return ['credential-not-app-level',
'the value deployed as the app-level token is not one. Fix that before ' +
'asking anything about Socket Mode.'];
}
if (probeState === 'token-from-another-app') {
return ['token-belongs-elsewhere',
'the token is well formed and useless here. Take the app-level token from ' +
"this app's Basic Information page."];
}
if (probeState === 'not-assessed') {
return ['not-assessed',
'the probe gave no usable answer, so Socket Mode readiness is unknown. ' +
'Report it that way rather than as healthy.'];
}
const gap = probeState === 'missing-authorizations-read';
if (settings === null || settings === undefined) {
if (gap) {
return ['app-level-scopes-incomplete',
'the token was minted with a partial app-level scope set. Without a ' +
'manifest we cannot say whether Socket Mode is the transport, but the ' +
'token should be regenerated either way.'];
}
return ['app-level-token-live',
'the token authenticates and holds an app-level scope. Supply a manifest to ' +
'say whether Socket Mode is actually enabled.'];
}
if (settings.socket_mode_enabled) {
if (gap) {
return ['socket-mode-cannot-connect',
'Socket Mode is enabled and the deployed app-level token was minted ' +
'without one of the two app-level scopes. That is why the client ' +
'reconnects forever and no event ever arrives.'];
}
return ['socket-mode-plausible',
'Socket Mode is enabled and the token authenticates with an app-level ' +
'scope. connections:write cannot be read and cannot be exercised without ' +
'minting a connection, so this is the strongest read-only evidence available.'];
}
if (gap) {
return ['scope-gap-without-socket-mode',
'the app-level token is short a scope, but Socket Mode is off so nothing is ' +
'broken today. Regenerate before turning it on.'];
}
if (settings.request_url) {
return ['http-events-not-socket',
'Socket Mode is off and a request URL is configured, so events arrive over ' +
'HTTP and the app-level token is not in the path.'];
}
return ['no-event-transport',
'Socket Mode is off and no request URL is configured. The app has no way to ' +
'receive events at all, and there is no error anywhere that says so.'];
}
async function probe(token, context = PROBE_CONTEXT) {
const url = `${API}apps.event.authorizations.list?event_context=${encodeURIComponent(context)}`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
try {
return await res.json();
} catch {
return { ok: false, error: 'unparseable_body' };
}
}
function arg(args, name, fallback = null) {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
}
async function main() {
const args = process.argv.slice(2);
const tokenEnv = arg(args, '--token-env', 'SLACK_APP_TOKEN');
const token = process.env[tokenEnv];
if (!token) {
console.error(`${tokenEnv} is not set; there is no app-level credential to probe`);
process.exitCode = 2;
return;
}
if (!token.startsWith('xapp-')) {
console.warn(`${tokenEnv} does not start with xapp-, so the probe below is very ` +
'likely to confirm the wrong credential class');
}
const manifestPath = arg(args, '--manifest');
const manifest = manifestPath
? JSON.parse(await readFile(manifestPath, 'utf8')) : null;
const settings = manifestSettings(manifest);
const [state, detail] = probeVerdict(await probe(token));
const [verdict, why] = socketReadiness(settings, state);
console.log(`${verdict.padEnd(30)} ${why}`);
console.log(` probe: ${state} -- ${detail}`);
if (!FINDINGS.has(verdict)) {
process.exitCode = 0;
return;
}
if (state === 'missing-authorizations-read') {
console.warn(' repair: Basic Information -> App-Level Tokens -> Generate. Tick ' +
'connections:write and authorizations:read together; an existing ' +
'app-level token cannot have a scope added.');
console.warn(` repair: deploy the new value as ${tokenEnv}, restart, and only ` +
'then revoke the old token from the same page.');
console.warn(' limit: the probe proves one app-level scope is absent. ' +
'connections:write is unobservable without minting a connection, ' +
'so its absence here is inference, not proof.');
}
if (verdict === 'no-event-transport') {
console.warn(' repair: enable Socket Mode, or set a request URL under Event ' +
'Subscriptions. With neither, no event is delivered.');
}
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 test that matters most is the one asserting an error is a pass: invalid_event_context has to classify as a healthy credential, because everything else in the matrix hangs off that inversion. After that the suite walks the manifest crossing, including the row nobody thinks to configure — Socket Mode off, no request URL, no error anywhere, and no events either.
from slack_socket_readiness import manifest_settings, probe_verdict, socket_readiness
def test_an_argument_complaint_proves_the_credential_and_the_scope():
state, detail = probe_verdict({"ok": False, "error": "invalid_event_context"})
assert state == "app-level-scope-present"
assert "the pass" in detail
def test_missing_scope_names_the_mint_time_mistake():
state, detail = probe_verdict({"ok": False, "error": "missing_scope"})
assert state == "missing-authorizations-read"
assert "mint" in detail
def test_auth_mismatch_is_the_wrong_app_not_the_wrong_scope():
assert probe_verdict({"ok": False, "error": "auth_mismatch"})[0] == "token-from-another-app"
def test_a_workspace_token_in_the_app_level_slot_is_a_class_problem():
assert probe_verdict({"ok": False,
"error": "not_allowed_token_type"})[0] == "wrong-credential-class"
def test_an_unknown_error_is_not_assessed_rather_than_healthy():
assert probe_verdict({"ok": False, "error": "fatal_error"})[0] == "not-assessed"
def test_manifest_settings_survives_a_manifest_with_nothing_in_it():
assert manifest_settings({}) == {"socket_mode_enabled": False, "request_url": None}
assert manifest_settings(None) is None
def test_socket_mode_on_plus_a_scope_gap_is_the_headline_finding():
verdict, detail = socket_readiness(
{"socket_mode_enabled": True, "request_url": None},
"missing-authorizations-read")
assert verdict == "socket-mode-cannot-connect"
assert "reconnects forever" in detail
def test_socket_mode_on_with_a_live_token_is_only_ever_plausible():
verdict, detail = socket_readiness(
{"socket_mode_enabled": True, "request_url": None}, "app-level-scope-present")
assert verdict == "socket-mode-plausible"
assert "strongest read-only evidence" in detail
def test_no_manifest_downgrades_the_conclusion_instead_of_guessing():
assert socket_readiness(None, "app-level-scope-present")[0] == "app-level-token-live"
assert socket_readiness(None, "missing-authorizations-read")[0] == "app-level-scopes-incomplete"
def test_socket_mode_off_with_a_request_url_is_not_a_finding():
verdict, _ = socket_readiness(
{"socket_mode_enabled": False, "request_url": "https://example.test/slack"},
"app-level-scope-present")
assert verdict == "http-events-not-socket"
def test_neither_transport_configured_is_a_finding_with_no_error_behind_it():
verdict, detail = socket_readiness(
{"socket_mode_enabled": False, "request_url": None}, "app-level-scope-present")
assert verdict == "no-event-transport"
assert "no error anywhere" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { manifestSettings, probeVerdict, socketReadiness } from './slack-socket-readiness.mjs';
test('an argument complaint proves the credential and the scope', () => {
const [state, detail] = probeVerdict({ ok: false, error: 'invalid_event_context' });
assert.equal(state, 'app-level-scope-present');
assert.match(detail, /the pass/);
});
test('missing_scope names the mint time mistake', () => {
const [state, detail] = probeVerdict({ ok: false, error: 'missing_scope' });
assert.equal(state, 'missing-authorizations-read');
assert.match(detail, /mint/);
});
test('auth_mismatch is the wrong app not the wrong scope', () => {
assert.equal(probeVerdict({ ok: false, error: 'auth_mismatch' })[0],
'token-from-another-app');
});
test('a workspace token in the app-level slot is a class problem', () => {
assert.equal(probeVerdict({ ok: false, error: 'not_allowed_token_type' })[0],
'wrong-credential-class');
});
test('an unknown error is not assessed rather than healthy', () => {
assert.equal(probeVerdict({ ok: false, error: 'fatal_error' })[0], 'not-assessed');
});
test('manifestSettings survives a manifest with nothing in it', () => {
assert.deepEqual(manifestSettings({}),
{ socket_mode_enabled: false, request_url: null });
assert.equal(manifestSettings(null), null);
});
test('socket mode on plus a scope gap is the headline finding', () => {
const [verdict, detail] = socketReadiness(
{ socket_mode_enabled: true, request_url: null }, 'missing-authorizations-read');
assert.equal(verdict, 'socket-mode-cannot-connect');
assert.match(detail, /reconnects forever/);
});
test('socket mode on with a live token is only ever plausible', () => {
const [verdict, detail] = socketReadiness(
{ socket_mode_enabled: true, request_url: null }, 'app-level-scope-present');
assert.equal(verdict, 'socket-mode-plausible');
assert.match(detail, /strongest read-only evidence/);
});
test('no manifest downgrades the conclusion instead of guessing', () => {
assert.equal(socketReadiness(null, 'app-level-scope-present')[0], 'app-level-token-live');
assert.equal(socketReadiness(null, 'missing-authorizations-read')[0],
'app-level-scopes-incomplete');
});
test('neither transport configured is a finding with no error behind it', () => {
const [verdict, detail] = socketReadiness(
{ socket_mode_enabled: false, request_url: null }, 'app-level-scope-present');
assert.equal(verdict, 'no-event-transport');
assert.match(detail, /no error anywhere/);
});
FAQ
Why not just call apps.connections.open and see what happens?
Because it mints a connection. The method returns a single-use WebSocket URL, counts against the ten connections an app may hold open, and changes state on Slack's side, so it is a write by any reasonable definition. On a running app an audit that opens sockets can also displace a live one, which is a worse outcome than not knowing.
Can I read the scopes on an app-level token?
No. auth.test rejects an xapp- token, there is no method that returns its grant, and the App-Level Tokens page shows the scopes only at creation time. The probe in this note is the whole observable surface: one read method, one credential class, and an error message that tells you which stage the request reached.
Does missing_scope on the probe really mean Socket Mode is broken?
It means this token was minted with a partial scope set, which is the mistake behind almost every case of this. It is not proof that connections:write is absent, because that scope cannot be observed at all without minting a connection. The script says so in its own output, and the repair is the same either way: regenerate with both boxes ticked.
Can I add connections:write to the token I already have?
No. App-level tokens are immutable once generated; the page offers generate and revoke and nothing in between. Generate a replacement with both scopes, deploy it, restart the process, and revoke the old one afterwards rather than before, so a failed deploy leaves you somewhere to fall back to.
What if I cannot export the manifest?
The script runs without one and downgrades its conclusion rather than assuming. You still learn whether the app-level token authenticates and whether it holds an app-level scope, which is most of the value; what you lose is the ability to say whether Socket Mode is the transport that matters here, and the output says that explicitly instead of implying a clean bill of health.
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.
- Socket Mode — Slack Docs
- apps.connections.open method reference — Slack Docs
- apps.event.authorizations.list method reference — Slack Docs
- Token types — Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.