Visitar URL original
Chat | Chat SDK

Chat

The Chat class coordinates adapters, state, and event handlers. Create one instance and register handlers for different event types.

import { Chat } from "chat";

Constructor

const bot = new Chat(config);
PropTypeDefaultDescription
userName?string
Default bot username across all adapters.
adapters?Record<string, Adapter>
Map of adapter name to adapter instance.
dedupeTtlMs?number600000
TTL for message deduplication entries in milliseconds. The default outlives the last Slack event retry, which arrives about 5 minutes after the original delivery. Increase it if your platform redelivers later than that.
state?StateAdapter
State adapter for subscriptions, locking, and caching.
logger?Logger | "debug" | "info" | "warn" | "error" | "silent"ConsoleLogger("info")
Logger instance or log level.
streamingUpdateIntervalMs?number500
Throttle interval for fallback streaming (post + edit) in milliseconds.
fallbackStreamingPlaceholderText?string | null"..."
Placeholder text posted when fallback streaming (post + edit) starts. Set to `null` to wait for the first streamed text before posting.
concurrency?"drop" | "queue" | "debounce" | "burst" | "concurrent" | ConcurrencyConfig"drop"
What happens when a message arrives on a thread while a handler is still running. See [Concurrency](/docs/concurrency).
lockScope?"thread" | "channel" | ((context: LockScopeContext) => LockScope | Promise<LockScope>)"thread"
Which messages share a lock: one per thread or one per channel. A function can choose per message. When unset, the adapter's default is used, which is `"channel"` for WhatsApp and Telegram. See [Lock scope](/docs/concurrency#lock-scope).
history?HistoryConfig | undefined
Configuration for the History API's user, thread, and channel scopes. `history.user` includes the identity resolver. See `bot.history` below.

These options are deprecated but still read, so existing bots keep working:

OptionReplacement
onLockConflictconcurrency. It only applies under the drop strategy, where 'force' releases the held lock instead of throwing LockError.
identityhistory.user.identity
transcriptshistory.user
threadHistoryhistory.thread
messageHistoryhistory.thread. It was renamed to threadHistory first, which takes precedence when both are set.

Event handlers

onNewMention

Fires when the bot is @-mentioned in a thread it has not subscribed to. Most bots start new conversations from this handler.

bot.onNewMention(async (thread, message) => {
  await thread.subscribe();
  await thread.post("Hello!");
});
PropTypeDescription
thread?Thread
The thread where the mention occurred.
message?Message
The message that contains the @-mention.

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.

bot.onDirectMessage(async (thread, message, channel) => {
  await thread.post(`Got your DM in ${channel.id}: ${message.text}`);
});
PropTypeDescription
thread?Thread
The DM thread where the message occurred.
message?Message
The direct message.
channel?Channel
The DM 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.

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 in unsubscribed threads that aren't DMs and don't mention the bot. Pass a regex to filter by message text, or pass only a handler to receive every such message, including attachment-only messages with empty text. All onNewMessage handlers run one after another in registration order.

bot.onNewMessage(/^!help/i, async (thread, message) => {
  await thread.post("Available commands: !help, !status");
});
bot.onNewMessage(async (thread, message) => {
  await thread.post(`Received: ${message.text || "an attachment"}`);
});
PropTypeDescription
pattern?RegExp
Optional regular expression tested against the message text. Omit it to receive every message that reaches pattern routing.
handler?(thread: Thread, message: Message, context?: MessageContext) => Promise<void>
Handler called for each matching message. `context` is set by the `queue` and `burst` concurrency strategies. See [MessageContext](/docs/concurrency#messagecontext).

The handler-only form doesn't receive DMs or mentions, even when no onDirectMessage or onNewMention handler is registered. Passing arguments in the wrong order, a pattern that isn't a RegExp, or no handler throws a ChatError when the handler is registered.

onReaction

Fires when a user adds or removes an emoji reaction.

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) => { /* ... */ });
PropTypeDescription
event.emoji?EmojiValue
Normalized emoji value. Supports `===` comparison.
event.rawEmoji?string
Platform-specific emoji string.
event.added?boolean
`true` if added, `false` if removed.
event.user?Author
The user who reacted.
event.thread?Thread
The thread where the reaction occurred.
event.message?Message | undefined
The message that was reacted to, when available.
event.messageId?string
The message ID that was reacted to.

onAction

Fires when a user clicks a button or selects an option in a card.

