Handling Events

You register a handler for each type of event your bot cares about, and the SDK routes each incoming webhook to the matching handler.

How routing works

When a message arrives, the SDK evaluates handlers in this order:

  1. Direct messages: if the thread is a DM and any onDirectMessage handlers are registered, they fire before onSubscribedMessage, onNewMention, and pattern handlers.
  2. Subscribed threads: if the thread is subscribed, onSubscribedMessage fires and no other message handler runs. DMs reach this step only when no onDirectMessage handlers are registered.
  3. Mentions: if the bot is @-mentioned in an unsubscribed thread, onNewMention fires. For backward compatibility, unsubscribed DMs are treated as mentions when no onDirectMessage handler is registered.
  4. Pattern routing: onNewMessage handlers run one after another in registration order. Regex handlers fire when their pattern matches the message text. Handlers registered without a pattern fire for every message that reaches this step, including messages with empty text.

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. Most new conversations with your bot start here.

lib/bot.ts
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. If your bot doesn't need ongoing context, respond without subscribing, as the triage bot below does.

Example: AI assistant with context

lib/bot.ts
bot.onNewMention(async (thread, message) => {
  await thread.subscribe();
  await thread.startTyping();

  const response = await generateAIResponse(message.text);
  await thread.post(response);
});

Example: Triage bot

lib/bot.ts
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.

lib/bot.ts
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.

Example: Conversational AI with history

lib/bot.ts
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

lib/bot.ts
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

lib/bot.ts
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 in unsubscribed threads that aren't DMs and don't mention the bot. Pass a regex for keyword-triggered responses that don't need an @-mention:

lib/bot.ts
bot.onNewMessage(/^help$/i, async (thread, message) => {
  await thread.post("Here's how I can help...");
});

The regex is tested against the message text. To receive every message at this step, including attachment-only messages with empty text, pass only a handler:

lib/bot.ts
bot.onNewMessage(async (thread, message) => {
  await thread.post(`Received: ${message.text || "an attachment"}`);
});

When several onNewMessage handlers apply to a message, they run one after another in registration order.

The handler-only form doesn't receive DMs or mentions, even when no handler for them is registered. Use onDirectMessage and onNewMention for those, and onSubscribedMessage for threads the bot has subscribed to.

Example: FAQ auto-responder

lib/bot.ts
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

lib/bot.ts
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.

lib/bot.ts
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

PropertyTypeDescription
emojiEmojiValueNormalized emoji name for cross-platform comparison
rawEmojistringPlatform-specific emoji string
addedbooleantrue if added, false if removed
userAuthorThe user who reacted
messageMessage (optional)The message that was reacted to
threadThreadThread for posting replies
messageIdstringID of the message that was reacted to
threadIdstringThread ID
adapterAdapterThe platform adapter
rawunknownPlatform-specific event payload

Example: Approval workflow

lib/bot.ts
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

lib/bot.ts
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.

lib/bot.ts
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:

lib/bot.ts
bot.onMessageDeleted(async (event) => {
  if (!event.previousMessage) {
    return;
  }
  await chat
    .thread(event.threadId)
    .post(`A message from ${event.previousMessage.author.userName} was deleted`);
});

MessageDeletedEvent

PropertyTypeDescription
messageIdstringPlatform-native id of the deleted message
threadIdstringThread the message was in
channelIdstringChannel the message was in
platformstringAdapter name, e.g. "slack"
previousMessageMessage | undefinedSnapshot, when the platform supplied enough to parse one
deletedAtDate | undefinedWhen the delete happened, when reported
rawunknownPlatform-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 /command invocations 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 agent session stop and title changes

onAgentSessionStopped fires when a user clicks Slack's native stop button. Chat SDK aborts the active thread.signal and transitions the session out of processing before invoking your handler.

lib/bot.ts
bot.onAgentSessionStopped(async (event) => {
  await releaseExternalResources(event.threadId);
});

onAgentSessionTitleChanged fires when a user renames a session in Slack:

lib/bot.ts
bot.onAgentSessionTitleChanged(async (event) => {
  await syncTitle(event.threadId, event.title);
});

Subscribe to agent_session_stopped and agent_session_title_changed in the Slack app manifest. Model calls should receive thread.signal so stop also cancels upstream generation.

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.

lib/bot.ts
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:

PropertyTypeDescription
channelIdstringThe assistant thread's channel
threadTsstringThread timestamp
threadIdstringThread ID
userIdstringUser who started the thread
contextobjectAssistant context (channelId, teamId, enterpriseId, threadEntryPoint)
adapterAdapterThe 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.

lib/bot.ts
bot.onAssistantContextChanged(async (event) => {
  const slack = bot.getAdapter("slack") as SlackAdapter;
  await slack.setAssistantStatus(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.

lib/bot.ts
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:

PropertyTypeDescription
userIdstringUser who opened the Home tab
channelIdstringChannel context
tabstring | undefinedThe opened tab ("home" or "messages"). Under agentView the event fires for every tab, so branch on this to tell a Home-tab open from the DM-open signal
entitiesAppContextEntity[] | undefinedFolded active-view context (agentView only)
adapterAdapterThe 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.

lib/bot.ts
bot.onAppContextChanged((event) => {
  for (const entity of event.entities) {
    if (entity.kind === "channel") {
      // User is viewing channel entity.channelId
    }
  }
});

The event object includes:

PropertyTypeDescription
channelIdstringThe agent conversation channel
userIdstringThe user whose view changed
entitiesAppContextEntity[]Relevance-ordered active-view entities; empty when the view is empty
rawunknownPlatform-specific raw payload
adapterAdapterThe 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 so do DM messages. Read the context at message time with getAppContext:

lib/bot.ts
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 member joins a channel. Slack emits it for every user who joins a channel the bot can see. Teams emits it only when the bot itself is added to a channel or group chat. Use it to post welcome messages or onboard users automatically.

lib/bot.ts
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:

PropertyTypeDescription
adapterAdapterThe adapter that emitted the event (Slack or Teams)
channelIdstringThe channel that was joined
userIdstringThe user who joined. On Teams this is always the bot (28:<appId>)
inviterIdstring (optional)The user who invited them

Installation lifecycle

onInstalled and onUninstalled receive bot installation and removal events. Currently only the Teams adapter emits them, for personal, group chat, and team installs.

bot.onInstalled(async (event) => {
  if (event.adapter.name !== "teams") return;
  // Persist the destination or start your onboarding flow.
  await recordInstallation(event);
});

bot.onUninstalled(async (event) => {
  if (event.adapter.name !== "teams") return;
  await removeInstallation(event);
});

Events include adapter, the activity id, the action, the platform conversationId, raw, and the optional actor userId, tenantId, and locale. The optional channelId is the normalized Chat destination; persist it and post later with bot.channel(channelId).post().

recordInstallation and removeInstallation are application-owned. For Teams team installs, use event.raw.channelData.team.id as the installation scope; the install and removal events can carry different conversationId values. Include the bot ID and tenant ID in your persistence key.

action is add, add-upgrade, remove, or remove-upgrade. The upgrade variants mean the app manifest added or removed the bot, so persist and clean up for both variants. Routine app upgrades do not emit these events. A Teams team or group chat install also emits onMemberJoinedChannel, so keep each welcome flow in one handler. Make side effects idempotent for webhook retries. Handler errors are logged, and asynchronous work is registered with the webhook's waitUntil.

See Teams installation lifecycle for persistence, reinstall cleanup, and proactive messages.

Read more