github-actions[bot] c0c0b1ec6f Version Packages (#53)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-18 11:40:12 -08:00
2026-02-18 11:40:12 -08:00
2026-02-05 19:52:12 -05:00
2026-01-02 17:19:09 -08:00
2026-02-18 11:40:12 -08:00
2026-02-18 11:40:12 -08:00
2026-02-16 16:53:22 -08:00
2026-02-17 15:01:05 -08:00
2026-01-20 19:24:12 -08:00
2026-01-02 17:19:09 -08:00
2026-01-06 14:46:51 -08:00
2026-02-16 17:33:08 -08:00
2026-01-02 14:20:41 -08:00
2025-12-31 13:14:26 -08:00
2026-01-02 14:20:41 -08:00
2025-12-22 18:21:52 -08:00
2026-02-16 16:42:44 -08:00
2026-02-16 16:41:46 -08:00
2026-01-02 13:56:55 -08:00
2026-01-04 18:55:01 -08:00
2026-01-04 18:55:01 -08:00
2025-12-21 20:48:14 -08:00
2025-12-31 14:24:46 -08:00

Chat SDK

A unified SDK for building chat bots across Slack, Microsoft Teams, Google Chat, and Discord.

Features

  • Multi-platform support with a single codebase
  • Mention-based thread subscriptions
  • Reaction handling with type-safe emoji
  • Cross-platform emoji helper for consistent rendering
  • AI SDK integration - Stream LLM responses directly to chat
  • Rich cards with buttons - TSX or object-based cards
  • Action callbacks - Handle button clicks across platforms
  • Modals & form inputs - Collect user input via modal dialogs
  • Slash commands - Handle /command invocations with responses or modals
  • File uploads - Send files with messages
  • DM support - Initiate direct messages programmatically
  • Message deduplication for platform quirks
  • Serverless-ready with pluggable state backends

Quick Start

1. Create your bot (lib/bot.ts)

import { Chat, ConsoleLogger, emoji } from "chat";
import { createSlackAdapter } from "@chat-adapter/slack";
import { createTeamsAdapter } from "@chat-adapter/teams";
import { createGoogleChatAdapter } from "@chat-adapter/gchat";
import { createDiscordAdapter } from "@chat-adapter/discord";
import { createRedisState } from "@chat-adapter/state-redis";

const logger = new ConsoleLogger("info");

export const bot = new Chat({
  userName: "mybot",
  logger,
  adapters: {
    slack: createSlackAdapter({
      botToken: process.env.SLACK_BOT_TOKEN!,
      signingSecret: process.env.SLACK_SIGNING_SECRET!,
      logger: logger.child("slack"),
    }),
    teams: createTeamsAdapter({
      appId: process.env.TEAMS_APP_ID!,
      appPassword: process.env.TEAMS_APP_PASSWORD!,
      logger: logger.child("teams"),
    }),
    gchat: createGoogleChatAdapter({
      credentials: JSON.parse(process.env.GOOGLE_CHAT_CREDENTIALS!),
      logger: logger.child("gchat"),
    }),
    discord: createDiscordAdapter({
      botToken: process.env.DISCORD_BOT_TOKEN!,
      publicKey: process.env.DISCORD_PUBLIC_KEY!,
      applicationId: process.env.DISCORD_APPLICATION_ID!,
      logger: logger.child("discord"),
    }),
  },
  state: createRedisState({ url: process.env.REDIS_URL!, logger }),
});

// Handle @mentions - works across all platforms
bot.onNewMention(async (thread) => {
  await thread.subscribe();
  // Emoji auto-converts to platform format: :wave: on Slack, 👋 on Teams/GChat
  await thread.post(`${emoji.wave} Hello! I'm now listening to this thread.`);
});

// Handle follow-up messages in subscribed threads
bot.onSubscribedMessage(async (thread, message) => {
  await thread.post(`${emoji.check} You said: ${message.text}`);
});

// Handle emoji reactions (type-safe emoji values)
bot.onReaction([emoji.thumbs_up, emoji.heart, emoji.fire], async (event) => {
  if (!event.added) return; // Only respond to added reactions
  await event.adapter.addReaction(event.threadId, event.messageId, event.emoji);
});

2. Create a webhook handler (app/api/webhooks/[platform]/route.ts)

import { after } from "next/server";
import { bot } from "@/lib/bot";

type Platform = keyof typeof bot.webhooks;

export async function POST(
  request: Request,
  { params }: { params: Promise<{ platform: string }> }
) {
  const { platform } = await params;

  const handler = bot.webhooks[platform as Platform];
  if (!handler) {
    return new Response(`Unknown platform: ${platform}`, { status: 404 });
  }

  return handler(request, {
    waitUntil: (task) => after(() => task),
  });
}

This creates endpoints for each platform:

  • POST /api/webhooks/slack
  • POST /api/webhooks/teams
  • POST /api/webhooks/gchat
  • POST /api/webhooks/discord

The waitUntil option ensures message processing completes after the response is sent (required for serverless).

Note for Discord: Discord uses HTTP Interactions for slash commands and button clicks, but requires a Gateway WebSocket connection for receiving messages. See SETUP.md for Discord Gateway configuration.

Setup

See SETUP.md for platform configuration instructions including:

  • Slack app creation and OAuth scopes
  • Microsoft Teams Azure Bot setup
  • Google Chat service account and Pub/Sub configuration
  • Discord application and Gateway setup
  • Environment variables reference

Emoji Helper

The emoji helper provides type-safe, cross-platform emoji that automatically convert to each platform's format. Use it with thread.post():

await thread.post(`${emoji.thumbs_up} Great job!`);
// Slack: ":+1: Great job!"
// Teams/GChat: "👍 Great job!"

Available emoji:

Name Emoji Name Emoji
emoji.thumbs_up 👍 emoji.thumbs_down 👎
emoji.heart ❤️ emoji.smile 😊
emoji.laugh 😂 emoji.thinking 🤔
emoji.eyes 👀 emoji.fire 🔥
emoji.check ✅ emoji.x ❌
emoji.question ❓ emoji.party 🎉
emoji.rocket 🚀 emoji.star ⭐
emoji.wave 👋 emoji.clap 👏
emoji["100"] 💯 emoji.warning ⚠️

For one-off custom emoji, use emoji.custom("name").

Custom Emoji (Type-Safe)

For workspace-specific emoji with full type safety, use createEmoji():

import { createEmoji } from "chat";

// Create emoji helper with custom emoji
const myEmoji = createEmoji({
  unicorn: { slack: "unicorn_face", gchat: "🦄" },
  company_logo: { slack: "company", gchat: "🏢" },
});

// Type-safe access to custom emoji (with autocomplete)
const message = `${myEmoji.unicorn} Magic! ${myEmoji.company_logo}`;
// Slack: ":unicorn_face: Magic! :company:"
// GChat: "🦄 Magic! 🏢"

Rich Cards with Buttons

Send interactive cards with buttons that work across all platforms. Cards automatically convert to Block Kit (Slack), Adaptive Cards (Teams), and Google Chat Cards.

Configure your tsconfig.json to use the chat JSX runtime:

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "chat"
  }
}

