chat
Version:
Unified chat abstraction for Slack, Teams, Google Chat, and Discord
694 lines (577 loc) • 21.3 kB
text/mdx
---
title: Chat
description: The main entry point for creating a multi-platform chat bot.
type: reference
---
The `Chat` class coordinates adapters, state, and event handlers. Create one instance and register handlers for different event types.
```typescript
import { Chat } from "chat";
```
## Constructor
```typescript
const bot = new Chat(config);
```
<TypeTable
type={{
userName: {
description: 'Default bot username across all adapters.',
type: 'string',
},
adapters: {
description: 'Map of adapter name to adapter instance.',
type: 'Record<string, Adapter>',
},
dedupeTtlMs: {
description: 'TTL for message deduplication entries in milliseconds. Increase if webhook cold starts cause platform retries after the default window.',
type: 'number',
default: '300000',
},
state: {
description: 'State adapter for subscriptions, locking, and caching.',
type: 'StateAdapter',
},
logger: {
description: 'Logger instance or log level. Defaults to ConsoleLogger("info") if omitted.',
type: 'Logger | "debug" | "info" | "warn" | "error" | "silent"',
default: 'ConsoleLogger("info")',
},
streamingUpdateIntervalMs: {
description: 'Throttle interval for fallback streaming (post + edit) in milliseconds.',
type: 'number',
default: '500',
},
}}
/>
## Event handlers
### onNewMention
Fires when the bot is @-mentioned in a thread it has **not** subscribed to. This is the primary entry point for new conversations.
```typescript
bot.onNewMention(async (thread, message) => {
await thread.subscribe();
await thread.post("Hello!");
});
```
<TypeTable
type={{
thread: {
description: 'The thread where the mention occurred.',
type: 'Thread',
},
message: {
description: 'The message that contains the @-mention.',
type: 'Message',
},
}}
/>
### onDirectMessage
Fires for every direct message when registered. Direct message handlers run before `onSubscribedMessage`, `onNewMention`, and pattern handlers. If no direct message handler is registered, unsubscribed DMs fall through to `onNewMention` for backward compatibility.
```typescript
bot.onDirectMessage(async (thread, message, channel) => {
await thread.post(`Got your DM in ${channel.id}: ${message.text}`);
});
```
<TypeTable
type={{
thread: {
description: 'The DM thread where the message occurred.',
type: 'Thread',
},
message: {
description: 'The direct message.',
type: 'Message',
},
channel: {
description: 'The DM channel.',
type: 'Channel',
},
}}
/>
### onSubscribedMessage
Fires for every new message in a subscribed non-DM thread. Once subscribed, messages (including @-mentions) route here instead of `onNewMention`. DM threads route to `onDirectMessage` first when a direct message handler is registered.
```typescript
bot.onSubscribedMessage(async (thread, message) => {
if (message.isMention) {
// User @-mentioned us in a thread we're already watching
}
await thread.post(`Got: ${message.text}`);
});
```
### onNewMessage
Fires for messages matching a regex pattern in **unsubscribed** threads.
```typescript
bot.onNewMessage(/^!help/i, async (thread, message) => {
await thread.post("Available commands: !help, !status");
});
```
<TypeTable
type={{
pattern: {
description: 'Regular expression to match against message text.',
type: 'RegExp',
},
handler: {
description: 'Handler called when the pattern matches.',
type: '(thread: Thread, message: Message) => Promise<void>',
},
}}
/>
### onReaction
Fires when a user adds or removes an emoji reaction.
```typescript
import { emoji } from "chat";
// Filter to specific emoji
bot.onReaction([emoji.thumbs_up, emoji.heart], async (event) => {
if (event.added) {
await event.thread.post(`Thanks for the ${event.emoji}!`);
}
});
// Handle all reactions
bot.onReaction(async (event) => { /* ... */ });
```
<TypeTable
type={{
'event.emoji': {
description: 'Normalized emoji value (supports === comparison).',
type: 'EmojiValue',
},
'event.rawEmoji': {
description: 'Platform-specific emoji string.',
type: 'string',
},
'event.added': {
description: 'true if added, false if removed.',
type: 'boolean',
},
'event.user': {
description: 'The user who reacted.',
type: 'Author',
},
'event.thread': {
description: 'The thread where the reaction occurred.',
type: 'Thread',
},
'event.message': {
description: 'The message that was reacted to (if available).',
type: 'Message | undefined',
},
'event.messageId': {
description: 'The message ID that was reacted to.',
type: 'string',
},
}}
/>
### onAction
Fires when a user clicks a button or selects an option in a card.
```typescript
// Single action
bot.onAction("approve", async (event) => {
if (event.thread) {
await event.thread.post("Approved!");
}
});
// Multiple actions
bot.onAction(["approve", "reject"], async (event) => { /* ... */ });
// All actions
bot.onAction(async (event) => { /* ... */ });
```
<TypeTable
type={{
'event.actionId': {
description: 'Action ID from the button or select.',
type: 'string',
},
'event.value': {
description: 'Optional payload value from the button.',
type: 'string | undefined',
},
'event.user': {
description: 'User who triggered the action.',
type: 'Author',
},
'event.thread': {
description: 'The thread containing the card, or null for view-based actions.',
type: 'Thread | null',
},
'event.triggerId': {
description: 'Trigger ID for opening modals (platform-specific, may expire quickly).',
type: 'string | undefined',
},
'event.openModal': {
description: 'Open a modal form in response to this action.',
type: '(modal: ModalElement | CardJSXElement) => Promise<{ viewId: string } | undefined>',
},
}}
/>
### onModalSubmit
Fires when a user submits a modal form.
```typescript
bot.onModalSubmit("feedback", async (event) => {
const comment = event.values.comment;
if (event.relatedThread) {
await event.relatedThread.post(`Feedback: ${comment}`);
}
});
```
<TypeTable
type={{
'event.callbackId': {
description: 'The callback ID specified when the modal was created.',
type: 'string',
},
'event.values': {
description: 'Form field values keyed by input ID.',
type: 'Record<string, string>',
},
'event.user': {
description: 'User who submitted the modal.',
type: 'Author',
},
'event.relatedThread': {
description: 'The thread where the modal was triggered from (if available).',
type: 'Thread | undefined',
},
'event.relatedMessage': {
description: 'The message containing the action that opened the modal.',
type: 'SentMessage | undefined',
},
'event.relatedChannel': {
description: 'The channel where the modal was triggered from (available when opened via slash commands).',
type: 'Channel | undefined',
},
'event.privateMetadata': {
description: 'Arbitrary string passed through the modal lifecycle.',
type: 'string | undefined',
},
}}
/>
Returns `ModalResponse | undefined` to control the modal after submission:
- `{ action: "close" }` — close the current view (goes back one level in the stack)
- `{ action: "clear" }` — close all views and dismiss the modal entirely
- `{ action: "errors", errors: { fieldId: "message" } }` — show validation errors
- `{ action: "update", modal: ModalElement }` — replace the modal content
- `{ action: "push", modal: ModalElement }` — push a new modal view onto the stack
### onOptionsLoad
Fires when an `ExternalSelect` requests options dynamically. The handler is keyed on the select's `id` and must return options synchronously enough for Slack's 3-second budget (the adapter caps the loader at ~2.5s and substitutes an empty result on timeout). Slack-only.
```typescript
bot.onOptionsLoad("assignee", async (event) => {
const people = await peopleService.search(event.query);
return people.map((p) => ({ label: p.fullName, value: p.id }));
});
```
Return an array of `OptionsLoadGroup` (`{ label, options }[]`) instead of a flat array to render grouped headers (e.g. "Recent" / "All"). Slack limits: max 100 groups, max 100 options per group.
<TypeTable
type={{
'event.actionId': {
description: 'The id of the select requesting options (matches the id passed to bot.onOptionsLoad).',
type: 'string',
},
'event.query': {
description: 'The text the user has typed so far.',
type: 'string',
},
'event.user': {
description: 'The user requesting options.',
type: 'Author',
},
'event.adapter': {
description: 'The adapter that received this event.',
type: 'Adapter',
},
'event.raw': {
description: 'Raw platform-specific payload.',
type: 'unknown',
},
}}
/>
### onSlashCommand
Fires when a user invokes a `/command` in the message composer. Currently supported on Slack and Discord.
```typescript
// Specific command
bot.onSlashCommand("/status", async (event) => {
await event.channel.post("All systems operational!");
});
// Multiple commands
bot.onSlashCommand(["/help", "/info"], async (event) => {
await event.channel.post(`You invoked ${event.command}`);
});
// Catch-all
bot.onSlashCommand(async (event) => {
console.log(`${event.command} ${event.text}`);
});
```
<TypeTable
type={{
'event.command': {
description: 'The command name (e.g., "/status").',
type: 'string',
},
'event.text': {
description: 'Arguments after the command.',
type: 'string',
},
'event.user': {
description: 'The user who invoked the command.',
type: 'Author',
},
'event.channel': {
description: 'The channel where the command was invoked.',
type: 'Channel',
},
'event.triggerId': {
description: 'Trigger ID for opening modals (time-limited).',
type: 'string | undefined',
},
'event.openModal': {
description: 'Open a modal form in response to this command.',
type: '(modal: ModalElement | CardJSXElement) => Promise<{ viewId: string } | undefined>',
},
'event.adapter': {
description: 'The platform adapter.',
type: 'Adapter',
},
'event.raw': {
description: 'Platform-specific raw payload.',
type: 'unknown',
},
}}
/>
### onModalClose
Fires when a user closes a modal (requires `notifyOnClose: true` on the modal).
```typescript
bot.onModalClose("feedback", async (event) => { /* ... */ });
```
### onAssistantThreadStarted
Fires when a user opens a new assistant thread (Slack Assistants API). Use this to set suggested prompts, show a status indicator, or send an initial greeting.
```typescript
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?" },
]);
});
```
<TypeTable
type={{
'event.threadId': {
description: 'Encoded thread ID.',
type: 'string',
},
'event.userId': {
description: 'The user who opened the thread.',
type: 'string',
},
'event.channelId': {
description: 'The DM channel ID.',
type: 'string',
},
'event.threadTs': {
description: 'Thread timestamp.',
type: 'string',
},
'event.context': {
description: 'Context about where the thread was opened (channel, team, enterprise, entry point).',
type: 'AssistantThreadContext',
},
'event.adapter': {
description: 'The platform adapter.',
type: 'Adapter',
},
}}
/>
### onAssistantContextChanged
Fires when a user navigates to a different channel while the assistant panel is open (Slack Assistants API). Use this to update suggested prompts or context based on the new channel.
```typescript
bot.onAssistantContextChanged(async (event) => {
const slack = bot.getAdapter("slack") as SlackAdapter;
await slack.setAssistantStatus(event.channelId, event.threadTs, "Updating context...");
});
```
The event shape is identical to `onAssistantThreadStarted`.
### onAppHomeOpened
Fires when a user opens the bot's Home tab in Slack. Use this to publish a dynamic Home tab view.
```typescript
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!" } }],
});
});
```
<TypeTable
type={{
'event.userId': {
description: 'The user who opened the Home tab.',
type: 'string',
},
'event.channelId': {
description: 'The channel ID associated with the Home tab.',
type: 'string',
},
'event.adapter': {
description: 'The platform adapter.',
type: 'Adapter',
},
}}
/>
## Utility methods
### webhooks
Type-safe webhook handlers keyed by adapter name. Pass these to your HTTP route handler.
```typescript
bot.webhooks.slack(request, { waitUntil });
bot.webhooks.teams(request, { waitUntil });
```
### getAdapter
Get a typed adapter instance by name.
```typescript
const slack = bot.getAdapter("slack");
```
#### Direct client access
Access the platform's typed native API client directly via an SDK-named getter — `.webClient` on Slack, `.linearClient` on Linear, `.octokit` on GitHub:
```typescript
// Slack - full WebClient from @slack/web-api
const slack = bot.getAdapter("slack").webClient;
await slack.pins.add({ channel: "C123ABC", timestamp: "1234567890.123456" });
// Linear - full LinearClient from @linear/sdk
const linear = bot.getAdapter("linear").linearClient;
const issue = await linear.issue("ENG-123");
const project = await issue.project;
// GitHub - full Octokit from @octokit/rest
const github = bot.getAdapter("github").octokit;
const { data: pulls } = await github.rest.pulls.list({
owner: "vercel",
repo: "chat",
state: "open",
});
```
The client uses the credentials from your adapter config. For multi-tenant / multi-workspace adapters (Slack, Linear, GitHub), it returns the client bound to the credentials for the current webhook request context.
<Callout type="info">
The previous `.client` getter still works on all three adapters as a deprecated alias for `.webClient` / `.linearClient` / `.octokit`.
</Callout>
<Callout type="warn">
Multi-tenant adapters (GitHub App without a fixed installation ID, Linear with per-org OAuth, Slack in multi-workspace mode) require a webhook handler context to resolve credentials when the native client getter is accessed. Calling it outside a handler throws.
For Slack, you can also bind a token explicitly outside a webhook with `adapter.withBotToken(token, () => adapter.webClient.…)` — useful for cron jobs or workflows. The same pattern is required when `botToken` is configured as an async resolver function, since `.webClient` resolves the token synchronously.
Single-tenant adapters (PAT, API key, static `botToken` string, or a synchronous `botToken` resolver) work anywhere.
</Callout>
| Adapter | Getter | Type |
|---------|--------|------|
| Slack | `.webClient` | `WebClient` from `@slack/web-api` |
| Linear | `.linearClient` | `LinearClient` from `@linear/sdk` |
| GitHub | `.octokit` | `Octokit` from `@octokit/rest` |
### openDM
Open a direct message thread with a user.
```typescript
const dm = await bot.openDM("U123456");
await dm.post("Hello via DM!");
// Or with an Author object
const dm = await bot.openDM(message.author);
```
### getUser
Look up user information by user ID. Returns a `UserInfo` object with name, email, avatar, and bot status, or `null` if the user was not found. Supported on Slack, Microsoft Teams, Discord, Google Chat, GitHub, Linear, and Telegram. Other adapters will throw `NOT_SUPPORTED`.
```typescript
const user = await bot.getUser("U123456");
console.log(user?.email); // "alice@company.com"
console.log(user?.fullName); // "Alice Smith"
```
```typescript
// Or with an Author object from a message handler
const user = await bot.getUser(message.author);
```
<TypeTable
type={{
userId: {
description: 'Platform-specific user ID.',
type: 'string',
},
userName: {
description: 'Username/handle.',
type: 'string',
},
fullName: {
description: 'Display name / full name.',
type: 'string',
},
isBot: {
description: 'Whether the user is a bot.',
type: 'boolean',
},
email: {
description: 'Email address (requires scopes on some platforms).',
type: 'string | undefined',
},
avatarUrl: {
description: 'Profile image URL.',
type: 'string | undefined',
},
}}
/>
<Callout type="info">
**Per-platform constraints:**
- **Slack** — requires both `users:read` and `users:read.email` scopes (the email scope must be granted at OAuth install time).
- **Discord** — bot tokens never see email (the `email` OAuth scope only applies in user-context auth).
- **Telegram** — bots can only look up users who have previously messaged them.
- **Microsoft Teams** — only works for users who previously interacted with the bot (cached from webhook activity). `avatarUrl` is not returned (Graph API requires a separate photo call).
- **Google Chat** — same caching constraint as Teams: only users seen in prior webhooks.
- **GitHub** — `email` is `null` unless the user made it public, or you authenticated with the `user:email` scope.
- **Linear** — full profile (incl. email + avatar) for any active workspace member.
Fields that aren't available return `undefined`. Numeric user IDs (Discord/Telegram/GitHub) can be ambiguous when multiple of those adapters are registered — `bot.getUser` throws a `ChatError` with code `AMBIGUOUS_USER_ID` in that case. Pass an `Author` from a message handler (which already carries the adapter), or call the adapter directly (`adapter.getUser(userId)`).
</Callout>
`bot.getUser` throws a `ChatError` in three cases. Handle them if your bot runs on multiple platforms:
| Code | When |
|------|------|
| `NOT_SUPPORTED` | The resolved adapter doesn't implement `getUser` (e.g. WhatsApp) |
| `AMBIGUOUS_USER_ID` | A numeric user ID could belong to more than one registered adapter (Discord/Telegram/GitHub) |
| `UNKNOWN_USER_ID_FORMAT` | The `userId` string doesn't match any registered platform's ID format |
```typescript
import { ChatError } from "chat";
try {
const user = await bot.getUser(userId);
if (!user) {
// User not found on this platform
}
} catch (error) {
if (error instanceof ChatError) {
if (error.code === "NOT_SUPPORTED") {
// This adapter doesn't support user lookups
} else if (error.code === "AMBIGUOUS_USER_ID") {
// Pass message.author or call adapter.getUser(userId) directly
} else if (error.code === "UNKNOWN_USER_ID_FORMAT") {
// userId doesn't match any known platform format
}
}
}
```
### thread
Get a Thread handle by its thread ID. Useful for posting to threads outside of webhook contexts (e.g. cron jobs, external triggers).
```typescript
const thread = bot.thread("slack:C123ABC:1234567890.123456");
await thread.post("Hello from a cron job!");
```
### channel
Get a Channel by its channel ID.
```typescript
const channel = bot.channel("slack:C123ABC");
for await (const msg of channel.messages) {
console.log(msg.text);
}
```
### initialize / shutdown
Manually manage the lifecycle. Initialization happens automatically on the first webhook, but you can call it explicitly for non-webhook use cases.
```typescript
await bot.initialize();
// ... do work ...
await bot.shutdown();
```
During shutdown, the SDK calls the optional `disconnect()` method on each adapter before disconnecting the state adapter. This lets adapters clean up platform connections, close WebSockets, or tear down subscriptions. If any adapter's `disconnect()` fails, the remaining adapters and state adapter still disconnect gracefully.
### reviver
Get a `JSON.parse` reviver that deserializes `Thread` and `Message` objects from workflow payloads.
```typescript
const data = JSON.parse(payload, bot.reviver());
await data.thread.post("Hello from workflow!");
```
There is also a standalone `reviver` export that works without a `Chat` instance. This is useful in Vercel Workflow functions where importing the full Chat instance (with its adapter dependencies) is not possible:
```typescript
import { reviver } from "chat";
const data = JSON.parse(payload, reviver) as { thread: Thread; message: Message };
```
The standalone reviver uses lazy adapter resolution - the adapter is looked up from the Chat singleton when first accessed. Call `chat.registerSingleton()` before using thread methods like `post()` (typically inside a `"use step"` function).