Diagnostic Slack
Thread-only and non-threadable channels refuse your post
This one is not a permission. The app is a member, the token is fine, the channel accepts messages from it all day — just not that message, in that position. A top-level post comes back restricted_action_thread_only_channel. A reply comes back restricted_action_non_threadable_channel, or cannot_reply_to_message, which is a different failure again.
The code has one posting mode and the channels have several conventions, and the mismatch only shows up in whichever channel somebody added last.
Slack channels can be configured so that all conversation happens in threads, in which case a top-level post is refused; and there are channels and messages where threading does not apply, in which case a reply is refused. An integration that always posts top-level, or that always replies under a stored thread_ts, works everywhere until it meets the channel that wants the other one.
There is no is_thread_only field to read, so the check is a convention check rather than a policy check, and the script is careful to say which it is doing. conversations.history shows how the channel actually behaves: how many top-level posts there are, how many of them carry reply_count, and how much of the traffic lives under a handful of parents. That shape is compared against the posting mode your integration is configured for. Separately, conversations.replies tells you whether a stored thread_ts still anchors a live parent, which is the other half of cannot_reply_to_message and has nothing to do with channel policy at all.
The problem in plain words
An integration picks its posting mode once, early, for reasons that had nothing to do with any channel. A digest posts top-level because that is what a digest is. An incident bot replies under a stored thread_ts because it wants the updates to stay together. A deploy notifier posts a parent and then threads its own updates under it. Each of those is a sensible default, and each is a global setting in a codebase whose channel list grows one row at a time.
Then somebody adds the incident channel that a team runs entirely in threads, and top-level posts stop being accepted. Or the alert stream is pointed at a channel where the stored parent has aged out of retention, and every reply comes back cannot_reply_to_message while nothing about the channel has changed. Both look like the channel rejecting the app, and neither is: the message is allowed, in the other position.
The two halves of the failure also want different repairs and get conflated constantly. A channel-level refusal is fixed by changing the mode you post in. A dead thread_ts is fixed by not storing a thread anchor forever — a parent can be deleted, and it can simply fall off the end of a workspace's retention window, after which the ID you cached refers to nothing. One of those is a configuration change and the other is a data-lifetime bug.
Why it happens
This is a position error, not a permission error. It shares the restricted_action prefix with the channel-lock family, which sends people to the permissions screen, where there is nothing to find. The app may post. It may not post there, in that arrangement.
No field describes the convention, so the history has to. conversations.info does not return a threading policy. What is readable is behaviour: a channel where nearly every message lives under three parents is being run as a thread-only channel whether or not a setting says so, and an integration that posts top-level into it is going to look wrong even in the cases where it is not refused.
The sample is smaller than you think. A non-Marketplace app installed after May 2025 is clamped to fifteen objects per conversations.history call and one call a minute. Fifteen messages is a thin basis for a claim about a convention, so the script prints the sample size beside the verdict and refuses to classify below a floor.
cannot_reply_to_message is a message-lifetime problem wearing channel clothes. The parent can be deleted, leaving a tombstone; it can age past the retention window; it can belong to a channel the token has since lost access to. None of those is a channel policy, and conversations.replies distinguishes them for the cost of one call.
The posting mode belongs to the target, not to the app. The durable repair is a per-channel setting rather than a global one, because the next channel somebody adds will have its own convention and the code will meet it in production.
The fix, as a flow
This is the one note here where nothing is forbidden: the message is allowed, in the other position. The chain shows a global decision meeting a channel that was added long afterwards, and the fix branch keeps the dead thread anchor apart from the two channel refusals, because it is a data lifetime bug rather than a convention.
How to fix it
Write down the mode the integration actually uses
Three of them cover almost everything: top-level, reply under a stored anchor, and parent-then-replies where the app posts its own parent and threads beneath it. The script takes the mode as an argument because it is a fact about your code, not about Slack, and nothing in the API will tell it to you.
Read the channel's history and count the shape, not the messages
conversations.history returns top-level messages, each carrying reply_count for the thread hanging off it. The three numbers that matter are the top-level count, how many of those have replies, and how many replies in total.
Refuse to classify a thin sample
Below a floor of readable posts the answer is undetermined and the script says so. This is the note where a rate-limit clamp is not a footnote: fifteen messages can make an ordinary channel look thread-only or look never-threaded depending on the hour you ran it.
Compare the convention against your mode
A thread-heavy channel plus a top-level poster predicts restricted_action_thread_only_channel. A never-threaded channel plus a replier predicts restricted_action_non_threadable_channel. The script names the error you would get, which is what makes the finding recognisable to whoever has that string in their logs.
Check every stored thread anchor separately
One conversations.replies with limit=1 per anchor. thread_not_found, a tombstone parent, and a live parent with replies are three different states, and only the third is a working anchor. This check has nothing to do with the channel's convention and everything to do with cannot_reply_to_message.
Make the mode a property of the target
Move the posting mode out of the global config and into the row that holds the channel ID. The script prints the line to add. Then stop storing thread anchors indefinitely: give them an expiry shorter than the workspace retention and fall back to a fresh parent when the anchor is gone.
How to check it worked
Run it against the channel that was refusing the message, with the mode the integration is configured for.
python3 slack_thread_mode_match.py --channel C0INCIDENT --mode reply --anchor 1735689600.000100
# C0INCIDENT shape thread-heavy 18 top-level post(s), 14 with replies, 212 replies
# C0INCIDENT mode compatible reply matches how this channel is used
# C0INCIDENT anchor anchored 1735689600.000100 still has a live parent
# 1 channel(s) checked, 0 mismatch(es), 0 dead anchor(s)
The full code
Three pure functions and two GET methods, and the split between them is the point of the note. threading_shape reads a convention out of history and is deliberately unwilling to call one below a sample floor. mode_mismatch turns the convention plus your posting mode into the error string you would see, which is what makes the finding searchable. anchor_state answers an unrelated question that produces a similar-looking failure: whether a stored thread_ts still points at anything.
"""Match an integration's Slack posting mode against how a channel is actually used.
Read only. One conversations.history per channel reads the threading convention,
one conversations.replies per stored anchor checks that it still anchors
something. Nothing is sent: the script names the error the current mode would
produce and prints the configuration line that stops producing it.
"""
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_thread_mode_match")
API = "https://slack.com/api/"
# System traffic. Nobody chose to post it and it says nothing about how the
# channel is used, so it is dropped before any shape is counted.
NOT_A_POST = frozenset({
"channel_join", "channel_leave", "channel_topic", "channel_purpose",
"channel_name", "channel_archive", "channel_unarchive", "pinned_item",
"unpinned_item", "bot_add", "bot_remove", "reminder_add",
})
MODES = ("top-level", "reply", "parent-then-replies")
# Below this many readable posts nothing is claimed. A non-Marketplace app is
# clamped to 15 objects per conversations.history call, which is thin enough to
# make an ordinary channel look like either extreme.
MIN_SAMPLE = 8
THREAD_HEAVY = 0.8
MIN_PARENTS = 3
def threading_shape(messages):
"""Read a channel's threading convention out of its history. Pure.
Returns (shape, stats). conversations.history returns top-level messages,
each carrying reply_count for the thread hanging off it, so the three numbers
worth having are the top-level count, how many of those were replied to, and
how many replies exist in total.
The shapes are named after behaviour rather than policy. Slack exposes no
threading setting, so "thread-heavy" is the strongest honest claim: this is
how the channel is used, which is what your message will be judged against.
"""
posts = parents = replies = broadcasts = 0
for message in messages or []:
if message.get("subtype") in NOT_A_POST:
continue
posts += 1
count = int(message.get("reply_count") or 0)
if count:
parents += 1
replies += count
if message.get("subtype") == "thread_broadcast":
broadcasts += 1
stats = {"sample": len(messages or []), "posts": posts, "parents": parents,
"replies": replies, "broadcasts": broadcasts}
if posts < MIN_SAMPLE:
return ("undetermined", stats)
share = replies / float(replies + posts) if (replies + posts) else 0.0
if share >= THREAD_HEAVY and parents >= MIN_PARENTS:
return ("thread-heavy", stats)
if replies == 0:
return ("never-threaded", stats)
return ("mixed", stats)
def mode_mismatch(shape, mode):
"""Compare the channel's convention against the mode the integration uses. Pure.
Returns (verdict, likely_error, detail). The error string is the useful half:
it is what somebody already has in their logs, and naming it is what turns a
shape into a recognisable finding.
"""
if mode not in MODES:
return ("unknown-mode", "",
"%r is not one of %s. The mode is a fact about your code and "
"nothing in the API will supply it." % (mode, ", ".join(MODES)))
if shape == "undetermined":
return ("undetermined", "",
"too little readable history to describe how this channel is used. "
"Re-run with a larger sample, or accept that this channel cannot be "
"assessed from here.")
if shape == "thread-heavy" and mode == "top-level":
return ("likely-blocked", "restricted_action_thread_only_channel",
"almost everything said here is said inside a thread, and this "
"integration posts at the top level. Where the channel enforces "
"that convention the post is refused; where it does not, the post "
"lands somewhere nobody is reading.")
if shape == "never-threaded" and mode in ("reply", "parent-then-replies"):
return ("likely-blocked", "restricted_action_non_threadable_channel",
"nothing in the readable history has ever been replied to, and "
"this integration threads. Either the channel does not support "
"threading or nobody uses it, and the first of those refuses the "
"message outright.")
if shape == "mixed":
return ("compatible", "",
"the channel is used both ways, so neither mode is refused by "
"convention. Any failure here is a permission or an anchor rather "
"than a position.")
return ("compatible", "", "%s matches how this channel is used" % mode)
def anchor_state(body):
"""Whether a stored thread_ts still anchors a live parent. Pure.
Nothing here is about channel policy. cannot_reply_to_message is usually a
message-lifetime problem: parents get deleted, and they fall off the end of a
workspace's retention window, after which a cached anchor refers to nothing.
"""
if body.get("ok") is not True:
error = body.get("error") or "<no error field>"
if error == "thread_not_found":
return ("missing-parent",
"conversations.replies says thread_not_found. The stored ts "
"anchors nothing: the parent was deleted or has aged past the "
"workspace retention window. A reply under it returns "
"cannot_reply_to_message.")
if error in ("not_in_channel", "channel_not_found", "missing_scope"):
return ("unreadable",
"conversations.replies says %s, which is a membership or scope "
"question rather than an answer about the thread." % error)
return ("unreadable",
"conversations.replies answered 200 with ok: false, error=%s" % error)
messages = body.get("messages") or []
if not messages:
return ("missing-parent",
"ok: true with an empty messages array. The thread is gone and "
"Slack did not consider that an error.")
parent = messages[0]
if parent.get("subtype") == "tombstone":
return ("deleted-parent",
"the parent is a tombstone: it was deleted while its replies "
"survived. Slack keeps the thread visible and refuses new replies "
"to it.")
count = int(parent.get("reply_count") or 0)
if count == 0:
return ("childless-parent",
"the ts resolves to a message with no replies yet. That is a "
"perfectly good anchor, and it is indistinguishable from a message "
"nobody has threaded under.")
return ("anchored", "the parent is there with %d repl(ies) under it" % count)
def get(session, method, params):
"""One read call. GET only, and the body is returned as Slack sent it."""
return session.get(API + method, params=params, timeout=30).json()
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--channel", required=True, action="append",
help="a channel ID the integration posts to; repeatable")
ap.add_argument("--mode", required=True, choices=list(MODES),
help="how this integration posts today")
ap.add_argument("--anchor", action="append", default=[],
help="a stored thread_ts to validate; repeatable")
ap.add_argument("--sample", type=int, default=100,
help="how many messages of history to read per channel")
ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
help="environment variable holding the bot token")
args = ap.parse_args()
token = os.environ.get(args.token_env)
if not token:
log.error("set %s (channels:read and channels:history are enough)",
args.token_env)
return 2
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + token})
mismatches = dead = 0
for cid in args.channel:
history = get(s, "conversations.history",
{"channel": cid, "limit": args.sample})
if history.get("ok") is not True:
log.warning("%-12s history unreadable, error=%s", cid,
history.get("error"))
continue
shape, stats = threading_shape(history.get("messages") or [])
verdict, likely, detail = mode_mismatch(shape, args.mode)
log.info("%-12s shape %-15s %d top-level post(s), %d with replies, "
"%d replies", cid, shape, stats["posts"], stats["parents"],
stats["replies"])
if verdict == "compatible":
log.info("%-12s mode %-15s %s", cid, verdict, detail)
else:
mismatches += 1
log.warning("%-12s mode %-15s %s", cid, verdict, detail)
if likely:
log.warning(" expect: %s on the next send", likely)
log.warning(" repair: set the posting mode on this target rather than "
"globally, for example %s: mode=%s", cid,
"reply" if args.mode == "top-level" else "top-level")
for anchor in args.anchor:
replies = get(s, "conversations.replies",
{"channel": cid, "ts": anchor, "limit": 1})
state, why = anchor_state(replies)
if state == "anchored" or state == "childless-parent":
log.info("%-12s anchor %-15s %s", cid, state, why)
continue
dead += 1
log.warning("%-12s anchor %-15s %s", cid, state, why)
log.warning(" repair: expire stored thread anchors well inside the "
"workspace retention window and fall back to a fresh "
"parent when the anchor is gone")
log.info("%d channel(s) checked, %d mismatch(es), %d dead anchor(s)",
len(args.channel), mismatches, dead)
return 1 if (mismatches or dead) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Match an integration's Slack posting mode against how a channel is used.
*
* Read only. One conversations.history per channel reads the threading
* convention, one conversations.replies per stored anchor checks that it still
* anchors something. Nothing is sent: the script names the error the current
* mode would produce and prints the configuration line that stops it.
*/
const API = 'https://slack.com/api/';
// System traffic. Nobody chose to post it and it says nothing about how the
// channel is used, so it is dropped before any shape is counted.
const NOT_A_POST = new Set([
'channel_join', 'channel_leave', 'channel_topic', 'channel_purpose',
'channel_name', 'channel_archive', 'channel_unarchive', 'pinned_item',
'unpinned_item', 'bot_add', 'bot_remove', 'reminder_add',
]);
export const MODES = ['top-level', 'reply', 'parent-then-replies'];
const MIN_SAMPLE = 8;
const THREAD_HEAVY = 0.8;
const MIN_PARENTS = 3;
/**
* Read a channel's threading convention out of its history. Pure.
* The shapes are named after behaviour rather than policy: Slack exposes no
* threading setting, so `thread-heavy` is the strongest honest claim.
*/
export function threadingShape(messages) {
let posts = 0; let parents = 0; let replies = 0; let broadcasts = 0;
for (const message of messages ?? []) {
if (NOT_A_POST.has(message.subtype)) continue;
posts += 1;
const count = Number(message.reply_count ?? 0);
if (count) { parents += 1; replies += count; }
if (message.subtype === 'thread_broadcast') broadcasts += 1;
}
const stats = { sample: (messages ?? []).length, posts, parents, replies, broadcasts };
if (posts < MIN_SAMPLE) return ['undetermined', stats];
const share = (replies + posts) ? replies / (replies + posts) : 0;
if (share >= THREAD_HEAVY && parents >= MIN_PARENTS) return ['thread-heavy', stats];
if (replies === 0) return ['never-threaded', stats];
return ['mixed', stats];
}
/**
* Compare the channel's convention against the mode the integration uses. Pure.
* Returns [verdict, likelyError, detail]; the error string is the half somebody
* already has in their logs.
*/
export function modeMismatch(shape, mode) {
if (!MODES.includes(mode)) {
return ['unknown-mode', '',
`${mode} is not one of ${MODES.join(', ')}. The mode is a fact about your ` +
'code and nothing in the API will supply it.'];
}
if (shape === 'undetermined') {
return ['undetermined', '',
'too little readable history to describe how this channel is used. Re-run ' +
'with a larger sample, or accept that this channel cannot be assessed from here.'];
}
if (shape === 'thread-heavy' && mode === 'top-level') {
return ['likely-blocked', 'restricted_action_thread_only_channel',
'almost everything said here is said inside a thread, and this integration ' +
'posts at the top level. Where the channel enforces that convention the post ' +
'is refused; where it does not, the post lands somewhere nobody is reading.'];
}
if (shape === 'never-threaded' && (mode === 'reply' || mode === 'parent-then-replies')) {
return ['likely-blocked', 'restricted_action_non_threadable_channel',
'nothing in the readable history has ever been replied to, and this ' +
'integration threads. Either the channel does not support threading or ' +
'nobody uses it, and the first of those refuses the message outright.'];
}
if (shape === 'mixed') {
return ['compatible', '',
'the channel is used both ways, so neither mode is refused by convention. ' +
'Any failure here is a permission or an anchor rather than a position.'];
}
return ['compatible', '', `${mode} matches how this channel is used`];
}
/**
* Whether a stored thread_ts still anchors a live parent. Pure.
* Nothing here is about channel policy: cannot_reply_to_message is usually a
* message-lifetime problem.
*/
export function anchorState(body) {
if (body.ok !== true) {
const error = body.error ?? '<no error field>';
if (error === 'thread_not_found') {
return ['missing-parent',
'conversations.replies says thread_not_found. The stored ts anchors ' +
'nothing: the parent was deleted or has aged past the workspace retention ' +
'window. A reply under it returns cannot_reply_to_message.'];
}
if (['not_in_channel', 'channel_not_found', 'missing_scope'].includes(error)) {
return ['unreadable',
`conversations.replies says ${error}, which is a membership or scope ` +
'question rather than an answer about the thread.'];
}
return ['unreadable',
`conversations.replies answered 200 with ok: false, error=${error}`];
}
const messages = body.messages ?? [];
if (messages.length === 0) {
return ['missing-parent',
'ok: true with an empty messages array. The thread is gone and Slack did ' +
'not consider that an error.'];
}
const parent = messages[0];
if (parent.subtype === 'tombstone') {
return ['deleted-parent',
'the parent is a tombstone: it was deleted while its replies survived. ' +
'Slack keeps the thread visible and refuses new replies to it.'];
}
const count = Number(parent.reply_count ?? 0);
if (count === 0) {
return ['childless-parent',
'the ts resolves to a message with no replies yet. That is a perfectly good ' +
'anchor, and it is indistinguishable from a message nobody has threaded under.'];
}
return ['anchored', `the parent is there with ${count} repl(ies) under it`];
}
async function get(token, method, params) {
const res = await fetch(`${API}${method}?${new URLSearchParams(params)}`, {
headers: { Authorization: `Bearer ${token}` },
});
return res.json();
}
function all(args, name) {
const out = [];
for (let i = 0; i < args.length; i += 1) if (args[i] === name) out.push(args[i + 1]);
return out;
}
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 channels = all(args, '--channel');
const mode = arg(args, '--mode', '');
if (channels.length === 0 || !MODES.includes(mode)) {
console.error(`usage: --channel C0... [--channel C1...] --mode ${MODES.join('|')} ` +
'[--anchor 1735689600.000100] [--sample 100] [--token-env SLACK_BOT_TOKEN]');
process.exitCode = 2;
return;
}
const anchors = all(args, '--anchor');
const sample = arg(args, '--sample', '100');
const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
const token = process.env[tokenEnv];
if (!token) {
console.error(`set ${tokenEnv} (channels:read and channels:history are enough)`);
process.exitCode = 2;
return;
}
let mismatches = 0;
let dead = 0;
for (const cid of channels) {
const history = await get(token, 'conversations.history',
{ channel: cid, limit: String(sample) });
if (history.ok !== true) {
console.warn(`${cid.padEnd(12)} history unreadable, error=${history.error}`);
continue;
}
const [shape, stats] = threadingShape(history.messages ?? []);
const [verdict, likely, detail] = modeMismatch(shape, mode);
console.log(`${cid.padEnd(12)} shape ${shape.padEnd(15)} ${stats.posts} ` +
`top-level post(s), ${stats.parents} with replies, ${stats.replies} replies`);
if (verdict === 'compatible') {
console.log(`${cid.padEnd(12)} mode ${verdict.padEnd(15)} ${detail}`);
} else {
mismatches += 1;
console.warn(`${cid.padEnd(12)} mode ${verdict.padEnd(15)} ${detail}`);
if (likely) console.warn(` expect: ${likely} on the next send`);
console.warn(' repair: set the posting mode on this target rather than ' +
`globally, for example ${cid}: mode=` +
`${mode === 'top-level' ? 'reply' : 'top-level'}`);
}
for (const anchor of anchors) {
const replies = await get(token, 'conversations.replies',
{ channel: cid, ts: anchor, limit: '1' });
const [state, why] = anchorState(replies);
if (state === 'anchored' || state === 'childless-parent') {
console.log(`${cid.padEnd(12)} anchor ${state.padEnd(15)} ${why}`);
continue;
}
dead += 1;
console.warn(`${cid.padEnd(12)} anchor ${state.padEnd(15)} ${why}`);
console.warn(' repair: expire stored thread anchors well inside the ' +
'workspace retention window and fall back to a fresh parent when the ' +
'anchor is gone');
}
}
console.log(`${channels.length} channel(s) checked, ${mismatches} mismatch(es), ` +
`${dead} dead anchor(s)`);
process.exitCode = (mismatches || dead) ? 1 : 0;
}
// Only run when invoked directly, so importing this module in the tests does not
// execute main() and fail the file on a missing token.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The shape functions are pinned from both ends: a channel that is nothing but threads, and a channel where no message has ever been replied to, with the sample floor between them so that neither can be claimed from a handful of messages. The anchor tests separate the three ways a stored thread_ts can be dead — a thread_not_found error, an ok: true response with no messages in it, and a tombstoned parent — because only the first of those looks like a failure.
from slack_thread_mode_match import anchor_state, mode_mismatch, threading_shape
def threads(parents, replies_each):
"""Top-level messages that each carry a busy thread."""
return [{"user": "U0AAA", "ts": "17000000%02d.0001" % i,
"reply_count": replies_each} for i in range(parents)]
def flat(count):
"""Top-level messages nobody has ever replied to."""
return [{"user": "U0AAA", "ts": "17000000%02d.0001" % i} for i in range(count)]
def test_a_channel_run_in_threads_reads_as_thread_heavy():
shape, stats = threading_shape(threads(10, 20))
assert shape == "thread-heavy"
assert stats["parents"] == 10
assert stats["replies"] == 200
def test_a_channel_nobody_threads_in_reads_as_never_threaded():
shape, stats = threading_shape(flat(20))
assert shape == "never-threaded"
assert stats["replies"] == 0
def test_a_channel_used_both_ways_is_mixed():
assert threading_shape(threads(3, 2) + flat(20))[0] == "mixed"
def test_system_traffic_is_not_a_post():
messages = flat(3) + [{"user": "U0BBB", "subtype": "channel_join"}] * 30
shape, stats = threading_shape(messages)
assert stats["posts"] == 3
assert shape == "undetermined"
def test_a_thin_sample_is_never_classified():
assert threading_shape(threads(2, 40))[0] == "undetermined"
assert threading_shape([])[0] == "undetermined"
def test_top_level_posting_into_a_thread_run_channel_names_its_error():
verdict, likely, detail = mode_mismatch("thread-heavy", "top-level")
assert verdict == "likely-blocked"
assert likely == "restricted_action_thread_only_channel"
assert "nobody is reading" in detail
def test_threading_into_a_channel_that_never_threads_names_the_other_error():
for mode in ("reply", "parent-then-replies"):
verdict, likely, _ = mode_mismatch("never-threaded", mode)
assert verdict == "likely-blocked"
assert likely == "restricted_action_non_threadable_channel"
def test_the_matching_pairs_are_compatible():
assert mode_mismatch("thread-heavy", "reply")[0] == "compatible"
assert mode_mismatch("never-threaded", "top-level")[0] == "compatible"
assert mode_mismatch("mixed", "top-level")[0] == "compatible"
def test_an_unassessable_channel_produces_no_prediction():
verdict, likely, _ = mode_mismatch("undetermined", "top-level")
assert verdict == "undetermined"
assert likely == ""
def test_a_mode_the_script_does_not_know_is_rejected_rather_than_guessed():
assert mode_mismatch("thread-heavy", "broadcast")[0] == "unknown-mode"
def test_thread_not_found_is_a_dead_anchor_and_says_which_error_follows():
state, why = anchor_state({"ok": False, "error": "thread_not_found"})
assert state == "missing-parent"
assert "cannot_reply_to_message" in why
def test_an_ok_response_with_no_messages_is_also_a_dead_anchor():
state, why = anchor_state({"ok": True, "messages": []})
assert state == "missing-parent"
assert "not consider that an error" in why
def test_a_tombstoned_parent_is_its_own_state():
body = {"ok": True, "messages": [{"subtype": "tombstone", "reply_count": 4}]}
assert anchor_state(body)[0] == "deleted-parent"
def test_a_live_parent_is_anchored():
body = {"ok": True, "messages": [{"ts": "1735689600.000100", "reply_count": 4}]}
state, why = anchor_state(body)
assert state == "anchored"
assert "4 repl" in why
def test_a_parent_with_no_replies_yet_is_still_a_good_anchor():
body = {"ok": True, "messages": [{"ts": "1735689600.000100"}]}
assert anchor_state(body)[0] == "childless-parent"
def test_a_scope_error_is_not_reported_as_a_dead_thread():
assert anchor_state({"ok": False, "error": "missing_scope"})[0] == "unreadable"
assert anchor_state({"ok": False, "error": "not_in_channel"})[0] == "unreadable"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { anchorState, modeMismatch, threadingShape }
from './slack-thread-mode-match.mjs';
/** Top-level messages that each carry a busy thread. */
function threads(parents, repliesEach) {
return Array.from({ length: parents }, (_, i) => ({
user: 'U0AAA', ts: `17000000${String(i).padStart(2, '0')}.0001`,
reply_count: repliesEach,
}));
}
/** Top-level messages nobody has ever replied to. */
function flat(count) {
return Array.from({ length: count }, (_, i) => ({
user: 'U0AAA', ts: `17000000${String(i).padStart(2, '0')}.0001`,
}));
}
test('a channel run in threads reads as thread heavy', () => {
const [shape, stats] = threadingShape(threads(10, 20));
assert.equal(shape, 'thread-heavy');
assert.equal(stats.parents, 10);
assert.equal(stats.replies, 200);
});
test('a channel nobody threads in reads as never threaded', () => {
const [shape, stats] = threadingShape(flat(20));
assert.equal(shape, 'never-threaded');
assert.equal(stats.replies, 0);
});
test('a channel used both ways is mixed', () => {
assert.equal(threadingShape([...threads(3, 2), ...flat(20)])[0], 'mixed');
});
test('system traffic is not a post', () => {
const joins = Array.from({ length: 30 },
() => ({ user: 'U0BBB', subtype: 'channel_join' }));
const [shape, stats] = threadingShape([...flat(3), ...joins]);
assert.equal(stats.posts, 3);
assert.equal(shape, 'undetermined');
});
test('a thin sample is never classified', () => {
assert.equal(threadingShape(threads(2, 40))[0], 'undetermined');
assert.equal(threadingShape([])[0], 'undetermined');
});
test('top level posting into a thread run channel names its error', () => {
const [verdict, likely, detail] = modeMismatch('thread-heavy', 'top-level');
assert.equal(verdict, 'likely-blocked');
assert.equal(likely, 'restricted_action_thread_only_channel');
assert.match(detail, /nobody is reading/);
});
test('threading into a channel that never threads names the other error', () => {
for (const mode of ['reply', 'parent-then-replies']) {
const [verdict, likely] = modeMismatch('never-threaded', mode);
assert.equal(verdict, 'likely-blocked');
assert.equal(likely, 'restricted_action_non_threadable_channel');
}
});
test('the matching pairs are compatible', () => {
assert.equal(modeMismatch('thread-heavy', 'reply')[0], 'compatible');
assert.equal(modeMismatch('never-threaded', 'top-level')[0], 'compatible');
assert.equal(modeMismatch('mixed', 'top-level')[0], 'compatible');
});
test('an unassessable channel produces no prediction', () => {
const [verdict, likely] = modeMismatch('undetermined', 'top-level');
assert.equal(verdict, 'undetermined');
assert.equal(likely, '');
});
test('a mode the script does not know is rejected rather than guessed', () => {
assert.equal(modeMismatch('thread-heavy', 'broadcast')[0], 'unknown-mode');
});
test('thread_not_found is a dead anchor and says which error follows', () => {
const [state, why] = anchorState({ ok: false, error: 'thread_not_found' });
assert.equal(state, 'missing-parent');
assert.match(why, /cannot_reply_to_message/);
});
test('an ok response with no messages is also a dead anchor', () => {
const [state, why] = anchorState({ ok: true, messages: [] });
assert.equal(state, 'missing-parent');
assert.match(why, /not consider that an error/);
});
test('a tombstoned parent is its own state', () => {
const body = { ok: true, messages: [{ subtype: 'tombstone', reply_count: 4 }] };
assert.equal(anchorState(body)[0], 'deleted-parent');
});
test('a live parent is anchored', () => {
const body = { ok: true, messages: [{ ts: '1735689600.000100', reply_count: 4 }] };
const [state, why] = anchorState(body);
assert.equal(state, 'anchored');
assert.match(why, /4 repl/);
});
test('a parent with no replies yet is still a good anchor', () => {
const body = { ok: true, messages: [{ ts: '1735689600.000100' }] };
assert.equal(anchorState(body)[0], 'childless-parent');
});
test('a scope error is not reported as a dead thread', () => {
assert.equal(anchorState({ ok: false, error: 'missing_scope' })[0], 'unreadable');
assert.equal(anchorState({ ok: false, error: 'not_in_channel' })[0], 'unreadable');
});
FAQ
Is there a field that says a channel is thread-only?
No. conversations.info returns no threading policy, which is why this check reads behaviour instead. That is a real limitation and the script names its shapes accordingly: thread-heavy describes how a channel is used, not what a setting says. It happens to be the more useful claim anyway, since a top-level post into a channel that runs on threads is a bad post even where it is technically allowed.
What is the difference between restricted_action_thread_only_channel and cannot_reply_to_message?
The first is the channel refusing a top-level post. The second is a specific thread refusing a reply, almost always because the parent no longer exists: it was deleted, or it aged out of the workspace retention window. One is fixed by changing where in the channel you post; the other is fixed by not caching a thread anchor for longer than the message it points at will live.
How large a history sample do I need?
The script floors at eight readable posts and prints the sample size beside every verdict. If your app is not Marketplace-listed and was installed after May 2025, conversations.history returns at most fifteen objects per call and you get one call a minute, so raising the limit does not help and you may need several runs. Below the floor the honest answer is undetermined.
Can I just always post in a thread to be safe?
No, because the mirror-image failure exists: a channel or message where threading does not apply refuses the reply with restricted_action_non_threadable_channel. There is no globally safe mode, which is the actual finding of this note. The mode has to be a property of the target channel, stored beside its ID.
Why does the check need me to tell it the posting mode?
Because nothing in the Slack API knows how your code sends. The convention is readable and the mode is not, so the comparison needs one fact from each side. Passing it in also makes the check honest about what it is doing: it is comparing a decision you made against a habit the channel has, not evaluating a permission.
Related field notes
- when it is a permission rather than a position
- why the history sample is fifteen objects
- every failure arrives as HTTP 200
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.
- chat.postMessage method reference — Slack Docs
- conversations.replies method reference — Slack Docs
- conversations.history method reference — Slack Docs
- Retrieving messages — Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.