// 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) => { /* ... */ });
PropTypeDescription
event.actionId?string
Action ID from the button or select.
event.value?string | undefined
Optional payload value from the button.
event.values?Record<string, string> | undefined
The card's own input values, keyed by input ID. Absent where the platform has no card inputs.
event.user?Author
User who triggered the action.
event.thread?Thread | null
The thread containing the card, or null for view-based actions.
event.triggerId?string | undefined
Trigger ID for opening modals. Platform-specific and may expire quickly.
event.openModal?(modal: ModalElement | ChatElement) => Promise<{ viewId: string } | undefined>
Open a modal form in response to this action.

onModalSubmit

Fires when a user submits a modal form.

bot.onModalSubmit("feedback", async (event) => {
  const comment = event.values.comment;
  if (event.relatedThread) {
    await event.relatedThread.post(`Feedback: ${comment}`);
  }
});
PropTypeDescription
event.callbackId?string
The callback ID specified when the modal was created.
event.values?Record<string, string>
Form field values keyed by input ID.
event.user?Author
User who submitted the modal.
event.relatedThread?Thread | undefined
The thread the modal was opened from, when available.
event.relatedMessage?SentMessage | undefined
The message containing the action that opened the modal.
event.relatedChannel?Channel | undefined
The channel the modal was opened from. Available when the modal was opened from a slash command.
event.privateMetadata?string | undefined
Arbitrary string passed through the modal lifecycle.

Returns ModalResponse | undefined to control the modal after submission:

ResponseEffect
{ action: "close" }Closes the current view and goes back one level in the stack
{ action: "clear" }Closes all views and dismisses the modal
{ action: "errors", errors: { fieldId: "message" } }Shows validation errors
{ action: "update", modal: ModalElement }Replaces the modal content
{ action: "push", modal: ModalElement }Pushes a new modal view onto the stack

onOptionsLoad

Fires when an ExternalSelect requests options dynamically. Slack only. The handler is keyed on the select's id and must return options within Slack's 3-second budget. The adapter caps the loader at about 2.5 seconds and returns an empty result on timeout.

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, such as "Recent" and "All". Slack allows at most 100 groups and 100 options per group.

PropTypeDescription
event.actionId?string
The `id` of the select requesting options. Matches the id passed to `bot.onOptionsLoad`.
event.query?string
The text the user has typed so far.
event.user?Author
The user requesting options.
event.adapter?Adapter
The adapter that received this event.
event.raw?unknown
Raw platform-specific payload.

onSlashCommand

Fires when a user invokes a /command in the message composer. Supported on Slack and Discord.

// 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}`);
});
PropTypeDescription
event.command?string
The command name, such as "/status".
event.text?string
Arguments after the command.
event.user?Author
The user who invoked the command.
event.channel?Channel
The channel where the command was invoked.
event.triggerId?string | undefined
Trigger ID for opening modals. Expires after a short time.
event.openModal?(modal: ModalElement | ChatElement) => Promise<{ viewId: string } | undefined>
Open a modal form in response to this command.
event.adapter?Adapter
The platform adapter.
event.raw?unknown
Platform-specific raw payload.

onModalClose

Fires when a user closes a modal. Requires notifyOnClose: true on the modal.

bot.onModalClose("feedback", async (event) => { /* ... */ });

onAssistantThreadStarted

Fires when a user opens a new assistant thread through the Slack Assistants API. Use it to set suggested prompts, show a status indicator, or send an initial greeting.

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?" },
  ]);
});
PropTypeDescription
event.threadId?string
Encoded thread ID.
event.userId?string
The user who opened the thread.
event.channelId?string
The DM channel ID.
event.threadTs?string
Thread timestamp.
event.context?AssistantThreadContext
Where the thread was opened: channel, team, enterprise, and entry point.
event.adapter?Adapter
The platform adapter.

onAssistantContextChanged

Fires when a user navigates to a different channel while the Slack Assistants API panel is open. Use it to update suggested prompts or context for the new channel.

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.

onAgentSessionStopped

Fires after a user clicks Slack's native agent-session stop button. Chat SDK aborts the active turn and moves the session out of processing before this handler runs.

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

The event contains threadId, channelId, threadTs, streamingMessageTs, userId, and adapter. streamingMessageTs lists the streaming messages Slack stopped and can be empty when no stream was active.

onAgentSessionTitleChanged

Fires when a user renames a Slack agent session.

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

The event contains title, previousTitle, threadId, channelId, threadTs, userId, and adapter. previousTitle is omitted when the session did not have a title before the change.

onAppHomeOpened

Fires when a user opens the bot's Home tab in Slack. Use it to publish a dynamic Home tab 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!" } }],
  });
});
PropTypeDescription
event.userId?string
The user who opened the Home tab.
event.channelId?string
The channel ID associated with the Home tab.
event.adapter?Adapter
The platform adapter.