Then use JSX syntax:

import {
  Card,
  CardText,
  Button,
  LinkButton,
  Actions,
  Section,
  Fields,
  Field,
  Divider,
  Image,
  Modal,
  TextInput,
  Select,
  SelectOption,
  RadioSelect,
} from "chat";

// Simple card with buttons
await thread.post(
  <Card title="Order #1234">
    <CardText>Your order has been received!</CardText>
    <Section>
      <CardText style="bold">Total: $50.00</CardText>
    </Section>
    <Actions>
      <Button id="approve" style="primary">
        Approve
      </Button>
      <Button id="reject" style="danger">
        Reject
      </Button>
      <LinkButton url="https://example.com/order/1234">View Order</LinkButton>
    </Actions>
  </Card>
);

// Card with fields (key-value pairs)
await thread.post(
  <Card title="User Profile">
    <Fields>
      <Field label="Name" value="John Doe" />
      <Field label="Role" value="Developer" />
      <Field label="Team" value="Platform" />
    </Fields>
    <Divider />
    <Actions>
      <Button id="edit">Edit Profile</Button>
    </Actions>
  </Card>
);

// Card with image
await thread.post(
  <Card title="Product Update">
    <Image url="https://example.com/product.png" alt="Product screenshot" />
    <CardText>Check out our new feature!</CardText>
  </Card>
);

