Handling Events
Register handlers for mentions, messages, reactions, member joins, and platform-specific events.
Chat SDK uses an event-driven architecture. You register handlers for different event types, and the SDK routes incoming webhooks to the appropriate handler.
How routing works
When a message arrives, the SDK evaluates handlers in this order:
- Direct messages — if the thread is a DM and any
onDirectMessagehandlers are registered, they fire beforeonSubscribedMessage,onNewMention, and pattern handlers. - Subscribed threads — if the thread is subscribed,
onSubscribedMessagefires and no other message handler runs. DMs only reach this step when noonDirectMessagehandlers are registered. - Mentions — if the bot is @-mentioned in an unsubscribed thread,
onNewMentionfires. Unsubscribed DMs without direct handlers are treated as mentions for backward compatibility. - Pattern matches — if the message text matches any
onNewMessageregex patterns, those handlers fire.
Reactions, slash commands, actions, and modals have their own dedicated routing and are not affected by subscription state.
Handling @-mentions
onNewMention fires when your bot is @-mentioned in a thread it hasn't subscribed to. This is the primary entry point for new conversations.
bot.onNewMention(async (thread, message) => {
await thread.subscribe();
await thread.post("Hello! I'm now listening to this thread.");
});The handler receives a Thread and a Message. Once you call thread.subscribe(), future messages in that thread route to onSubscribedMessage instead.
When to use
- AI assistants — subscribe on first mention, then respond to all follow-up messages in the thread.
- Ticket bots — create a ticket when mentioned, then track the conversation.
- One-shot commands — respond to a mention without subscribing for bots that don't need ongoing context.
Example: AI assistant with context
bot.onNewMention(async (thread, message) => {
await thread.subscribe();
await thread.startTyping();
const response = await generateAIResponse(message.text);
await thread.post(response);
});Example: Triage bot
bot.onNewMention(async (thread, message) => {
const ticket = await createTicket({
title: message.text.slice(0, 100),
reporter: message.author.fullName,
});
await thread.post(`Ticket created: ${ticket.url}`);
});Handling subscribed messages
onSubscribedMessage fires for every new message in a non-DM thread your bot has subscribed to. Once subscribed, messages (including @-mentions) route here instead of onNewMention.
If an onDirectMessage handler is registered, DM messages route there before subscription routing. Without a direct handler, subscribed DMs route to onSubscribedMessage.
bot.onSubscribedMessage(async (thread, message) => {
if (message.isMention) {
await thread.post("You mentioned me!");
return;
}
await thread.post(`Got your message: ${message.text}`);
});Messages sent by the bot itself do not trigger this handler. You don't need to filter out your own messages.
When to use
- Conversational AI — maintain a back-and-forth conversation with message history.
- Thread monitoring — watch a thread for updates and react to specific keywords or patterns.
- Collaborative workflows — track all messages in a thread to update external systems.
Example: Conversational AI with history
import { toAiMessages } from "chat/ai";
bot.onSubscribedMessage(async (thread, message) => {
await thread.startTyping();
// Build conversation history from thread messages
const messages = [];
for await (const msg of thread.allMessages) {
messages.push(msg);
}
const history = await toAiMessages(messages);
const response = await generateAIResponse(history);
await thread.post(response);
});See toAiMessages for all options including multi-user name prefixing, message transforms, and attachment handling.
Example: Unsubscribe on keyword
bot.onSubscribedMessage(async (thread, message) => {
if (message.text.toLowerCase().includes("stop")) {
await thread.unsubscribe();
await thread.post("Got it, I'll stop watching this thread.");
return;
}
// Handle other messages...
});Example: Thread state for multi-step flows
interface ThreadState {
step: "awaiting_name" | "awaiting_email" | "done";
}
const bot = new Chat<typeof adapters, ThreadState>({ /* ...config */ });
bot.onNewMention(async (thread) => {
await thread.subscribe();
await thread.setState({ step: "awaiting_name" });
await thread.post("Let's get you set up. What's your name?");
});
bot.onSubscribedMessage(async (thread, message) => {
const state = await thread.state;
switch (state?.step) {
case "awaiting_name":
await thread.setState({ step: "awaiting_email" });
await thread.post(`Thanks, ${message.text}! What's your email?`);
break;
case "awaiting_email":
await thread.setState({ step: "done" });
await thread.post("All set! Your account has been created.");
await thread.unsubscribe();
break;
}
});Handling pattern matches
onNewMessage fires for messages matching a regex pattern in threads the bot is not subscribed to. Use it for keyword-triggered responses without requiring an @-mention.
bot.onNewMessage(/^help$/i, async (thread, message) => {
await thread.post("Here's how I can help...");
});The first argument is a RegExp that's tested against the message text. Only messages in unsubscribed threads are evaluated.
When to use
- Keyword triggers — respond to specific words or phrases without requiring a mention.
- Auto-responders — detect common questions and provide instant answers.
- Escalation detection — watch for urgent language and alert the right people.
Example: FAQ auto-responder
bot.onNewMessage(/\b(deploy|deployment|ship)\b/i, async (thread, message) => {
await thread.post(
"Deployments run automatically on push to `main`. " +
"Check status at https://dashboard.example.com/deploys"
);
});Example: Incident detection
bot.onNewMessage(/\b(outage|down|incident|p[01])\b/i, async (thread, message) => {
await thread.subscribe();
await thread.post(
"I've flagged this as a potential incident and I'm monitoring this thread."
);
await notifyOnCallTeam({
channel: thread.channel.id,
reporter: message.author.fullName,
text: message.text,
});
});Handling reactions
onReaction fires when users add or remove emoji reactions to messages. You can handle all reactions or filter by specific emoji.
import { emoji } from "chat";
// Handle specific emoji
bot.onReaction(["thumbs_up", "heart"], async (event) => {
if (!event.added) return;
await event.adapter.addReaction(
event.threadId,
event.messageId,
emoji.raised_hands
);
});
// Handle all reactions
bot.onReaction(async (event) => {
console.log(`${event.user.userName} ${event.added ? "added" : "removed"} ${event.emoji}`);
});ReactionEvent
| Property | Type | Description |
|---|---|---|
emoji | EmojiValue | Normalized emoji name for cross-platform comparison |
rawEmoji | string | Platform-specific emoji string |
added | boolean | true if added, false if removed |
user | Author | The user who reacted |
message | Message (optional) | The message that was reacted to |
thread | Thread | Thread for posting replies |
messageId | string | ID of the message that was reacted to |
threadId | string | Thread ID |
adapter | Adapter | The platform adapter |
raw | unknown | Platform-specific event payload |
When to use
- Approval workflows — use thumbs up/down as lightweight approve/reject signals.
- Bookmarking — save messages to an external system when a specific emoji is added.
- Polls and voting — count reactions as votes.
Example: Approval workflow
bot.onReaction(["thumbs_up", "thumbs_down"], async (event) => {
if (!event.added) return;
const approved = event.emoji === "thumbs_up";
const status = approved ? "approved" : "rejected";
await event.thread.post(
`Request ${status} by ${event.user.fullName}`
);
await updateRequestStatus(event.messageId, status, event.user.userId);
});Example: Save to external system
bot.onReaction(["bookmark"], async (event) => {
if (!event.added || !event.message) return;
await saveToDatabase({
text: event.message.text,
savedBy: event.user.userId,
source: event.threadId,
});
await event.thread.post(
`Bookmarked by ${event.user.fullName}`
);
});Handling edits and deletes
onMessageUpdated fires when a user edits a message, and onMessageDeleted when one is removed. Both are lifecycle events: they never route through onNewMessage, onNewMention, or onSubscribedMessage, and the concurrency strategies do not apply to them.
bot.onMessageUpdated(async (thread, message, previousMessage) => {
await mirror.update(message.id, message.text);
if (previousMessage) {
await audit.record(`"${previousMessage.text}" became "${message.text}"`);
}
});
bot.onMessageDeleted(async (event) => {
await mirror.remove(event.messageId);
});previousMessage is the message as it read before the edit, supplied when the platform sends it. Slack does, so you can diff the change rather than only seeing the result. Treat it as optional: a platform that reports edits without the prior text leaves it undefined.
The bot's own edits are filtered out, so a streamed reply that renders through post-and-edit does not call onMessageUpdated back once per delta.
Why the two shapes differ
onMessageUpdated receives (thread, message, previousMessage?) like the other message handlers, because an edit carries a full replacement message.
onMessageDeleted receives a single event instead. A delete has no message: platforms usually report only the id of what was removed, and previousMessage is best-effort. Use chat.thread(event.threadId) when you need a Thread, for example to post a notice in the conversation:
bot.onMessageDeleted(async (event) => {
if (!event.previousMessage) {
return;
}
await chat
.thread(event.threadId)
.post(`A message from ${event.previousMessage.author.userName} was deleted`);
});MessageDeletedEvent
| Property | Type | Description |
|---|---|---|
messageId | string | Platform-native id of the deleted message |
threadId | string | Thread the message was in |
channelId | string | Channel the message was in |
platform | string | Adapter name, e.g. "slack" |
previousMessage | Message | undefined | Snapshot, when the platform supplied enough to parse one |
deletedAt | Date | undefined | When the delete happened, when reported |
raw | unknown | Platform-specific delete event |
Only the Slack adapter emits these events today. Registering the handlers on another platform is harmless but nothing will call them. Check the Message edit events and Message delete events rows on each adapter page before relying on them.
Handling interactions
For button clicks, slash commands, and modal forms, see the dedicated guides:
- Slash Commands — handle
/commandinvocations from the message composer. - Actions — handle button clicks and interactive card events.
- Modals — collect structured input through modal dialogs with validation.
Handling Slack-specific events
These handlers are specific to the Slack platform and require the Slack adapter.
Handling assistant threads
onAssistantThreadStarted fires when a user opens a new assistant thread in Slack. Use it with the Slack Assistants API to set suggested prompts and status indicators.
bot.onAssistantThreadStarted(async (event) => {
const slack = bot.getAdapter("slack") as SlackAdapter;
await slack.setSuggestedPrompts(event.channelId, event.threadTs, [
{ title: "Get started", message: "What can you help me with?" },
{ title: "Summarize", message: "Summarize the current channel" },
]);
});The event object includes:
| Property | Type | Description |
|---|---|---|
channelId | string | The assistant thread's channel |
threadTs | string | Thread timestamp |
threadId | string | Thread ID |
userId | string | User who started the thread |
context | object | Assistant context (channelId, teamId, enterpriseId, threadEntryPoint) |
adapter | Adapter | The Slack adapter |
Handling assistant context changes
onAssistantContextChanged fires when the assistant context changes, for example when a user navigates to a different channel while the assistant thread is open.
bot.onAssistantContextChanged(async (event) => {
const slack = bot.getAdapter("slack") as SlackAdapter;
await slack.setStatus(event.channelId, event.threadTs, "Updating context...");
// Update prompts based on new context
const channelName = event.context.channelId ?? "general";
await slack.setSuggestedPrompts(event.channelId, event.threadTs, [
{ title: "Summarize", message: `Summarize #${channelName}` },
]);
});Handling App Home opens
onAppHomeOpened fires when a user opens your bot's Home tab in Slack. Use it to publish a dynamic view.
bot.onAppHomeOpened(async (event) => {
const slack = bot.getAdapter("slack") as SlackAdapter;
await slack.publishHomeView(event.userId, {
type: "home",
blocks: [
{
type: "section",
text: { type: "mrkdwn", text: `Welcome, <@${event.userId}>!` },
},
{
type: "actions",
elements: [
{
type: "button",
text: { type: "plain_text", text: "Open Dashboard" },
url: "https://dashboard.example.com",
},
],
},
],
});
});The event object includes:
| Property | Type | Description |
|---|---|---|
userId | string | User who opened the Home tab |
channelId | string | Channel context |
tab | string | undefined | The opened tab ("home" or "messages"). Under agentView the event fires for every tab — branch on this to tell a Home-tab open from the DM-open signal |
entities | AppContextEntity[] | undefined | Folded active-view context (agentView only) |
adapter | Adapter | The Slack adapter |
Handling active-view context (Agent messaging)
Under Slack's Agent messaging experience (agent_view), onAppContextChanged fires when the user's active view changes (opening a channel, DM, or canvas). The event carries normalized entities describing what the user is viewing.
bot.onAppContextChanged((event) => {
for (const entity of event.entities) {
if (entity.kind === "channel") {
// User is viewing channel entity.channelId
}
}
});The event object includes:
| Property | Type | Description |
|---|---|---|
channelId | string | The agent conversation channel |
userId | string | The user whose view changed |
entities | AppContextEntity[] | Relevance-ordered active-view entities; empty when the view is empty |
raw | unknown | Platform-specific raw payload |
adapter | Adapter | The Slack adapter |
Each AppContextEntity is one of: { kind: "channel", channelId }, { kind: "canvas", canvasId }, { kind: "list", listId }, { kind: "message", messageTs, channelId }, or { kind: "unknown", type, value } (all with optional teamId/enterpriseId).
Slack also folds this context onto the events the user acts through. onAppHomeOpened events carry the same normalized entities, and DM messages carry it too — read it at message time with getAppContext:
import { getAppContext } from "@chat-adapter/slack";
bot.onDirectMessage((thread, message) => {
const viewing = getAppContext(message);
const viewedThread = viewing.find((e) => e.kind === "message");
// viewedThread?.messageTs is the root ts of the thread the user is viewing
});Handling member joined channel
onMemberJoinedChannel fires when a user joins a Slack channel. Use it to post welcome messages or onboard users automatically.
bot.onMemberJoinedChannel(async (event) => {
// Only post when the bot itself joins
if (event.userId !== event.adapter.botUserId) {
return;
}
await event.adapter.postMessage(
event.channelId,
"Hello! I'm now available in this channel. Mention me to get started."
);
});The event object includes:
| Property | Type | Description |
|---|---|---|
adapter | Adapter | The Slack adapter |
channelId | string | The channel that was joined |
userId | string | The user who joined |
inviterId | string (optional) | The user who invited them |
Read more
Slash Commands
Handle slash command invocations and respond with messages or modals.
Actions
Handle button clicks and interactive card events across platforms.
Modals
Collect structured user input through modal dialogs with text fields, dropdowns, and validation.
Message
Normalized message format with text, AST, author, and metadata.