---
title: Handling Events
description: Register handlers for mentions, messages, reactions, member joins, and platform-specific events.
docs_index: /llms.txt
lastUpdated: 2026-10-17
type: guide
prerequisites:
  - /docs/getting-started
related:
  - /docs/slash-commands
  - /docs/actions
  - /docs/modals
  - /docs/api/message
---

> For an index of all documentation, see [/llms.txt](/llms.txt).

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.

```typescript title="lib/bot.ts" lineNumbers
bot.onNewMention(async (thread, message) => {
  await thread.subscribe();
  await thread.post("Hello! I'm now listening to this thread.");
});
```

The handler receives a [`Thread`](/docs/api/thread) and a [`Message`](/docs/api/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

```typescript title="lib/bot.ts" lineNumbers
bot.onNewMention(async (thread, message) => {
  await thread.subscribe();
  await thread.startTyping();

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

### Example: Triage bot

```typescript title="lib/bot.ts" lineNumbers
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`.

```typescript title="lib/bot.ts" lineNumbers
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

```typescript title="lib/bot.ts" lineNumbers
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`](/docs/ai/to-ai-messages) for all options including multi-user name prefixing, message transforms, and attachment handling.

### Example: Unsubscribe on keyword

```typescript title="lib/bot.ts" lineNumbers
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

```typescript title="lib/bot.ts" lineNumbers
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:

```typescript title="lib/bot.ts" lineNumbers
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:

```typescript title="lib/bot.ts" lineNumbers
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

```typescript title="lib/bot.ts" lineNumbers
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

```typescript title="lib/bot.ts" lineNumbers
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](/docs/emoji) reactions to messages. You can handle all reactions or filter by specific emoji.

```typescript title="lib/bot.ts" lineNumbers
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                     |

### Example: Approval workflow

```typescript title="lib/bot.ts" lineNumbers
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

```typescript title="lib/bot.ts" lineNumbers
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.

```typescript title="lib/bot.ts" lineNumbers
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:

```typescript title="lib/bot.ts" lineNumbers
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](/adapters) before relying on them.

## Handling interactions

For button clicks, slash commands, and modal forms, see the dedicated guides:

- [Slash Commands](/docs/slash-commands): handle `/command` invocations from the message composer.
- [Actions](/docs/actions): handle button clicks and interactive card events.
- [Modals](/docs/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.

```typescript title="lib/bot.ts" lineNumbers
bot.onAgentSessionStopped(async (event) => {
  await releaseExternalResources(event.threadId);
});
```

`onAgentSessionTitleChanged` fires when a user renames a session in Slack:

```typescript title="lib/bot.ts" lineNumbers
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](/adapters/official/slack#legacy-slack-assistants-api) to set suggested prompts and status indicators.

```typescript title="lib/bot.ts" lineNumbers
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.

```typescript title="lib/bot.ts" lineNumbers
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.

```typescript title="lib/bot.ts" lineNumbers
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, so 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](/adapters/official/slack#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.

```typescript title="lib/bot.ts" lineNumbers
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 so do DM messages. Read the context at message time with `getAppContext`:

```typescript title="lib/bot.ts" lineNumbers
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.

```typescript title="lib/bot.ts" lineNumbers
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 adapter that emitted the event (Slack or Teams)                 |
| `channelId` | `string`            | The channel that was joined                                         |
| `userId`    | `string`            | The user who joined. On Teams this is always the bot (`28:<appId>`) |
| `inviterId` | `string` (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.

```typescript
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](/adapters/official/teams#installation-lifecycle)
for persistence, reinstall cleanup, and proactive messages.

---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)