// Card with inline select and radio buttons
await thread.post(
  <Card title="Task Settings">
    <Actions>
      <Select id="priority" label="Priority" placeholder="Select priority">
        <SelectOption label="High" value="high" description="Urgent tasks" />
        <SelectOption label="Medium" value="medium" />
        <SelectOption label="Low" value="low" />
      </Select>
      <RadioSelect id="status" label="Status">
        <SelectOption label="Open" value="open" />
        <SelectOption label="In Progress" value="in_progress" />
        <SelectOption label="Done" value="done" />
      </RadioSelect>
      <Button id="save" style="primary">Save</Button>
    </Actions>
  </Card>
);

Note: Use CardText (not Text) when using JSX to avoid conflicts with React's built-in types.

Action Callbacks

Handle button clicks from cards:

import { Chat, type ActionEvent } from "chat";

// Handle a specific action
bot.onAction("approve", async (event: ActionEvent) => {
  await event.thread.post(`Order approved by ${event.user.fullName}!`);
});

// Handle multiple actions
bot.onAction(["approve", "reject"], async (event: ActionEvent) => {
  const action = event.actionId === "approve" ? "approved" : "rejected";
  await event.thread.post(`Order ${action}!`);
});

// Catch-all action handler
bot.onAction(async (event: ActionEvent) => {
  console.log(`Action: ${event.actionId}, Value: ${event.value}`);
});

The ActionEvent includes actionId, value, user, thread, messageId, threadId, adapter, triggerId, and raw properties.

Modals & Form Inputs

Open modal dialogs to collect structured user input. Modals support text inputs, dropdowns, and validation. Currently supported on Slack.

Opening a Modal

Modals are opened in response to button clicks using event.openModal(). The SDK automatically tracks the thread and message context, making it available as relatedThread and relatedMessage in the submit/close handlers:

import { Modal, TextInput, Select, SelectOption } from "chat";

// Handle a button click that opens a modal
bot.onAction("feedback", async (event) => {
  await event.openModal(
    <Modal
      callbackId="feedback_form"
      title="Send Feedback"
      submitLabel="Send"
      closeLabel="Cancel"
      notifyOnClose
    >
      <TextInput
        id="message"
        label="Your Feedback"
        placeholder="Tell us what you think..."
        multiline
      />
      <Select id="category" label="Category" placeholder="Select a category">
        <SelectOption label="Bug Report" value="bug" />
        <SelectOption label="Feature Request" value="feature" />
        <SelectOption label="General Feedback" value="general" />
      </Select>
      <TextInput
        id="email"
        label="Email (optional)"
        placeholder="your@email.com"
        optional
      />
    </Modal>
  );
});

Modal Components

Component Description
Modal Container with callbackId, title, submitLabel, closeLabel, notifyOnClose
TextInput Text field with id, label, placeholder, initialValue, multiline, optional, maxLength
Select Dropdown with id, label, placeholder, initialOption, optional
RadioSelect Radio button group with id, label, initialOption, optional
SelectOption Option for Select/RadioSelect with label, value, and description (optional)

Handling Modal Submissions