onInstalled

Fires when the bot is installed, including when an app upgrade adds the bot to the manifest. Routine upgrades do not emit this event. Only the Teams adapter emits it, for personal, group chat, and team installs.

bot.onInstalled(async (event) => {
  if (!event.channelId) return;
  await bot.channel(event.channelId).post("Thanks for installing!");
});
PropTypeDescription
event.action?"add" | "add-upgrade"
Install action: "add" or "add-upgrade".
event.channelId?string | undefined
Normalized destination for the installation, safe to persist for later proactive posts.
event.conversationId?string
Platform conversation ID of the installation location.
event.id?string
Platform activity ID, useful for idempotency.
event.userId?string | undefined
The user who installed the bot.
event.tenantId?string | undefined
Tenant the installation belongs to.
event.adapter?Adapter
The platform adapter.

onUninstalled

Fires when the bot is removed, including when an app upgrade removes it from the manifest. Same event shape as onInstalled, with action set to "remove" or "remove-upgrade". Clean up durable records for both actions, and do not post to the conversation after removal.

bot.onUninstalled(async (event) => {
  if (event.adapter.name === "teams") {
    await removeTeamsInstallation(event);
  } else if (event.adapter.name === "slack") {
    await removeSlackInstallation(event.conversationId);
  }
});

removeTeamsInstallation and removeSlackInstallation are application-owned. For Teams team installs, associate records with event.raw.channelData.team.id as well as conversationId, which can differ between installation and removal. See the Teams persistence example.

The Slack adapter emits onUninstalled, with action set to "remove", when a workspace or org uninstalls the app. conversationId is the Slack installation key: the team_id, or the enterprise_id for org-wide installs. tenantId is the Enterprise Grid enterprise_id when the event carries one. See Slack uninstalls.

Utility methods

abortTurn

Abort the active handler turn for a thread. Chat SDK signals work in this process immediately and publishes the cancellation through the configured state adapter for another serverless instance to observe.

await bot.abortTurn(thread.id);

Platform adapters normally invoke this for native cancellation events. Model and tool calls must receive thread.signal to stop their own upstream work.

webhooks

Typed webhook handlers keyed by adapter name. Call them from your HTTP route handler.

bot.webhooks.slack(request, { waitUntil });
bot.webhooks.teams(request, { waitUntil });

getAdapter

Get a typed adapter instance by name.

const slack = bot.getAdapter("slack");

Direct client access

Each adapter exposes its platform's typed native API client through a getter named after the SDK: .webClient on Slack, .linearClient on Linear, and .octokit on GitHub.

// 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 or multi-workspace adapters (Slack, Linear, GitHub), it returns the client bound to the credentials for the current webhook request context.

The previous .client getter still works on all three adapters as a deprecated alias for .webClient, .linearClient, and .octokit.

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.…), for example in 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.

AdapterGetterType
Slack.webClientWebClient from @slack/web-api
Linear.linearClientLinearClient from @linear/sdk
GitHub.octokitOctokit from @octokit/rest

openDM

Open a direct message thread with a user.

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.

bot.getUser picks the adapter from the format of the user ID, so it only works for platforms whose IDs it can recognize: Slack (U... or W...), Microsoft Teams (29:...), Google Chat (users/...), Linear (UUID), and Discord, Telegram, and GitHub (numeric). For other adapters that implement getUser, such as X, Twilio, and Twitch, call the adapter directly with bot.getAdapter("x").getUser(userId).

const user = await bot.getUser("U123456");
console.log(user?.email);    // "alice@company.com"
console.log(user?.fullName); // "Alice Smith"
// Or with an Author object from a message handler. The adapter is still
// inferred from author.userId.
const user = await bot.getUser(message.author);
PropTypeDescription
userId?string
Platform-specific user ID.
userName?string
Username or handle.
fullName?string
Display name or full name.
isBot?boolean
Whether the user is a bot.
email?string | undefined
Email address. Requires extra scopes on some platforms.
avatarUrl?string | undefined
Profile image URL.
PlatformConstraints
SlackRequires the users:read and users:read.email scopes. The email scope must be granted at OAuth install time.
DiscordBot tokens never see email. The email OAuth scope only applies to user-context auth.
TelegramBots can only look up users who have previously messaged them.
Microsoft TeamsOnly works for users who previously interacted with the bot, because profiles are cached from webhook activity. avatarUrl is not returned because the Graph API requires a separate photo call.
Google ChatSame caching constraint as Teams: only users seen in prior webhooks.
GitHubemail is only returned if the user made it public or you authenticated with the user:email scope.
LinearReturns the full profile, including email and avatar, for any active workspace member.
XReturns the name, username, and avatar. email is not returned. Call through the adapter, because X's numeric IDs aren't inferred.
TwitchReturns the display name, login, and avatar from Get Users. email is not returned. Call through the adapter, because Twitch's numeric IDs aren't inferred.
TwilioDoesn't call Twilio. Returns a profile built from the phone number, with the number as userId, userName, and fullName.

