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

# Handling 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:

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 only reach this step when no `onDirectMessage` handlers are registered.
3. **Mentions** — if the bot is @-mentioned in an unsubscribed thread, `onNewMention` fires. Unsubscribed DMs without direct handlers are treated as mentions for backward compatibility.
4. **Pattern matches** — if the message text matches any `onNewMessage` regex 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.

```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.

### 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

```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}`);
});
```

<Callout type="info">
  Messages sent by the bot itself do not trigger this handler. You don't need to filter out your own messages.
</Callout>

### 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

```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 matching a regex pattern in threads the bot is **not** subscribed to. Use it for keyword-triggered responses without requiring 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 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

```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                     |

### 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

```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                           |

<Callout type="warn">
  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.
</Callout>

## 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#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 — 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 DM messages carry it too — read it 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                                           |


---

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)