Threads, mentions and context: making a Slack agent behave like a colleague
A colleague pulled into a thread reads the messages above the mention, answers in the thread, and stays out of the conversation they were not part of. None of the three is a Slack agent's default. What counts as conversation state in a channel, where the reply belongs, and how to stop the agent answering questions nobody asked it.
A colleague pulled into a thread does three things without being asked. They read the handful of messages above the mention. They answer in the thread rather than in the channel. And when the conversation carries on without them, they stay out of it.
None of the three is the default behaviour of a Slack agent, and the distance between an agent that does them and one that does not is most of what people mean when they say the integration feels wrong. It is never the model. It is a reply posted over the top of a channel instead of inside the thread, an answer to a question whose first half the agent never saw, or a bot still talking after everybody else has moved on.
The scopes post covers what the agent is allowed to read. This is the engineering problem sitting on top of it: given the events you are actually pushed, what counts as conversation state in a channel, where the answer belongs, and how to keep the agent quiet the rest of the time. It is the harder half, and it is the half every vendor page skips, because their thread handling is their product and yours is a line in their changelog.
- events a mention driven agent receives for the next message in a thread it just answereda follow up without an @ never reaches your endpoint at all
- 0
- where
say()posts the reply when you do not hand it a threadeven when the user mentioned the agent inside a thread, which gets reported as a bug and is not one - channel
- field that decides whether the answer lands in the thread or over it`thread_ts`, present on the event only when the mention was already inside a thread
- 1
- scope
conversations.repliesneeds as standing channel history`channels:history` either way, so one thread is narrower in your logs rather than on the install screen - same
`thread_ts` or `ts`: the line that decides where the answer lands
An app_mention event carries ts, the timestamp of the message that addressed you, and carries thread_ts only when that message was already sitting inside a thread. So the reply target is thread_ts ?? ts, and that single expression is the difference between an agent that answers where it was asked and one that answers a threaded question in front of the whole channel.
It is worth writing by hand rather than leaving to a helper. Bolt's say() posts to the channel unless you pass a thread, which has been filed as a bug more than once and is not one: the SDK has no opinion about where your answer belongs. The newer streaming helpers do take the thread from the payload, which means two code paths in the same app can disagree about this if you let them.
type SlackMessageEvent = {
channel: string;
ts: string; // the message that addressed us
thread_ts?: string; // present only if that message was already in a thread
user?: string;
bot_id?: string;
subtype?: string;
text?: string;
};
/**
* Where the answer goes. A top level mention starts a thread under itself,
* which keeps the channel readable and gives every follow up one parent.
* Never pass a reply's own `ts` as the parent: Slack's guidance is to use
* the parent instead, and a thread hanging off a thread does not exist.
*/
export function replyTarget(event: SlackMessageEvent) {
return { channel: event.channel, thread_ts: event.thread_ts ?? event.ts };
}
export async function reply(event: SlackMessageEvent, text: string) {
return slack.chat.postMessage({
...replyTarget(event),
text,
// Defaults to false. Leave it false. Broadcasting a reply puts the
// agent's answer in front of a channel that did not ask for it, which
// is the asker's call to make rather than the agent's.
reply_broadcast: false,
});
}Two errors on that call mean something other than a failure, and neither should ever enter a retry loop. cannot_reply_to_message means the message you are threading onto cannot carry replies. restricted_action_thread_locked means an admin has locked the thread, which is a person deliberately ending a conversation your agent is now trying to continue. Log both, drop the job, and do not back off against either.
The follow up that never arrives
Here is the failure that makes an agent feel broken while every log line looks healthy. It answers a mention in a thread. The user reads the answer and types no, the other environment without an @, because that is how people talk to colleagues. On app_mentions:read alone, nothing arrives. No event, no error, no trace. The agent has not failed to reply, it has not been spoken to, and from the outside those two are indistinguishable.
There are three ways out of that and they are not interchangeable.
| Strategy | What it costs | When it is the right answer |
|---|---|---|
| Require the mention every turn | Nothing. It runs on the events and the two scopes you already have. | Shared channels where people are mostly talking to each other. The agent reads as a colleague who answers when addressed, and you have to @ it is a convention a team picks up in a day. |
Subscribe to message.channels | channels:history, which rewrites the install screen, plus the Tier 1 limits for a distributed non Marketplace app and a classification decision on every message in the channel. | A channel created for the agent, where every message is for it and the membership boundary is doing the work. Defensible there, hard to justify anywhere else. |
| Move the conversation to a DM or the assistant pane | im:history and assistant:write, both narrower than channel history because the surface belongs to the agent by construction. | One to one work: drafting, triage, anything where the thread is between one person and the agent and nobody else should be reading it anyway. |
My default in a shared channel is the first one, and I would argue it is a feature rather than a limitation. An agent that only speaks when addressed is behaving the way you would want a new team member to behave in a channel full of other people's conversations. The second option is the one to reach for deliberately, for one channel, with the sentence that justifies it written down before the scope goes into the manifest.
Build the transcript from what you were handed
The reflex for the agent needs the thread is conversations.replies, and it is worth being exact about what that costs. The method requires a history scope for the conversation type, which for a public channel is channels:history: the same grant standing history needs. Fetching one thread on demand is genuinely narrower in your logs, your cache and your token spend, and it is not narrower at all on the consent screen, where the admin reads an identical sentence. If you were hoping the single thread fetch was a way to have context without the conversation about permissions, it is not.
Which points at the better option for most agents. Every event you were pushed is context you were legitimately given, already addressed to you, already logged. Keep it. Keyed by channel and thread parent, the conversation you need is yours before anybody has to approve anything.
// One row per message we were actually given. (channel, threadTs) is the
// conversation key, so every turn in a thread collapses onto one record set.
type Turn = {
channel: string;
threadTs: string; // the parent, shared by every turn in the conversation
ts: string; // unique per message, and therefore our idempotency key
role: "user" | "agent";
userId?: string;
text: string;
};
export async function assembleThread(event: SlackMessageEvent, text: string) {
const threadTs = event.thread_ts ?? event.ts;
// Upsert on (channel, ts) so a Slack retry rewrites the same row rather
// than adding the same question to the transcript a second time.
await turns.upsert({
channel: event.channel,
threadTs,
ts: event.ts,
role: "user",
userId: event.user,
text,
});
const thread = await turns
.where({ channel: event.channel, threadTs })
.orderBy("ts", "asc");
// A long thread does not fit the window, and the oldest turns are rarely
// the ones being referred to. Keep the first message, which usually says
// what the thread is for, and the last few, which are the conversation.
return compact(thread, { keepFirst: 1, keepLast: 12 });
}Stop answering questions nobody asked
Most of what makes an agent feel intrusive is not what it says, it is how often it decides something was addressed to it. These are the filters that go in before the model, in this order, and they are the cheapest reliability work in the whole integration.
- 1Drop anything from a bot, starting with yourself. A
bot_idon the payload, or your own bot user id inuser, means this is not a person asking. Two agents in one channel that both answer mentions will eventually mention each other, and the resulting thread is funny exactly once. - 2Treat a
subtypeas a reason to stop and look.channel_join,message_changed,message_deleted,file_share,thread_broadcast. Handle the two or three you have decided to support and drop the rest, because a default of anything that looks like a message means the agent answers an edit of a question it already answered. - 3Deduplicate on
event_id, never on an id you mint on receipt. Slack retries anything you did not acknowledge inside three seconds, and a retry is a second delivery of one event rather than a second event. Store theevent_id, and treat theX-Slack-Retry-Numheader as a signal that your handler is too slow instead of something to tolerate quietly. - 4Strip the mention token before the model reads the text. The raw text arrives as
<@U04ABCDEF> what about staging. Leave the token in and the model starts addressing people by user id, which is the clearest possible tell that nobody has read the agent's own output. - 5Only act in threads where the agent was addressed. Being able to see a thread is not an invitation to it. The conversation keys the agent owns are the ones it was mentioned in, and a message arriving on any other key is logged and dropped even when a history scope made it visible.
- 6Treat every message as untrusted input. A channel message can contain instructions aimed at your agent, and the content of a conversation is never authority to act, which is the same boundary as page content in a website agent. The thread decides what the agent knows. It does not decide what the agent may do.
An event arrives at your endpoint
Verify the signature over the raw body, acknowledge inside three seconds, then queue.
Is it from a bot, or from us?
↳ yes? drop it before the agent starts a conversation with itself
Does it carry a subtype you have not decided to handle?
↳ yes? log the subtype once, then drop
Key it to
thread_ts ?? tsThe parent, so every turn in the conversation lands on one record set.
Assemble the turns you already hold for that key
First message plus the last dozen. Not the thread, and not the channel.
Can the request be answered without messages you were never given?
↳ no? say what is missing and ask, in the thread
Reply to the parent, in one message
thread_tsset,reply_broadcastfalse, and no second message correcting the first.Never: answer a thread the agent was not addressed in
Visibility is not an invitation, and this is the behaviour people ask to have switched off.
Nothing on this path calls a history method, and nothing on it needs a third scope.
Status, title and the context the assistant pane forgets
If the agent lives in the assistant side panel rather than in channels, Slack hands you the conversation surface instead of making you infer it. That runs on assistant:write, chat:write and im:history, with the assistant_thread_started, assistant_thread_context_changed and message.im events, and it gives you the three signals that make an agent feel attentive: setSuggestedPrompts for the empty state, setTitle so the thread is findable later, and setStatus for the seconds while the model is working. The status line earns more than it looks like it should, because the alternative to Thinking is an interface that appears to have ignored somebody.
The part that catches people out is the context. When a user opens the panel while looking at a channel, assistant_thread_started arrives with the channel they are viewing attached. The message.im payloads that follow do not carry it. So the user types summarise this meaning the channel behind the panel, and your handler has no idea which channel that is unless you stored it when the thread opened and read it back on every turn. Bolt's saveThreadContext and getThreadContext exist for exactly this, persisting it as message metadata by default.
And one line in Slack's own documentation deserves more attention than it gets: check channel access with conversations.info before using that context for anything. The panel telling you where a person is standing is not the same as your agent being allowed in there, and an agent that acts on the context without checking is a privilege escalation with a friendly icon.
This is loop engineering wearing a Slack hat
Step back and almost none of the above is really about Slack. The thread is the state, the channel is the environment, the mention is the trigger, and every decision in this post is one of the three in harness, loop and graph: what goes into the state, when the loop is allowed to run, and where it stops. Slack only makes the boundaries unusually visible, because a conversation you got wrong is sitting in a channel with everybody's name on it.
Which is also why moving an agent to a second channel is cheap and moving it to a second definition of a conversation is not. WhatsApp gives you a phone number and a 24 hour window. Slack gives you a thread parent and a membership list. Teams gives you a tenant admin. The model code barely changes between them; the state model changes every time, and that is the part worth designing before the first demo rather than after the first complaint.
An agent that answers only when addressed, in the thread where it was addressed, from the turns it was actually given, needs no history scope and no explanation. One that reads a channel in case it turns out to be useful later needs both.
All of this is a day of work when it goes in at the start. The reply target is one expression, the transcript is one table with a two column key, the filters are a short list of early returns, and the compaction rule is a decision rather than an algorithm. Done late, it is a rewrite of how the agent understands a conversation, usually prompted by somebody senior asking why the bot answered a question that was not for it, in a channel where that was the wrong thing to do. The same instinct, applied to the agent's own behaviour rather than to its inputs, is in bounding an agent so it finishes the job.
If you have a Slack agent answering in the wrong place, losing the thread between turns, or replying to things nobody asked it, tell me what it has to do and I will come back with the event design, the state model and a fixed price. Start that conversation here, or see what this sort of work looks like on the AI capabilities page.
Questions people ask
How do I make a Slack bot reply in the thread instead of the channel?
Pass thread_ts on every reply, set to the event's thread_ts if present and otherwise its ts. An app_mention event only carries thread_ts when the mention was already inside a thread, so the expression thread_ts ?? ts covers both cases, and a top level mention starts a thread under itself. Do not rely on Bolt's say(), which posts to the channel unless given a thread, and leave reply_broadcast false so the answer stays in the thread.
How does a Slack agent keep conversation history across turns?
Store every message the agent was actually sent, keyed by channel and thread parent, and rebuild the transcript from that table on each turn. Upsert on channel and ts so a Slack retry rewrites the same row. This gives thread context without requesting channels:history, which conversations.replies would require. The trade off is that the transcript only contains what was said to the agent, so it should say so honestly rather than pretend to have read the whole thread.
Why does my Slack AI agent not respond to follow up messages in a thread?
With only app_mentions:read, Slack never sends an event for a thread message that does not mention the app, so the follow up never reaches your endpoint. There are three fixes: require the mention every turn, which suits shared channels; subscribe to message.channels, which needs channels:history and suits a channel created for the agent; or move one to one work into a DM or the assistant pane using im:history and assistant:write.
How does a Slack assistant pane agent know which channel the user is viewing?
The channel arrives only on the assistant_thread_started event, not on the message.im events that follow. Store it when the thread opens and read it back on every turn, which Bolt's saveThreadContext and getThreadContext handle by persisting it as message metadata. Before acting on that channel, check access with conversations.info, because the panel showing where a person is standing does not mean the agent is allowed to read there.
Building something like this?
I design and ship these systems for clients: retrieval over private data, agents that complete real tasks, and the Laravel platforms underneath them.