Handle form submissions with onModalSubmit. The event includes relatedThread and relatedMessage which automatically reference the thread/message where the modal was triggered:

import type { ModalSubmitEvent } from "chat";

bot.onModalSubmit("feedback_form", async (event: ModalSubmitEvent) => {
  const { message, category, email } = event.values;

  // Validate input - return errors to show in the modal
  if (!message || message.length < 5) {
    return {
      action: "errors",
      errors: { message: "Feedback must be at least 5 characters" },
    };
  }

  // Process the submission
  console.log("Received feedback:", { message, category, email });

  // Post confirmation to the original thread
  if (event.relatedThread) {
    await event.relatedThread.post(`Feedback received! Category: ${category}`);
  }
  // Optionally update the original message that triggered the modal
  if (event.relatedMessage) {
    await event.relatedMessage.edit("✅ Feedback submitted!");
  }

  // Return nothing to close the modal
});

Modal Response Types

Return different responses from onModalSubmit to control modal behavior:

Response Description
{ action: "close" } Close the modal (default if nothing returned)
{ action: "errors", errors: { fieldId: "Error message" } } Show validation errors on specific fields
{ action: "update", modal: ModalElement } Update the modal content
{ action: "push", modal: ModalElement } Push a new modal view onto the stack

Handling Modal Close

Optionally handle when users close/cancel a modal (requires notifyOnClose on the Modal):

import type { ModalCloseEvent } from "chat";

bot.onModalClose("feedback_form", async (event: ModalCloseEvent) => {
  console.log(`${event.user.userName} cancelled the feedback form`);

  // Post a follow-up to the original thread
  if (event.relatedThread) {
    await event.relatedThread.post(
      "No worries, let us know if you change your mind!"
    );
  }
});

The ModalSubmitEvent includes callbackId, viewId, values, user, adapter, relatedThread, relatedMessage, relatedChannel, and raw properties. The ModalCloseEvent includes the same properties except values.

Slash Commands

Handle slash command invocations from users:

bot.onSlashCommand("/feedback", async (event) => {
  await event.openModal(
    <Modal callbackId="feedback_form" title="Send Feedback">
      <TextInput id="message" label="Your Feedback" multiline />
    </Modal>
  );
});

bot.onModalSubmit("feedback_form", async (event) => {
  if (event.relatedChannel) {
    await event.relatedChannel.post(`Feedback received: ${event.values.message}`);
  }
});

The SlashCommandEvent includes command, text, user, channel, triggerId, openModal(), adapter, and raw properties.

AI Integration & Streaming