Fields that aren't available return undefined.

Numeric user IDs from Discord, Telegram, and GitHub are ambiguous when more than one of those adapters is registered. In that case bot.getUser throws a ChatError with code AMBIGUOUS_USER_ID, even when you pass an Author, because the adapter is inferred from the ID alone. Call the adapter directly instead, for example thread.adapter.getUser?.(message.author.userId) inside a handler or bot.getAdapter("telegram").getUser(userId) elsewhere.

bot.getUser throws a ChatError in three cases. Handle them if your bot runs on multiple platforms.

CodeWhen
NOT_SUPPORTEDThe inferred adapter doesn't implement getUser
AMBIGUOUS_USER_IDA numeric user ID could belong to more than one registered adapter (Discord, Telegram, or GitHub)
UNKNOWN_USER_ID_FORMATThe userId string doesn't match any registered platform's ID format
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. Use it to post to threads outside a webhook, such as from a cron job or an external trigger.

const thread = bot.thread("slack:C123ABC:1234567890.123456");
await thread.post("Hello from a cron job!");

channel

Get a Channel by its channel ID.

const channel = bot.channel("slack:C123ABC");

for await (const msg of channel.messages) {
  console.log(msg.text);
}

initialize / shutdown

Manage the lifecycle manually. Initialization happens automatically on the first webhook. Call initialize() yourself when the bot does work outside a webhook.

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. Adapters use it to clean up platform connections, close WebSockets, or tear down subscriptions. If one adapter's disconnect() fails, the remaining adapters and the state adapter still disconnect.

reviver

Get a JSON.parse reviver that deserializes Thread and Message objects from workflow payloads.

const data = JSON.parse(payload, bot.reviver());
await data.thread.post("Hello from workflow!");

Threads and channels restored by bot.reviver() stay bound to that bot's adapters and state, and restored threads use that bot's streaming defaults, even if another Chat instance is registered later. When using multiple bots, parse each payload with the receiving bot's reviver. This does not isolate state keys if the bots share the same state store.

The binding is not serialized. If a restored object crosses a Workflow step boundary, automatic deserialization uses the registered singleton again. For multiple bots, pass the serialized data and explicitly restore it with the receiving bot's reviver inside the step.

There is also a workflow-safe serialization entrypoint that works without importing the Chat runtime. Use it in Vercel Workflow files so serializer registration does not load Node-only runtime dependencies:

import { Message, reviver, ThreadImpl } from "chat/serialization";

const data = JSON.parse(payload, reviver) as {
  thread: ThreadImpl;
  message: Message;
};

Message, ThreadImpl, and ChannelImpl retain their automatic Workflow serialization when imported from either chat or chat/serialization. The dedicated entrypoint guarantees their serializer module graph does not load the Node-only Chat conversation context.

The standalone reviver resolves adapters lazily, looking them up from the Chat singleton on first access. Call chat.registerSingleton() before using thread methods like post() (typically inside a "use step" function). Once a restored thread or channel resolves its runtime, it retains that Chat instance for its adapter, state, and streaming defaults, even if the singleton changes. Registering the correct bot before first use remains the caller's responsibility; use bot.reviver() when the owner must be explicit.

history

Namespaced access to the History API: cross-platform user history, per-thread reads and cache, and per-channel reads.

// User scope: cross-platform, keyed by identity resolver result
await bot.history.user.append(thread, message);
const entries = await bot.history.user.list({ userKey, limit: 20 });
await bot.history.user.delete({ userKey });

// Thread scope: adapter.fetchMessages with cache fallback
const { messages } = await bot.history.thread.list(thread.id, { limit: 20 });

// Channel scope: platform APIs
const { messages: channelMessages } = await bot.history.channel.listMessages(
  channel.id,
  { limit: 10 }
);
const { threads } = await bot.history.channel.listThreads(channel.id, {
  limit: 20,
});

See History API reference for full details and the History guide for setup and patterns.

bot.transcripts is a deprecated alias for bot.history.user. Accessing either when user history is not configured throws.

Read more