Stream LLM responses directly to chat platforms. The SDK accepts any AsyncIterable<string> (like AI SDK's textStream), automatically using native streaming APIs where available (Slack) or falling back to post+edit for other platforms.

import { Chat } from "chat";

// Stream AI response on @mention
bot.onNewMention(async (thread, message) => {
  const result = await agent.stream({ prompt: message.text });
  await thread.post(result.textStream);
});

Platform Behavior

Platform Streaming Method
Slack Native streaming API (chatStream)
Teams Post + edit with throttling
Google Chat Post + edit with throttling
Discord Post + edit with throttling

The fallback method posts an initial message, then edits it as chunks arrive (throttled to avoid rate limits).

The SDK also supports per-thread state via thread.setState() and thread.state for tracking conversation modes, user preferences, or any thread-specific context.

File Uploads

Send files along with messages:

import type { Thread } from "chat";

// Send a file with a message
const reportBuffer = Buffer.from("PDF content");
await thread.post({
  markdown: "Here's the report you requested:",
  files: [
    {
      data: reportBuffer,
      filename: "report.pdf",
      mimeType: "application/pdf",
    },
  ],
});

// Send multiple files
const image1 = Buffer.from("image1");
const image2 = Buffer.from("image2");
await thread.post({
  markdown: "Attached are the images:",
  files: [
    { data: image1, filename: "screenshot1.png" },
    { data: image2, filename: "screenshot2.png" },
  ],
});

// Files only (with minimal text)
const buffer = Buffer.from("document content");
await thread.post({
  markdown: "",
  files: [{ data: buffer, filename: "document.xlsx" }],
});

Reading Attachments

Access attachments from incoming messages:

import { Chat } from "chat";

bot.onSubscribedMessage(async (thread, message) => {
  for (const attachment of message.attachments ?? []) {
    console.log(`File: ${attachment.name}, Type: ${attachment.mimeType}`);

    // Download the file data
    if (attachment.fetchData) {
      const data = await attachment.fetchData();
      // Process the file...
      console.log(`Downloaded ${data.length} bytes`);
    }
  }
});

The Attachment interface includes type, url, name, mimeType, size, width, height, and fetchData properties.

Direct Messages

Initiate DM conversations programmatically. The adapter is automatically inferred from the userId format:

import { Chat } from "chat";

// Open a DM using Author object (convenient in handlers)
bot.onSubscribedMessage(async (thread, message) => {
  if (message.text === "DM me") {
    const dmThread = await bot.openDM(message.author);
    await dmThread.post("Hello! This is a direct message.");
  }
});

// Or use userId string directly - adapter inferred from format:
// - Slack: U... (e.g., "U1234567890")
// - Teams: 29:... (e.g., "29:abc123...")
// - Google Chat: users/... (e.g., "users/123456789")
const dmThread = await bot.openDM("U1234567890");

// Check if a thread is a DM
bot.onSubscribedMessage(async (thread, message) => {
  if (thread.isDM) {
    await thread.post("This is a private conversation.");
  }
});

Ephemeral Messages

Send a message visible only to a specific user:

await thread.postEphemeral(user, "Only you can see this!", {
  fallbackToDM: true,
});

The fallbackToDM option is required and controls behavior on platforms without native ephemeral support:

  • fallbackToDM: true - Send as DM if native ephemeral isn't supported
  • fallbackToDM: false - Return null if native ephemeral isn't supported

Platform Behavior

Platform Native Support Behavior Where it appears Persistence
Slack Yes Ephemeral in channel In the channel, only visible to target user Session-only (disappears on page reload)
Google Chat Yes Private message In the space, only visible to target user Persists until deleted
Discord No DM (if enabled) In a DM conversation with the bot Persists in DM
Teams No DM (if enabled) In a DM conversation with the bot Persists in DM

Key differences:

  • Slack: True ephemeral - message appears in the channel context but disappears when the user refreshes. Other users never see it. Note: Requires additional OAuth scopes beyond chat:write - add channels:write (public), groups:write (private), im:write (DMs), or mpim:write (group DMs) depending on your use case.
  • Google Chat: Private message viewer - message appears in the space but only the target user can see it. It persists and can be deleted by the bot.
  • Discord/Teams: No native ephemeral support. With fallbackToDM: true, sends a DM instead. With fallbackToDM: false, returns null.

Examples

Always deliver the message (DM fallback):

const result = await thread.postEphemeral(user, "Private notification", {
  fallbackToDM: true,
});

if (result?.usedFallback) {
  // Was sent as DM on Discord/Teams
  console.log(`Sent as DM: ${result.threadId}`);
}

Only send if native ephemeral is supported:

const result = await thread.postEphemeral(user, "Contextual hint", {
  fallbackToDM: false,
});

if (!result) {
  // Platform doesn't support native ephemeral (Discord/Teams)
  // Message was not sent - handle accordingly or skip
}

Development

pnpm install
pnpm build
pnpm dev         # Run example app
pnpm typecheck
pnpm lint

License

MIT

S
Description
Build multi-platform chat bots with Chat SDK (chat npm package). Use when developers want to scaffold a bot with create-chat-sdk, build a Slack, Teams,…
Readme MIT 23 MiB
Languages
TypeScript 89%
MDX 10.9%