---
title: Actions
description: Handle button clicks and interactive card events across platforms.
type: guide
prerequisites:
  - /docs/cards
related:
  - /docs/modals
---

# Actions



Actions let you handle button clicks, dropdown selections, and other interactive events from [cards](/docs/cards). Register handlers with `onAction` to respond when users interact with your cards.

## Handle a specific action

```typescript title="lib/bot.ts" lineNumbers
bot.onAction("approve", async (event) => {
  await event.thread.post(`Order approved by ${event.user.fullName}!`);
});
```

## Handle multiple actions

```typescript title="lib/bot.ts" lineNumbers
bot.onAction(["approve", "reject"], async (event) => {
  const action = event.actionId === "approve" ? "approved" : "rejected";
  await event.thread.post(`Order ${action} by ${event.user.fullName}`);
});
```

## Catch-all handler

Register a handler without an action ID to catch all actions:

```typescript title="lib/bot.ts" lineNumbers
bot.onAction(async (event) => {
  console.log(`Action: ${event.actionId}, Value: ${event.value}`);
});
```

## ActionEvent

The `event` object passed to action handlers:

| Property    | Type                       | Description                                                                        |
| ----------- | -------------------------- | ---------------------------------------------------------------------------------- |
| `actionId`  | `string`                   | The `id` from the Button or Select component                                       |
| `value`     | `string` (optional)        | The `value` from the Button or selected option                                     |
| `user`      | `Author`                   | The user who clicked                                                               |
| `thread`    | `Thread \| null`           | The thread containing the card (null for view-based actions like home tab buttons) |
| `messageId` | `string`                   | The message containing the card                                                    |
| `threadId`  | `string`                   | Thread ID                                                                          |
| `adapter`   | `Adapter`                  | The platform adapter                                                               |
| `triggerId` | `string` (optional)        | Platform trigger ID (used for opening modals)                                      |
| `openModal` | `(modal) => Promise<void>` | Open a modal dialog                                                                |
| `raw`       | `unknown`                  | Platform-specific event payload                                                    |

## Pass data with buttons

Use the `value` prop on buttons to pass extra context to your handler:

```tsx title="lib/bot.tsx"
<Button id="report" value="bug">Report Bug</Button>
<Button id="report" value="feature">Request Feature</Button>
```

```typescript title="lib/bot.ts" lineNumbers
bot.onAction("report", async (event) => {
  if (event.value === "bug") {
    // Open bug report flow
  } else if (event.value === "feature") {
    // Open feature request flow
  }
});
```

## Open a modal from an action

Use `event.openModal()` to open a [modal](/docs/modals) in response to a button click:

```tsx title="lib/bot.tsx" lineNumbers
import { Modal, TextInput, Select, SelectOption } from "chat";

bot.onAction("feedback", async (event) => {
  await event.openModal(
    <Modal callbackId="feedback_form" title="Send Feedback" submitLabel="Send">
      <TextInput id="message" label="Your Feedback" multiline />
      <Select id="category" label="Category">
        <SelectOption label="Bug" value="bug" />
        <SelectOption label="Feature" value="feature" />
      </Select>
    </Modal>
  );
});
```

<Callout type="info">
  Modals are currently supported on Slack and Teams. Other platforms will receive a no-op
  or fallback behavior.
</Callout>

## Callback URLs

Buttons accept a `callbackUrl` prop. When clicked, the action data is POSTed to that URL in addition to firing any `onAction` handler. This pairs naturally with webhook-based workflow engines to build approval flows without any `onAction` handler at all:

```tsx title="lib/bot.tsx" lineNumbers
bot.onNewMention(async (thread) => {
  const approveUrl = "https://example.com/webhook/approve";
  const denyUrl = "https://example.com/webhook/deny";

  await thread.post(
    <Card title="Deploy v2.4.1?">
      <Actions>
        <Button callbackUrl={approveUrl} id="approve" style="primary">
          Approve
        </Button>
        <Button callbackUrl={denyUrl} id="deny" style="danger">
          Deny
        </Button>
      </Actions>
    </Card>
  );
});
```

### Callback payload

The POST body sent to the `callbackUrl`:

```json
{
  "type": "action",
  "actionId": "approve",
  "user": { "id": "U123", "name": "alice" },
  "threadId": "slack:C123:1234567890.123",
  "messageId": "1234567890.456"
}
```

If the button also has a `value` prop, it is included in the payload as `"value"`.

<Callout type="info">
  Platform limits apply to encoded button data. Discord's `custom_id` has a 100
  character limit - if the action ID plus callback token exceed this, posting
  the card throws a `ValidationError`. Telegram's `callback_data` has a 64 byte
  limit - buttons that exceed this will throw a `ValidationError`. Keep action
  IDs short when using `callbackUrl` on these platforms.
</Callout>

For modals, see [callbackUrl on modals](/docs/modals#callback-urls).


---
title: Platform Adapters
description: Platform-specific adapters that connect your bot to any messaging platform.
type: overview
prerequisites:
  - /docs/getting-started
---

# Platform Adapters



Adapters handle webhook verification, message parsing, and API calls for each platform. Install only the adapters you need. Browse all available adapters — including community-built ones — on the [Adapters](/adapters) page.

Need a browser chat UI? See the [Web adapter](/adapters/official/web) — it speaks the AI SDK UI stream protocol and works with React (`@ai-sdk/react`), Vue (`@ai-sdk/vue`), and Svelte (`@ai-sdk/svelte`), so the same bot serves Slack, Teams, **and** any browser framework out of the box.

Ready to build your own? Follow the [building](/docs/contributing/building) guide.

## Feature matrix

<GlobalFeatureMatrix type="platform" />

### Messaging

| Feature            | [Slack](/adapters/slack) | [Teams](/adapters/teams)         | [Google Chat](/adapters/google-chat) | [Discord](/adapters/discord) | [Telegram](/adapters/telegram)           | [GitHub](/adapters/github) | [Linear](/adapters/linear)          | [WhatsApp](/adapters/whatsapp) | [Messenger](/adapters/messenger) |
| ------------------ | ------------------------ | -------------------------------- | ------------------------------------ | ---------------------------- | ---------------------------------------- | -------------------------- | ----------------------------------- | ------------------------------ | -------------------------------- |
| Post message       | <Check />                | <Check />                        | <Check />                            | <Check />                    | <Check />                                | <Check />                  | <Check />                           | <Check />                      | <Check />                        |
| Edit message       | <Check />                | <Check />                        | <Check />                            | <Check />                    | <Check />                                | <Check />                  | <Warn /> Partial                    | <Cross />                      | <Cross />                        |
| Delete message     | <Check />                | <Check />                        | <Check />                            | <Check />                    | <Check />                                | <Check />                  | <Warn /> Partial                    | <Cross />                      | <Cross />                        |
| File uploads       | <Check />                | <Check />                        | <Cross />                            | <Check />                    | <Warn /> Single file/media               | <Cross />                  | <Cross />                           | <Check /> Images, audio, docs  | <Cross />                        |
| Streaming          | <Check /> Native         | <Warn /> Native (DMs) / Buffered | <Warn /> Post+Edit                   | <Warn /> Post+Edit           | <Warn /> Private chat drafts / Post+Edit | <Warn /> Buffered          | <Warn /> Agent sessions / Post+Edit | <Warn /> Buffered              | <Warn /> Buffered                |
| Scheduled messages | <Check /> Native         | <Cross />                        | <Cross />                            | <Cross />                    | <Cross />                                | <Cross />                  | <Cross />                           | <Cross />                      | <Cross />                        |

### Rich content

| Feature         | Slack               | Teams          | Google Chat       | Discord       | Telegram                           | GitHub        | Linear        | WhatsApp                      | Messenger                |
| --------------- | ------------------- | -------------- | ----------------- | ------------- | ---------------------------------- | ------------- | ------------- | ----------------------------- | ------------------------ |
| Card format     | Block Kit           | Adaptive Cards | Google Chat Cards | Embeds        | Markdown + inline keyboard buttons | GFM Markdown  | Markdown      | WhatsApp templates            | Generic/Button Templates |
| Buttons         | <Check />           | <Check />      | <Check />         | <Check />     | <Warn /> Inline keyboard callbacks | <Cross />     | <Cross />     | <Check /> Interactive replies | <Warn /> Max 3, postback |
| Link buttons    | <Check />           | <Check />      | <Check />         | <Check />     | <Warn /> Inline keyboard URLs      | <Cross />     | <Cross />     | <Cross />                     | <Check />                |
| Select menus    | <Check />           | <Cross />      | <Check />         | <Cross />     | <Cross />                          | <Cross />     | <Cross />     | <Cross />                     | <Cross />                |
| Tables          | <Check /> Block Kit | <Check /> GFM  | <Warn /> ASCII    | <Check /> GFM | <Warn /> ASCII                     | <Check /> GFM | <Check /> GFM | <Cross />                     | <Warn /> ASCII           |
| Fields          | <Check />           | <Check />      | <Check />         | <Check />     | <Check />                          | <Check />     | <Check />     | <Warn /> Template variables   | <Warn /> ASCII           |
| Images in cards | <Check />           | <Check />      | <Check />         | <Check />     | <Cross />                          | <Check />     | <Cross />     | <Check />                     | <Check />                |
| Modals          | <Check />           | <Check />      | <Cross />         | <Cross />     | <Cross />                          | <Cross />     | <Cross />     | <Cross />                     | <Cross />                |

### Conversations

| Feature                                                                                  | Slack            | Teams           | Google Chat      | Discord   | Telegram            | GitHub    | Linear                  | WhatsApp  | Messenger |
| ---------------------------------------------------------------------------------------- | ---------------- | --------------- | ---------------- | --------- | ------------------- | --------- | ----------------------- | --------- | --------- |
| Slash commands                                                                           | <Check />        | <Cross />       | <Cross />        | <Check /> | <Cross />           | <Cross /> | <Cross />               | <Cross /> | <Cross /> |
| Mentions                                                                                 | <Check />        | <Check />       | <Check />        | <Check /> | <Check />           | <Check /> | <Check />               | <Cross /> | <Check /> |
| Add reactions                                                                            | <Check />        | <Cross />       | <Check />        | <Check /> | <Check />           | <Check /> | <Check />               | <Check /> | <Cross /> |
| Remove reactions                                                                         | <Check />        | <Cross />       | <Check />        | <Check /> | <Check />           | <Warn />  | <Warn />                | <Check /> | <Cross /> |
| Typing indicator                                                                         | <Check />        | <Check />       | <Cross />        | <Check /> | <Check />           | <Cross /> | <Warn /> Agent sessions | <Warn />  | <Check /> |
| DMs                                                                                      | <Check />        | <Check />       | <Check />        | <Check /> | <Check />           | <Cross /> | <Cross />               | <Check /> | <Check /> |
| Ephemeral messages                                                                       | <Check /> Native | <Cross />       | <Check /> Native | <Cross /> | <Cross />           | <Cross /> | <Cross />               | <Cross /> | <Cross /> |
| User lookup ([`getUser`](/docs/api/chat#getuser))                                        | <Check />        | <Warn /> Cached | <Warn /> Cached  | <Check /> | <Warn /> Seen users | <Check /> | <Check />               | <Cross /> | <Cross /> |
| Parent subject ([`message.subject`](/docs/subject))                                      | <Cross />        | <Cross />       | <Cross />        | <Cross /> | <Cross />           | <Check /> | <Check />               | <Cross /> | <Cross /> |
| Native client ([`.webClient` / `.octokit` / `.linearClient`](/docs/api/chat#getadapter)) | <Check />        | <Cross />       | <Cross />        | <Cross /> | <Cross />           | <Check /> | <Check />               | <Cross /> | <Cross /> |
| Custom API endpoint (`apiUrl`)                                                           | <Check />        | <Check />       | <Check />        | <Check /> | <Check />           | <Check /> | <Check />               | <Check /> | <Check /> |

### Message history

| Feature                | Slack     | Teams     | Google Chat | Discord   | Telegram        | GitHub    | Linear    | WhatsApp                           | Messenger                          |
| ---------------------- | --------- | --------- | ----------- | --------- | --------------- | --------- | --------- | ---------------------------------- | ---------------------------------- |
| Fetch messages         | <Check /> | <Check /> | <Check />   | <Check /> | <Warn /> Cached | <Check /> | <Check /> | <Warn /> Cached sent messages only | <Warn /> Cached sent messages only |
| Fetch single message   | <Check /> | <Cross /> | <Cross />   | <Cross /> | <Warn /> Cached | <Cross /> | <Cross /> | <Cross />                          | <Warn /> Cached                    |
| Fetch thread info      | <Check /> | <Check /> | <Check />   | <Check /> | <Check />       | <Check /> | <Check /> | <Check />                          | <Check />                          |
| Fetch channel messages | <Check /> | <Check /> | <Check />   | <Check /> | <Warn /> Cached | <Check /> | <Cross /> | <Cross />                          | <Warn /> Cached                    |
| List threads           | <Check /> | <Check /> | <Check />   | <Check /> | <Cross />       | <Check /> | <Cross /> | <Cross />                          | <Cross />                          |
| Fetch channel info     | <Check /> | <Check /> | <Check />   | <Check /> | <Check />       | <Check /> | <Cross /> | <Cross />                          | <Check />                          |
| Post channel message   | <Check /> | <Check /> | <Check />   | <Check /> | <Check />       | <Cross /> | <Cross /> | <Check />                          | <Check />                          |

<Callout type="info">
  <Warn /> indicates partial support — the feature works with limitations. See individual adapter pages for details.
</Callout>

## How adapters work

Each adapter implements a standard interface that the `Chat` class uses to route events and send messages. When a webhook arrives:

1. The adapter verifies the request signature
2. Parses the platform-specific payload into a normalized `Message`
3. Routes to your handlers via the `Chat` class
4. Converts outgoing messages from markdown/AST/cards to the platform's native format

## Using multiple adapters

Register multiple [adapters](/adapters) and your event handlers work across all of them:

```typescript title="lib/bot.ts" lineNumbers
import { Chat } from "chat";
import { createSlackAdapter } from "@chat-adapter/slack";
import { createTeamsAdapter } from "@chat-adapter/teams";
import { createGoogleChatAdapter } from "@chat-adapter/gchat";
import { createRedisState } from "@chat-adapter/state-redis";

const bot = new Chat({
  userName: "mybot",
  adapters: {
    slack: createSlackAdapter(),
    teams: createTeamsAdapter(),
    gchat: createGoogleChatAdapter(),
  },
  state: createRedisState(),
});

// This handler fires for mentions on any platform
bot.onNewMention(async (thread) => {
  await thread.subscribe();
  await thread.post("Hello!");
});
```

Each adapter auto-detects credentials from environment variables, so you only need to pass config when overriding defaults.

<Callout type="info">
  The examples above use Redis for state. See [State Adapters](/docs/state) for all available options.
</Callout>

Each adapter creates a webhook handler accessible via `bot.webhooks.<name>`.

## Customizing an adapter via subclassing

Each official adapter exposes its extension surface as `protected` members so you can subclass it to override or extend platform-specific behavior without forking the package. Use this when you need to handle a payload type the built-in adapter doesn't cover, intercept verification, or wrap an existing handler.

```typescript title="lib/custom-telegram.ts" lineNumbers
import { TelegramAdapter, type TelegramUpdate } from "@chat-adapter/telegram";
import type { WebhookOptions } from "chat";

export class CustomTelegramAdapter extends TelegramAdapter {
  protected override processUpdate(
    update: TelegramUpdate,
    options?: WebhookOptions
  ): void {
    // Handle a payload type the base adapter doesn't, e.g. chat_join_request.
    if ("chat_join_request" in update) {
      this.logger.info("Received chat_join_request", { update });
      return;
    }
    super.processUpdate(update, options);
  }
}
```

Construct your subclass anywhere you'd construct the base adapter — for example, `adapters: { telegram: new CustomTelegramAdapter({ ... }) }`. Members marked `private` (internal caches, in-flight runtime state, one-shot warning flags) intentionally remain inaccessible; if you find a hook you need that isn't `protected`, please open an issue.

<Callout type="warn">
  The `protected` extension surface is intentionally broader than the public API but is not yet considered fully stable. Method signatures may evolve (renames, parameter changes, new hook splits) in minor releases as we learn from real-world subclasses. Pin the adapter version you build against, watch the changelog for the affected adapter, and prefer overriding the smallest hook that solves your problem so upgrades stay easy. If you rely on a particular hook, please open an issue so we can promote it to a stable, documented extension point.
</Callout>


---
title: Cards
description: Send rich interactive cards with buttons, fields, and images across all platforms.
type: guide
prerequisites:
  - /docs/usage
related:
  - /docs/actions
  - /docs/modals
---

# Cards



Cards let you send structured, interactive messages that render natively on each platform — Block Kit on Slack, Adaptive Cards on Teams, and Google Chat Cards.

## Setup

Configure your `tsconfig.json` to use the Chat SDK JSX runtime:

```json title="tsconfig.json"
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "chat"
  }
}
```

Or use a per-file pragma:

```tsx title="lib/bot.tsx"
/** @jsxImportSource chat */
```

## Basic card

```tsx title="lib/bot.tsx" lineNumbers
import { Card, CardText, Button, Actions } from "chat";

await thread.post(
  <Card title="Order #1234">
    <CardText>Your order has been received!</CardText>
    <Actions>
      <Button id="approve" style="primary">Approve</Button>
      <Button id="reject" style="danger">Reject</Button>
    </Actions>
  </Card>
);
```

## Components

### Card

The top-level container. Accepts `title` and optional `subtitle`.

```tsx title="lib/bot.tsx" lineNumbers
<Card title="My Card" subtitle="Optional subtitle">
  {/* children */}
</Card>
```

### CardText

Renders formatted text. Supports a subset of markdown.

```tsx title="lib/bot.tsx" lineNumbers
<CardText>**Bold** and _italic_ text</CardText>
<CardText style="bold">Bold section header</CardText>
```

<Callout type="info">
  Use `CardText` instead of `Text` when using JSX to avoid conflicts with React's built-in types.
</Callout>

### Section

Groups related content together.

```tsx title="lib/bot.tsx" lineNumbers
<Section>
  <CardText>Section content here</CardText>
</Section>
```

### Fields

Renders key-value pairs in a compact layout.

```tsx title="lib/bot.tsx" lineNumbers
<Fields>
  <Field label="Name" value="John Doe" />
  <Field label="Role" value="Developer" />
  <Field label="Team" value="Platform" />
</Fields>
```

### Button

An action button that triggers an `onAction` handler.

```tsx title="lib/bot.tsx" lineNumbers
<Button id="approve" style="primary">Approve</Button>
<Button id="reject" style="danger">Reject</Button>
<Button id="details">View Details</Button>
```

The `id` maps to your `onAction` handler. Optional `value` passes extra data:

```tsx title="lib/bot.tsx"
<Button id="report" value="bug">Report Bug</Button>
```

Set `actionType="modal"` to indicate the button opens a [modal](/docs/modals). The button still triggers your `onAction` handler, where you call `event.openModal()` — this prop tells adapters like Teams to wire up the button for dialog opening:

```tsx title="lib/bot.tsx"
<Button id="open-feedback" actionType="modal">Give Feedback</Button>
```

Optional `callbackUrl` causes the action data to be POSTed to a URL when clicked. See [Callback URLs](/docs/actions#callback-urls) for details.

```tsx title="lib/bot.tsx"
<Button callbackUrl={webhook.url} id="approve" style="primary">Approve</Button>
```

### CardLink

Inline hyperlink rendered as text. Unlike `LinkButton` (which must be inside `Actions`), `CardLink` can be placed directly in a card alongside other content.

```tsx title="lib/bot.tsx"
<CardLink url="https://example.com/order/1234" label="View order details" />
```

Or with children as the label:

```tsx title="lib/bot.tsx"
<CardLink url="https://example.com/docs">Read the docs</CardLink>
```

<Callout type="info">
  `CardLink` renders as a platform-native link: `<url|label>` on Slack, `[label](url)` on Teams/Discord/GitHub/Linear, and `<a href>` on Google Chat.
</Callout>

### LinkButton

Opens an external URL. No `onAction` handler needed.

```tsx title="lib/bot.tsx"
<LinkButton url="https://example.com/order/1234">View Order</LinkButton>
```

### Actions

Container for buttons and interactive elements.

```tsx title="lib/bot.tsx" lineNumbers
<Actions>
  <Button id="approve" style="primary">Approve</Button>
  <Button id="reject" style="danger">Reject</Button>
  <LinkButton url="https://example.com">View</LinkButton>
</Actions>
```

### Select

Inline dropdown menu.

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

Selection triggers an `onAction` handler with the `id` as the `actionId` and the selected value.

### RadioSelect

Radio button group for mutually exclusive choices.

```tsx title="lib/bot.tsx" lineNumbers
<Actions>
  <RadioSelect id="status" label="Status">
    <SelectOption label="Open" value="open" />
    <SelectOption label="In Progress" value="in_progress" />
    <SelectOption label="Done" value="done" />
  </RadioSelect>
</Actions>
```

### Table

Structured data display with column headers and rows. Renders as a native table on platforms that support it (Teams, GitHub, Linear) and as padded ASCII text elsewhere.

```tsx title="lib/bot.tsx" lineNumbers
<Table
  headers={["Name", "Age", "Role"]}
  rows={[
    ["Alice", "30", "Engineer"],
    ["Bob", "25", "Designer"],
  ]}
/>
```

Optional column alignment:

```tsx title="lib/bot.tsx"
<Table
  headers={["Name", "Amount"]}
  rows={[["Alice", "$100"], ["Bob", "$200"]]}
  align={["left", "right"]}
/>
```

### Image

Embeds an image in the card.

```tsx title="lib/bot.tsx"
<Image url="https://example.com/screenshot.png" alt="Screenshot" />
```

### Divider

A visual separator between sections.

```tsx title="lib/bot.tsx" lineNumbers
<CardText>Above the line</CardText>
<Divider />
<CardText>Below the line</CardText>
```

## Full example

```tsx title="lib/bot.tsx" lineNumbers
import {
  Card, CardText, CardLink, Button, LinkButton, Actions,
  Section, Fields, Field, Divider, Image,
  Select, SelectOption, RadioSelect,
} from "chat";

await thread.post(
  <Card title="User Profile" subtitle="Account details">
    <Image url="https://example.com/avatar.png" alt="User avatar" />
    <Fields>
      <Field label="Name" value="Jane Smith" />
      <Field label="Role" value="Engineer" />
      <Field label="Team" value="Platform" />
    </Fields>
    <CardLink url="https://example.com/profile/123">View full profile</CardLink>
    <Divider />
    <Section>
      <CardText>Select an action below to manage this profile.</CardText>
    </Section>
    <Actions>
      <Select id="role" label="Change Role" placeholder="Select role">
        <SelectOption label="Engineer" value="engineer" />
        <SelectOption label="Manager" value="manager" />
        <SelectOption label="Admin" value="admin" />
      </Select>
      <Button id="edit" style="primary">Edit Profile</Button>
      <Button id="deactivate" style="danger">Deactivate</Button>
      <LinkButton url="https://example.com/profile/123">View Full Profile</LinkButton>
    </Actions>
  </Card>
);
```


---
title: Overlapping Messages
description: Control how overlapping messages on the same thread are handled - burst, queue, debounce, drop, or process concurrently.
type: guide
prerequisites:
  - /docs/handling-events
related:
  - /docs/state
  - /docs/streaming
---

# Overlapping Messages



When multiple messages arrive on the same thread while a handler is still processing, the SDK needs a strategy. By default, the incoming message is dropped. The `concurrency` option on `ChatConfig` lets you choose what happens instead.

## Strategies

### Drop (default)

The original behavior. If a handler is already running on a thread, the new message is discarded and a `LockError` is thrown. No queuing, no retries.

```typescript title="lib/bot.ts"
const bot = new Chat({
  concurrency: "drop",
  // ...
});
```

### Queue

Messages that arrive while a handler is running are enqueued. When the current handler finishes, only the **latest** queued message is dispatched. All intermediate messages are provided as `context.skipped`, giving your handler full visibility into what happened while it was busy.

```typescript title="lib/bot.ts" lineNumbers
const bot = new Chat({
  concurrency: "queue",
  // ...
});

bot.onNewMention(async (thread, message, context) => {
  if (context && context.skipped.length > 0) {
    await thread.post(
      `You sent ${context.totalSinceLastHandler} messages while I was thinking. Responding to your latest.`
    );
  }

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

**Flow:**

```
A arrives  → acquire lock → process A
B arrives  → lock busy → enqueue B
C arrives  → lock busy → enqueue C
D arrives  → lock busy → enqueue D
A done     → drain: [B, C, D] → handler(D, { skipped: [B, C] })
D done     → queue empty → release lock
```

### Burst

Waits for `debounceMs` before the first handler on an idle thread, then drains the collected burst like `queue`. The latest message is dispatched, and earlier messages in the burst are available as `context.skipped`.

Use this for assistant-style bots where users often send one logical turn as several short messages inside a small window and you want one response with full context. Compared to `debounce`, `burst` keeps the earlier messages in `context.skipped` instead of dropping them, and it flushes queued messages that arrive while the handler is running.

Choose `debounce` when the latest message replaces earlier ones, like rapid corrections. Choose `burst` when earlier messages still matter, like "hey", "quick question", and then the actual question. The tradeoff is that even a lone message waits for `debounceMs` before the handler runs.

```typescript title="lib/bot.ts" lineNumbers
const bot = new Chat({
  concurrency: { strategy: "burst", debounceMs: 1000 },
  // ...
});

bot.onNewMention(async (thread, message, context) => {
  const turn = [...(context?.skipped ?? []), message]
    .map((m) => m.text)
    .join("\n\n");

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

**Flow:**

```
A arrives  → acquire lock → enqueue A → sleep(debounceMs)
B arrives  → lock busy → enqueue B
C arrives  → lock busy → enqueue C
             ... debounceMs elapses ...
           → drain: [A, B, C] → handler(C, { skipped: [A, B] })
D arrives while C is running → lock busy → enqueue D
E arrives while C is running → lock busy → enqueue E
C done     → drain: [D, E] → handler(E, { skipped: [D] })
E done     → queue empty → release lock
```

### Debounce

The first message waits for `debounceMs`. Messages that arrive during that window replace the pending message, so only the **final message in the burst window** is processed.

This is particularly useful for platforms like **WhatsApp** and **Telegram** where users tend to send a flurry of short messages in quick succession instead of composing a single message - "hey", "quick question", "how do I reset my password?" arriving as three separate webhooks within a few seconds. Without debounce, the bot would respond to "hey" before the actual question even arrives. With debounce, the SDK waits briefly and processes only the final message in the window.

```typescript title="lib/bot.ts" lineNumbers
const bot = new Chat({
  concurrency: { strategy: "debounce", debounceMs: 1500 },
  // ...
});
```

<Callout type="info">
  WhatsApp and Telegram adapters default to `lockScope: "channel"`, so debounce applies to the entire conversation — not just a single thread.
</Callout>

**Flow:**

```
A arrives  → acquire lock → store A as pending → sleep(debounceMs)
B arrives  → lock busy → overwrite pending with B (A dropped)
C arrives  → lock busy → overwrite pending with C (B dropped)
             ... debounceMs elapses with no new message ...
           → process C → release lock
```

Debounce also works well for rapid corrections ("wait, I meant...") and multi-part messages on any platform.

### Concurrent

No locking at all. Every message is processed immediately in its own handler invocation. Use this for stateless handlers where thread ordering doesn't matter.

```typescript title="lib/bot.ts"
const bot = new Chat({
  concurrency: "concurrent",
  // ...
});
```

## Configuration

For fine-grained control, pass a `ConcurrencyConfig` object instead of a strategy string:

```typescript title="lib/bot.ts" lineNumbers
const bot = new Chat({
  concurrency: {
    strategy: "queue",
    maxQueueSize: 20,          // Max queued messages per thread (default: 10)
    onQueueFull: "drop-oldest", // or "drop-newest" (default: "drop-oldest")
    queueEntryTtlMs: 60_000,  // Discard stale entries after 60s (default: 90s)
  },
  // ...
});
```

### All options

| Option            | Strategies             | Default         | Description                                                                      |
| ----------------- | ---------------------- | --------------- | -------------------------------------------------------------------------------- |
| `strategy`        | all                    | `"drop"`        | The concurrency strategy to use                                                  |
| `maxQueueSize`    | queue, burst           | `10`            | Maximum queued messages per thread                                               |
| `onQueueFull`     | queue, burst           | `"drop-oldest"` | Whether to evict the oldest or reject the newest message when the queue is full  |
| `queueEntryTtlMs` | queue, debounce, burst | `90000`         | TTL for queued entries in milliseconds. Expired entries are discarded on dequeue |
| `debounceMs`      | debounce, burst        | `1500`          | Debounce window in milliseconds                                                  |
| `maxConcurrent`   | concurrent             | `Infinity`      | Max concurrent handlers per thread                                               |

<Callout type="warn">
  `maxConcurrent` only applies to the `concurrent` strategy. Pairing it with any other strategy logs a warning and the value is ignored. Setting `maxConcurrent` to a value less than `1` throws at construction time — `0` would deadlock the strategy and is rejected up front.
</Callout>

## MessageContext

All handler types (`onNewMention`, `onSubscribedMessage`, `onNewMessage`) accept an optional `MessageContext` as their last parameter. It is populated when using the `queue` strategy for queued messages, and when using `burst` for a collapsed turn. A lone `burst` message receives `skipped: []` and `totalSinceLastHandler: 1`.

```typescript
interface MessageContext {
  /** Messages that arrived while the previous handler was running, in chronological order. */
  skipped: Message[];
  /** Total messages received since last handler ran (skipped.length + 1). */
  totalSinceLastHandler: number;
}
```

Existing handlers that don't use `context` are unaffected — the parameter is optional.

### Example: Pass all messages to an LLM

```typescript title="lib/bot.ts" lineNumbers
bot.onSubscribedMessage(async (thread, message, context) => {
  // Combine skipped messages with the current one for full context
  const allMessages = [...(context?.skipped ?? []), message];

  const response = await generateAIResponse(
    allMessages.map((m) => m.text).join("\n\n")
  );
  await thread.post(response);
});
```

## Lock scope

By default, locks are scoped to the thread — messages in different threads are processed independently. For platforms like WhatsApp and Telegram where conversations happen at the channel level rather than in threads, the lock scope defaults to `"channel"`.

You can override this globally:

```typescript title="lib/bot.ts" lineNumbers
const bot = new Chat({
  concurrency: "queue",
  lockScope: "channel", // or "thread" (default)
  // ...
});
```

Or resolve it dynamically per message:

```typescript title="lib/bot.ts" lineNumbers
const bot = new Chat({
  concurrency: "queue",
  lockScope: ({ isDM, adapter }) => {
    // Use channel scope for DMs, thread scope for group channels
    return isDM ? "channel" : "thread";
  },
  // ...
});
```

## State adapter requirements

The `queue`, `debounce`, and `burst` strategies require three additional methods on your state adapter:

| Method                              | Description                                                            |
| ----------------------------------- | ---------------------------------------------------------------------- |
| `enqueue(threadId, entry, maxSize)` | Atomically append a message to the thread's queue. Returns new depth.  |
| `dequeue(threadId)`                 | Pop the next (oldest) message from the queue. Returns `null` if empty. |
| `queueDepth(threadId)`              | Return the current number of queued messages.                          |

All built-in state adapters (`@chat-adapter/state-memory`, `@chat-adapter/state-redis`, `@chat-adapter/state-ioredis`) implement these methods. The Redis adapters use Lua scripts for atomicity.

## Observability

All strategies emit structured log events at `info` level:

| Event                    | Strategy               | Data                                              |
| ------------------------ | ---------------------- | ------------------------------------------------- |
| `message-queued`         | queue, burst           | threadId, messageId, queueDepth                   |
| `message-dequeued`       | queue, debounce, burst | threadId, messageId, skippedCount for queue/burst |
| `message-dropped`        | drop, queue, burst     | threadId, messageId, reason                       |
| `message-expired`        | queue, debounce, burst | threadId, messageId                               |
| `message-superseded`     | debounce               | threadId, droppedId                               |
| `message-debouncing`     | debounce, burst        | threadId, messageId, debounceMs                   |
| `message-debounce-reset` | debounce               | threadId, messageId                               |

## Choosing a strategy

| Use case                                  | Strategy     | Why                                                                                       |
| ----------------------------------------- | ------------ | ----------------------------------------------------------------------------------------- |
| Simple bots, one-shot commands            | `drop`       | No complexity, no queue overhead                                                          |
| AI chatbots, customer support             | `queue`      | Never lose messages; handler sees full conversation context                               |
| AI chatbots with multi-message user turns | `burst`      | Wait for the idle burst window, then respond once with every message in that window       |
| WhatsApp/Telegram bots, rapid corrections | `debounce`   | Users send many short messages in quick succession; wait briefly and keep only the latest |
| Stateless lookups, translations           | `concurrent` | Maximum throughput, no ordering needed                                                    |

## Backward compatibility

* The default strategy is `drop` — existing behavior is unchanged.
* The deprecated `onLockConflict` option continues to work but should be replaced with `concurrency`.
* Handler signatures are backward-compatible; the new `context` parameter is optional.
* Deduplication always runs regardless of strategy.


---
title: Conversation History
description: Persist messages per user across every platform — for LLM context, audit, or compliance.
type: guide
prerequisites:
  - /docs/state
related:
  - /docs/handling-events
  - /docs/api/transcripts
---

# Conversation History



Bots that hold context across a user's conversations need somewhere to store it. The platform's own message history won't do — a user might talk to your bot in Slack today and Discord tomorrow, and you want the same memory to follow them.

`bot.transcripts` keeps a per-user transcript in your state adapter, keyed by a stable identifier you choose (an email, an internal user ID, anything that's the same person no matter where they are).

## Setup

You opt in by setting two fields on `ChatConfig`:

```typescript title="lib/bot.ts" lineNumbers
import { Chat } from "chat";
import { createSlackAdapter } from "@chat-adapter/slack";
import { createDiscordAdapter } from "@chat-adapter/discord";
import { createRedisState } from "@chat-adapter/state-redis";

const bot = new Chat({
  userName: "mybot",
  adapters: {
    slack: createSlackAdapter(),
    discord: createDiscordAdapter(),
  },
  state: createRedisState({ url: process.env.REDIS_URL! }),

  // Resolve the cross-platform identifier for an inbound message.
  // Return null for messages you don't want to remember.
  identity: ({ author }) => author.email ?? null,

  // Storage tuning. retention is the list TTL, refreshed on every append.
  transcripts: {
    retention: "30d",
    maxPerUser: 200,
  },
});
```

`transcripts` and `identity` are paired — set one without the other and the constructor throws. This keeps the API loud rather than silently no-op'ing on every call.

## Building LLM context

The most common pattern: append the user's message, build a prompt from recent transcript entries, post the reply, append the reply too.

```typescript title="lib/bot.ts" lineNumbers
bot.onSubscribedMessage(async (thread, msg) => {
  await bot.transcripts.append(thread, msg);

  const recent = await bot.transcripts.list({
    userKey: msg.userKey!,
    limit: 20,
  });

  const reply = await generateReply(recent, msg);
  await thread.post(reply);

  await bot.transcripts.append(
    thread,
    { role: "assistant", text: reply },
    { userKey: msg.userKey! }
  );
});
```

A few things worth knowing:

* **`msg.userKey`** is set automatically from your `identity` resolver before your handler runs. If the resolver returned `null`, it stays `undefined` and the `append` call no-ops.
* **Bot replies are explicit.** The SDK doesn't auto-capture `thread.post()` output — you decide what gets remembered. That's important for retries, intermediate streaming chunks, and anything you don't want feeding back into the model later.
* **Order is chronological.** `list` returns oldest-first, ready to feed into a model. Set `limit` to keep prompts bounded.

## Identity resolution

`identity` runs once per inbound message during dispatch. The `author`, `message`, and `adapter` name are all available:

```typescript
identity: async ({ adapter, author, message }) => {
  // Look up by email when the platform exposes it
  if (author.email) {
    return author.email;
  }
  // Or map a platform user to an internal ID
  return await lookupUser(adapter, author.userId);
}
```

Return `null` when you can't resolve a key. The SDK won't fall back to a platform-specific ID — that would silently fragment a user's transcript across platforms, which is exactly what this feature is here to prevent.

If your resolver throws, the SDK logs a warning and dispatches the message without a `userKey`. Handlers still run; only the persistence is skipped.

## Filtering entries

`list` accepts a few filters. They compose, and they're applied after `getList` — useful for narrowing prompts without restructuring storage.

```typescript
// Recent N across all platforms
await bot.transcripts.list({ userKey: "mike@acme.com", limit: 50 });

// Single platform
await bot.transcripts.list({ userKey: "mike@acme.com", platforms: ["slack"] });

// Single thread
await bot.transcripts.list({
  userKey: "mike@acme.com",
  threadId: "slack:C123:1234.5678",
});

// Only the user's own messages
await bot.transcripts.list({ userKey: "mike@acme.com", roles: ["user"] });
```

## Deleting a user's transcript

For data-subject requests or simple "forget me" flows:

```typescript
await bot.transcripts.delete({ userKey: "mike@acme.com" });
// → { deleted: 47 }
```

This wipes every entry stored under the key. Single-entry and time-range deletes aren't part of the API — `appendToList` doesn't support them safely under concurrent writes.

## Where it's stored

`bot.transcripts` is backed by `StateAdapter.appendToList` / `getList` / `delete`. Every built-in state adapter (`memory`, `redis`, `ioredis`, `pg`) supports these primitives, so this works on whichever one you've already configured.

Entries are written under the key `transcripts:user:{userKey}` as a capped list. `appendToList` is atomic, so concurrent inbound messages on the same user don't race.

## Reference

See [Transcripts](/docs/api/transcripts) for full type signatures, configuration options, and the entry shape.


---
title: Direct Messages
description: Initiate DM conversations with users programmatically.
type: guide
prerequisites:
  - /docs/usage
---

# Direct Messages



Open direct message conversations with users using `bot.openDM()`. For globally recognizable user IDs, the adapter is automatically inferred from the ID format.

## DM behavior

DMs behave slightly differently from channel messages:

* **Direct message handlers** — if you register `onDirectMessage`, every incoming DM routes there before `onSubscribedMessage`, `onNewMention`, and pattern handlers. This keeps DM-centric flows like WhatsApp conversations, Telegram DMs, and web chat on one consistent handler.
* **Mention fallback** — if no `onDirectMessage` handlers are registered, DMs continue through normal routing. Unsubscribed DMs are treated as mentions, so existing `onNewMention` bots keep working without requiring the user to @-mention the bot.
* **Per-conversation threading** — Each top-level DM starts a new conversation. Thread replies within a DM continue the same conversation, giving you the same per-thread isolation as channels.

## Handle incoming DMs

```typescript title="lib/bot.ts" lineNumbers
bot.onDirectMessage(async (thread, message) => {
  await thread.post(`You said: ${message.text}`);
});
```

## Open a DM

### From an Author object

The most common pattern — use the `author` from an incoming message:

```typescript title="lib/bot.ts" lineNumbers
bot.onSubscribedMessage(async (thread, message) => {
  if (message.text === "DM me") {
    const dmThread = await bot.openDM(message.author);
    await dmThread.post("Hello! This is a private message.");
  }
});
```

### From a user ID

Pass a user ID string directly. The adapter is inferred from the ID format:

```typescript title="lib/bot.ts"
const dmThread = await bot.openDM("U1234567890"); // Slack
```

| Format          | Platform            |
| --------------- | ------------------- |
| `U...` / `W...` | Slack               |
| `29:...`        | Teams               |
| `users/...`     | Google Chat         |
| Numeric ID      | Discord or Telegram |

<Callout type="info">
  Numeric IDs can be ambiguous when multiple numeric-ID adapters are registered. For platforms whose user IDs are not globally distinguishable, call the adapter directly and wrap the returned thread ID with `bot.thread()`.
</Callout>

```typescript title="lib/bot.ts"
const threadId = await bot.getAdapter("whatsapp").openDM("15551234567");
const dmThread = bot.thread(threadId);
```

## Check if a thread is a DM

```typescript title="lib/bot.ts" lineNumbers
bot.onSubscribedMessage(async (thread, message) => {
  if (thread.isDM) {
    await thread.post("This is a private conversation.");
  }
});
```


---
title: Emoji
description: Type-safe, cross-platform emoji that automatically convert to each platform's format.
type: reference
---

# Emoji



The `emoji` helper provides cross-platform emoji that automatically convert to the correct format for each platform. On Slack, emoji render as `:shortcode:` format. On other platforms, they render as Unicode characters.

## Usage

```typescript title="lib/bot.ts" lineNumbers
import { emoji } from "chat";

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

Emoji also work in reactions:

```typescript title="lib/bot.ts" lineNumbers
await sent.addReaction(emoji.check);

bot.onReaction(["thumbs_up", "heart", "fire"], async (event) => {
  if (!event.added) return;
  await event.adapter.addReaction(event.threadId, event.messageId, emoji.raised_hands);
});
```

## 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`     | ⚠️    |
| `emoji.raised_hands` | 🙌    | `emoji.muscle`      | 💪    |
| `emoji.ok_hand`      | 👌    | `emoji.sad`         | 😢    |
| `emoji.memo`         | 📝    | `emoji.gear`        | ⚙️    |
| `emoji.wrench`       | 🔧    | `emoji.bug`         | 🐛    |
| `emoji.calendar`     | 📅    | `emoji.clock`       | 🕐    |
| `emoji.sun`          | ☀️    | `emoji.rainbow`     | 🌈    |

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

## Custom emoji

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

```typescript title="lib/bot.ts" lineNumbers
import { createEmoji } from "chat";

const myEmoji = createEmoji({
  unicorn: { slack: "unicorn_face", gchat: "🦄" },
  company_logo: { slack: "company", gchat: "🏢" },
});

await thread.post(`${myEmoji.unicorn} Magic! ${myEmoji.company_logo}`);
// Slack: ":unicorn_face: Magic! :company:"
// GChat: "🦄 Magic! 🏢"
```

You can also extend the built-in emoji map with TypeScript module augmentation:

```typescript title="lib/emoji.d.ts" lineNumbers
declare module "chat" {
  interface CustomEmojiMap {
    my_custom: EmojiFormats;
  }
}
```


---
title: Ephemeral Messages
description: Send messages visible only to a specific user.
type: guide
prerequisites:
  - /docs/usage
related:
  - /docs/direct-messages
---

# Ephemeral Messages



Ephemeral messages are visible only to a specific user within a thread. They're useful for confirmations, hints, and private notifications.

## Send an ephemeral message

```typescript title="lib/bot.ts" lineNumbers
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 a DM if native ephemeral is not supported
* `fallbackToDM: false` — return `null` if native ephemeral is not supported

## Platform behavior

| Platform    | Native support | Behavior                 | Persistence                         |
| ----------- | -------------- | ------------------------ | ----------------------------------- |
| Slack       | Yes            | Ephemeral in channel     | Session-only (disappears on reload) |
| Google Chat | Yes            | Private message in space | Persists until deleted              |
| Discord     | No             | DM fallback              | Persists in DM                      |
| Teams       | No             | DM fallback              | Persists in DM                      |

## Check for fallback

```typescript title="lib/bot.ts" lineNumbers
const result = await thread.postEphemeral(user, "Private notification", {
  fallbackToDM: true,
});

if (result?.usedFallback) {
  console.log("Sent as DM instead of ephemeral");
}
```

## Graceful degradation

Only send if the platform supports native ephemeral:

```typescript title="lib/bot.ts" lineNumbers
const result = await thread.postEphemeral(user, "Contextual hint", {
  fallbackToDM: false,
});

if (!result) {
  // Platform doesn't support native ephemeral
  // Message was not sent
}
```

## Ephemeral cards

Cards work with ephemeral messages too:

```tsx title="lib/bot.tsx" lineNumbers
await thread.postEphemeral(
  event.user,
  <Card title="Ephemeral Card">
    <CardText>Only you can see this card.</CardText>
    <Actions>
      <Button id="open_modal" style="primary">Open Modal</Button>
    </Actions>
  </Card>,
  { fallbackToDM: true }
);
```


---
title: Error Handling
description: Handle rate limits, unsupported features, and other errors from adapters.
type: guide
prerequisites:
  - /docs/usage
---

# Error Handling



The SDK provides typed error classes for common failure scenarios. All errors are importable from the `chat` package.

```typescript
import { ChatError, RateLimitError, NotImplementedError, LockError } from "chat";
```

## Error types

### ChatError

Base error class for all SDK errors. Every error below extends `ChatError`. The `code` property carries a machine-readable identifier you can branch on:

| Code                     | Thrown by                      | Meaning                                                                                |
| ------------------------ | ------------------------------ | -------------------------------------------------------------------------------------- |
| `NOT_SUPPORTED`          | `bot.openDM`, `bot.getUser`    | The resolved adapter doesn't implement this method                                     |
| `INVALID_THREAD_ID`      | `bot.thread`, internal routing | Thread ID does not match the `adapter:channel:thread` shape                            |
| `INVALID_CHANNEL_ID`     | `bot.channel`                  | Channel ID does not match the `adapter:channel` shape                                  |
| `ADAPTER_NOT_FOUND`      | `bot.thread`, `bot.channel`    | Thread/channel ID references an adapter that wasn't registered on this `Chat` instance |
| `AMBIGUOUS_USER_ID`      | `bot.getUser`, `bot.openDM`    | Numeric user ID could match more than one registered adapter (Discord/Telegram/GitHub) |
| `UNKNOWN_USER_ID_FORMAT` | `bot.getUser`, `bot.openDM`    | The `userId` doesn't match any platform's known ID format                              |
| `RATE_LIMITED`           | Any platform call              | Platform returned 429; see `RateLimitError` below                                      |
| `NOT_IMPLEMENTED`        | Any platform call              | The adapter doesn't implement this feature; see `NotImplementedError` below            |
| `LOCK_FAILED`            | Inbound message routing        | Distributed lock was busy; see `LockError` below                                       |

<TypeTable
  type={{
  message: {
    description: 'Human-readable error message.',
    type: 'string',
  },
  code: {
    description: 'Machine-readable error code (see table above).',
    type: 'string',
  },
  cause: {
    description: 'Original error that caused this one.',
    type: 'unknown | undefined',
  },
}}
/>

### RateLimitError

Thrown when a platform API returns a 429 response. The `retryAfterMs` property tells you how long to wait before retrying.

```typescript title="lib/bot.ts" lineNumbers
import { RateLimitError } from "chat";

try {
  await thread.post("Hello!");
} catch (error) {
  if (error instanceof RateLimitError) {
    console.log(`Rate limited, retry after ${error.retryAfterMs}ms`);
  }
}
```

<TypeTable
  type={{
  code: {
    description: 'Always "RATE_LIMITED".',
    type: 'string',
  },
  retryAfterMs: {
    description: 'Milliseconds to wait before retrying.',
    type: 'number | undefined',
  },
}}
/>

### NotImplementedError

Thrown when you call a feature that a platform doesn't support. For example, calling `addReaction()` on Teams or `schedule()` on adapters without native scheduling support.

```typescript title="lib/bot.ts" lineNumbers
import { NotImplementedError } from "chat";

try {
  await thread.addReaction(emoji.thumbs_up);
} catch (error) {
  if (error instanceof NotImplementedError) {
    console.log(`Feature not supported: ${error.feature}`);
  }
}
```

<TypeTable
  type={{
  code: {
    description: 'Always "NOT_IMPLEMENTED".',
    type: 'string',
  },
  feature: {
    description: 'Name of the unsupported feature.',
    type: 'string | undefined',
  },
}}
/>

See the [feature matrix](/docs/adapters) for which features are supported on each platform.

### LockError

Thrown when the SDK fails to acquire a distributed lock on a thread (used to prevent concurrent processing of messages in the same thread). You can control this behavior with the [`onLockConflict`](/docs/usage#configuration-options) option — set it to `'force'` to release the existing lock instead of throwing.

<TypeTable
  type={{
  code: {
    description: 'Always "LOCK_FAILED".',
    type: 'string',
  },
}}
/>

## Adapter errors

Adapters also throw specialized errors from the `@chat-adapter/shared` package:

| Error                   | Code                | Description                                               |
| ----------------------- | ------------------- | --------------------------------------------------------- |
| `AdapterRateLimitError` | `RATE_LIMITED`      | Platform rate limit hit, includes `retryAfter` in seconds |
| `AuthenticationError`   | `AUTH_FAILED`       | Invalid or expired credentials                            |
| `ResourceNotFoundError` | `NOT_FOUND`         | Requested resource (channel, message) doesn't exist       |
| `PermissionError`       | `PERMISSION_DENIED` | Bot lacks required permissions/scopes                     |
| `ValidationError`       | `VALIDATION_ERROR`  | Invalid input data (e.g. message too long)                |
| `NetworkError`          | `NETWORK_ERROR`     | Connectivity issue with platform API                      |

## Catching errors

Use `instanceof` to handle specific error types:

```typescript title="lib/bot.ts" lineNumbers
import { RateLimitError, NotImplementedError } from "chat";

bot.onNewMention(async (thread, message) => {
  try {
    await thread.post("Processing...");
    await thread.addReaction(emoji.eyes);
  } catch (error) {
    if (error instanceof RateLimitError) {
      // Wait and retry
      await new Promise((r) => setTimeout(r, error.retryAfterMs ?? 5000));
      await thread.post("Processing...");
    } else if (error instanceof NotImplementedError) {
      // Skip unsupported features gracefully
    } else {
      throw error;
    }
  }
});
```


---
title: File Uploads
description: Send and receive files across chat platforms.
type: guide
prerequisites:
  - /docs/usage
---

# File Uploads



## Send files

Attach files to messages using the `files` property:

```typescript title="lib/bot.ts" lineNumbers
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",
    },
  ],
});
```

### Typed attachments

Use `attachments` when you already have normalized `Attachment` objects and the adapter supports typed outgoing media. Telegram supports one outgoing attachment per message and uses the native media method for the attachment type:

```typescript title="lib/bot.ts" lineNumbers
await thread.post({
  markdown: "Here's the image:",
  attachments: [
    {
      data: imageBuffer,
      name: "diagram.png",
      mimeType: "image/png",
      type: "image",
    },
  ],
});
```

Outgoing `attachments` are available on `{ raw }`, `{ markdown }`, and `{ ast }` messages. Card messages use `files` for uploads. Use `files` for generic uploads. On Telegram, `files` always upload as documents, while `attachments` preserve image, audio, video, or file media type. Use `data` or `fetchData` for private/authenticated files; URL-only attachments must be public URLs Telegram can fetch directly.

### Multiple files

```typescript title="lib/bot.ts" lineNumbers
await thread.post({
  markdown: "Attached are the images:",
  files: [
    { data: image1, filename: "screenshot1.png" },
    { data: image2, filename: "screenshot2.png" },
  ],
});
```

### Files without text

```typescript title="lib/bot.ts" lineNumbers
await thread.post({
  markdown: "",
  files: [{ data: buffer, filename: "document.xlsx" }],
});
```

## Receive files

Access attachments from incoming messages:

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

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

### Attachment properties

| Property        | Type                                | Description                                                              |
| --------------- | ----------------------------------- | ------------------------------------------------------------------------ |
| `type`          | `string`                            | Attachment type (e.g., "image", "file")                                  |
| `url`           | `string` (optional)                 | Public URL                                                               |
| `name`          | `string` (optional)                 | Filename                                                                 |
| `mimeType`      | `string` (optional)                 | MIME type                                                                |
| `size`          | `number` (optional)                 | File size in bytes                                                       |
| `width`         | `number` (optional)                 | Image width                                                              |
| `height`        | `number` (optional)                 | Image height                                                             |
| `fetchData`     | `() => Promise<Buffer>` (optional)  | Download the file data                                                   |
| `fetchMetadata` | `Record<string, string>` (optional) | Platform-specific IDs for reconstructing `fetchData` after serialization |


---
title: Getting Started
description: Pick a guide to start building with Chat SDK.
---

# Getting Started



## Usage

Learn the core patterns for handling incoming events and posting messages back to your users.

<Cards>
  <Card title="Creating a Chat Instance" description="Initialize the Chat class with adapters, state, and configuration options." href="/docs/usage" />

  <Card title="Threads, Messages, and Channels" description="Work with threads, messages, and channels across platforms." href="/docs/threads-messages-channels" />

  <Card title="Handling Events" description="Register handlers for mentions, messages, reactions, and platform-specific events." href="/docs/handling-events" />

  <Card title="Posting Messages" description="Different ways to render and send messages with thread.post()." href="/docs/posting-messages" />
</Cards>

## Adapters

Connect your bot to chat platforms and persist state across restarts.

<Cards>
  <Card title="Platform Adapters" description="Platform-specific adapters for Slack, Teams, Google Chat, Discord, Telegram, GitHub, and Linear." href="/docs/adapters" />

  <Card title="State Adapters" description="Pluggable state adapters for thread subscriptions, distributed locking, and caching." href="/docs/state" />
</Cards>

Browse all official and community adapters on the [Adapters](/adapters) page.

## Resources

* [The Complete Guide to Chat SDK](https://vercel.com/kb/guide/the-complete-guide-to-chat-sdk) — End-to-end walkthrough that takes you from zero to a deployed multi-platform bot, covering adapters, state, handlers, cards, and streaming.

See all guides and templates on the [resources](/resources) page.


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

# 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 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 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 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.setStatus(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              |
| `adapter`   | `Adapter` | The Slack adapter            |

### Handling member joined channel

`onMemberJoinedChannel` fires when a user joins a Slack channel. 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 Slack adapter           |
| `channelId` | `string`            | The channel that was joined |
| `userId`    | `string`            | The user who joined         |
| `inviterId` | `string` (optional) | The user who invited them   |


---
title: Introduction
description: A unified SDK for building chat bots across Slack, Microsoft Teams, Google Chat, Discord, Telegram, and more.
type: overview
---

# Introduction



Chat SDK is a TypeScript library for building chat bots that work across multiple platforms with a single codebase. Write your bot logic once and deploy it to Slack, Microsoft Teams, Google Chat, Discord, Telegram, GitHub, Linear, WhatsApp, and Messenger.

## Why Chat SDK?

Building a chat bot that works across multiple platforms typically means maintaining separate codebases, learning different APIs, and handling platform-specific quirks individually. Chat SDK abstracts these differences behind a unified interface.

* **Single codebase** for all platforms
* **Type-safe** [adapters](/adapters) and event handlers with full TypeScript support
* **Event-driven** architecture with handlers for mentions, messages, reactions, button clicks, slash commands, and modals
* **Thread subscriptions** for multi-turn conversations
* **Rich UI** with JSX cards, buttons, and modals that render natively on each platform
* **AI streaming** with first-class support for streaming LLM responses
* **Serverless-ready** with distributed state via Redis and message deduplication

## How it works

Chat SDK has three core concepts:

1. **Chat** — the main entry point that coordinates [adapters](/adapters) and routes events to your handlers
2. **[Adapters](/adapters)** — platform-specific implementations that handle webhook parsing, message formatting, and API calls
3. **State** — a pluggable persistence layer for thread subscriptions and distributed locking

```typescript title="lib/bot.ts" lineNumbers
import { Chat } from "chat";
import { createSlackAdapter } from "@chat-adapter/slack";
import { createRedisState } from "@chat-adapter/state-redis";

const bot = new Chat({
  userName: "mybot",
  adapters: {
    slack: createSlackAdapter(),
  },
  state: createRedisState(),
});

bot.onNewMention(async (thread) => {
  await thread.subscribe();
  await thread.post("Hello! I'm listening to this thread.");
});
```

Each adapter factory auto-detects credentials from environment variables (`SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET`, `REDIS_URL`, etc.), so you can get started with zero config. Pass explicit values to override.

## Supported platforms

| Platform        | Package                   | Mentions | Reactions    | Cards    | Modals | Streaming                       | DMs |
| --------------- | ------------------------- | -------- | ------------ | -------- | ------ | ------------------------------- | --- |
| Slack           | `@chat-adapter/slack`     | Yes      | Yes          | Yes      | Yes    | Native                          | Yes |
| Microsoft Teams | `@chat-adapter/teams`     | Yes      | Read-only    | Yes      | Yes    | Native (DMs) / Buffered         | Yes |
| Google Chat     | `@chat-adapter/gchat`     | Yes      | Yes          | Yes      | No     | Post+Edit                       | Yes |
| Discord         | `@chat-adapter/discord`   | Yes      | Yes          | Yes      | No     | Post+Edit                       | Yes |
| Telegram        | `@chat-adapter/telegram`  | Yes      | Yes          | Partial  | No     | Private chat drafts / Post+Edit | Yes |
| GitHub          | `@chat-adapter/github`    | Yes      | Yes          | No       | No     | Buffered                        | No  |
| Linear          | `@chat-adapter/linear`    | Yes      | Yes          | No       | No     | Agent sessions / Post+Edit      | No  |
| WhatsApp        | `@chat-adapter/whatsapp`  | N/A      | Yes          | Partial  | No     | Buffered                        | Yes |
| Twilio          | `@chat-adapter/twilio`    | N/A      | No           | Fallback | No     | Buffered                        | Yes |
| Messenger       | `@chat-adapter/messenger` | Yes      | Receive-only | Partial  | No     | Buffered                        | Yes |

## AI coding agent support

If you use an AI coding agent like [Claude Code](https://docs.anthropic.com/en/docs/claude-code), you can teach it about Chat SDK by installing the skill:

```bash
npx skills add vercel/chat
```

This gives your agent access to Chat SDK's documentation, patterns, and best practices so it can help you build bots more effectively.

## Packages

The SDK is distributed as a set of packages you install based on your needs:

| Package                       | Description                                                                                                                                                                              |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `chat`                        | Core SDK with `Chat` class, types, JSX runtime, and utilities                                                                                                                            |
| `chat/ai`                     | [AI utilities](/docs/ai) — [`createChatTools`](/docs/ai/ai-sdk-tools) for agent operations and [`toAiMessages`](/docs/ai/to-ai-messages) for converting chat history into AI SDK prompts |
| `@chat-adapter/slack`         | Slack adapter                                                                                                                                                                            |
| `@chat-adapter/teams`         | Microsoft Teams adapter                                                                                                                                                                  |
| `@chat-adapter/gchat`         | Google Chat adapter                                                                                                                                                                      |
| `@chat-adapter/discord`       | Discord adapter                                                                                                                                                                          |
| `@chat-adapter/telegram`      | Telegram adapter                                                                                                                                                                         |
| `@chat-adapter/github`        | GitHub Issues adapter                                                                                                                                                                    |
| `@chat-adapter/linear`        | Linear Issues adapter                                                                                                                                                                    |
| `@chat-adapter/whatsapp`      | WhatsApp Business adapter                                                                                                                                                                |
| `@chat-adapter/twilio`        | Twilio SMS and MMS adapter                                                                                                                                                               |
| `@chat-adapter/messenger`     | Facebook Messenger adapter                                                                                                                                                               |
| `@chat-adapter/state-redis`   | Redis state adapter (production)                                                                                                                                                         |
| `@chat-adapter/state-ioredis` | ioredis state adapter (alternative)                                                                                                                                                      |
| `@chat-adapter/state-pg`      | PostgreSQL state adapter (production)                                                                                                                                                    |
| `@chat-adapter/state-memory`  | In-memory state adapter (development)                                                                                                                                                    |


---
title: Modals
description: Collect structured user input through modal dialogs with text fields, dropdowns, and validation.
type: guide
prerequisites:
  - /docs/actions
---

# Modals



Modals open form dialogs in response to button clicks or [slash commands](/docs/slash-commands). They support text inputs, dropdowns, radio buttons, and server-side validation. Currently supported on Slack and Teams.

## Open a modal

Modals are opened from [action handlers](/docs/actions) or [slash command handlers](/docs/slash-commands) using `event.openModal()`:

```tsx title="lib/bot.tsx" lineNumbers
import { Modal, TextInput, Select, SelectOption } from "chat";

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" value="general" />
      </Select>
      <TextInput
        id="email"
        label="Email (optional)"
        placeholder="your@email.com"
        optional
      />
    </Modal>
  );
});
```

## Components

### Modal

The top-level container for the form.

| Prop              | Type                 | Description                                   |
| ----------------- | -------------------- | --------------------------------------------- |
| `callbackId`      | `string`             | Identifier for matching submit/close handlers |
| `title`           | `string`             | Modal title                                   |
| `submitLabel`     | `string` (optional)  | Submit button text (defaults to "Submit")     |
| `closeLabel`      | `string` (optional)  | Cancel button text (defaults to "Cancel")     |
| `notifyOnClose`   | `boolean` (optional) | Fire `onModalClose` when user cancels         |
| `callbackUrl`     | `string` (optional)  | URL to POST form values to on submit          |
| `privateMetadata` | `string` (optional)  | Custom context passed through to handlers     |

### TextInput

A text field for user input.

| Prop           | Type                 | Description                              |
| -------------- | -------------------- | ---------------------------------------- |
| `id`           | `string`             | Field identifier (key in `event.values`) |
| `label`        | `string`             | Field label                              |
| `placeholder`  | `string` (optional)  | Placeholder text                         |
| `initialValue` | `string` (optional)  | Pre-filled value                         |
| `multiline`    | `boolean` (optional) | Render as textarea                       |
| `optional`     | `boolean` (optional) | Allow empty submission                   |
| `maxLength`    | `number` (optional)  | Maximum character count                  |

### Select

A dropdown for selecting a single option.

| Prop            | Type                 | Description            |
| --------------- | -------------------- | ---------------------- |
| `id`            | `string`             | Field identifier       |
| `label`         | `string`             | Field label            |
| `placeholder`   | `string` (optional)  | Placeholder text       |
| `initialOption` | `string` (optional)  | Pre-selected value     |
| `optional`      | `boolean` (optional) | Allow empty submission |

### ExternalSelect

A dropdown that loads its options dynamically from a handler as the user types. Useful for large or remote-backed option sets (people, tickets, records) where a static `<Select>` would be impractical. Slack-only.

| Prop             | Type                          | Description                                                                                                                                                                                                                                               |
| ---------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`             | `string`                      | Field identifier (key in `event.values`)                                                                                                                                                                                                                  |
| `label`          | `string`                      | Field label                                                                                                                                                                                                                                               |
| `placeholder`    | `string` (optional)           | Placeholder text                                                                                                                                                                                                                                          |
| `minQueryLength` | `number` (optional)           | Minimum characters before the loader fires (Slack default: 3)                                                                                                                                                                                             |
| `initialOption`  | `{ label, value }` (optional) | Pre-selected option when the modal opens (must match an option returned by the loader). For static `<Select>`, `initialOption` is just the value string — for `<ExternalSelect>` it's the full `{ label, value }` object since the loader hasn't run yet. |
| `optional`       | `boolean` (optional)          | Allow empty submission                                                                                                                                                                                                                                    |

Register the loader with `onOptionsLoad`:

```tsx title="lib/bot.tsx" lineNumbers
import { ExternalSelect, Modal } from "chat";

bot.onAction("assign", async (event) => {
  await event.openModal(
    <Modal callbackId="assign_form" title="Assign to…">
      <ExternalSelect
        id="assignee"
        label="Assignee"
        placeholder="Search people"
        minQueryLength={1}
      />
    </Modal>
  );
});

bot.onOptionsLoad("assignee", async (event) => {
  const people = await peopleService.search(event.query);
  return people.map((p) => ({ label: p.fullName, value: p.id }));
});

bot.onModalSubmit("assign_form", async (event) => {
  const assigneeId = event.values.assignee;
  // …
});
```

The selected value arrives in `event.values` on submit just like a static `<Select>`.

#### Grouped options

Return an array of groups instead of a flat options array to render headers between sections (e.g. "Recent" / "All"):

```tsx
bot.onOptionsLoad("assignee", async (event) => {
  const [recent, all] = await Promise.all([
    peopleService.recent(event.user.userId),
    peopleService.search(event.query),
  ]);
  return [
    { label: "Recent", options: recent.map((p) => ({ label: p.fullName, value: p.id })) },
    { label: "All", options: all.map((p) => ({ label: p.fullName, value: p.id })) },
  ];
});
```

Slack limits: max 100 groups, max 100 options per group, group label max 75 characters.

<Callout type="warn">
  Slack requires a response within 3 seconds for options requests. The adapter caps the loader at \~2.5s and returns an empty result on timeout — keep your loader fast (cache, prefetch, or narrow the query server-side).
</Callout>

<Callout type="info">
  **Slack setup:** `ExternalSelect` uses Slack's `block_suggestion` payload, which is dispatched to the **Options Load URL**. In your [Slack app settings](https://api.slack.com/apps) go to **Interactivity & Shortcuts** → **Select Menus** and set the **Options Load URL** to the same endpoint as your Interactivity Request URL (e.g. `https://your-domain.com/api/webhooks/slack`). Without this, typing into an external select will silently return no results.
</Callout>

### RadioSelect

A radio button group for mutually exclusive options.

| Prop            | Type                 | Description            |
| --------------- | -------------------- | ---------------------- |
| `id`            | `string`             | Field identifier       |
| `label`         | `string`             | Field label            |
| `initialOption` | `string` (optional)  | Pre-selected value     |
| `optional`      | `boolean` (optional) | Allow empty submission |

### SelectOption

An option for `Select` or `RadioSelect`.

| Prop          | Type                | Description               |
| ------------- | ------------------- | ------------------------- |
| `label`       | `string`            | Display text              |
| `value`       | `string`            | Value passed to handler   |
| `description` | `string` (optional) | Help text below the label |

## Handle submissions

Register a handler with `onModalSubmit` using the same `callbackId`:

```typescript title="lib/bot.ts" lineNumbers
bot.onModalSubmit("feedback_form", async (event) => {
  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" },
    };
  }

  // Post confirmation to the original thread
  if (event.relatedThread) {
    await event.relatedThread.post(`Feedback received! Category: ${category}`);
  }

  // Update the message that triggered the modal
  if (event.relatedMessage) {
    await event.relatedMessage.edit("Feedback submitted!");
  }

  // Return nothing (or { action: "close" }) to close the modal
});
```

### Response types

| Response                                               | Description                                               |
| ------------------------------------------------------ | --------------------------------------------------------- |
| `undefined` or `{ 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 on specific fields                 |
| `{ action: "update", modal: ModalElement }`            | Replace the modal content                                 |
| `{ action: "push", modal: ModalElement }`              | Push a new modal view onto the stack                      |

### ModalSubmitEvent

| Property          | Type                     | Description                                                                         |
| ----------------- | ------------------------ | ----------------------------------------------------------------------------------- |
| `callbackId`      | `string`                 | Modal identifier                                                                    |
| `viewId`          | `string`                 | Platform view ID                                                                    |
| `values`          | `Record<string, string>` | Form field values keyed by input `id`                                               |
| `user`            | `Author`                 | The user who submitted                                                              |
| `privateMetadata` | `string` (optional)      | Custom context from the Modal component                                             |
| `relatedThread`   | `Thread` (optional)      | Thread where the modal was triggered                                                |
| `relatedMessage`  | `SentMessage` (optional) | Message with the button that opened the modal                                       |
| `relatedChannel`  | `Channel` (optional)     | Channel where the modal was triggered (from [slash commands](/docs/slash-commands)) |
| `adapter`         | `Adapter`                | The platform adapter                                                                |
| `raw`             | `unknown`                | Platform-specific payload                                                           |

## Handle cancellation

Optionally handle when users cancel a modal. Requires `notifyOnClose` on the `Modal` component:

```typescript title="lib/bot.ts" lineNumbers
bot.onModalClose("feedback_form", async (event) => {
  console.log(`${event.user.userName} cancelled the feedback form`);

  if (event.relatedThread) {
    await event.relatedThread.post("No worries, let us know if you change your mind!");
  }
});
```

## Callback URLs

Like buttons, modals accept a `callbackUrl`. When the modal is submitted, the form values are POSTed to the URL:

```tsx title="lib/bot.tsx" lineNumbers
await event.openModal(
  <Modal callbackUrl={webhook.url} callbackId="intake" title="Request Access" submitLabel="Submit">
    <TextInput id="reason" label="Reason" multiline />
  </Modal>
);
```

The POST body for modal submissions:

```json
{
  "type": "modal_submit",
  "callbackId": "intake",
  "values": { "reason": "Need access to production logs" },
  "user": { "id": "U123", "name": "alice" }
}
```

## Pass context with privateMetadata

Use `privateMetadata` to carry context from the button click through to the submit handler:

```tsx title="lib/bot.tsx" lineNumbers
bot.onAction("report", async (event) => {
  await event.openModal(
    <Modal
      callbackId="report_form"
      title="Report Bug"
      submitLabel="Submit"
      privateMetadata={JSON.stringify({
        reportType: event.value,
        threadId: event.threadId,
      })}
    >
      <TextInput id="title" label="Bug Title" />
      <TextInput id="steps" label="Steps to Reproduce" multiline />
    </Modal>
  );
});

bot.onModalSubmit("report_form", async (event) => {
  const metadata = event.privateMetadata
    ? JSON.parse(event.privateMetadata)
    : {};

  console.log(metadata.reportType); // "bug"
});
```


---
title: Posting Messages
description: Different ways to render and send messages with thread.post().
type: guide
prerequisites:
  - /docs/usage
related:
  - /docs/cards
  - /docs/streaming
  - /docs/files
---

# Posting Messages



`thread.post()` accepts several message formats, each suited to different use cases. Choose the format that best fits your content — from plain strings to structured AST to rich interactive cards.

## Plain text

The simplest option. Pass a string and it goes through as-is to the platform.

```typescript title="lib/bot.ts" lineNumbers
await thread.post("Hello world");
```

This sends the string directly without any formatting conversion.

## Markdown

Pass a `{ markdown }` object to have the SDK render standard markdown on each platform — passed through to Slack's native `markdown_text` field, converted to HTML for Teams, and so on.

```typescript title="lib/bot.ts" lineNumbers
await thread.post({
  markdown: "**Bold**, _italic_, and `code`",
});
```

Under the hood, the SDK parses the markdown into an mdast AST, then each adapter handles it natively or converts it to the platform's format.

## AST builders

For programmatic control over message formatting, use the mdast AST builder functions exported from `chat`. This is the recommended approach for most use cases — it gives you fine-grained control without the overhead of card rendering.

```typescript title="lib/bot.ts" lineNumbers
import { root, paragraph, text, strong, link } from "chat";

await thread.post({
  ast: root([
    paragraph([
      strong([text("Deployment complete")]),
      text(" — "),
      link("https://example.com", [text("View site")]),
    ]),
  ]),
});
```

### Available builders

| Builder                       | Description                  | Example                                  |
| ----------------------------- | ---------------------------- | ---------------------------------------- |
| `root(children)`              | Root node (required wrapper) | `root([paragraph([...])])`               |
| `paragraph(children)`         | Paragraph block              | `paragraph([text("Hello")])`             |
| `text(value)`                 | Plain text                   | `text("Hello")`                          |
| `strong(children)`            | **Bold** text                | `strong([text("bold")])`                 |
| `emphasis(children)`          | *Italic* text                | `emphasis([text("italic")])`             |
| `strikethrough(children)`     | ~~Strikethrough~~ text       | `strikethrough([text("done")])`          |
| `inlineCode(value)`           | `Inline code`                | `inlineCode("const x = 1")`              |
| `codeBlock(value, lang?)`     | Fenced code block            | `codeBlock("const x = 1", "ts")`         |
| `link(url, children, title?)` | Hyperlink                    | `link("https://...", [text("click")])`   |
| `blockquote(children)`        | Block quote                  | `blockquote([paragraph([text("...")])])` |

### Parsing markdown to AST

You can also parse a markdown string into an AST, manipulate it, then send it:

```typescript title="lib/bot.ts" lineNumbers
import { parseMarkdown, stringifyMarkdown } from "chat";

const ast = parseMarkdown("**Hello** world");
// Manipulate the AST...
await thread.post({ ast });
```

## Cards

When you need interactive elements like buttons, dropdowns, or structured layouts, use cards. Cards render natively on each platform — Block Kit on Slack, Adaptive Cards on Teams, and Google Chat Cards.

### Function syntax

Use the function-call API for type-safe card construction:

```typescript title="lib/bot.ts" lineNumbers
import { Card, Text, Actions, Button } from "chat";

await thread.post(
  Card({
    title: "Order #1234",
    children: [
      Text("Your order has been received!"),
      Actions([
        Button({ id: "approve", label: "Approve", style: "primary" }),
        Button({ id: "reject", label: "Reject", style: "danger" }),
      ]),
    ],
  })
);
```

### JSX syntax

You can also use JSX if you configure the `chat` JSX runtime:

```json title="tsconfig.json"
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "chat"
  }
}
```

```tsx title="lib/bot.tsx"
import { Card, CardText, Actions, Button } from "chat";

await thread.post(
  <Card title="Order #1234">
    <CardText>Your order has been received!</CardText>
    <Actions>
      <Button id="approve" style="primary">Approve</Button>
      <Button id="reject" style="danger">Reject</Button>
    </Actions>
  </Card>
);
```

<Callout type="warn">
  The JSX syntax requires `jsxImportSource: "chat"` in your `tsconfig.json` (or a per-file `/** @jsxImportSource chat */` pragma). Without this, TypeScript won't recognize the card JSX types. If you run into type issues with JSX, use the function-call syntax instead — it produces the same output with better type inference.
</Callout>

See the [Cards](/docs/cards) page for the full list of card components.

## Streaming

Pass an AI SDK stream to `thread.post()` to stream a message in real time. The SDK uses platform-native streaming where available and falls back to post-then-edit or buffered delivery depending on the platform.

```typescript title="lib/bot.ts" lineNumbers
import { ToolLoopAgent } from "ai";

const agent = new ToolLoopAgent({ model, instructions: "You are a helpful assistant." });
const result = await agent.stream({ prompt: message.text });
await thread.post(result.fullStream);
```

Both `fullStream` and `textStream` are supported. Use `fullStream` with multi-step agents — it preserves paragraph breaks between steps. Any `AsyncIterable<string>` also works for custom streams.

For multi-turn conversations, use [`toAiMessages()`](/docs/ai/to-ai-messages) to convert thread history into the `{ role, content }[]` format expected by AI SDKs.

To pass platform-specific streaming options (e.g. Slack task grouping or stop blocks), wrap the stream in a [`StreamingPlan`](/docs/streaming#streaming-with-options) and post that.

See the [Streaming](/docs/streaming) page for details on platform behavior and configuration.

## Attachments and files

Any structured message format (`markdown`, `ast`, or `card`) supports `files` for uploading attachments alongside the message:

```typescript title="lib/bot.ts" lineNumbers
await thread.post({
  markdown: "Here's the report:",
  files: [{ data: buffer, filename: "report.pdf" }],
});
```

Use `attachments` on `{ raw }`, `{ markdown }`, or `{ ast }` when an adapter supports typed media uploads, such as Telegram's single image/audio/video/file upload support.

See the [Files](/docs/files) page for more on attachments.

## Choosing a format

| Format                                                    | Use when                                          | Example                                           |
| --------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------- |
| Plain string                                              | Simple, unformatted text                          | Status updates, acknowledgements                  |
| `{ markdown }`                                            | You have a markdown string (e.g. from a template) | Notifications with links and formatting           |
| `{ ast }`                                                 | You need programmatic formatting control          | Dynamic messages built from data                  |
| Card (function)                                           | You need buttons, fields, or structured layouts   | Approval flows, dashboards                        |
| Card (JSX)                                                | Same as above, with JSX syntax preference         | Same use cases as function cards                  |
| `AsyncIterable`                                           | Streaming AI responses                            | Chat with LLMs                                    |
| [`Plan`](/docs/streaming#plan-api)                        | Step-by-step tasks that mutate after posting      | Multi-step agents, deploy progress                |
| [`StreamingPlan`](/docs/streaming#streaming-with-options) | Streaming with platform-specific options          | Slack streaming with grouped tasks or stop blocks |

For most cases, **AST builders** give the best balance of control and simplicity. Reach for **cards** when you need interactive elements like buttons or dropdowns.


---
title: Slack Low-Level APIs
description: Use Slack request verification, formatting, Web API, and Block Kit helpers without the full Chat runtime.
type: guide
prerequisites:
  - /adapters/official/slack
related:
  - /docs/handling-events
  - /docs/cards
  - /docs/slash-commands
---

# Slack Low-Level APIs



The Slack adapter is the right default for most bots. It verifies requests, resolves tokens, parses Slack payloads, stores thread state, and routes events through `Chat`.

Use the low-level Slack subpaths when your app already owns routing, state, sessions, or workflow execution and only needs the Slack-specific primitives.

| Subpath                       | Use for                                                                                                      |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `@chat-adapter/slack/webhook` | Request verification, body parsing, Events API payloads, slash commands, interactions, and continuation data |
| `@chat-adapter/slack/format`  | Slack mrkdwn tokens, text objects, dates, links, mentions, and simple mrkdwn to Markdown conversion          |
| `@chat-adapter/slack/api`     | Fetch-based Slack Web API calls, thread replies, views, and files without `@slack/web-api`                   |
| `@chat-adapter/slack/blocks`  | Runtime-free conversion from simple card objects and input requests to Slack Block Kit                       |

<Callout type="info">
  These subpaths are for custom runtimes. If you want Chat SDK to handle webhook routing, state, subscriptions, and platform normalization, use `createSlackAdapter` from `@chat-adapter/slack`.
</Callout>

## Webhooks

[Slack signs incoming HTTP requests](https://docs.slack.dev/authentication/verifying-requests-from-slack/) with `x-slack-signature` and `x-slack-request-timestamp`. `verifySlackRequest` reads the request body, verifies the signature with your signing secret, and returns the raw body so you can parse it once.

```typescript title="app/api/slack/route.ts" lineNumbers
import {
  parseSlackWebhookBody,
  verifySlackRequest,
} from "@chat-adapter/slack/webhook";
import { postSlackMessage } from "@chat-adapter/slack/api";

export async function POST(request: Request) {
  const body = await verifySlackRequest(request, {
    signingSecret: process.env.SLACK_SIGNING_SECRET!,
  });

  const payload = parseSlackWebhookBody(body, {
    contentType: request.headers.get("content-type"),
    headers: request.headers,
  });

  if (payload.kind === "url_verification") {
    return Response.json({ challenge: payload.challenge });
  }

  if (payload.kind === "app_mention") {
    await postSlackMessage({
      channel: payload.continuation.channelId,
      markdownText: `received: ${payload.text}`,
      threadTs: payload.continuation.threadTs,
      token: process.env.SLACK_BOT_TOKEN!,
    });
  }

  return new Response(null, { status: 200 });
}
```

[Slack slash commands](https://docs.slack.dev/interactivity/implementing-slash-commands/) and interactions should be acknowledged quickly. Slack documents a 3000 ms acknowledgement window for slash commands, so do slow work in your queue or workflow runtime after returning a 2xx response.

If you do not need direct access to the verified raw body, `readSlackWebhook` combines verification and parsing:

```typescript
import { readSlackWebhook } from "@chat-adapter/slack/webhook";

const payload = await readSlackWebhook(request, {
  signingSecret: process.env.SLACK_SIGNING_SECRET!,
});
```

If your framework already buffered the request body, use `verifySlackSignature` with the raw body and headers, then pass that same body to `parseSlackWebhookBody`.

### Payloads

`parseSlackWebhookBody` returns typed payloads:

| Kind               | Slack surface                                          |
| ------------------ | ------------------------------------------------------ |
| `url_verification` | Events API URL verification                            |
| `app_mention`      | App mention events                                     |
| `direct_message`   | Direct message events                                  |
| `slash_command`    | Slash command form posts                               |
| `block_actions`    | Button, select, and Block Kit action payloads          |
| `block_suggestion` | External select suggestion payloads                    |
| `view_submission`  | Modal submissions                                      |
| `view_closed`      | Modal close events                                     |
| `unsupported`      | Valid Slack payloads not normalized by this helper yet |

Message-like payloads include `continuation`, which contains provider-native reply context:

```typescript
type SlackContinuation = {
  channelId: string;
  enterpriseId?: string;
  teamId?: string;
  threadTs: string;
};
```

This is not a Chat SDK `Thread`. It is the durable Slack data you need to reply later with `@chat-adapter/slack/api`.

App mention and direct message payloads also include typed `files` parsed from Slack file objects. Each file keeps the raw Slack object plus common fields like `id`, `name`, `mimeType`, `size`, `url`, and `downloadUrl`.

Interaction payloads expose convenience fields from Slack's raw payload:

* `block_actions` includes `actions`, `messageBlocks`, `messagePromptBlock`, `messagePromptText`, `messageTs`, `triggerId`, `responseUrl`, `user`, and `continuation`
* `view_submission` includes `callbackId`, `privateMetadata`, `values`, `responseUrls`, and `user`

## Formatting

Slack uses mrkdwn and special tokens for mentions, channels, dates, and links. The format subpath gives you small helpers for those strings.

The helper surface includes `escapeSlackText`, `unescapeSlackText`, `createSlackPlainText`, `createSlackMrkdwn`, `formatSlackUser`, `formatSlackChannel`, `formatSlackUserGroup`, `formatSlackSpecialMention`, `formatSlackLink`, `formatSlackDate`, and simple mrkdwn to Markdown normalization.

```typescript title="format.ts" lineNumbers
import {
  createSlackMrkdwn,
  formatSlackDate,
  formatSlackLink,
  formatSlackUser,
  slackMrkdwnToMarkdown,
} from "@chat-adapter/slack/format";

const text = createSlackMrkdwn(
  `${formatSlackUser("U123")} approved ${formatSlackLink("https://example.com", "the deploy")}`
);

const when = formatSlackDate(
  new Date("2026-05-27T12:00:00Z"),
  "{date_short_pretty} at {time}",
  "May 27 at 12:00"
);

const markdown = slackMrkdwnToMarkdown("hello <@U123|jane>, see <https://example.com|this>");
```

`linkBareSlackMentions` only links Slack user IDs like `@U123`. It does not resolve display names, because Slack mentions are ID-based.

## Web API

The API subpath calls [Slack Web API](https://docs.slack.dev/apis/web-api/) methods with `fetch`. It does not import `@slack/web-api`.

```typescript title="slack.ts" lineNumbers
import {
  postSlackMessage,
  sendSlackResponseUrl,
  updateSlackMessage,
} from "@chat-adapter/slack/api";

const posted = await postSlackMessage({
  channel: "C123",
  markdownText: "**hello**",
  token: process.env.SLACK_BOT_TOKEN!,
});

await updateSlackMessage({
  channel: "C123",
  text: "updated",
  token: process.env.SLACK_BOT_TOKEN!,
  ts: posted.id,
});

await sendSlackResponseUrl("https://hooks.slack.com/actions/T/1/abc", {
  replaceOriginal: true,
  text: "done",
});
```

Use `callSlackApi` when you need a Slack method that does not have a helper yet:

```typescript
import { callSlackApi } from "@chat-adapter/slack/api";

const result = await callSlackApi(
  "reactions.add",
  { channel: "C123", name: "white_check_mark", timestamp: "1710000000.000001" },
  { token: process.env.SLACK_BOT_TOKEN! }
);
```

`markdownText` maps to the `markdown_text` field on [`chat.postMessage`](https://docs.slack.dev/reference/methods/chat.postMessage/) and cannot be combined with `text` or `blocks`. Use `text` with `blocks` when you need fallback text.

The subpath also includes `postSlackEphemeral`, `deleteSlackMessage`, `resolveSlackBotToken`, `encodeSlackApiBody`, and `assertSlackOk`.

Use `fetchSlackThreadReplies` when a custom runtime needs to refresh a thread with [`conversations.replies`](https://docs.slack.dev/reference/methods/conversations.replies/):

```typescript
import { fetchSlackThreadReplies } from "@chat-adapter/slack/api";

const replies = await fetchSlackThreadReplies({
  channel: payload.continuation.channelId,
  limit: 50,
  token: process.env.SLACK_BOT_TOKEN!,
  ts: payload.continuation.threadTs,
});
```

Use `openSlackView` to open a modal from an interaction `trigger_id`:

```typescript
import { openSlackView } from "@chat-adapter/slack/api";

await openSlackView({
  token: process.env.SLACK_BOT_TOKEN!,
  triggerId: payload.triggerId,
  view: {
    type: "modal",
    title: { type: "plain_text", text: "Answer" },
    blocks: [],
  },
});
```

### Files

[Slack's current external upload flow](https://docs.slack.dev/changelog/2024-04-a-better-way-to-upload-files-is-here-to-stay) uses `files.getUploadURLExternal`, then uploads bytes to the returned URL, then calls `files.completeUploadExternal`.

```typescript
import { uploadSlackFiles } from "@chat-adapter/slack/api";

await uploadSlackFiles(
  [{ data: new Uint8Array([1, 2, 3]), filename: "report.txt" }],
  {
    channelId: "C123",
    initialComment: "report attached",
    token: process.env.SLACK_BOT_TOKEN!,
  }
);
```

Use `fetchSlackFile` for private Slack file URLs that require bearer token authorization.

## Blocks

The blocks subpath converts simple card objects into Slack Block Kit without importing the full `chat` JSX runtime.

It exports `cardToSlackBlocks`, `cardToBlockKit`, `cardToSlackFallbackText`, `cardToFallbackText`, and `convertSlackEmojiPlaceholders`.

```typescript title="blocks.ts" lineNumbers
import {
  cardToSlackBlocks,
  cardToSlackFallbackText,
} from "@chat-adapter/slack/blocks";
import { postSlackMessage } from "@chat-adapter/slack/api";

const card = {
  children: [
    { content: "deploy v2.4.1?", type: "text" },
    {
      children: [
        { id: "approve", label: "Approve", style: "primary", type: "button" },
        { id: "deny", label: "Deny", style: "danger", type: "button" },
      ],
      type: "actions",
    },
  ],
  title: "Deployment",
  type: "card",
} as const;

await postSlackMessage({
  blocks: cardToSlackBlocks(card),
  channel: "C123",
  text: cardToSlackFallbackText(card),
  token: process.env.SLACK_BOT_TOKEN!,
});
```

Use the full Chat SDK card JSX when you want cross-platform rendering. Use `@chat-adapter/slack/blocks` when you are building a Slack-only runtime and want Block Kit output directly.

The blocks subpath also includes small input request helpers for Slack-only runtimes:

```typescript
import {
  inputRequestToSlackBlocks,
  parseSlackInputResponse,
} from "@chat-adapter/slack/blocks";
import { postSlackMessage } from "@chat-adapter/slack/api";

await postSlackMessage({
  blocks: inputRequestToSlackBlocks({
    options: [
      { id: "approve", label: "Approve", style: "primary" },
      { id: "deny", label: "Deny", style: "danger" },
    ],
    prompt: "Approve deploy?",
    requestId: "deploy-1",
  }),
  channel: "C123",
  text: "Approve deploy?",
  token: process.env.SLACK_BOT_TOKEN!,
});

if (payload.kind === "block_actions") {
  const action = payload.actions[0];
  const response = action ? parseSlackInputResponse(action) : null;
}
```

Set `display: "radio"` for radio buttons, or `display: "select"` for a static select menu. Set `allowFreeform: true` to add a "Type your answer" button next to the provided options.

For freeform answers, use `buildSlackFreeformView` with `openSlackView`, then read the submitted value from `payload.values` with `parseSlackFreeformValue`.

## Import boundaries

The low-level Slack subpaths are designed to avoid the full runtime import graph:

* no `chat` import
* no `@chat-adapter/shared` import
* no `@slack/web-api` import
* no `@slack/socket-mode` import

The package still installs the full Slack adapter dependencies. The subpaths keep your source and bundle imports clean, but they are not a package-size split.


---
title: Slash Commands
description: Handle slash command invocations and respond with messages or modals.
type: guide
prerequisites:
  - /docs/getting-started
related:
  - /docs/modals
  - /adapters/official/slack
  - /adapters/official/discord
---

# Slash Commands



Slash commands let users invoke your bot with `/command` syntax. Register handlers with `onSlashCommand` to respond.

Slash commands are supported on [Slack](/adapters/official/slack) and [Discord](/adapters/official/discord).

## Handle a specific command

```typescript title="lib/bot.ts" lineNumbers
bot.onSlashCommand("/status", async (event) => {
  await event.channel.post("All systems operational!");
});
```

## Handle multiple commands

```typescript title="lib/bot.ts" lineNumbers
bot.onSlashCommand(["/help", "/info"], async (event) => {
  await event.channel.post(`You invoked ${event.command}`);
});
```

## Catch-all handler

Register a handler without a command to catch all slash commands:

```typescript title="lib/bot.ts" lineNumbers
bot.onSlashCommand(async (event) => {
  console.log(`Command: ${event.command}, Args: ${event.text}`);
});
```

## SlashCommandEvent

The `event` object passed to slash command handlers:

| Property    | Type                                                  | Description                               |
| ----------- | ----------------------------------------------------- | ----------------------------------------- |
| `command`   | `string`                                              | The command name (e.g., `/status`)        |
| `text`      | `string`                                              | Arguments after the command               |
| `user`      | `Author`                                              | The user who invoked the command          |
| `channel`   | `Channel`                                             | The channel where the command was invoked |
| `adapter`   | `Adapter`                                             | The platform adapter                      |
| `triggerId` | `string` (optional)                                   | Platform trigger ID for opening modals    |
| `openModal` | `(modal) => Promise<{ viewId: string } \| undefined>` | Open a modal dialog                       |
| `raw`       | `unknown`                                             | Platform-specific payload                 |

## Respond to a command

Use `event.channel` to post messages:

```typescript title="lib/bot.ts" lineNumbers
bot.onSlashCommand("/greet", async (event) => {
  // Public message to the channel
  await event.channel.post(`Hello, ${event.user.fullName}!`);

  // Ephemeral message (only visible to the user)
  await event.channel.postEphemeral(
    event.user,
    "This message is just for you!",
    { fallbackToDM: false }
  );
});
```

## Open a modal

Use `event.openModal()` to open a [modal](/docs/modals) in response to a slash command:

```tsx title="lib/bot.tsx" lineNumbers
import { Modal, TextInput, Select, SelectOption } from "chat";

bot.onSlashCommand("/feedback", async (event) => {
  const result = await event.openModal(
    <Modal callbackId="feedback_form" title="Send Feedback" submitLabel="Send">
      <TextInput id="message" label="Your Feedback" multiline />
      <Select id="category" label="Category">
        <SelectOption label="Bug" value="bug" />
        <SelectOption label="Feature" value="feature" />
      </Select>
    </Modal>
  );

  if (!result) {
    await event.channel.post("Couldn't open the feedback form. Please try again.");
  }
});
```

<Callout type="info">
  When a modal is opened from a slash command, the submit handler receives `relatedChannel` instead of `relatedThread`. Use this to post back to the channel where the command was invoked.
</Callout>

```typescript title="lib/bot.ts" lineNumbers
bot.onModalSubmit("feedback_form", async (event) => {
  const { message, category } = event.values;

  // Post to the channel where the slash command was invoked
  if (event.relatedChannel) {
    await event.relatedChannel.post(`Feedback received! Category: ${category}`);
  }
});
```

## Discord

Discord slash commands are received via [HTTP Interactions](/adapters/official/discord#http-interactions-vs-gateway) — no Gateway connection is needed. The adapter automatically sends a deferred response to Discord, then resolves it when your handler calls `event.channel.post()`.

### Subcommands

Discord supports subcommand groups and subcommands. The adapter flattens these into the `event.command` path:

| Discord command                       | `event.command`       | `event.text` |
| ------------------------------------- | --------------------- | ------------ |
| `/status`                             | `/status`             | `""`         |
| `/project create --name="Acme"`       | `/project create`     | `Acme`       |
| `/project issue list --status="open"` | `/project issue list` | `open`       |

For full option details (names, types), use `event.raw` to access the original Discord interaction payload.

### Registering commands with Discord

You need to register slash commands with the [Discord API](https://discord.com/developers/docs/interactions/application-commands) before they appear in the client. The Chat SDK handles incoming commands but does not register them for you.


---
title: State Adapters
description: Pluggable state adapters for thread subscriptions, distributed locking, and caching.
type: overview
prerequisites:
  - /docs/getting-started
---

# State Adapters



State adapters handle persistent storage for thread subscriptions, distributed locks (to prevent duplicate processing), and caching. You must provide a state adapter when creating a `Chat` instance. Browse all available state adapters on the [Adapters](/adapters) page.

## What state adapters manage

### Thread subscriptions

When your bot calls `thread.subscribe()`, the state adapter persists that subscription. On subsequent webhooks, the SDK checks subscriptions to route messages to `onSubscribedMessage` handlers. With a production adapter, subscriptions survive restarts and work across multiple instances.

### Distributed locking

When a webhook arrives, the SDK acquires a lock on the thread to prevent duplicate processing. This is critical for serverless deployments where multiple instances may receive the same event.

By default, if a lock is already held, the incoming message is dropped with a `LockError`. For long-running handlers (e.g. AI agent streaming), you can configure `onLockConflict: 'force'` to force-release the existing lock and allow the new message through:

```typescript
const chat = new Chat({
  userName: 'my-bot',
  adapters: { slack },
  state: createRedisState(),
  onLockConflict: 'force',
});
```

You can also pass a callback for custom logic:

```typescript
onLockConflict: (threadId, message) => {
  return message.text.includes('stop') ? 'force' : 'drop';
}
```

Note that force-releasing a lock does not cancel the previous handler — it continues running. Only the lock is released, so two handlers may briefly run concurrently on the same thread.

### Caching

State adapters provide key-value storage with TTL for thread state (`thread.setState()`), message deduplication, and other internal caching.


---
title: Streaming
description: Stream real-time text responses from AI models and other async sources to chat platforms.
type: guide
prerequisites:
  - /docs/usage
---

# Streaming



Chat SDK accepts any `AsyncIterable<string>` as a message, enabling real-time streaming of AI responses and other incremental content to chat platforms. For platforms with native or structured streaming support, you can also stream `StreamChunk` objects for rich content like task progress cards and plan updates.

## AI SDK integration

Pass an AI SDK `fullStream` or `textStream` directly to `thread.post()`:

```typescript title="lib/bot.ts" lineNumbers
import { ToolLoopAgent } from "ai";

const agent = new ToolLoopAgent({
  model: "anthropic/claude-4.5-sonnet",
  instructions: "You are a helpful assistant.",
});

bot.onNewMention(async (thread, message) => {
  const result = await agent.stream({ prompt: message.text });
  await thread.post(result.fullStream);
});
```

### Why `fullStream` over `textStream`?

When AI SDK agents make tool calls between text steps, `textStream` concatenates all text without separators — `"hello.how are you?"` instead of `"hello.\n\nhow are you?"`. The `fullStream` contains explicit `finish-step` events that Chat SDK uses to inject paragraph breaks between steps automatically.

Both stream types are auto-detected:

```typescript
// Recommended: fullStream preserves step boundaries
await thread.post(result.fullStream);

// Also works: textStream for single-step generation
await thread.post(result.textStream);
```

## Custom streams

Any async iterable works:

```typescript title="lib/bot.ts" lineNumbers
const stream = (async function* () {
  yield "Processing";
  yield "...";
  yield " done!";
})();

await thread.post(stream);
```

## Platform behavior

| Platform    | Method                                | Description                                                                                                                                       |
| ----------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Slack       | Native streaming API                  | Uses Slack's `chatStream` for smooth, real-time updates                                                                                           |
| Telegram    | Private chat draft previews           | Uses Telegram's `sendMessageDraft` in private chats and falls back to post + edit elsewhere                                                       |
| Teams       | Native (DMs) / Buffered (group chats) | Uses the Teams SDK's native `stream.emit()` for direct messages; accumulates chunks and posts one final message when no native streamer is active |
| Google Chat | Post + Edit                           | Posts a message then edits it as chunks arrive                                                                                                    |
| Discord     | Post + Edit                           | Posts a message then edits it as chunks arrive                                                                                                    |
| GitHub      | Buffered                              | Accumulates chunks and posts one final comment                                                                                                    |
| Linear      | Agent sessions / Post + Edit          | Uses agent session activities in agent-session threads; falls back to post+edit comments in issue threads                                         |
| WhatsApp    | Buffered                              | Accumulates chunks and sends one final message                                                                                                    |
| Messenger   | Buffered                              | Accumulates chunks and sends one final message                                                                                                    |

The post+edit fallback throttles edits to avoid rate limits. Configure the update interval when creating your `Chat` instance:

```typescript title="lib/bot.ts" lineNumbers
const bot = new Chat({
  // ...
  streamingUpdateIntervalMs: 500, // Default: 500ms
});
```

### Disabling the placeholder message

By default, post+edit adapters send an initial `"..."` placeholder message before the first chunk arrives. You can disable this to wait for real content before posting:

```typescript title="lib/bot.ts" lineNumbers
const bot = new Chat({
  // ...
  fallbackStreamingPlaceholderText: null,
});
```

You can also customize the placeholder text:

```typescript title="lib/bot.ts"
const bot = new Chat({
  // ...
  fallbackStreamingPlaceholderText: "Thinking...",
});
```

## Markdown healing

During streaming, chunks often arrive mid-word or mid-syntax — for example, `**bold` before the closing `**` arrives. The SDK automatically heals incomplete markdown in intermediate renders using [remend](https://www.npmjs.com/package/remend), so messages always display with correct formatting while streaming.

The final message uses the raw accumulated text without healing, so the original markdown is preserved.

## Table buffering

When streaming content that contains GFM tables (e.g. from an LLM), the SDK automatically buffers potential table headers until a separator line (`|---|---|`) confirms them. This prevents tables from briefly flashing as raw pipe-delimited text before the table structure is complete.

This happens transparently — no configuration needed.

## Structured streaming chunks

For Slack native streams and Linear agent-session streams, you can yield `StreamChunk` objects alongside plain text for rich progress updates:

```typescript title="lib/bot.ts" lineNumbers
import type { StreamChunk } from "chat";

const stream = (async function* () {
  yield { type: "markdown_text", text: "Searching..." } satisfies StreamChunk;

  yield {
    type: "task_update",
    id: "search-1",
    title: "Searching documents",
    details: "Querying internal docs and ranking the best matches",
    status: "in_progress",
  } satisfies StreamChunk;

  // ... do work ...

  yield {
    type: "task_update",
    id: "search-1",
    title: "Searching documents",
    details: "Ranked 3 relevant results",
    status: "complete",
    output: "Found 3 results",
  } satisfies StreamChunk;

  yield { type: "markdown_text", text: "Here are your results..." } satisfies StreamChunk;
})();

await thread.post(stream);
```

### Chunk types

| Type            | Fields                                         | Description                                                                                                 |
| --------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `markdown_text` | `text`                                         | Streamed text content                                                                                       |
| `task_update`   | `id`, `title`, `status`, `details?`, `output?` | Tool/step progress updates (`pending`, `in_progress`, `complete`, `error`) with optional extra task context |
| `plan_update`   | `title`                                        | Plan title updates on supported platforms                                                                   |

### Streaming with options

Wrap a stream in a `StreamingPlan` to pass platform-specific options through `thread.post()` without dropping down to `adapter.stream()` directly:

```typescript
import { StreamingPlan } from "chat";

const planned = new StreamingPlan(stream, {
  groupTasks: "plan",         // Slack: render task cards as a single grouped block
  endWith: [feedbackBlock],   // Slack: Block Kit elements appended after stream stops
  updateIntervalMs: 750,      // Post+edit cadence on supported adapters
});

await thread.post(planned);
```

| Option             | Platform           | Description                                                                                |
| ------------------ | ------------------ | ------------------------------------------------------------------------------------------ |
| `groupTasks`       | Slack              | `"timeline"` (default) renders task cards inline; `"plan"` groups them into one plan block |
| `endWith`          | Slack              | Block Kit elements attached when the stream stops (e.g. retry / feedback buttons)          |
| `updateIntervalMs` | Post+edit adapters | Minimum interval between post+edit cycles in ms (default `500`)                            |

Adapters without structured chunk support extract text from `markdown_text` chunks and ignore other types. Slack-only options are silently ignored on other platforms.

## Stop blocks (Slack only)

Use `endWith` on `StreamingPlan` to attach Block Kit elements to the final message. This is useful for adding action buttons after a streamed response completes:

```typescript title="lib/bot.ts" lineNumbers
import { StreamingPlan } from "chat";

const planned = new StreamingPlan(textStream, {
  endWith: [
    {
      type: "actions",
      elements: [{
        type: "button",
        text: { type: "plain_text", text: "Retry" },
        action_id: "retry",
      }],
    },
  ],
});

await thread.post(planned);
```

## Plan API

For step-by-step task progress that lives outside an LLM stream, post a `Plan` directly. `Plan` is a `PostableObject` you can mutate after posting — every mutation re-renders the block in place.

```typescript title="lib/bot.ts" lineNumbers
import { Plan } from "chat";

const plan = new Plan({ initialMessage: "Researching options..." });
await thread.post(plan);

const lookup = await plan.addTask({ title: "Look up customer record" });
// ...do work...
await plan.updateTask("Found 3 matches");

await plan.addTask({ title: "Summarize findings" });
await plan.complete({ completeMessage: "Done!" });
```

By default `updateTask()` mutates the most recent `in_progress` task. Pass `{ id }` to target a specific task — useful when work runs in parallel or out of order:

```typescript
const fetchTask = await plan.addTask({ title: "Fetch data" });
const transformTask = await plan.addTask({ title: "Transform" });

// Update a specific task by id, even if it isn't the most recent in_progress one.
await plan.updateTask({ id: fetchTask.id, output: "Got 42 rows" });
await plan.updateTask({ id: transformTask.id, status: "complete" });
```

Adapters that don't support PostableObject editing (e.g. WhatsApp) render the plan as a fallback emoji-list message; the plan still posts, but mutations are no-ops.

| Method                          | Description                                                                                                     |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `addTask({ title, children? })` | Append a new task. The previous in-progress task is auto-completed                                              |
| `updateTask(input)`             | Mutate the current (or `{ id }`-targeted) task's `output`, `status`, or `title`                                 |
| `complete({ completeMessage })` | Mark all in-progress tasks complete and update the plan title                                                   |
| `reset({ initialMessage })`     | Discard all tasks and start fresh with a new initial message — useful when re-using a plan handle for a new run |

## Streaming with conversation history

Combine message history with streaming for multi-turn AI conversations.
Use [`toAiMessages()`](/docs/ai/to-ai-messages) to convert chat messages into the `{ role, content }` format expected by AI SDKs:

```typescript title="lib/bot.ts" lineNumbers
import { toAiMessages } from "chat/ai";

bot.onSubscribedMessage(async (thread, message) => {
  // Fetch recent messages for context
  const result = await thread.adapter.fetchMessages(thread.id, { limit: 20 });

  const history = await toAiMessages(result.messages);

  const response = await agent.stream({ prompt: history });
  await thread.post(response.fullStream);
});
```

See the [`toAiMessages` reference](/docs/ai/to-ai-messages) for all options including `includeNames`, `transformMessage`, and attachment handling.


---
title: Message Subject
description: Fetch the parent resource that a message is about.
type: guide
prerequisites:
  - /docs/handling-events
related:
  - /docs/conversation-history
---

# Message Subject



When your bot receives a comment on a Linear issue or GitHub PR, `message.subject` resolves the parent resource so your handler knows what the conversation is about.

## Usage

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

  if (subject) {
    await thread.post(
      `This is about: ${subject.title} (${subject.status})\n${subject.url}`
    );
  }
});
```

On Linear and GitHub, comment webhooks deliver the comment text but not the parent issue or pull request — `message.subject` fetches it from the platform API on first access. The result is cached on the message instance. On chat platforms (which have no parent-resource concept), or if the API call fails, it returns `null`.

See [`MessageSubject`](/docs/api/message#messagesubject) for the full type shape.

### Platform support

| Platform | `message.subject` returns                  |
| -------- | ------------------------------------------ |
| Linear   | Parent issue (from comment webhooks)       |
| GitHub   | Parent issue or PR (from comment webhooks) |

All other platforms return `null`.

## User info

For user profile details, use [`bot.getUser`](/docs/api/chat#getuser):

```typescript title="lib/bot.ts" lineNumbers
bot.onNewMention(async (thread, message) => {
  const user = await bot.getUser(message.author);
  if (user) {
    await thread.post(`Hi ${user.fullName} (${user.email})`);
  }
});
```

For anything beyond `message.subject`, access the platform's typed API client via [`bot.getAdapter("github").octokit`](/docs/api/chat#getadapter) or [`bot.getAdapter("linear").linearClient`](/docs/api/chat#getadapter).


---
title: Testing
description: Test your bot handlers and custom adapters with @chat-adapter/tests — Vitest factories, custom matchers, and a setup file.
type: guide
prerequisites:
  - /docs/getting-started
related:
  - /docs/state
  - /docs/handling-events
  - /docs/contributing/testing
---

# Testing



The [`@chat-adapter/tests`](https://www.npmjs.com/package/@chat-adapter/tests) package gives you Vitest factories, custom matchers, and a setup file for testing bots and custom adapters built on Chat SDK.

## Install

```bash
pnpm add -D @chat-adapter/tests
```

`chat` and `vitest` are peer dependencies — they should already be in your project.

## Setup file (recommended)

Auto-register all matchers by adding the package's setup file to your Vitest config:

```typescript title="vitest.config.ts" lineNumbers
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    setupFiles: ["@chat-adapter/tests/setup"],
  },
});
```

Without the setup file, register matchers manually:

```typescript
import { matchers } from "@chat-adapter/tests/matchers";
expect.extend(matchers);
```

## Mock factories

```typescript
import {
  createMockAdapter,
  createMockChatInstance,
  createMockState,
  createTestMessage,
  mockLogger,
} from "@chat-adapter/tests";
```

| Factory                                   | Returns            | Notes                                                                                            |
| ----------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------ |
| `createMockAdapter(name?, overrides?)`    | `Adapter`          | Every method is `vi.fn()` with sensible defaults                                                 |
| `createMockChatInstance(options?)`        | `ChatInstance`     | Every `process*` handler is `vi.fn()`; `getState`/`getUserName`/`getLogger` wired up             |
| `createMockState()`                       | `MockStateAdapter` | In-memory `Map`s for subscriptions, locks, KV, lists, queues; `cache` exposes the underlying map |
| `createTestMessage(id, text, overrides?)` | `Message`          | Markdown text is parsed into the formatted AST                                                   |
| `mockLogger` / `createMockLogger()`       | `Logger`           | Shared default vs fresh-per-call                                                                 |

## Matchers

| Matcher                                                           | Asserts                                                                         |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `expect(adapter).toHavePosted(threadId, textPattern?)`            | `adapter.postMessage` was called for this thread                                |
| `expect(adapter).toHaveEdited(threadId, messageId, textPattern?)` | `adapter.editMessage` was called for this message                               |
| `expect(adapter).toHaveDeleted(threadId, messageId)`              | `adapter.deleteMessage` was called for this message                             |
| `expect(adapter).toHaveReactedWith(threadId, messageId, emoji)`   | `adapter.addReaction` was called with the emoji (string or `EmojiValue.name`)   |
| `expect(adapter).toHaveStartedTyping(threadId)`                   | `adapter.startTyping` was called for this thread                                |
| `expect(adapter).toHavePostedToChannel(channelId, textPattern?)`  | `adapter.postChannelMessage` was called for this channel                        |
| `expect(chat).toHaveDispatched(handler)`                          | The named `process*` handler on the mock `ChatInstance` was called              |
| `expect(state).toBeSubscribedTo(threadId)`                        | `state.isSubscribed(threadId)` resolves to `true` (async — `await expect(...)`) |

Text-pattern matchers extract a comparable string from `AdapterPostableMessage` — strings directly, `PostableMarkdown.markdown`, `PostableRaw.raw`, and `PostableCard.fallbackText`. AST-shaped messages and cards without `fallbackText` aren't text-matchable; assert without `textPattern` and inspect `mock.calls` directly.

## Bot authors: test your handlers

When you're building a bot on top of Chat SDK, the kit lets you exercise your handlers without a real Slack/Teams/etc. webhook on the wire:

```typescript title="bot.test.ts"
import { describe, expect, it } from "vitest";
import { Chat } from "chat";
import { createMockAdapter, createMockState } from "@chat-adapter/tests";

describe("bot handlers", () => {
  it("replies with a greeting on mention", async () => {
    const slack = createMockAdapter("slack");
    const state = createMockState();
    const bot = new Chat({
      userName: "mybot",
      adapters: { slack },
      state,
    });

    bot.onNewMention(async (thread) => {
      await thread.post("hello there");
    });

    // Drive a synthesized mention through the bot…
    // (use your adapter's webhook path or a thread-level call)

    expect(slack).toHavePosted("slack:C1:t1", /hello there/);
  });
});
```

## Adapter authors: test webhook → dispatch

When you're building a custom `Adapter`, the kit gives you a `ChatInstance` mock you can hand to your adapter and assert that webhooks route through the right `process*` hook with the right normalized payload:

```typescript title="adapter.test.ts"
import { describe, expect, it } from "vitest";
import { createMockChatInstance } from "@chat-adapter/tests";
import { MyAdapter } from "./adapter";

describe("MyAdapter.handleWebhook", () => {
  it("dispatches incoming messages through processMessage", async () => {
    const chat = createMockChatInstance();
    const adapter = new MyAdapter({ /* config */ });
    await adapter.initialize(chat);

    const request = new Request("https://example.com/webhook", {
      method: "POST",
      body: JSON.stringify({ /* platform-specific payload */ }),
      headers: { "content-type": "application/json" },
    });
    const response = await adapter.handleWebhook(request);

    expect(response.status).toBe(200);
    expect(chat).toHaveDispatched("processMessage");
  });
});
```

## Adapter-specific helpers

Helpers that depend on a specific platform's wire format (signed Slack webhooks, Teams claim builders, etc.) live in each adapter's own `/testing` subpath rather than in this kit, so adopting `@chat-adapter/tests` doesn't pull in adapter dependencies you don't use.

If you're contributing adapters or core to this repo, see the [Testing adapters contributing guide](/docs/contributing/testing) for hand-rolled patterns used inside `packages/`.


---
title: Threads, Messages, and Channels
description: Work with threads, messages, and channels across platforms.
type: guide
prerequisites:
  - /docs/usage
related:
  - /docs/handling-events
  - /docs/posting-messages
---

# Threads, Messages, and Channels



## Threads

A `Thread` represents a conversation thread on any platform. It provides methods for posting messages, managing subscriptions, and accessing message history.

Thread instances are most often supplied by the SDK to your event handlers. You can also construct one explicitly from a thread ID — useful for cron jobs, workflow steps, or any other context outside an inbound webhook:

```typescript title="lib/bot.ts" lineNumbers
const thread = bot.thread("slack:C123ABC:1234567890.123456");
await thread.post("Reminder from a cron job");
```

For DM-style conversations, use [`bot.openDM(userIdOrAuthor)`](/docs/direct-messages) instead. It resolves the right channel and thread for user ID formats the SDK can infer.

### Post a message

```typescript title="lib/bot.ts" lineNumbers
// Plain text
await thread.post("Hello world");

// Markdown (converted to each platform's format)
await thread.post("**Bold** and _italic_ text");

// Structured message with attachments
await thread.post({
  markdown: "Here's a file:",
  files: [{ data: buffer, filename: "report.pdf" }],
});
```

### Subscribe and unsubscribe

Subscriptions persist across restarts (stored in your state adapter). When a non-DM thread is subscribed, all messages route to `onSubscribedMessage`. DM threads route to `onDirectMessage` first when a direct message handler is registered.

```typescript title="lib/bot.ts" lineNumbers
await thread.subscribe();
await thread.unsubscribe();

const subscribed = await thread.isSubscribed();
```

### Participants

Get the unique human participants in a thread. Returns deduplicated authors, excluding all bots. Useful for deciding whether to subscribe based on how many humans are in the conversation.

```typescript title="lib/bot.ts" lineNumbers
bot.onNewMention(async (thread) => {
  const participants = await thread.getParticipants();
  if (participants.length === 1) {
    await thread.subscribe();
    await thread.post("I'm here to help!");
  }
});

bot.onSubscribedMessage(async (thread) => {
  const participants = await thread.getParticipants();
  if (participants.length > 1) {
    await thread.unsubscribe();
    return;
  }
  // respond...
});
```

<Callout type="warn">
  Each call fetches the full message history to find all participants. On threads with long history this makes multiple API calls to the platform. Consider checking `message.author` against a known set before calling `getParticipants()` on every incoming message.
</Callout>

### Typing indicator

```typescript title="lib/bot.ts"
await thread.startTyping();
```

<Callout type="info">
  Not all platforms support typing indicators. The call is a no-op on unsupported platforms. See the [adapter feature matrix](/docs/adapters) for details.
</Callout>

### Message history

Access recent messages or iterate through full history:

```typescript title="lib/bot.ts" lineNumbers
// Cached messages from the webhook payload
const recent = thread.recentMessages;

// Newest first (auto-paginates)
for await (const msg of thread.messages) {
  console.log(msg.text);
}

// Oldest first (auto-paginates)
for await (const msg of thread.allMessages) {
  console.log(msg.text);
}
```

### Thread state

Store typed, per-thread state that persists across requests. Pass a generic type parameter to `Chat` to get typed thread state across all handlers:

```typescript title="lib/bot.ts" lineNumbers
interface ThreadState {
  aiMode?: boolean;
  context?: string;
}

const bot = new Chat<typeof adapters, ThreadState>({
  // ...config
});

bot.onNewMention(async (thread) => {
  await thread.setState({ aiMode: true });

  const state = await thread.state; // ThreadState | null
  if (state?.aiMode) {
    // AI mode is enabled
  }
});
```

State is stored in your state adapter with a 30-day TTL. Use `{ replace: true }` to replace state entirely instead of merging:

```typescript title="lib/bot.ts"
await thread.setState({ aiMode: false }, { replace: true });
```

### Scheduled messages

Schedule a message for future delivery. The returned `ScheduledMessage` includes a `cancel()` method to abort before it's sent.

```typescript title="lib/bot.ts" lineNumbers
const scheduled = await thread.schedule("Reminder: standup in 5 minutes!", {
  postAt: new Date("2026-03-09T09:00:00Z"),
});

// Cancel before it's sent
await scheduled.cancel();
```

<Callout type="info">
  Scheduled messages are currently only supported by the Slack adapter. Other adapters throw `NotImplementedError`. See the [feature matrix](/docs/adapters) for details.
</Callout>

## Messages

Incoming messages are normalized across platforms into a consistent format:

| Property      | Type                      | Description                                  |
| ------------- | ------------------------- | -------------------------------------------- |
| `id`          | `string`                  | Platform message ID                          |
| `threadId`    | `string`                  | Thread ID in `adapter:channel:thread` format |
| `text`        | `string`                  | Plain text content                           |
| `formatted`   | `Root`                    | mdast AST representation                     |
| `raw`         | `unknown`                 | Original platform-specific payload           |
| `author`      | `Author`                  | Message author info                          |
| `metadata`    | `MessageMetadata`         | Timestamps and edit status                   |
| `attachments` | `Attachment[]` (optional) | File attachments                             |
| `isMention`   | `boolean` (optional)      | Whether the bot was @-mentioned              |

### Author

```typescript lineNumbers
interface Author {
  userId: string;
  userName: string;
  fullName: string;
  isBot: boolean | "unknown";
  isMe: boolean; // true if message is from the bot itself
}
```

For richer user info (email, avatar), use [`chat.getUser()`](/docs/api/chat#getuser):

```typescript title="lib/bot.ts"
const user = await bot.getUser(message.author);
console.log(user?.email); // "alice@company.com"
```

### Sent messages

When you post a message, you get back a `SentMessage` with methods to edit, delete, and react:

```typescript title="lib/bot.ts" lineNumbers
const sent = await thread.post("Processing...");
// Do some work...
await sent.edit("Done!");

// Or delete
await sent.delete();

// Add/remove reactions
await sent.addReaction(emoji.check);
await sent.removeReaction(emoji.check);
```

## Channels

A `Channel` represents the container that holds threads (e.g., a Slack channel, a Teams conversation). Navigate to a channel from a thread or get one directly:

```typescript title="lib/bot.ts" lineNumbers
// From a thread
const channel = thread.channel;

// Directly by ID
const channel = bot.channel("slack:C123ABC");
```

### List threads

Iterate threads in a channel, most recently active first:

```typescript title="lib/bot.ts" lineNumbers
for await (const thread of channel.threads()) {
  console.log(thread.rootMessage.text, thread.replyCount);
}
```

### Channel messages

Iterate top-level messages (not thread replies):

```typescript title="lib/bot.ts" lineNumbers
for await (const msg of channel.messages) {
  console.log(msg.text);
}
```

### Post to a channel

Post a top-level message (not inside a thread):

```typescript title="lib/bot.ts"
await channel.post("Hello channel!");
```

### Channel metadata

```typescript title="lib/bot.ts"
const info = await channel.fetchMetadata();
console.log(info.name, info.memberCount);
```

## Thread ID format

All thread IDs follow the pattern `{adapter}:{channel}:{thread}`:

* **Slack**: `slack:C123ABC:1234567890.123456`
* **Teams**: `teams:{base64(conversationId)}:{base64(serviceUrl)}`
* **Google Chat**: `gchat:spaces/ABC123:{base64(threadName)}`
* **Discord**: `discord:{guildId}:{channelId}/{messageId}`

You typically don't need to construct these yourself — they're provided by the SDK in event handlers.

## Logging

The `logger` option is optional — if omitted, Chat SDK uses `ConsoleLogger("info")` by default. Each adapter also creates its own child logger automatically.

```typescript title="lib/bot.ts" lineNumbers
// Use defaults (ConsoleLogger at "info" level)
const bot = new Chat({
  // ...
});

// Or set a specific log level
const bot = new Chat({
  // ...
  logger: "debug", // "debug" | "info" | "warn" | "error" | "silent"
});

// Or use a custom ConsoleLogger for child loggers
import { ConsoleLogger } from "chat";

const logger = new ConsoleLogger("info");
const bot = new Chat({
  // ...
  logger,
});
```

You can pass child loggers to adapters for prefixed log output, but adapters create their own child loggers by default:

```typescript title="lib/bot.ts"
createSlackAdapter({
  logger: logger.child("slack"), // optional — auto-created if omitted
});
```


---
title: Creating a Chat Instance
description: Initialize the Chat class with adapters, state, and configuration options.
type: guide
prerequisites:
  - /docs/getting-started
related:
  - /docs/handling-events
  - /docs/adapters
  - /docs/state
---

# Creating a Chat Instance



The `Chat` class is the main entry point for your bot. It coordinates adapters, routes events to your handlers, and manages thread state.

## Basic setup

```typescript title="lib/bot.ts" lineNumbers
import { Chat } from "chat";
import { createSlackAdapter } from "@chat-adapter/slack";
import { createRedisState } from "@chat-adapter/state-redis";

const bot = new Chat({
  userName: "mybot",
  adapters: {
    slack: createSlackAdapter(),
  },
  state: createRedisState(),
});

bot.onNewMention(async (thread) => {
  await thread.subscribe();
  await thread.post("Hello! I'm listening to this thread.");
});
```

<Callout type="info">
  This example uses Redis. Chat SDK also supports [PostgreSQL](/adapters/official/postgres) and [ioredis](/adapters/official/ioredis) as production state adapters. See [State Adapters](/docs/state) for all options.
</Callout>

Each adapter factory auto-detects credentials from environment variables (`SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET`, `REDIS_URL`, etc.), so you can get started with zero config. Pass explicit values to override.

## Multiple adapters

Register multiple [adapters](/adapters) to deploy your bot across platforms simultaneously:

```typescript title="lib/bot.ts" lineNumbers
import { Chat } from "chat";
import { createSlackAdapter } from "@chat-adapter/slack";
import { createTeamsAdapter } from "@chat-adapter/teams";
import { createDiscordAdapter } from "@chat-adapter/discord";
import { createRedisState } from "@chat-adapter/state-redis";

const bot = new Chat({
  userName: "mybot",
  adapters: {
    slack: createSlackAdapter(),
    teams: createTeamsAdapter(),
    discord: createDiscordAdapter(),
  },
  state: createRedisState(),
});
```

Your event handlers work identically across all registered adapters — the SDK normalizes messages, threads, and reactions into a consistent format.

## Configuration options

| Option                             | Type                                                                              | Default    | Description                                                                                                                                                     |
| ---------------------------------- | --------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `userName`                         | `string`                                                                          | *required* | Default bot username across all adapters                                                                                                                        |
| `adapters`                         | `Record<string, Adapter>`                                                         | *required* | Map of adapter name to adapter instance                                                                                                                         |
| `state`                            | `StateAdapter`                                                                    | *required* | State adapter for subscriptions and locking                                                                                                                     |
| `logger`                           | `Logger \| LogLevel`                                                              | `"info"`   | Logger instance or log level (`"debug"`, `"info"`, `"warn"`, `"error"`, `"silent"`)                                                                             |
| `dedupeTtlMs`                      | `number`                                                                          | `300000`   | TTL in ms for message deduplication (5 minutes)                                                                                                                 |
| `concurrency`                      | `"drop" \| "queue" \| "debounce" \| "burst" \| "concurrent" \| ConcurrencyConfig` | `"drop"`   | Strategy for overlapping messages on the same thread                                                                                                            |
| `streamingUpdateIntervalMs`        | `number`                                                                          | `500`      | Update interval in ms for post+edit streaming                                                                                                                   |
| `fallbackStreamingPlaceholderText` | `string \| null`                                                                  | `"..."`    | Placeholder text while streaming starts. Set to `null` to skip                                                                                                  |
| `onLockConflict`                   | `'drop' \| 'force' \| (threadId, message) => 'drop' \| 'force'`                   | `"drop"`   | Behavior when a thread lock is already held. `'force'` releases the existing lock and re-acquires it, enabling interrupt/steerability for long-running handlers |

## Accessing adapters

Use `getAdapter` to access platform-specific APIs when you need functionality beyond the unified interface:

```typescript title="lib/bot.ts" lineNumbers
import type { SlackAdapter } from "@chat-adapter/slack";

const slack = bot.getAdapter("slack") as SlackAdapter;
await slack.setSuggestedPrompts(channelId, threadTs, [
  { title: "Get started", message: "What can you help me with?" },
]);
```

For typed access to the platform's native API client, use the SDK-named getter on each adapter:

```typescript title="lib/bot.ts" lineNumbers
const slack = bot.getAdapter("slack").webClient; // WebClient
const linear = bot.getAdapter("linear").linearClient; // LinearClient
const github = bot.getAdapter("github").octokit; // Octokit
```

The previous `.client` getter still works as a deprecated alias on all three adapters.

See [`getAdapter`](/docs/api/chat#getadapter) for multi-tenant constraints.

## Webhook routing

The `webhooks` property provides type-safe handlers for each registered adapter. Wire these up to your HTTP framework's routes:

```typescript title="app/api/webhooks/slack/route.ts" lineNumbers
import { bot } from "@/lib/bot";

export const POST = bot.webhooks.slack;
```

```typescript title="app/api/webhooks/teams/route.ts" lineNumbers
import { bot } from "@/lib/bot";

export const POST = bot.webhooks.teams;
```

## Lifecycle

The Chat instance initializes lazily on the first webhook. You can also initialize manually:

```typescript title="lib/bot.ts" lineNumbers
await bot.initialize();
```

For graceful shutdown (e.g. in serverless teardown), call `shutdown`:

```typescript title="lib/bot.ts" lineNumbers
await bot.shutdown();
```

## Singleton pattern

Register a singleton when you need to access the Chat instance from multiple files:

```typescript title="lib/bot.ts" lineNumbers
const bot = new Chat({ /* ...config */ }).registerSingleton();
export default bot;
```

```typescript title="lib/utils.ts" lineNumbers
import { Chat } from "chat";

const bot = Chat.getSingleton();
```

## Direct messaging

Open a DM thread with a user by passing their platform user ID or an `Author` object:

```typescript title="lib/bot.ts" lineNumbers
const dm = await bot.openDM("U123ABC");
await dm.post("Hey! Just wanted to follow up on your request.");
```

## Channel access

Get a channel directly by its ID:

```typescript title="lib/bot.ts" lineNumbers
const channel = bot.channel("slack:C123ABC");
await channel.post("Announcement: deploy complete!");
```


---
title: AI SDK Tools
description: Give an AI agent the ability to operate inside your workspace. Post messages, send DMs, react, edit, delete; all with built-in approval gates.
type: guide
prerequisites:
  - /docs/usage
related:
  - /docs/ai
  - /docs/ai/to-ai-messages
  - /docs/streaming
  - /docs/conversation-history
---

# AI SDK Tools



`createChatTools` exposes Chat SDK operations as ready-to-use [AI SDK](https://ai-sdk.dev) tools so an agent can act inside the same workspaces your bot is connected to: read messages, post replies, send DMs, react, edit, delete, and manage thread subscriptions across every adapter you've registered.

Write operations require user approval out of the box, toggle them globally or per-tool when you want unattended execution.

## Installation

The tools live in the [`chat/ai`](/docs/ai) subpath of the core `chat` package:

```ts
import { createChatTools } from "chat/ai";
```

`ai` and `zod` are optional peer dependencies — install them if you haven't already:

<PackageInstall package="ai zod" />

<Callout>
  Pair `createChatTools` with [`toAiMessages`](/docs/ai/to-ai-messages)
  to feed prior thread history into the agent before it picks a tool.
  Both ship from the same `chat/ai` subpath, which keeps the optional
  `ai` / `zod` peer deps out of bundles that don't import them.
</Callout>

## Quick start

Pass your `Chat` instance and the tools you want into any AI SDK call:

```typescript title="lib/agent.ts" lineNumbers
import { Chat } from "chat";
import { createChatTools } from "chat/ai";
import { createSlackAdapter } from "@chat-adapter/slack";
import { createMemoryState } from "@chat-adapter/state-memory";
import { generateText } from "ai";

const chat = new Chat({
  userName: "mybot",
  adapters: { slack: createSlackAdapter() },
  state: createMemoryState(),
});

const result = await generateText({
  model: "anthropic/claude-sonnet-4.6",
  tools: createChatTools({
    chat,
    preset: "messenger",
    requireApproval: false, // unattended script, no human-in-the-loop needed
  }),
  prompt:
    "Post a friendly hello in slack:C0123ABC and react to it with a thumbs up.",
});
```

Each tool resolves the right adapter from the id prefix you give it (`slack:...`, `discord:...`, `gchat:...`), so the same agent can drive any platform your `Chat` instance is wired up to.

## Presets

Pass `preset` to scope the toolset down to what an agent actually needs.

```typescript
// Read-only — fetch messages, threads, channel info, users
createChatTools({ chat, preset: "reader" });

// Basic posting — read + post + DM + react + typing indicator
createChatTools({ chat, preset: "messenger" });

// Full management — everything including edit, delete, subscriptions
createChatTools({ chat, preset: "moderator" });
```

Presets compose — pass an array to combine them:

```typescript
createChatTools({ chat, preset: ["reader", "messenger"] });
```

Omit `preset` entirely to get every tool (same as `'moderator'`).

| Preset      | Tools included                                                                                                                                                                                       |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `reader`    | `fetchMessages`, `fetchChannelMessages`, `fetchThread`, `listThreads`, `getThreadParticipants`, `getChannelInfo`, `getUser`                                                                          |
| `messenger` | `fetchMessages`, `fetchThread`, `getChannelInfo`, `getUser`, `postMessage`, `postChannelMessage`, `sendDirectMessage`, `addReaction`, `removeReaction`, `startTyping`                                |
| `moderator` | All read tools plus `postMessage`, `postChannelMessage`, `sendDirectMessage`, `editMessage`, `deleteMessage`, `addReaction`, `removeReaction`, `subscribeThread`, `unsubscribeThread`, `startTyping` |

## Approval control

Write operations (posting, editing, deleting, reacting, subscribing) default to `needsApproval: true`. The AI SDK pauses execution and surfaces an approval request that your application is expected to confirm before the tool runs. This keeps a human in the loop for anything visible to the workspace.

```typescript
// All writes need approval (default)
createChatTools({ chat });

// No approval needed
createChatTools({ chat, requireApproval: false });

// Per-tool — only destructive actions need approval
createChatTools({
  chat,
  requireApproval: {
    deleteMessage: true,
    editMessage: true,
    sendDirectMessage: false,
    postMessage: false,
    addReaction: false,
  },
});
```

Read tools (`fetchMessages`, `fetchThread`, `getChannelInfo`, …) and the `startTyping` indicator never require approval.

## Cherry-picking tools

Each tool is also exported as a standalone factory you can hand to `tools` directly:

```typescript title="lib/agent.ts" lineNumbers
import { fetchMessages, postMessage, addReaction } from "chat/ai";

const tools = {
  fetchMessages: fetchMessages(chat),
  postMessage: postMessage(chat, { needsApproval: false }),
  addReaction: addReaction(chat, { needsApproval: false }),
};
```

Useful when you want a small, targeted toolset without going through `createChatTools`.

## Tool overrides

Customize any AI SDK [`tool()`](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling) property per tool, keyed by tool name:

```typescript
import type { ChatToolName, ToolOverrides } from "chat/ai";

createChatTools({
  chat,
  overrides: {
    postMessage: {
      description: "Reply in the active customer support thread.",
      needsApproval: false,
    },
    deleteMessage: { needsApproval: true },
  },
});
```

| Property           | Type                  | Description                                                   |
| ------------------ | --------------------- | ------------------------------------------------------------- |
| `description`      | `string`              | Custom tool description shown to the model                    |
| `title`            | `string`              | Human-readable title                                          |
| `strict`           | `boolean`             | Strict mode for input generation                              |
| `inputExamples`    | `array`               | Examples that show the model what tool input should look like |
| `metadata`         | `object`              | Tool metadata propagated to tool call and result parts        |
| `needsApproval`    | `boolean \| function` | Gate execution behind an approval request                     |
| `providerOptions`  | `ProviderOptions`     | Provider-specific metadata                                    |
| `onInputStart`     | `function`            | Callback when argument streaming starts                       |
| `onInputDelta`     | `function`            | Callback on each streaming delta                              |
| `onInputAvailable` | `function`            | Callback when full input is available                         |
| `toModelOutput`    | `function`            | Custom mapping of tool result to model output                 |

Core properties (`execute`, `inputSchema`, `outputSchema`, and tool-kind fields like `type`, `id`, `args`) cannot be overridden so tool semantics stay stable.

## Available tools

All ids accept the full Chat SDK form: `slack:C123ABC:1234567890.123456` for a thread, `slack:C123ABC` for a channel, and the platform-native user id (e.g. `U123456` on Slack, `users/123` on Google Chat). The tools auto-detect the adapter from the prefix.

### Reading

| Tool                    | Description                                                     |
| ----------------------- | --------------------------------------------------------------- |
| `fetchMessages`         | Fetch recent messages from a thread (paginated)                 |
| `fetchChannelMessages`  | Fetch top-level messages in a channel (not thread replies)      |
| `fetchThread`           | Fetch metadata for a thread (channel id, visibility, DM status) |
| `listThreads`           | List recent threads in a channel with their root message        |
| `getThreadParticipants` | Return the unique non-bot participants in a thread              |
| `getChannelInfo`        | Fetch channel metadata (name, member count, visibility)         |
| `getUser`               | Look up a user's profile by id                                  |

### Writing

| Tool                 | Description                                          | Default approval |
| -------------------- | ---------------------------------------------------- | ---------------- |
| `postMessage`        | Post a reply in an existing thread                   | required         |
| `postChannelMessage` | Post a top-level message in a channel                | required         |
| `sendDirectMessage`  | Open a DM with a user and post in it                 | required         |
| `editMessage`        | Edit a message the bot previously posted             | required         |
| `deleteMessage`      | Delete a message the bot previously posted           | required         |
| `addReaction`        | Add an emoji reaction to a message                   | required         |
| `removeReaction`     | Remove a previously-added reaction                   | required         |
| `subscribeThread`    | Subscribe the bot to all future messages in a thread | required         |
| `unsubscribeThread`  | Stop receiving non-mention messages in a thread      | required         |
| `startTyping`        | Show a typing indicator in a thread                  | not gated        |

## API

### `createChatTools(options)`

Returns an object of tools, ready to spread into `tools` of any AI SDK call.

```typescript
type ChatToolsOptions = {
  chat: Chat;
  requireApproval?: boolean | Partial<Record<ChatWriteToolName, boolean>>;
  preset?: ChatToolPreset | ChatToolPreset[];
  overrides?: Partial<Record<ChatToolName, ToolOverrides>>;
};

type ChatToolPreset = "reader" | "messenger" | "moderator";
```

| Option            | Description                                                                                                 |
| ----------------- | ----------------------------------------------------------------------------------------------------------- |
| `chat`            | The `Chat` instance the tools dispatch operations against. Required.                                        |
| `preset`          | Preset (or array of presets) restricting which tools are returned. Omit to get every tool.                  |
| `requireApproval` | `true` (default), `false`, or per-tool overrides. Read tools and `startTyping` are never gated.             |
| `overrides`       | Per-tool customization of any AI SDK `tool()` property except `execute`, `inputSchema`, and `outputSchema`. |


---
title: Overview
description: AI utilities that ship with Chat SDK — agent tools, message conversion, and supporting types.
type: overview
---

# Overview



The `chat/ai` subpath is the home for AI utilities that ship with Chat SDK.

```ts
import { createChatTools, toAiMessages } from "chat/ai";
```

Add the optional peers if you don't already have them:

<PackageInstall package="ai zod" />

## What's included

| Page                                      | What it gives you                                                                                                                                                                                                                                        |
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [AI SDK Tools](/docs/ai/ai-sdk-tools)     | `createChatTools` and standalone tool factories that let an agent post messages, send DMs, react, edit, delete, and manage subscriptions across every adapter your `Chat` instance has registered. Built-in approval gates and presets keep writes safe. |
| [`toAiMessages`](/docs/ai/to-ai-messages) | Convert Chat SDK [`Message[]`](/docs/api/message) into the `{ role, content }[]` shape expected by AI SDK calls. Handles role mapping, attachments, links, sorting, and optional per-message transforms.                                                 |
| [Types](/docs/ai/types)                   | Reference for every type exported from `chat/ai` — agent message shapes, tool option contracts, presets, approval config, and the binding type that ties tools to your `Chat` instance.                                                                  |

## Typical flow

A Chat SDK bot wired to a tool-calling agent usually looks like this:

```typescript title="lib/agent.ts" lineNumbers
import { Chat } from "chat";
import { createChatTools, toAiMessages } from "chat/ai";
import { ToolLoopAgent } from "ai";

const chat = new Chat({ /* adapters, state, ... */ });

const agent = new ToolLoopAgent({
  model: "anthropic/claude-sonnet-4.6",
  instructions: "You operate inside a chat workspace via Chat SDK tools.",
  tools: createChatTools({ chat, preset: "messenger", requireApproval: true }),
});

bot.onSubscribedMessage(async (thread) => {
  const { messages } = await thread.adapter.fetchMessages(thread.id, {
    limit: 20,
  });
  const history = await toAiMessages(messages);
  const result = await agent.stream({ prompt: history });
  await thread.post(result.fullStream);
});
```

1. [`toAiMessages`](/docs/ai/to-ai-messages) converts messages into an output compatible with AI SDK's `ModelMessage[]`.
2. [`createChatTools`](/docs/ai/ai-sdk-tools) gives the agent pre-built and fully customizable AI SDK tools.
3. The streamed response is rendered back into the thread via the standard [`thread.post(stream)`](/docs/streaming) flow.

## Backwards compatibility

`toAiMessages` and the related `Ai*` types are still re-exported from the top-level `chat` package so older bots keep working. Those re-exports are now flagged with `@deprecated` JSDoc — your editor will surface a hint pointing at `chat/ai`. Migrating is a one-line import change:

```diff
- import { toAiMessages } from "chat";
+ import { toAiMessages } from "chat/ai";
```

## Resources

* [Human-in-the-Loop with Chat SDK and Workflow SDK](https://vercel.com/kb/guide/human-in-the-loop-with-chat-sdk-and-workflow-sdk) — Pause durable workflows on Slack approval cards using Chat SDK and Workflow SDK. Uses `createWebhook` to suspend workflows until a button click, with patterns for multi-stage approvals, timeouts via durable sleep, and approver validation.

See all guides and templates on the [resources](/resources) page.


---
title: toAiMessages
description: Convert Chat SDK messages to AI SDK conversation format.
type: reference
related:
  - /docs/ai
  - /docs/ai/ai-sdk-tools
  - /docs/ai/types
  - /docs/streaming
---

# toAiMessages



Convert an array of [`Message`](/docs/api/message) objects into the `{ role, content }[]` format expected by the AI SDK. The output is structurally compatible with AI SDK's `ModelMessage[]`.

```typescript
import { toAiMessages } from "chat/ai";
```

<Callout>
  `toAiMessages` is also re-exported from the main `chat` entrypoint
  for backwards compatibility (with a `@deprecated` JSDoc hint), but
  new code should import it from [`chat/ai`](/docs/ai) alongside
  [`createChatTools`](/docs/ai/ai-sdk-tools) and the rest of the AI
  utilities.
</Callout>

## Usage

```typescript title="lib/bot.ts" lineNumbers
import { toAiMessages } from "chat/ai";

bot.onSubscribedMessage(async (thread, message) => {
  const result = await thread.adapter.fetchMessages(thread.id, { limit: 20 });
  const history = await toAiMessages(result.messages);
  const response = await agent.stream({ prompt: history });
  await thread.post(response.fullStream);
});
```

## Signature

```typescript
function toAiMessages(
  messages: Message[],
  options?: ToAiMessagesOptions
): Promise<AiMessage[]>
```

### Parameters

<TypeTable
  type={{
  messages: {
    description: 'Array of Chat SDK Message objects. Works with FetchResult.messages, thread.recentMessages, or any collected iterable.',
    type: 'Message[]',
  },
  options: {
    description: 'Optional configuration.',
    type: 'ToAiMessagesOptions',
    default: '{}',
  },
}}
/>

### Options

<TypeTable
  type={{
  includeNames: {
    description: 'Prefix user messages with [username]: for multi-user context.',
    type: 'boolean',
    default: 'false',
  },
  transformMessage: {
    description: 'Transform or filter each message after default processing. Return null to skip the message.',
    type: '(aiMessage: AiMessage, source: Message) => AiMessage | null | Promise<AiMessage | null>',
  },
  onUnsupportedAttachment: {
    description: 'Called when an attachment type is not supported (video, audio).',
    type: '(attachment: Attachment, message: Message) => void',
    default: 'console.warn',
  },
}}
/>

### Returns

`Promise<AiMessage[]>` — an array of messages with `role` and `content` fields, directly assignable to AI SDK's `ModelMessage[]`.

## Behavior

* **Role mapping** — `author.isMe === true` maps to `"assistant"`, all others to `"user"`
* **Filtering** — Messages with empty or whitespace-only text are removed
* **Sorting** — Messages are sorted chronologically (oldest first) by `metadata.dateSent`
* **Links** — Link metadata (URL, title, description, site name) is appended to message content. Embedded message links are labeled as `[Embedded message: ...]`
* **Attachments** — Images and text files (JSON, XML, YAML, etc.) are included as multipart content using `fetchData()`. Video and audio attachments trigger `onUnsupportedAttachment`

## Return types

```typescript
type AiMessage = AiUserMessage | AiAssistantMessage;

interface AiUserMessage {
  role: "user";
  content: string | AiMessagePart[];
}

interface AiAssistantMessage {
  role: "assistant";
  content: string;
}
```

User messages have multipart `content` when attachments are present:

```typescript
type AiMessagePart = AiTextPart | AiImagePart | AiFilePart;

interface AiTextPart {
  type: "text";
  text: string;
}

interface AiImagePart {
  type: "image";
  image: DataContent | URL;
  mediaType?: string;
}

interface AiFilePart {
  type: "file";
  data: DataContent | URL;
  filename?: string;
  mediaType: string;
}
```

## Examples

### Multi-user context

Prefix each user message with their username so the AI model can distinguish speakers:

```typescript
const history = await toAiMessages(result.messages, { includeNames: true });
// [{ role: "user", content: "[alice]: Hello" },
//  { role: "assistant", content: "Hi there!" },
//  { role: "user", content: "[bob]: Thanks" }]
```

### Transforming messages

Replace raw user IDs with readable names:

```typescript
const history = await toAiMessages(result.messages, {
  transformMessage: (aiMessage) => {
    if (typeof aiMessage.content === "string") {
      return {
        ...aiMessage,
        content: aiMessage.content.replace(/<@U123>/g, "@VercelBot"),
      };
    }
    return aiMessage;
  },
});
```

### Filtering messages

Skip messages from a specific user:

```typescript
const history = await toAiMessages(result.messages, {
  transformMessage: (aiMessage, source) => {
    if (source.author.userId === "U_NOISY_BOT") return null;
    return aiMessage;
  },
});
```

### Handling unsupported attachments

```typescript
const history = await toAiMessages(result.messages, {
  onUnsupportedAttachment: (attachment, message) => {
    logger.warn(`Skipped ${attachment.type} attachment in message ${message.id}`);
  },
});
```

## Supported attachment types

| Type    | MIME types                                                                                                                                  | Included as                                  |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
| `image` | Any image MIME type                                                                                                                         | `FilePart` with base64 data                  |
| `file`  | `text/*`, `application/json`, `application/xml`, `application/javascript`, `application/typescript`, `application/yaml`, `application/toml` | `FilePart` with base64 data                  |
| `video` | Any                                                                                                                                         | Skipped (triggers `onUnsupportedAttachment`) |
| `audio` | Any                                                                                                                                         | Skipped (triggers `onUnsupportedAttachment`) |
| `file`  | Other (e.g. `application/pdf`)                                                                                                              | Silently skipped                             |

<Callout type="info">
  Attachments require `fetchData()` to be available on the attachment object. Attachments without `fetchData()` are silently skipped.
</Callout>


---
title: Types
description: TypeScript types exported from the chat/ai subpath.
type: reference
related:
  - /docs/ai
  - /docs/ai/ai-sdk-tools
  - /docs/ai/to-ai-messages
---

# Types



Every type exported from `chat/ai`. Pulling these from the subpath keeps the optional `ai` and `zod` peer deps out of bundles that don't import them.

```ts
import type {
  AiMessage,
  AiUserMessage,
  AiAssistantMessage,
  AiMessagePart,
  AiTextPart,
  AiImagePart,
  AiFilePart,
  ToAiMessagesOptions,
  ChatBinding,
  ChatTools,
  ChatToolName,
  ChatToolPreset,
  ChatWriteToolName,
  ApprovalConfig,
  ToolOptions,
  ToolOverrides,
} from "chat/ai";
```

## Conversation messages

Used by [`toAiMessages`](/docs/ai/to-ai-messages) and any agent prompt you build by hand. The shapes are structurally compatible with AI SDK's `ModelMessage` so the result is directly assignable to `prompt` / `messages`.

### AiMessage

```typescript
type AiMessage = AiUserMessage | AiAssistantMessage;
```

A single normalized turn in a conversation — the array form is what AI SDK calls expect.

### AiUserMessage

```typescript
interface AiUserMessage {
  role: "user";
  content: string | AiMessagePart[];
}
```

User content can be plain text, or a multipart array when attachments are present.

### AiAssistantMessage

```typescript
interface AiAssistantMessage {
  role: "assistant";
  content: string;
}
```

Assistant turns are always plain strings — `toAiMessages` produces this for any message authored by the bot itself (`author.isMe === true`).

### AiMessagePart

```typescript
type AiMessagePart = AiTextPart | AiImagePart | AiFilePart;
```

The discriminated union used inside multipart user messages.

### AiTextPart

```typescript
interface AiTextPart {
  type: "text";
  text: string;
}
```

### AiImagePart

```typescript
interface AiImagePart {
  type: "image";
  image: DataContent | URL;
  mediaType?: string;
}
```

`DataContent` matches AI SDK's type — `string | Uint8Array | ArrayBuffer | Buffer`.

### AiFilePart

```typescript
interface AiFilePart {
  type: "file";
  data: DataContent | URL;
  filename?: string;
  mediaType: string;
}
```

`toAiMessages` emits text-like attachments (JSON, XML, YAML, source files, etc.) as file parts.

### ToAiMessagesOptions

```typescript
interface ToAiMessagesOptions {
  includeNames?: boolean;
  transformMessage?: (
    aiMessage: AiMessage,
    source: Message
  ) => AiMessage | null | Promise<AiMessage | null>;
  onUnsupportedAttachment?: (
    attachment: Attachment,
    message: Message
  ) => void;
}
```

See [`toAiMessages`](/docs/ai/to-ai-messages) for behavior and examples.

## Tools

Returned by [`createChatTools`](/docs/ai/ai-sdk-tools) and used to configure it.

### ChatBinding

```typescript
type ChatBinding = Chat<any, any>;
```

Whatever [`Chat`](/docs/api/chat) instance the tools should dispatch operations against. The generics are intentionally loose so any strongly-typed `Chat<TAdapters, TState>` is assignable.

### ChatTools

```typescript
type ChatTools = ReturnType<typeof createChatTools>;
```

Convenience alias for the object returned by `createChatTools` — handy when you want to type a wrapper or pass the toolset around.

### ChatToolPreset

```typescript
type ChatToolPreset = "reader" | "messenger" | "moderator";
```

Predefined toolset scopes. See [Presets](/docs/ai/ai-sdk-tools#presets) for the exact tool list per preset.

### ChatToolName

```typescript
type ChatToolName =
  | "fetchMessages"
  | "fetchChannelMessages"
  | "fetchThread"
  | "listThreads"
  | "getThreadParticipants"
  | "getChannelInfo"
  | "getUser"
  | "startTyping"
  | "postMessage"
  | "postChannelMessage"
  | "sendDirectMessage"
  | "editMessage"
  | "deleteMessage"
  | "addReaction"
  | "removeReaction"
  | "subscribeThread"
  | "unsubscribeThread";
```

The names of every generated tool. Useful when typing per-tool overrides.

### ChatWriteToolName

```typescript
type ChatWriteToolName =
  | "postMessage"
  | "postChannelMessage"
  | "sendDirectMessage"
  | "editMessage"
  | "deleteMessage"
  | "addReaction"
  | "removeReaction"
  | "subscribeThread"
  | "unsubscribeThread";
```

The names of every mutating tool. Useful when wiring per-tool approval overrides.

### ApprovalConfig

```typescript
type ApprovalConfig =
  | boolean
  | Partial<Record<ChatWriteToolName, boolean>>;
```

Controls the `requireApproval` option:

* `true` (default) — every write tool needs approval.
* `false` — no write tool needs approval.
* object — per-tool override; unspecified write tools fall back to `true`.

### ToolOptions

```typescript
interface ToolOptions {
  needsApproval?: boolean;
}
```

Common options accepted by every standalone write-tool factory (e.g. `postMessage(chat, { needsApproval: false })`).

### ToolOverrides

```typescript
type ToolOverrides = Partial<
  Pick<
    Tool,
    | "description"
    | "inputExamples"
    | "metadata"
    | "needsApproval"
    | "onInputAvailable"
    | "onInputDelta"
    | "onInputStart"
    | "providerOptions"
    | "strict"
    | "title"
    | "toModelOutput"
  >
>;
```

Per-tool overrides accepted by `createChatTools({ overrides })`. Core fields like `execute`, `inputSchema`, `outputSchema`, `type`, `id`, and `args` are intentionally excluded so tool semantics stay stable across upgrades.


---
title: Cards
description: Rich card components for cross-platform interactive messages.
type: reference
---

# Cards



Card components render natively on each platform — Block Kit on Slack, Adaptive Cards on Teams, Embeds on Discord, and Google Chat Cards.

```typescript
import { Card, Text, CardLink, Button, Actions, Section, Fields, Field, Divider, Image, LinkButton, Table } from "chat";
```

All components support both function-call and JSX syntax. Function-call syntax is recommended for better type inference.

## Card

Top-level container for a rich message.

```typescript
Card({
  title: "Order #1234",
  subtitle: "Pending approval",
  children: [Text("Total: $50.00")],
})
```

<TypeTable
  type={{
  title: {
    description: 'Card title.',
    type: 'string',
  },
  subtitle: {
    description: 'Card subtitle.',
    type: 'string',
  },
  imageUrl: {
    description: 'Header image URL.',
    type: 'string',
  },
  children: {
    description: 'Card content elements.',
    type: 'CardChild[]',
  },
}}
/>

## Text

Text content element. Use `CardText` instead of `Text` in JSX to avoid conflicts with React's built-in types.

```typescript
Text("Hello, world!")
Text("Important", { style: "bold" })
Text("Subtle note", { style: "muted" })
```

<TypeTable
  type={{
  content: {
    description: 'Text content (first argument).',
    type: 'string',
  },
  'options.style': {
    description: 'Text style.',
    type: '"plain" | "bold" | "muted"',
  },
}}
/>

## Button

Interactive button that triggers an `onAction` handler.

```typescript
Button({ id: "approve", label: "Approve", style: "primary" })
Button({ id: "delete", label: "Delete", style: "danger", value: "item-123" })
```

<TypeTable
  type={{
  id: {
    description: 'Unique action ID for callback routing.',
    type: 'string',
  },
  label: {
    description: 'Button label text.',
    type: 'string',
  },
  style: {
    description: 'Visual style.',
    type: '"primary" | "danger" | "default"',
  },
  value: {
    description: 'Optional payload sent with the action callback.',
    type: 'string',
  },
  actionType: {
    description: 'Hints to adapters like Teams that this button will open a modal via event.openModal().',
    type: '"action" | "modal"',
    default: '"action"',
  },
  callbackUrl: {
    description: 'URL to POST action data to when this button is clicked.',
    type: 'string',
  },
}}
/>

## CardLink

Inline hyperlink rendered as text. Can be placed directly in a card alongside other content, unlike `LinkButton` which must live inside `Actions`.

```typescript
CardLink({ url: "https://example.com", label: "Visit Site" })
```

<TypeTable
  type={{
  url: {
    description: 'URL to link to.',
    type: 'string',
  },
  label: {
    description: 'Link label text.',
    type: 'string',
  },
}}
/>

## LinkButton

Button that opens a URL. No `onAction` handler needed.

```typescript
LinkButton({ url: "https://example.com", label: "View Docs" })
```

<TypeTable
  type={{
  url: {
    description: 'URL to open when clicked.',
    type: 'string',
  },
  label: {
    description: 'Button label text.',
    type: 'string',
  },
  style: {
    description: 'Visual style.',
    type: '"primary" | "danger" | "default"',
  },
}}
/>

## Actions

Container for buttons and interactive elements. Required wrapper around `Button`, `LinkButton`, `Select`, and `RadioSelect`.

```typescript
Actions([
  Button({ id: "approve", label: "Approve", style: "primary" }),
  Button({ id: "reject", label: "Reject", style: "danger" }),
  LinkButton({ url: "https://example.com", label: "View" }),
])
```

## Section

Groups related content together.

```typescript
Section([
  Text("Grouped content"),
  Image({ url: "https://example.com/photo.png" }),
])
```

## Fields

Renders key-value pairs in a compact, multi-column layout.

```typescript
Fields([
  Field({ label: "Name", value: "Jane Smith" }),
  Field({ label: "Role", value: "Engineer" }),
])
```

## Field

A single key-value pair. Must be used inside `Fields`.

<TypeTable
  type={{
  label: {
    description: 'Field label.',
    type: 'string',
  },
  value: {
    description: 'Field value.',
    type: 'string',
  },
}}
/>

## Image

Embeds an image in the card.

```typescript
Image({ url: "https://example.com/screenshot.png", alt: "Screenshot" })
```

<TypeTable
  type={{
  url: {
    description: 'Image URL.',
    type: 'string',
  },
  alt: {
    description: 'Alt text for accessibility.',
    type: 'string',
  },
}}
/>

## Table

Structured data display with column headers and rows.

```typescript
Table({
  headers: ["Name", "Age", "Role"],
  rows: [
    ["Alice", "30", "Engineer"],
    ["Bob", "25", "Designer"],
  ],
})
```

<TypeTable
  type={{
  headers: {
    description: 'Column header labels.',
    type: 'string[]',
  },
  rows: {
    description: 'Data rows (each row is an array of cell strings).',
    type: 'string[][]',
  },
  align: {
    description: 'Column alignment.',
    type: '"left" | "center" | "right"[]',
  },
}}
/>

On platforms with native table support (Teams, GitHub, Linear), tables render as formatted tables. On other platforms (Slack, Google Chat, Discord, Telegram), tables render as padded ASCII text.

## Divider

A visual separator between sections.

```typescript
Divider()
```

## CardChild types

The `children` array in `Card` and `Section` accepts these element types:

| Type             | Created by   |
| ---------------- | ------------ |
| `TextElement`    | `Text()`     |
| `LinkElement`    | `CardLink()` |
| `ImageElement`   | `Image()`    |
| `DividerElement` | `Divider()`  |
| `ActionsElement` | `Actions()`  |
| `SectionElement` | `Section()`  |
| `FieldsElement`  | `Fields()`   |
| `TableElement`   | `Table()`    |


---
title: Channel
description: Channel container that holds threads, with methods for listing, posting, and iteration.
type: reference
---

# Channel



A `Channel` represents a channel or conversation container that holds threads. Both `Thread` and `Channel` extend the shared `Postable` interface, so they share common methods like `post()`, `state`, and `messages`.

Get a channel via `thread.channel` or `chat.channel()`:

```typescript
// Navigate from a thread
const channel = thread.channel;

// Get directly by ID
const channel = chat.channel("slack:C123ABC");
```

## Properties

<TypeTable
  type={{
  id: {
    description: 'Channel ID (e.g., "slack:C123ABC", "gchat:spaces/ABC123").',
    type: 'string',
  },
  name: {
    description: 'Channel name (e.g., "#general"). Null until fetchMetadata() is called.',
    type: 'string | null',
  },
  adapter: {
    description: 'The platform adapter this channel belongs to.',
    type: 'Adapter',
  },
  isDM: {
    description: 'Whether this is a direct message conversation.',
    type: 'boolean',
  },
}}
/>

## Channel ID format

Channel IDs are derived from thread IDs by dropping the thread-specific part. By default, this is the first two colon-separated segments:

| Platform    | Thread ID                                   | Channel ID            |
| ----------- | ------------------------------------------- | --------------------- |
| Slack       | `slack:C123ABC:1234567890.123456`           | `slack:C123ABC`       |
| Teams       | `teams:{base64}:{base64}`                   | `teams:{base64}`      |
| Google Chat | `gchat:spaces/ABC123:{base64}`              | `gchat:spaces/ABC123` |
| Discord     | `discord:{guildId}:{channelId}/{messageId}` | `discord:{guildId}`   |

## messages

Iterate channel-level messages (top-level, not thread replies) newest first. Auto-paginates lazily.

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

## threads

Iterate threads in the channel, most recently active first. Returns lightweight `ThreadSummary` objects.

```typescript
for await (const thread of channel.threads()) {
  console.log(thread.rootMessage.text, thread.replyCount);
}
```

### ThreadSummary

<TypeTable
  type={{
  id: {
    description: 'Full thread ID.',
    type: 'string',
  },
  rootMessage: {
    description: 'The first message of the thread.',
    type: 'Message',
  },
  replyCount: {
    description: 'Number of replies (if available).',
    type: 'number | undefined',
  },
  lastReplyAt: {
    description: 'Timestamp of most recent reply.',
    type: 'Date | undefined',
  },
}}
/>

## post

Post a message to the channel top-level (not in a thread).

```typescript
await channel.post("Hello channel!");
await channel.post({ markdown: "**Announcement**: New release!" });
```

Accepts the same message formats as `thread.post()` — see [PostableMessage](/docs/api/postable-message).

## schedule

Schedule a message for future delivery to the channel top-level. Currently only supported by the Slack adapter — other adapters throw `NotImplementedError`.

```typescript
const scheduled = await channel.schedule("Weekly reminder: update your status!", {
  postAt: new Date("2026-03-10T09:00:00Z"),
});

// Cancel before it's sent
await scheduled.cancel();
```

Accepts the same message formats as `channel.post()` (except streaming). See [ScheduledMessage](/docs/api/thread#scheduledmessage) for the return type.

## fetchMetadata

Fetch channel metadata from the platform.

```typescript
const info = await channel.fetchMetadata();
console.log(info.name, info.memberCount);
```

### ChannelInfo

<TypeTable
  type={{
  id: {
    description: 'Channel ID.',
    type: 'string',
  },
  name: {
    description: 'Channel name.',
    type: 'string | undefined',
  },
  isDM: {
    description: 'Whether this is a direct message.',
    type: 'boolean | undefined',
  },
  memberCount: {
    description: 'Number of members in the channel.',
    type: 'number | undefined',
  },
  metadata: {
    description: 'Platform-specific metadata.',
    type: 'Record<string, unknown>',
  },
}}
/>

## state

Store typed, per-channel state. Works the same as thread state with a 30-day TTL.

```typescript
const state = await channel.state;
await channel.setState({ lastAnnouncement: new Date().toISOString() });
```

## postEphemeral

Post a message visible only to a specific user.

```typescript
await channel.postEphemeral(userId, "Only you can see this", {
  fallbackToDM: true,
});
```

## startTyping

Show a typing indicator. No-op on platforms that don't support it. On Slack, you can pass an optional `status` string to show a custom loading message (requires `assistant:write` scope).

```typescript
await channel.startTyping();

// With custom status (Slack only)
await channel.startTyping("Searching documents...");
```

## mentionUser

Get a platform-specific @-mention string.

```typescript
await channel.post(`Hey ${channel.mentionUser(userId)}, check this out!`);
```


---
title: Chat
description: The main entry point for creating a multi-platform chat bot.
type: reference
---

# Chat



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


---
title: Overview
description: API reference for the Chat SDK core package.
type: overview
---

# Overview



Complete API reference for the `chat` package. All exports are available from the top-level import:

```typescript
import { Chat, root, paragraph, text, Card, Button, emoji } from "chat";
```

## Core

| Export                                                  | Description                                                            |
| ------------------------------------------------------- | ---------------------------------------------------------------------- |
| [`Chat`](/docs/api/chat)                                | Main class — registers adapters, event handlers, and webhook routing   |
| [`Thread`](/docs/api/thread)                            | Conversation thread with methods for posting, subscribing, and state   |
| [`Channel`](/docs/api/channel)                          | Channel/conversation container that holds threads                      |
| [`Message`](/docs/api/message)                          | Normalized message with text, AST, author, and metadata                |
| [`ScheduledMessage`](/docs/api/thread#scheduledmessage) | Returned by `thread.schedule()` / `channel.schedule()` with `cancel()` |

## Message formats

| Export                                                      | Description                                                         |
| ----------------------------------------------------------- | ------------------------------------------------------------------- |
| [`PostableMessage`](/docs/api/postable-message)             | Union type accepted by `thread.post()`                              |
| [`Plan`](/docs/api/postable-message#plan)                   | Step-by-step task list that mutates after posting                   |
| [`StreamingPlan`](/docs/api/postable-message#streamingplan) | Wraps an async iterable with platform-specific streaming options    |
| [`Cards`](/docs/api/cards)                                  | Rich card components — `Card`, `Text`, `Button`, `Actions`, etc.    |
| [`Markdown`](/docs/api/markdown)                            | AST builder functions — `root`, `paragraph`, `text`, `strong`, etc. |
| [`Modals`](/docs/api/modals)                                | Modal form components — `Modal`, `TextInput`, `Select`, etc.        |

## AI utilities

`toAiMessages`, `createChatTools`, and the supporting types live in the [`chat/ai`](/docs/ai) subpath — see the [AI section](/docs/ai) for the full reference.


---
title: Markdown
description: AST builder functions and utilities for programmatic message formatting.
type: reference
---

# Markdown



The SDK uses [mdast](https://github.com/syntax-tree/mdast) (Markdown AST) as the canonical format for message formatting. Each adapter converts the AST to the platform's native format.

```typescript
import {
  root, paragraph, text, strong, emphasis, strikethrough,
  inlineCode, codeBlock, link, blockquote,
  parseMarkdown, stringifyMarkdown, toPlainText, walkAst,
  tableToAscii, tableElementToAscii,
} from "chat";
```

## Type re-exports

The chat package re-exports mdast's union and content types so adapters and downstream code can build exhaustively-typed AST walkers without depending on `mdast` directly:

```typescript
import type { Nodes, Root, Content } from "chat";

function render(node: Nodes): string {
  switch (node.type) {
    case "text": return node.value;
    case "strong": return node.children.map(render).join("");
    // ...
    default: throw new Error(`Unhandled: ${node satisfies never}`);
  }
}
```

Adapters use this pattern to make the type checker reject the build when a new mdast node type is introduced upstream.

## Node builders

### root

Root node — the required top-level wrapper for an AST.

```typescript
root([
  paragraph([text("Hello, world!")]),
])
```

<TypeTable
  type={{
  children: {
    description: 'Top-level content nodes (paragraphs, code blocks, blockquotes, lists).',
    type: 'Content[]',
  },
}}
/>

### paragraph

A paragraph block.

```typescript
paragraph([text("Hello "), strong([text("world")])])
```

### text

Plain text node.

```typescript
text("Hello, world!")
```

### strong

**Bold** text.

```typescript
strong([text("important")])
```

### emphasis

*Italic* text.

```typescript
emphasis([text("emphasized")])
```

### strikethrough

~~Strikethrough~~ text.

```typescript
strikethrough([text("removed")])
```

### inlineCode

`Inline code` span.

```typescript
inlineCode("const x = 1")
```

### codeBlock

Fenced code block with optional language.

```typescript
codeBlock("const x = 1;", "typescript")
```

<TypeTable
  type={{
  value: {
    description: 'Code content.',
    type: 'string',
  },
  lang: {
    description: 'Language identifier for syntax highlighting.',
    type: 'string | undefined',
  },
}}
/>

### link

Hyperlink.

```typescript
link("https://example.com", [text("click here")])
link("https://example.com", [text("click here")], "tooltip title")
```

<TypeTable
  type={{
  url: {
    description: 'Link URL.',
    type: 'string',
  },
  children: {
    description: 'Link text content.',
    type: 'Content[]',
  },
  title: {
    description: 'Optional tooltip text.',
    type: 'string | undefined',
  },
}}
/>

### blockquote

Block quotation.

```typescript
blockquote([paragraph([text("Quoted text")])])
```

## Parsing and stringifying

### parseMarkdown

Parse a markdown string into an mdast AST.

```typescript
const ast = parseMarkdown("**Hello** world");
```

### stringifyMarkdown

Convert an mdast AST back to a markdown string.

```typescript
const md = stringifyMarkdown(ast); // "**Hello** world"
```

### toPlainText

Strip all formatting and return plain text.

```typescript
const plain = toPlainText(ast); // "Hello world"
```

### markdownToPlainText

Shorthand for parsing markdown and extracting plain text.

```typescript
const plain = markdownToPlainText("**Hello** world"); // "Hello world"
```

## AST utilities

### walkAst

Transform an AST by visiting each node. Return a new value to replace the node, or `undefined` to keep it unchanged.

```typescript
const transformed = walkAst(ast, (node) => {
  if (isStrongNode(node)) {
    return emphasis(getNodeChildren(node));
  }
  return undefined;
});
```

### Type guards

Functions for checking node types:

| Guard                    | Matches       |
| ------------------------ | ------------- |
| `isTextNode(node)`       | Plain text    |
| `isParagraphNode(node)`  | Paragraph     |
| `isStrongNode(node)`     | Bold          |
| `isEmphasisNode(node)`   | Italic        |
| `isDeleteNode(node)`     | Strikethrough |
| `isInlineCodeNode(node)` | Inline code   |
| `isCodeNode(node)`       | Code block    |
| `isLinkNode(node)`       | Link          |
| `isBlockquoteNode(node)` | Blockquote    |
| `isListNode(node)`       | List          |
| `isListItemNode(node)`   | List item     |
| `isTableNode(node)`      | Table         |
| `isTableRowNode(node)`   | Table row     |
| `isTableCellNode(node)`  | Table cell    |

### getNodeChildren / getNodeValue

Safely access node properties without type narrowing.

```typescript
const children = getNodeChildren(node); // Content[] | undefined
const value = getNodeValue(node);       // string | undefined
```

## Table utilities

### tableToAscii

Render an mdast `Table` node as a padded ASCII table string. Used by adapters that lack native table support (Google Chat, Discord, Telegram).

```typescript
import { parseMarkdown, tableToAscii, isTableNode } from "chat";

const ast = parseMarkdown("| Name | Role |\n|------|------|\n| Alice | Engineer |");
// Find the table node and convert it
```

Output:

```
Name  | Role
------|--------
Alice | Engineer
```

### tableElementToAscii

Render a table from headers and string row arrays as a padded ASCII table. Used for card `TableElement` fallback rendering.

```typescript
import { tableElementToAscii } from "chat";

const ascii = tableElementToAscii(
  ["Name", "Age", "Role"],
  [
    ["Alice", "30", "Engineer"],
    ["Bob", "25", "Designer"],
  ]
);
```

## Platform formatting

The SDK uses mdast as the canonical format and each adapter converts it to the platform's native syntax. You write standard markdown and the SDK handles the translation — but it helps to know how each platform renders common formatting.

| Feature       | Slack                   | Teams           | Google Chat               |
| ------------- | ----------------------- | --------------- | ------------------------- |
| Bold          | `**text**`              | `**text**`      | `*text*`                  |
| Italic        | `_text_`                | `_text_`        | `_text_`                  |
| Strikethrough | `~~text~~`              | `~~text~~`      | `~text~`                  |
| Code          | `` `code` ``            | `` `code` ``    | `` `code` ``              |
| Code blocks   | ` ``` `                 | ` ``` `         | ` ``` `                   |
| Links         | `[text](url)`           | `[text](url)`   | `[text](url)`             |
| Lists         | Supported               | Supported       | Supported                 |
| Blockquotes   | `>`                     | `>`             | Simulated with `>` prefix |
| Tables        | Native (markdown\_text) | Native GFM      | ASCII fallback            |
| Mentions      | `<@USER>`               | `<at>name</at>` | `<users/{id}>`            |

<Callout type="info">
  Slack accepts standard markdown via the `markdown_text` field on `chat.postMessage` and friends, so the SDK passes markdown through directly. Incoming Slack messages still arrive as legacy mrkdwn (`*bold*`, `<url|text>`) and are parsed transparently. If you need to send mrkdwn yourself, use `{ raw: "..." }`.
</Callout>

<Callout type="info">
  You don't need to worry about these differences when using the SDK — the AST builders and `parseMarkdown` handle conversion automatically. This table is useful if you're working with `raw` platform payloads or debugging formatting issues.
</Callout>


---
title: Message
description: Normalized message format with text, AST, author, and metadata.
type: reference
---

# Message



Incoming messages are normalized across all platforms into a consistent `Message` object.

```typescript
import { Message } from "chat";
```

## Properties

<TypeTable
  type={{
  id: {
    description: 'Platform-specific message ID.',
    type: 'string',
  },
  threadId: {
    description: 'Thread ID in adapter:channel:thread format.',
    type: 'string',
  },
  text: {
    description: 'Plain text content with all formatting stripped.',
    type: 'string',
  },
  formatted: {
    description: 'mdast AST representation — the canonical format for processing.',
    type: 'Root',
  },
  raw: {
    description: 'Original platform-specific payload (escape hatch).',
    type: 'unknown',
  },
  author: {
    description: 'Message author info.',
    type: 'Author',
  },
  metadata: {
    description: 'Timestamps and edit status.',
    type: 'MessageMetadata',
  },
  attachments: {
    description: 'File attachments.',
    type: 'Attachment[]',
  },
  links: {
    description: 'Links found in the message, with optional preview metadata.',
    type: 'LinkPreview[]',
  },
  isMention: {
    description: 'Whether the bot was @-mentioned in this message.',
    type: 'boolean | undefined',
  },
  subject: {
    description: 'Resolves the parent resource (issue, PR) this message is about. Returns null on chat platforms. See [Message Subject](/docs/subject).',
    type: 'Promise<MessageSubject | null>',
  },
}}
/>

## Author

<TypeTable
  type={{
  userId: {
    description: 'Platform-specific user ID.',
    type: 'string',
  },
  userName: {
    description: 'Username/handle for @-mentions.',
    type: 'string',
  },
  fullName: {
    description: 'Display name.',
    type: 'string',
  },
  isBot: {
    description: 'Whether the author is a bot.',
    type: 'boolean | "unknown"',
  },
  isMe: {
    description: 'Whether the author is this bot.',
    type: 'boolean',
  },
}}
/>

### How `isMe` works

Each adapter detects whether a message came from the bot itself. The detection logic varies by platform:

| Platform    | Detection method                                                                                                                                                     |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Slack       | Checks `event.user === botUserId` (primary), then `event.bot_id === botId` (for `bot_message` subtypes). Both IDs are fetched during initialization via `auth.test`. |
| Teams       | Checks `activity.from.id === appId` (exact match), then checks if `activity.from.id` ends with `:{appId}` (handles `28:{appId}` format).                             |
| Google Chat | Checks `message.sender.name === botUserId`. The bot user ID is learned dynamically from message annotations when the bot is first @-mentioned.                       |

<Callout type="info">
  All adapters return `false` if the bot ID isn't known yet. This is a safe default that prevents the bot from ignoring messages it should process.
</Callout>

## MessageMetadata

<TypeTable
  type={{
  dateSent: {
    description: 'When the message was sent.',
    type: 'Date',
  },
  edited: {
    description: 'Whether the message has been edited.',
    type: 'boolean',
  },
  editedAt: {
    description: 'When the message was last edited.',
    type: 'Date | undefined',
  },
}}
/>

## Attachment

<TypeTable
  type={{
  type: {
    description: 'Attachment type.',
    type: '"image" | "file" | "video" | "audio"',
  },
  url: {
    description: 'URL to the file.',
    type: 'string | undefined',
  },
  data: {
    description: 'Binary data (if already fetched).',
    type: 'Buffer | Blob | undefined',
  },
  name: {
    description: 'Filename.',
    type: 'string | undefined',
  },
  mimeType: {
    description: 'MIME type.',
    type: 'string | undefined',
  },
  size: {
    description: 'File size in bytes.',
    type: 'number | undefined',
  },
  'fetchData()': {
    description: 'Fetch the attachment data. Handles platform auth automatically.',
    type: '() => Promise<Buffer> | undefined',
  },
  fetchMetadata: {
    description: 'Platform-specific IDs for reconstructing fetchData after serialization (e.g. WhatsApp mediaId, Telegram fileId).',
    type: 'Record<string, string> | undefined',
  },
}}
/>

## LinkPreview

Links found in incoming messages are extracted and exposed as `LinkPreview` objects. On platforms that support it (currently Slack), links pointing to other chat messages include a `fetchMessage()` callback to retrieve the full linked message.

<TypeTable
  type={{
  url: {
    description: 'The URL.',
    type: 'string',
  },
  title: {
    description: 'Title from unfurl metadata (if available).',
    type: 'string | undefined',
  },
  description: {
    description: 'Description from unfurl metadata (if available).',
    type: 'string | undefined',
  },
  imageUrl: {
    description: 'Preview image URL (if available).',
    type: 'string | undefined',
  },
  siteName: {
    description: 'Site name, e.g. "Vercel" (if available).',
    type: 'string | undefined',
  },
  'fetchMessage()': {
    description: 'Fetch the linked chat message. Available when the URL points to a message on the same platform (e.g. a Slack message link).',
    type: '() => Promise<Message> | undefined',
  },
}}
/>

<Callout type="info">
  When using [`toAiMessages()`](/docs/ai/to-ai-messages), link metadata is automatically appended to the message content. Embedded message links are labeled as `[Embedded message: ...]` so the AI model understands the context.
</Callout>

### Platform support

| Platform | Link extraction                                       | `fetchMessage()`                                 |
| -------- | ----------------------------------------------------- | ------------------------------------------------ |
| Slack    | URLs from `rich_text` blocks or `<url>` text patterns | Slack message links (`*.slack.com/archives/...`) |
| Others   | Not yet — `links` is always `[]`                      | —                                                |

## MessageSubject

Returned by `message.subject` on platforms with parent resources. See [Message Subject](/docs/subject) for usage.

<TypeTable
  type={{
  type: {
    description: 'Resource kind, e.g. "issue" or "pull_request".',
    type: 'string',
  },
  id: {
    description: 'Resource identifier (e.g. "ENG-123" or "42").',
    type: 'string',
  },
  title: {
    description: 'Resource title.',
    type: 'string | undefined',
  },
  description: {
    description: 'Full description/body in markdown.',
    type: 'string | undefined',
  },
  status: {
    description: 'Current status (e.g. "In Progress", "open").',
    type: 'string | undefined',
  },
  url: {
    description: 'Web URL to the resource.',
    type: 'string | undefined',
  },
  author: {
    description: 'Resource creator.',
    type: '{ id: string; name: string } | undefined',
  },
  assignee: {
    description: 'Current assignee.',
    type: '{ id: string; name: string } | undefined',
  },
  labels: {
    description: 'Labels/tags.',
    type: 'string[] | undefined',
  },
  raw: {
    description: 'Full platform API response.',
    type: 'unknown',
  },
}}
/>

## Serialization

Messages can be serialized for workflow engines and external systems.

```typescript
// Serialize
const json = message.toJSON();

// Deserialize
const restored = Message.fromJSON(json);
```

The serialized format converts `Date` fields to ISO strings and omits non-serializable fields like `data` buffers and `fetchData` functions. The `fetchMetadata` field is preserved so that adapters can reconstruct `fetchData` when the message is rehydrated from a queue.


---
title: Modals
description: Modal form components for collecting user input.
type: reference
---

# Modals



Modals display form dialogs that collect structured user input. Currently supported on Slack and Teams.

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

## Modal

Top-level container for a form dialog. Open a modal from an `onAction` or `onSlashCommand` handler using `event.openModal()`.

```typescript
bot.onAction("open-form", async (event) => {
  await event.openModal(
    Modal({
      callbackId: "feedback",
      title: "Submit Feedback",
      submitLabel: "Send",
      children: [
        TextInput({ id: "comment", label: "Comment", multiline: true }),
      ],
    })
  );
});
```

<TypeTable
  type={{
  callbackId: {
    description: 'Unique ID for routing to onModalSubmit/onModalClose handlers.',
    type: 'string',
  },
  title: {
    description: 'Modal title displayed in the header.',
    type: 'string',
  },
  submitLabel: {
    description: 'Label for the submit button.',
    type: 'string',
    default: '"Submit"',
  },
  closeLabel: {
    description: 'Label for the close/cancel button.',
    type: 'string',
    default: '"Cancel"',
  },
  notifyOnClose: {
    description: 'Whether to fire onModalClose when the user dismisses the modal.',
    type: 'boolean',
    default: 'false',
  },
  callbackUrl: {
    description: 'URL to POST form values to when the modal is submitted.',
    type: 'string',
  },
  privateMetadata: {
    description: 'Arbitrary string passed through the modal lifecycle (e.g., JSON context).',
    type: 'string',
  },
  children: {
    description: 'Form input elements.',
    type: 'ModalChild[]',
  },
}}
/>

## TextInput

A text input field.

```typescript
TextInput({
  id: "name",
  label: "Your name",
  placeholder: "Enter your name",
})

TextInput({
  id: "description",
  label: "Description",
  multiline: true,
  maxLength: 500,
  optional: true,
})
```

<TypeTable
  type={{
  id: {
    description: 'Input ID — used as the key in event.values.',
    type: 'string',
  },
  label: {
    description: 'Label displayed above the input.',
    type: 'string',
  },
  placeholder: {
    description: 'Placeholder text.',
    type: 'string',
  },
  initialValue: {
    description: 'Pre-filled value.',
    type: 'string',
  },
  multiline: {
    description: 'Render as a textarea.',
    type: 'boolean',
    default: 'false',
  },
  optional: {
    description: 'Whether the field can be left empty.',
    type: 'boolean',
    default: 'false',
  },
  maxLength: {
    description: 'Maximum character length.',
    type: 'number',
  },
}}
/>

## Select

Dropdown menu.

```typescript
Select({
  id: "priority",
  label: "Priority",
  placeholder: "Select priority",
  options: [
    SelectOption({ label: "High", value: "high", description: "Urgent tasks" }),
    SelectOption({ label: "Medium", value: "medium" }),
    SelectOption({ label: "Low", value: "low" }),
  ],
})
```

<TypeTable
  type={{
  id: {
    description: 'Input ID — used as the key in event.values.',
    type: 'string',
  },
  label: {
    description: 'Label displayed above the select.',
    type: 'string',
  },
  placeholder: {
    description: 'Placeholder text.',
    type: 'string',
  },
  initialOption: {
    description: 'Pre-selected option value.',
    type: 'string',
  },
  optional: {
    description: 'Whether the field can be left empty.',
    type: 'boolean',
    default: 'false',
  },
  options: {
    description: 'Select options.',
    type: 'SelectOptionElement[]',
  },
}}
/>

## ExternalSelect

Dropdown that loads options dynamically from a handler as the user types. Slack-only. Pair with [`bot.onOptionsLoad`](/docs/api/chat#onoptionsload) to supply options. See [Modals → ExternalSelect](/docs/modals#externalselect) for a full example, grouped-options support, and Slack setup notes.

```typescript
ExternalSelect({
  id: "assignee",
  label: "Assignee",
  placeholder: "Search people",
  minQueryLength: 1,
  initialOption: { label: "Alice", value: "U123" },
})
```

<TypeTable
  type={{
  id: {
    description: 'Input ID — used as the key in event.values.',
    type: 'string',
  },
  label: {
    description: 'Label displayed above the select.',
    type: 'string',
  },
  placeholder: {
    description: 'Placeholder text.',
    type: 'string',
  },
  minQueryLength: {
    description: 'Minimum characters before the loader fires (Slack default: 3).',
    type: 'number',
  },
  initialOption: {
    description: 'Pre-selected option when the modal opens. Unlike static Select where initialOption is a value string, ExternalSelect needs the full label/value object since the loader has not run yet.',
    type: '{ label: string, value: string }',
  },
  optional: {
    description: 'Whether the field can be left empty.',
    type: 'boolean',
    default: 'false',
  },
}}
/>

The loader registered via `bot.onOptionsLoad("assignee", handler)` returns either a flat `SelectOptionElement[]` or `OptionsLoadGroup[]` (`{ label, options }[]`) for grouped options.

## RadioSelect

Radio button group for mutually exclusive choices.

```typescript
RadioSelect({
  id: "status",
  label: "Status",
  options: [
    SelectOption({ label: "Open", value: "open" }),
    SelectOption({ label: "Closed", value: "closed" }),
  ],
})
```

Same props as `Select` (except `placeholder`).

## SelectOption

An option used inside `Select` and `RadioSelect`.

```typescript
SelectOption({ label: "High", value: "high", description: "Urgent tasks" })
```

<TypeTable
  type={{
  label: {
    description: 'Display text.',
    type: 'string',
  },
  value: {
    description: 'Value sent in event.values when selected.',
    type: 'string',
  },
  description: {
    description: 'Optional description shown below the label.',
    type: 'string',
  },
}}
/>

## ModalChild types

The `children` array in `Modal` accepts these element types:

| Type                 | Created by                     |
| -------------------- | ------------------------------ |
| `TextInputElement`   | `TextInput()`                  |
| `SelectElement`      | `Select()`                     |
| `RadioSelectElement` | `RadioSelect()`                |
| `TextElement`        | `Text()` — static text content |
| `FieldsElement`      | `Fields()` — key-value display |


---
title: PostableMessage
description: The union type accepted by thread.post() for sending messages.
type: reference
---

# PostableMessage



`PostableMessage` is the union of all message formats accepted by `thread.post()` and `sent.edit()`.

```typescript
type PostableMessage =
  | AdapterPostableMessage
  | AsyncIterable<string | StreamChunk | StreamEvent>
  | PostableObject;
```

`PostableObject` covers `Plan` (mutable task lists) and `StreamingPlan` (streams with platform-specific options) — both documented below.

## String

Raw text passed through as-is to the platform.

```typescript
await thread.post("Hello world");
```

## PostableRaw

Explicit raw text — behaves the same as a plain string.

```typescript
await thread.post({ raw: "Hello world" });
```

<TypeTable
  type={{
  raw: {
    description: 'Text passed through as-is to the platform.',
    type: 'string',
  },
  attachments: {
    description: 'Typed media attachments for adapters that support outgoing attachments.',
    type: 'Attachment[]',
  },
  files: {
    description: 'Files to upload.',
    type: 'FileUpload[]',
  },
}}
/>

## PostableMarkdown

Markdown converted to each platform's native format.

```typescript
await thread.post({ markdown: "**Bold** and _italic_" });
```

<TypeTable
  type={{
  markdown: {
    description: 'Markdown text, converted to platform format via mdast.',
    type: 'string',
  },
  attachments: {
    description: 'Typed media attachments for adapters that support outgoing attachments.',
    type: 'Attachment[]',
  },
  files: {
    description: 'Files to upload.',
    type: 'FileUpload[]',
  },
}}
/>

## PostableAst

mdast AST converted to each platform's native format. See [Markdown](/docs/api/markdown) for builder functions.

```typescript
import { root, paragraph, text, strong } from "chat";

await thread.post({
  ast: root([paragraph([strong([text("Hello")])])]),
});
```

<TypeTable
  type={{
  ast: {
    description: 'mdast AST root node.',
    type: 'Root',
  },
  attachments: {
    description: 'Typed media attachments for adapters that support outgoing attachments.',
    type: 'Attachment[]',
  },
  files: {
    description: 'Files to upload.',
    type: 'FileUpload[]',
  },
}}
/>

## PostableCard

Rich card with interactive elements. See [Cards](/docs/api/cards) for components.

```typescript
import { Card, Text } from "chat";

await thread.post(Card({ title: "Hello", children: [Text("World")] }));
```

You can also pass a card with explicit fallback text:

```typescript
await thread.post({
  card: Card({ title: "Hello", children: [Text("World")] }),
  fallbackText: "Hello — World",
});
```

<TypeTable
  type={{
  card: {
    description: 'Rich card element.',
    type: 'CardElement',
  },
  fallbackText: {
    description: 'Plain text fallback for clients that cannot render cards.',
    type: 'string | undefined',
  },
  files: {
    description: 'Files to upload.',
    type: 'FileUpload[]',
  },
}}
/>

## Plan

A `Plan` is a step-by-step task list that mutates after posting. Each `addTask` / `updateTask` / `complete` call re-renders the same message in place. See [Plan API](/docs/streaming#plan-api) for full usage.

```typescript
import { Plan } from "chat";

const plan = new Plan({ initialMessage: "Researching options..." });
await thread.post(plan);
await plan.addTask({ title: "Look up records" });
await plan.complete({ completeMessage: "Done!" });
```

Adapters that don't support `PostableObject` editing render the plan as fallback text and ignore subsequent mutations.

## StreamingPlan

Wraps an async iterable with platform-specific streaming options. Use this when you need to pass options like task grouping or stop blocks through `thread.post()`. See [Streaming with options](/docs/streaming#streaming-with-options).

```typescript
import { StreamingPlan } from "chat";

const planned = new StreamingPlan(stream, {
  groupTasks: "plan",
  endWith: [feedbackBlock],
  updateIntervalMs: 750,
});

await thread.post(planned);
```

<TypeTable
  type={{
  groupTasks: {
    description: 'Slack: render task_update chunks as `"plan"` (single grouped block) or `"timeline"` (inline cards, default).',
    type: '"plan" | "timeline" | undefined',
  },
  endWith: {
    description: 'Slack: Block Kit elements appended when the stream stops (e.g. retry / feedback buttons).',
    type: 'unknown[] | undefined',
  },
  updateIntervalMs: {
    description: 'Post+edit adapters: minimum interval between update cycles in ms.',
    type: 'number | undefined',
    default: '500',
  },
}}
/>

## AsyncIterable (streaming)

An async iterable of strings, `StreamChunk` objects, or stream events. The SDK streams the message in real time using platform-native APIs where available.

You can yield structured `StreamChunk` objects for rich content like task progress cards on platforms that support it (Slack). See [Streaming](/docs/streaming#structured-streaming-chunks-slack-only) for details.

Both AI SDK stream types are supported:

```typescript
// fullStream (recommended) — preserves step boundaries in multi-step agents
const result = await agent.stream({ prompt: message.text });
await thread.post(result.fullStream);

// textStream — plain string chunks
await thread.post(result.textStream);
```

When using `fullStream`, the SDK auto-detects `text-delta` and `finish-step` events, extracting text and inserting paragraph breaks between agent steps.

## FileUpload

Used in the `files` field of any structured message format.

<TypeTable
  type={{
  data: {
    description: 'Binary file data.',
    type: 'Buffer | Blob | ArrayBuffer',
  },
  filename: {
    description: 'Filename.',
    type: 'string',
  },
  mimeType: {
    description: 'MIME type (inferred from filename if not provided).',
    type: 'string | undefined',
  },
}}
/>


---
title: Thread
description: Represents a conversation thread with methods for posting, subscribing, and state management.
type: reference
---

# Thread



A `Thread` is provided to your event handlers and represents a conversation thread on any platform. You can also create thread handles directly using `chat.thread()` or `chat.openDM()`.

## Properties

<TypeTable
  type={{
  id: {
    description: 'Full thread ID in adapter:channel:thread format.',
    type: 'string',
  },
  channelId: {
    description: 'Channel/conversation ID.',
    type: 'string',
  },
  adapter: {
    description: 'The platform adapter this thread belongs to.',
    type: 'Adapter',
  },
  isDM: {
    description: 'Whether this is a direct message conversation.',
    type: 'boolean',
  },
  channel: {
    description: 'The Channel containing this thread.',
    type: 'Channel',
  },
  recentMessages: {
    description: 'Cached messages from the webhook payload.',
    type: 'Message[]',
  },
}}
/>

## post

Post a message to the thread. Accepts strings, structured messages, cards, streams, and `PostableObject` instances (`Plan`, `StreamingPlan`).

```typescript
// Plain text
await thread.post("Hello!");

// Markdown
await thread.post({ markdown: "**Bold** text" });

// AST
await thread.post({ ast: root([paragraph([text("Hello")])]) });

// Card
await thread.post(Card({ title: "Hi", children: [Text("Hello")] }));

// Stream (fullStream recommended for multi-step agents)
await thread.post(result.fullStream);

// Plan (mutable task list)
const plan = new Plan({ initialMessage: "Working..." });
await thread.post(plan);
await plan.addTask({ title: "Step 1" });

// Streaming with platform options
await thread.post(new StreamingPlan(stream, { groupTasks: "plan" }));
```

**Parameters:** `message: string | PostableMessage | CardJSXElement`

**Returns:** `Promise<SentMessage | PostableObject>` — for plain messages and streams, a `SentMessage` with `edit()`, `delete()`, `addReaction()`, and `removeReaction()` methods; for `Plan` / `StreamingPlan` inputs, the same object is returned so you can keep mutating it.

See [Posting Messages](/docs/posting-messages) for details on each format.

## postEphemeral

Post a message visible only to a specific user.

```typescript
await thread.postEphemeral(userId, "Only you can see this", {
  fallbackToDM: true,
});
```

<TypeTable
  type={{
  user: {
    description: 'User ID string or Author object.',
    type: 'string | Author',
  },
  message: {
    description: 'Message content (streaming not supported).',
    type: 'AdapterPostableMessage | CardJSXElement',
  },
  'options.fallbackToDM': {
    description: 'If true, falls back to DM when native ephemeral is not supported. If false, returns null.',
    type: 'boolean',
  },
}}
/>

**Returns:** `Promise<EphemeralMessage | null>`

## schedule

Schedule a message for future delivery. Currently only supported by the Slack adapter — other adapters throw `NotImplementedError`.

```typescript
const scheduled = await thread.schedule("Reminder: standup in 5 minutes!", {
  postAt: new Date("2026-03-09T09:00:00Z"),
});

// Cancel before it's sent
await scheduled.cancel();
```

**Parameters:** `message: string | PostableMessage | CardJSXElement`, `options: { postAt: Date }`

**Returns:** `Promise<ScheduledMessage>`

<Callout type="warn">
  Streaming and file uploads are not supported in scheduled messages.
</Callout>

## getParticipants

Get the unique human participants in a thread. Returns deduplicated authors, excluding all bots. Useful for subscribing only to 1:1 conversations and unsubscribing when others join.

```typescript
const participants = await thread.getParticipants();

// Subscribe only when one person is talking to the bot
if (participants.length === 1) {
  await thread.subscribe();
}

// Unsubscribe when the thread becomes a group conversation
if (participants.length > 1) {
  await thread.unsubscribe();
}
```

<Callout type="warn">
  Each call fetches the full message history to find all participants. On threads with long history this makes multiple API calls to the platform. Consider checking `message.author` against a known set before calling `getParticipants()` on every incoming message.
</Callout>

## subscribe / unsubscribe

Manage thread subscriptions. Subscribed non-DM threads route all messages to `onSubscribedMessage` handlers. DM threads route to `onDirectMessage` first when a direct message handler is registered.

```typescript
await thread.subscribe();
await thread.unsubscribe();
const subscribed = await thread.isSubscribed();
```

Subscriptions persist across restarts via your state adapter.

## state

Store typed, per-thread state that persists across requests. State has a 30-day TTL.

```typescript
// Read state
const state = await thread.state; // TState | null

// Merge into existing state
await thread.setState({ aiMode: true });

// Replace state entirely
await thread.setState({ aiMode: false }, { replace: true });
```

## startTyping

Show a typing indicator in the thread. No-op on platforms that don't support it. On Slack, you can pass an optional `status` string to show a custom loading message (requires `assistant:write` scope).

```typescript
await thread.startTyping();

// With custom status (Slack only)
await thread.startTyping("Searching documents...");
```

## messages / allMessages

Iterate through message history.

```typescript
// Newest first (auto-paginates)
for await (const msg of thread.messages) {
  console.log(msg.text);
}

// Oldest first (auto-paginates)
for await (const msg of thread.allMessages) {
  console.log(msg.text);
}
```

## refresh

Re-fetch messages from the API and update `recentMessages`.

```typescript
await thread.refresh();
```

## mentionUser

Get a platform-specific @-mention string for a user.

```typescript
await thread.post(`Hey ${thread.mentionUser(userId)}, check this out!`);
```

## Serialization

Threads can be serialized for workflow engines and external systems. The serialized thread includes the current message if one is available.

```typescript
// Serialize
const json = thread.toJSON();

// Pass to a workflow
await workflow.start("my-workflow", {
  thread: thread.toJSON(),
});
```

The serialized format includes the thread ID, channel ID, adapter name, DM status, and the current message (if present).

### Deserialization

Use `bot.reviver()` as a `JSON.parse` reviver to automatically restore `Thread` and `Message` objects from serialized payloads:

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

Under the hood, the reviver calls `ThreadImpl.fromJSON()` and `Message.fromJSON()` for any serialized objects it encounters.

## ScheduledMessage

Returned by `thread.schedule()` and `channel.schedule()`.

<TypeTable
  type={{
  scheduledMessageId: {
    description: 'Platform-specific scheduled message ID.',
    type: 'string',
  },
  channelId: {
    description: 'Channel ID where the message will be posted.',
    type: 'string',
  },
  postAt: {
    description: 'When the message will be sent.',
    type: 'Date',
  },
  raw: {
    description: 'Platform-specific raw response.',
    type: 'unknown',
  },
  'cancel()': {
    description: 'Cancel the scheduled message before it is sent.',
    type: '() => Promise<void>',
  },
}}
/>

## SentMessage

Returned by `thread.post()`. Extends `Message` with mutation methods.

<TypeTable
  type={{
  'edit(newContent)': {
    description: 'Edit this message.',
    type: '(content: string | PostableMessage | CardJSXElement) => Promise<SentMessage>',
  },
  'delete()': {
    description: 'Delete this message.',
    type: '() => Promise<void>',
  },
  'addReaction(emoji)': {
    description: 'Add a reaction to this message.',
    type: '(emoji: EmojiValue | string) => Promise<void>',
  },
  'removeReaction(emoji)': {
    description: 'Remove a reaction from this message.',
    type: '(emoji: EmojiValue | string) => Promise<void>',
  },
}}
/>


---
title: Transcripts
description: Cross-platform per-user transcript persistence — configuration, methods, and entry shape.
type: reference
---

# Transcripts



`bot.transcripts` provides per-user message persistence keyed by a stable cross-platform identifier. See the [Conversation history](/docs/conversation-history) guide for usage patterns.

```typescript
import { Chat } from "chat";
```

## Configuration

`transcripts` and `identity` are configured on `ChatConfig`. Both must be set together — passing `transcripts` without `identity` throws at construction.

### ChatConfig.transcripts

<TypeTable
  type={{
  retention: {
    description: 'List TTL, refreshed on every append. Accepts ms or a duration string ("45s", "30m", "6h", "7d"). Omit for no expiry.',
    type: 'number | DurationString | undefined',
  },
  maxPerUser: {
    description: 'Hard cap per user. Older entries are evicted on append.',
    type: 'number',
    default: '200',
  },
  storeFormatted: {
    description: 'Persist the mdast `formatted` field alongside `text`. Off by default to keep storage small.',
    type: 'boolean',
    default: 'false',
  },
}}
/>

### ChatConfig.identity

```typescript
identity: (context: IdentityContext) => string | null | Promise<string | null>;
```

Called once per inbound message during dispatch. The result is attached to the `Message` instance as `message.userKey`. Return `null` to skip persistence for an event.

#### IdentityContext

<TypeTable
  type={{
  adapter: {
    description: 'Adapter name (e.g. "slack", "discord").',
    type: 'string',
  },
  author: {
    description: 'Message author info.',
    type: 'Author',
  },
  message: {
    description: 'The inbound message.',
    type: 'Message',
  },
}}
/>

## Methods

Access via `bot.transcripts`. Throws if `transcripts` was not configured on the `Chat` instance.

### append

Persist a `Message` (typically the inbound user message) or an `AppendInput` (typically a bot reply you just posted).

```typescript
append(
  thread: Postable,
  message: Message | AppendInput,
  options?: AppendOptions,
): Promise<TranscriptEntry | null>;
```

When `message` is a `Message`, `userKey` is read from the instance. If it's `undefined` (the resolver returned `null`), the call is a no-op and returns `null`. When `message` is an `AppendInput`, `options.userKey` is required.

#### AppendInput

<TypeTable
  type={{
  role: {
    description: 'Role tag for the entry.',
    type: '"user" | "assistant" | "system"',
  },
  text: {
    description: 'Plain-text body.',
    type: 'string',
  },
  formatted: {
    description: 'Optional mdast AST. Only stored when `transcripts.storeFormatted` is true.',
    type: 'FormattedContent | undefined',
  },
  platformMessageId: {
    description: 'Platform-native message ID, when known.',
    type: 'string | undefined',
  },
}}
/>

#### AppendOptions

<TypeTable
  type={{
  userKey: {
    description: 'Required when appending an `AppendInput` (assistant or system role); ignored when appending a `Message`.',
    type: 'string | undefined',
  },
}}
/>

### list

Returns entries in chronological order (oldest first). When `limit` is set, returns the newest `N` entries — still chronologically.

```typescript
list(query: ListQuery): Promise<TranscriptEntry[]>;
```

#### ListQuery

<TypeTable
  type={{
  userKey: {
    description: 'Cross-platform user key.',
    type: 'string',
  },
  limit: {
    description: 'Maximum entries returned. Cannot exceed `maxPerUser` because that is the storage cap.',
    type: 'number',
    default: '50',
  },
  platforms: {
    description: 'Filter to a subset of adapter names.',
    type: 'string[] | undefined',
  },
  threadId: {
    description: 'Filter to a single thread.',
    type: 'string | undefined',
  },
  roles: {
    description: 'Filter to specific roles.',
    type: '("user" | "assistant" | "system")[] | undefined',
  },
}}
/>

### count

```typescript
count(query: CountQuery): Promise<number>;
```

Returns the total number of entries stored under the user key. `CountQuery` has a single field, `userKey: string`.

### delete

```typescript
delete(target: { userKey: string }): Promise<{ deleted: number }>;
```

Wipes every entry stored under the user key. Returns the count that was removed. Single-entry and time-range deletes are not supported — the underlying `appendToList` primitive can't support them safely under concurrent writes.

## TranscriptEntry

Returned by `append` and `list`.

<TypeTable
  type={{
  id: {
    description: 'UUID assigned by the SDK at append time.',
    type: 'string',
  },
  userKey: {
    description: 'Cross-platform user key from the IdentityResolver.',
    type: 'string',
  },
  role: {
    description: 'Role tag.',
    type: '"user" | "assistant" | "system"',
  },
  text: {
    description: 'Plain-text body — canonical for prompt building.',
    type: 'string',
  },
  formatted: {
    description: 'mdast AST. Only present when `transcripts.storeFormatted` is true.',
    type: 'FormattedContent | undefined',
  },
  platform: {
    description: 'Originating adapter name.',
    type: 'string',
  },
  threadId: {
    description: 'Originating thread ID.',
    type: 'string',
  },
  platformMessageId: {
    description: 'Platform-native message ID, when known.',
    type: 'string | undefined',
  },
  timestamp: {
    description: 'ms-since-epoch, set at append time on the SDK side.',
    type: 'number',
  },
}}
/>

## Storage

Backed by `StateAdapter.appendToList` / `getList` / `delete`. Every built-in state adapter (`memory`, `redis`, `ioredis`, `pg`) supports these primitives.

Entries are stored under `transcripts:user:{userKey}` as a capped list. `appendToList` is atomic, so concurrent inbound messages don't race.

The `retention` value is applied as the list TTL and refreshed on every append. With `retention: "30d"`, a user who hasn't talked to the bot in 30 days has their transcript expire automatically.


---
title: Building a community adapter
description: Learn how to build, package, and publish your own Chat SDK adapter for any messaging platform.
type: guide
prerequisites:
  - /docs/getting-started
  - /docs/adapters
related:
  - /docs/contributing/testing
  - /docs/cards
  - /docs/actions
---

# Building a community adapter



## What adapters are

Adapters are the bridge between Chat SDK and a messaging platform. Each adapter handles webhook verification, message parsing, and API calls for one platform so your handler code stays platform-agnostic.

Chat SDK ships with Vercel-maintained adapters for Slack, Teams, Google Chat, Discord, Telegram, GitHub, and Linear. Community developers can build adapters for any other platform using the same `Adapter` interface.

### Adapter tiers

| Tier            | Description                                         | Examples                         |
| --------------- | --------------------------------------------------- | -------------------------------- |
| Official        | Published under `@chat-adapter/*` by Vercel         | Slack, Teams, Discord            |
| Vendor official | Built and maintained by the platform company itself | Resend building a Resend adapter |
| Community       | Built by third-party developers                     | Any open-source adapter          |

<Callout type="warn">
  The `@chat-adapter/` npm scope is reserved for official adapters. Publish your adapter under your own scope or as an unscoped package.
</Callout>

#### Qualifications for vendor official tier

* Commitment for continued maintenance of the adapter.
* GitHub hosting in official vendor-owned org.
* Documentation of the adapter in primary vendor docs.
* Announcement of the adapter in blog post or changelog and social media.

## Project setup

This guide uses a hypothetical **Matrix** adapter as a running example. Replace "matrix" with your platform name throughout.

### package.json

```json title="package.json" lineNumbers
{
  "name": "chat-adapter-matrix",
  "version": "0.1.0",
  "description": "Matrix adapter for Chat SDK",
  "type": "module",
  "main": "./dist/index.js",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsup",
    "dev": "tsup --watch",
    "test": "vitest run --coverage",
    "test:watch": "vitest",
    "typecheck": "tsc --noEmit",
    "clean": "rm -rf dist"
  },
  "peerDependencies": {
    "chat": "^4.0.0"
  },
  "dependencies": {
    "@chat-adapter/shared": "^4.0.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "chat": "^4.0.0",
    "tsup": "^8.3.0",
    "typescript": "^5.7.0",
    "vitest": "^4.0.0"
  },
  "publishConfig": {
    "access": "public"
  },
  "keywords": ["chat-sdk", "chat-adapter", "matrix"],
  "license": "MIT"
}
```

Key points:

* ESM-only (`"type": "module"`)
* `chat` is a **peer dependency** — your adapter runs inside the consumer's Chat instance
* `@chat-adapter/shared` provides error classes and utility functions

### tsup.config.ts

```typescript title="tsup.config.ts" lineNumbers
import { defineConfig } from "tsup";

export default defineConfig({
  entry: ["src/index.ts"],
  format: ["esm"],
  dts: true,
  clean: true,
  sourcemap: true,
});
```

### tsconfig.json

```json title="tsconfig.json" lineNumbers
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "declaration": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "strictNullChecks": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}
```

### vitest.config.ts

```typescript title="vitest.config.ts" lineNumbers
import { defineProject } from "vitest/config";

export default defineProject({
  test: {
    globals: true,
    environment: "node",
    coverage: {
      provider: "v8",
      reporter: ["text", "json-summary"],
      include: ["src/**/*.ts"],
      exclude: ["src/**/*.test.ts"],
    },
  },
});
```

## Define your types

Start by defining the platform-specific types your adapter needs.

```typescript title="src/types.ts" lineNumbers
/** Decoded thread ID components for Matrix */
export interface MatrixThreadId {
  /** Matrix room ID (e.g., "!abc123:matrix.org") */
  roomId: string;
  /** Matrix event ID for the thread root (e.g., "$event123") */
  eventId?: string;
}

/** Configuration for the Matrix adapter */
export interface MatrixAdapterConfig {
  /** Matrix homeserver URL */
  homeserverUrl: string;
  /** Access token for the bot account */
  accessToken: string;
  /** Optional bot display name override */
  userName?: string;
}
```

Every adapter needs:

1. A **thread ID interface** — the decoded components of your `{adapter}:{segment1}:{segment2}` thread ID
2. A **config interface** — credentials and options needed to connect to the platform

## Implement the Adapter interface

Create your adapter class implementing the `Adapter` interface from `chat`. The following sections walk through each group of methods you need to implement.

Start with the class skeleton and constructor:

```typescript title="src/adapter.ts" lineNumbers
import {
  extractCard,
  extractFiles,
  toBuffer,
  ValidationError,
} from "@chat-adapter/shared";
import type {
  Adapter,
  AdapterPostableMessage,
  ChatInstance,
  EmojiValue,
  FetchOptions,
  FetchResult,
  FormattedContent,
  Logger,
  RawMessage,
  ThreadInfo,
  WebhookOptions,
} from "chat";
import { ConsoleLogger, Message } from "chat";
import { MatrixFormatConverter } from "./format-converter";
import type { MatrixAdapterConfig, MatrixThreadId } from "./types";

export class MatrixAdapter implements Adapter<MatrixThreadId, unknown> {
  readonly name = "matrix";
  readonly userName: string;
  readonly botUserId?: string;

  private chat: ChatInstance | null = null;
  private logger: Logger;
  private config: MatrixAdapterConfig;
  private converter = new MatrixFormatConverter();

  constructor(config: MatrixAdapterConfig & { logger?: Logger }) {
    this.config = config;
    this.userName = config.userName ?? "matrix-bot";
    this.logger = config.logger ?? new ConsoleLogger();
  }

  // Methods shown in sections below...
}
```

The `Adapter` interface takes two generics: `TThreadId` (your decoded thread ID shape) and `TRawMessage` (the platform's raw message type).

### Initialization

The SDK calls `initialize` once when the `Chat` instance is created. Use it to store the `ChatInstance` reference, set up your logger, validate credentials, and fetch bot info.

```typescript title="src/adapter.ts"
async initialize(chat: ChatInstance): Promise<void> {
  this.chat = chat;
  this.logger = chat.getLogger("matrix");

  // Validate credentials, fetch bot user info, etc.
  // Example: const me = await this.apiCall("/account/whoami");
  // this.botUserId = me.user_id;
}
```

### Disconnect

The optional `disconnect()` method is called during `chat.shutdown()` to clean up resources. Use it to close persistent connections, tear down subscriptions, or release any platform-specific resources.

```typescript title="src/adapter.ts"
async disconnect(): Promise<void> {
  // Close WebSocket connections, clean up subscriptions, etc.
  // Example: await this.matrixClient.stop();
}
```

Adapters that don't hold persistent connections can skip this method entirely.

### Thread ID encode/decode

Thread IDs typically follow the pattern `{adapter}:{segment1}:{segment2}`, though some adapters use more or fewer segments. The `encodeThreadId` and `decodeThreadId` methods must roundtrip consistently. Use `base64url` encoding for segments that contain special characters.

```typescript title="src/adapter.ts" lineNumbers
encodeThreadId(data: MatrixThreadId): string {
  const roomSegment = Buffer.from(data.roomId).toString("base64url");
  if (data.eventId) {
    const eventSegment = Buffer.from(data.eventId).toString("base64url");
    return `matrix:${roomSegment}:${eventSegment}`;
  }
  return `matrix:${roomSegment}`;
}

decodeThreadId(threadId: string): MatrixThreadId {
  const parts = threadId.split(":");
  if (parts.length < 2 || parts[0] !== "matrix") {
    throw new ValidationError(`Invalid Matrix thread ID: ${threadId}`);
  }
  const roomId = Buffer.from(parts[1], "base64url").toString();
  const eventId = parts[2]
    ? Buffer.from(parts[2], "base64url").toString()
    : undefined;
  return { roomId, eventId };
}
```

### Webhook handling

`handleWebhook` is the entry point for all incoming platform events. Always:

1. Verify the request signature first (return 401 if invalid)
2. Parse the platform payload
3. Call `this.chat.processMessage()` with positional args — it handles `waitUntil` internally
4. Return a fast 200 response immediately

```typescript title="src/adapter.ts" lineNumbers
async handleWebhook(
  request: Request,
  options?: WebhookOptions
): Promise<Response> {
  // 1. Verify request signature
  const signature = request.headers.get("x-matrix-signature");
  if (!signature) {
    return new Response("Missing signature", { status: 401 });
  }

  const body = await request.text();
  const isValid = this.verifySignature(body, signature);
  if (!isValid) {
    return new Response("Invalid signature", { status: 401 });
  }

  // 2. Parse the webhook payload
  const payload = JSON.parse(body);

  // 3. Process the message asynchronously
  if (this.chat && payload.type === "m.room.message") {
    const threadId = this.encodeThreadId({
      roomId: payload.room_id,
      eventId: payload.thread_root_id,
    });

    // Use a factory function for lazy async parsing
    const isMention = this.checkMention(payload);
    const factory = async (): Promise<Message<unknown>> => {
      const msg = this.parseMessage(payload);
      if (isMention) {
        msg.isMention = true;
      }
      return msg;
    };

    // processMessage handles waitUntil registration internally
    this.chat.processMessage(this, threadId, factory, options);
  }

  // 4. Return a fast 200 to acknowledge receipt
  return new Response("OK", { status: 200 });
}
```

### Message parsing

Convert the raw platform message into a normalized `Message` instance. The `author` fields use `userId` and `userName`, and `isBot` accepts `boolean | "unknown"`. Include a `metadata` object with `dateSent` and `edited` instead of a top-level `createdAt`.

```typescript title="src/adapter.ts" lineNumbers
parseMessage(raw: unknown): Message<unknown> {
  const payload = raw as Record<string, unknown>;

  return new Message({
    id: payload.event_id as string,
    threadId: this.encodeThreadId({
      roomId: payload.room_id as string,
      eventId: payload.thread_root_id as string | undefined,
    }),
    text: payload.body as string,
    formatted: this.converter.toAst(payload.body as string),
    raw,
    author: {
      userId: payload.sender as string,
      userName: payload.sender as string,
      fullName: payload.sender_display_name as string ?? "",
      isBot: (payload.sender as string).startsWith("@bot"),
      isMe: false,
    },
    metadata: {
      dateSent: new Date(payload.origin_server_ts as number),
      edited: false,
    },
    attachments: [],
  });
}
```

### Sending messages

Use `extractCard()` and `extractFiles()` from `@chat-adapter/shared` to check for rich content. Use `extractPostableAttachments()` if your adapter maps normalized `Attachment` objects to platform-native media uploads. Use your format converter's `renderPostable()` to convert the message to platform format.

```typescript title="src/adapter.ts" lineNumbers
async postMessage(
  threadId: string,
  message: AdapterPostableMessage
): Promise<RawMessage<unknown>> {
  const { roomId, eventId } = this.decodeThreadId(threadId);

  const card = extractCard(message);
  const files = extractFiles(message);

  // Upload files if present
  for (const file of files) {
    const buffer = await toBuffer(file.data);
    // Upload to Matrix media repo...
  }

  // Render text content
  const text = card
    ? this.converter.renderPostable({ card: message.card })
    : this.converter.renderPostable(message);

  const response = await this.sendMatrixMessage(roomId, text, eventId);
  return { raw: response, id: response.event_id };
}

async editMessage(
  threadId: string,
  messageId: string,
  message: AdapterPostableMessage
): Promise<RawMessage<unknown>> {
  const { roomId } = this.decodeThreadId(threadId);
  const text = this.converter.renderPostable(message);
  const response = await this.editMatrixMessage(roomId, messageId, text);
  return { raw: response, id: response.event_id };
}

async deleteMessage(threadId: string, messageId: string): Promise<void> {
  const { roomId } = this.decodeThreadId(threadId);
  await this.redactMatrixEvent(roomId, messageId);
}
```

### Buttons and callback URLs

When the host app passes a `Card` with `<Button callbackUrl={...}>`, the SDK rewrites each such button **before** your adapter sees the postable: the `callbackUrl` is stored in the state adapter under a short token, and the button's `value` field is replaced with `__cb:<16-hex-chars>` (21 characters total). Your adapter does not need to know this happens — just round-trip `button.value` through your platform's button payload.

What this means in practice:

1. **Send side**: when rendering a `ButtonElement` to your platform's button payload, encode both `button.id` (the action ID) and `button.value` (which may be `undefined`, a user-supplied value, or a callback token). Pick a delimiter that cannot appear in either, and validate the encoded string fits the platform's limit.
2. **Receive side**: when the user clicks a button, decode the platform payload back into `actionId` and `value`, and pass them to `chat.processAction({ actionId, value, ... })`. The SDK will detect the `__cb:` prefix, look up the stored callback URL, POST to it, and pass the original value (if any) to user `onAction` handlers.

Discord's adapter is a good reference — it joins the action ID and value with `\n` and validates against Discord's 100-character `custom_id` limit:

```typescript title="src/cards.ts (discord)" lineNumbers
const DISCORD_CUSTOM_ID_DELIMITER = "\n";
const DISCORD_CUSTOM_ID_MAX_LENGTH = 100;

export function encodeDiscordCustomId(
  actionId: string,
  value?: string
): string {
  if (value == null || value === "") {
    validateLength(actionId);
    return actionId;
  }
  const encoded = `${actionId}${DISCORD_CUSTOM_ID_DELIMITER}${value}`;
  validateLength(encoded);
  return encoded;
}

export function decodeDiscordCustomId(customId: string): {
  actionId: string;
  value: string | undefined;
} {
  const idx = customId.indexOf(DISCORD_CUSTOM_ID_DELIMITER);
  if (idx === -1) {
    return { actionId: customId, value: undefined };
  }
  // Use the FIRST delimiter only — values may legitimately contain "\n".
  return {
    actionId: customId.slice(0, idx),
    value: customId.slice(idx + 1),
  };
}
```

<Callout type="info">
  Platform button-data limits are the main constraint to plan for. Discord's
  `custom_id` is 100 chars; Telegram's `callback_data` is 64 bytes. The SDK's
  callback token is fixed at 21 chars (`__cb:` + 16 hex), so the worst-case
  payload your encoding must fit is `actionId + delimiter + 21`. If the
  encoded string exceeds the platform limit, throw a `ValidationError` from
  `@chat-adapter/shared` so the host app fails fast at post time rather than
  silently truncating.
</Callout>

Modals with `callbackUrl` are handled entirely inside the SDK via stored modal context — your adapter does not need any special handling. Just call `chat.processModalSubmit(event, contextId, { waitUntil })` from your webhook handler and the SDK will POST to the modal's `callbackUrl` (using `waitUntil` if you provide it, so the response is not blocked).

### Reactions

Handle both `EmojiValue` objects and plain strings. `EmojiValue` has a `name` property and `toString()` method — there is no `unicode` field.

```typescript title="src/adapter.ts" lineNumbers
async addReaction(
  threadId: string,
  messageId: string,
  emoji: EmojiValue | string
): Promise<void> {
  const { roomId } = this.decodeThreadId(threadId);
  const emojiStr = typeof emoji === "string" ? emoji : emoji.name;
  await this.sendReaction(roomId, messageId, emojiStr);
}

async removeReaction(
  threadId: string,
  messageId: string,
  emoji: EmojiValue | string
): Promise<void> {
  const { roomId } = this.decodeThreadId(threadId);
  const emojiStr = typeof emoji === "string" ? emoji : emoji.name;
  await this.removeMatrixReaction(roomId, messageId, emojiStr);
}
```

### Fetching and typing

`fetchMessages` should return messages in chronological order (oldest first). The `nextCursor` enables pagination.

```typescript title="src/adapter.ts" lineNumbers
async fetchMessages(
  threadId: string,
  options?: FetchOptions
): Promise<FetchResult<unknown>> {
  const { roomId } = this.decodeThreadId(threadId);
  // Fetch from platform API with pagination
  return { messages: [], nextCursor: undefined };
}

async fetchThread(threadId: string): Promise<ThreadInfo> {
  const { roomId } = this.decodeThreadId(threadId);
  return {
    id: threadId,
    title: undefined,
    createdAt: new Date(),
  };
}

async startTyping(threadId: string): Promise<void> {
  const { roomId } = this.decodeThreadId(threadId);
  // Send typing notification via platform API
}
```

### Formatting

Delegate to your format converter (covered in the next section).

```typescript title="src/adapter.ts"
renderFormatted(content: FormattedContent): string {
  return this.converter.fromAst(content.ast);
}
```

## Build a format converter

Each adapter needs a format converter that translates between the platform's text format and mdast (Markdown AST), the canonical format used by Chat SDK.

```typescript title="src/format-converter.ts" lineNumbers
import {
  BaseFormatConverter,
  type Root,
  parseMarkdown,
  stringifyMarkdown,
  text,
  strong,
  emphasis,
  inlineCode,
  codeBlock,
  link,
  paragraph,
  root,
} from "chat";
import type { AdapterPostableMessage } from "chat";

export class MatrixFormatConverter extends BaseFormatConverter {
  /**
   * Convert platform text to mdast AST.
   * If your platform uses standard markdown, just use parseMarkdown().
   */
  toAst(platformText: string): Root {
    // Matrix supports standard markdown, so we can parse directly
    return parseMarkdown(platformText);
  }

  /**
   * Convert mdast AST to platform text format.
   * Walk the AST and produce platform-specific markup.
   */
  fromAst(ast: Root): string {
    // Matrix supports standard markdown, so we can stringify directly
    return stringifyMarkdown(ast);
  }

  /**
   * Override renderPostable only if your platform needs custom rendering
   * (e.g., converting @mentions to platform-specific syntax).
   * The base class already handles text/formatted/card fallback logic.
   */
  renderPostable(message: AdapterPostableMessage): string {
    // Example: convert @mention syntax to Matrix pill format
    const rendered = super.renderPostable(message);
    return rendered.replace(
      /@(\w+)/g,
      (_, name) => `<a href="https://matrix.to/#/@${name}:matrix.org">@${name}</a>`
    );
  }
}
```

For platforms with non-standard formatting, implement custom parsing in `toAst()` and rendering in `fromAst()`. See the [Discord adapter](https://github.com/vercel/chat/blob/main/packages/adapter-discord/src/markdown.ts) for an example of handling platform-specific mention syntax.

## Optional methods

These methods are not required but extend your adapter's capabilities:

| Method                                        | Purpose                                                                             |
| --------------------------------------------- | ----------------------------------------------------------------------------------- |
| `disconnect()`                                | Clean up connections and resources during shutdown                                  |
| `openDM(userId)`                              | Open a direct message conversation                                                  |
| `isDM(threadId)`                              | Check if a thread is a DM                                                           |
| `stream(threadId, textStream)`                | Stream AI responses in real-time                                                    |
| `openModal(triggerId, modal)`                 | Open a modal/dialog form                                                            |
| `postEphemeral(threadId, userId, message)`    | Post a message visible to one user                                                  |
| `postChannelMessage(channelId, message)`      | Post a top-level message (not in a thread)                                          |
| `onThreadSubscribe(threadId)`                 | Hook for platform-specific subscription setup                                       |
| `fetchChannelInfo(channelId)`                 | Fetch channel metadata                                                              |
| `listThreads(channelId)`                      | List threads in a channel                                                           |
| `fetchMessage(threadId, messageId)`           | Fetch a single message by ID                                                        |
| `fetchChannelMessages(channelId)`             | Fetch top-level channel messages                                                    |
| `channelIdFromThreadId(threadId)`             | Extract channel ID from a thread ID                                                 |
| `scheduleMessage(threadId, message, options)` | Schedule a message for future delivery; return a `ScheduledMessage` with `cancel()` |

Implement only the methods your platform supports. The SDK gracefully handles missing optional methods.

## Factory function

Export a factory function that creates your adapter with environment variable fallbacks:

```typescript title="src/factory.ts" lineNumbers
import { ConsoleLogger } from "chat";
import type { Logger } from "chat";
import { ValidationError } from "@chat-adapter/shared";
import { MatrixAdapter } from "./adapter";
import type { MatrixAdapterConfig } from "./types";

export function createMatrixAdapter(
  config?: Partial<MatrixAdapterConfig> & { logger?: Logger }
): MatrixAdapter {
  const homeserverUrl =
    config?.homeserverUrl ?? process.env.MATRIX_HOMESERVER_URL;
  const accessToken =
    config?.accessToken ?? process.env.MATRIX_ACCESS_TOKEN;

  if (!homeserverUrl) {
    throw new ValidationError(
      "Matrix homeserver URL is required. Pass it in config or set MATRIX_HOMESERVER_URL."
    );
  }
  if (!accessToken) {
    throw new ValidationError(
      "Matrix access token is required. Pass it in config or set MATRIX_ACCESS_TOKEN."
    );
  }

  return new MatrixAdapter({
    homeserverUrl,
    accessToken,
    userName: config?.userName,
    logger: config?.logger,
  });
}
```

Then export both the class and factory from your entry point:

```typescript title="src/index.ts" lineNumbers
export { MatrixAdapter } from "./adapter";
export { MatrixFormatConverter } from "./format-converter";
export { createMatrixAdapter } from "./factory";
export type { MatrixAdapterConfig, MatrixThreadId } from "./types";
```

## Shared utilities

The `@chat-adapter/shared` package provides utilities you should use instead of reimplementing:

### Error classes

```typescript
import {
  AdapterError,          // Base error class
  AdapterRateLimitError, // Platform rate limit hit
  AuthenticationError,   // Invalid credentials
  ResourceNotFoundError, // Thread/message not found
  PermissionError,       // Insufficient permissions
  ValidationError,       // Invalid input
  NetworkError,          // HTTP/connection failure
} from "@chat-adapter/shared";
```

Throw these errors from your adapter methods. The SDK catches and logs them with appropriate context.

### Message utilities

```typescript
import {
  extractCard,   // Extract CardElement from AdapterPostableMessage
  extractFiles,  // Extract FileUpload[] from AdapterPostableMessage
  toBuffer,      // Convert FileDataInput to Buffer (async)
  toBufferSync,  // Convert FileDataInput to Buffer (sync)
  cardToFallbackText, // Convert card to plain text
} from "@chat-adapter/shared";
```

### Token encryption

If your adapter persists OAuth tokens to a `StateAdapter`, encrypt them at rest with the shared AES-256-GCM helpers instead of rolling your own:

```typescript
import {
  encryptToken,         // Encrypt a string into an EncryptedTokenData envelope
  decryptToken,         // Decrypt an envelope back to the original string
  decodeKey,            // Decode a hex-64 or base64-44 32-byte key (throws on wrong length)
  isEncryptedTokenData, // Type guard for tolerating legacy plaintext records
  type EncryptedTokenData,
} from "@chat-adapter/shared";
```

Accept the key as an optional `encryptionKey` config field (auto-detected from a `*_ENCRYPTION_KEY` env var), encrypt on `setInstallation()`, decrypt on `getInstallation()`, and use `isEncryptedTokenData` to keep accepting plaintext records so operators can roll the key in without flushing existing installs. See `@chat-adapter/slack` and `@chat-adapter/linear` for reference implementations.


---
title: Documenting your adapter
description: Write a README, configuration reference, and usage examples for your community adapter.
type: guide
prerequisites:
  - /docs/contributing/building
  - /docs/contributing/testing
related:
  - /docs/contributing/publishing
  - /docs/adapters
---

# Documenting your adapter



## Why documentation matters

Your adapter's README is the first thing developers see on npm and GitHub. Clear documentation reduces support questions, builds trust, and encourages adoption.

All Vercel-maintained adapters follow a consistent structure. Community adapters should aim for the same bar.

## README structure

Your `README.md` should include these sections in order:

### Title and badges

Start with the package name as an H1, followed by npm badges and a one-line description.

```markdown title="README.md"
# chat-adapter-matrix

[![npm version](https://img.shields.io/npm/v/chat-adapter-matrix)](https://www.npmjs.com/package/chat-adapter-matrix)
[![npm downloads](https://img.shields.io/npm/dm/chat-adapter-matrix)](https://www.npmjs.com/package/chat-adapter-matrix)

Matrix adapter for [Chat SDK](https://chat-sdk.dev/docs).
```

### Installation

Show the install command with `chat` as a co-dependency.

````markdown title="README.md"
## Installation

```bash
npm install chat chat-adapter-matrix
```
````

### Quick start

A minimal working example that developers can copy-paste. Include the factory function with explicit config so readers understand what credentials are needed.

````markdown title="README.md"
## Usage

```typescript
import { Chat } from "chat";
import { createMatrixAdapter } from "chat-adapter-matrix";

const bot = new Chat({
  userName: "mybot",
  adapters: {
    matrix: createMatrixAdapter({
      homeserverUrl: process.env.MATRIX_HOMESERVER_URL!,
      accessToken: process.env.MATRIX_ACCESS_TOKEN!,
    }),
  },
});

bot.onNewMention(async (thread, message) => {
  await thread.post("Hello from Matrix!");
});
```
````

### Environment variables

List every environment variable your adapter reads, with a description and example value.

```markdown title="README.md"
## Environment variables

| Variable | Required | Description |
|----------|----------|-------------|
| `MATRIX_HOMESERVER_URL` | Yes | Matrix homeserver URL (e.g., `https://matrix.example.com`) |
| `MATRIX_ACCESS_TOKEN` | Yes | Bot account access token |
| `MATRIX_BOT_USERNAME` | No | Override the bot display name |
```

### Configuration reference

Document every field in your config interface, including defaults.

```markdown title="README.md"
## Configuration

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `homeserverUrl` | `string` | `MATRIX_HOMESERVER_URL` | Matrix homeserver URL |
| `accessToken` | `string` | `MATRIX_ACCESS_TOKEN` | Bot account access token |
| `userName` | `string` | `"matrix-bot"` | Bot display name |
| `logger` | `Logger` | `ConsoleLogger` | Custom logger instance |
```

### Platform setup

Walk through creating the bot account on the platform. Use numbered steps, link to the platform's developer portal, and call out where to find each credential.

```markdown title="README.md"
## Platform setup

1. Create a bot account on your Matrix homeserver
2. Generate an access token for the bot
3. Set the webhook URL to `https://your-domain.com/api/webhooks/matrix`
```

### Features

List what your adapter supports. Use a feature table if it helps. Call out any limitations.

```markdown title="README.md"
## Features

- Mentions and DMs
- Rich text (bold, italic, code, links)
- Reactions (add and remove)
- File uploads
- Typing indicators
- Thread support
```

### License

```markdown title="README.md"
## License

MIT
```

## Code-level documentation

### Exported types

Export your config and thread ID interfaces so consumers can use them in their own type annotations. TypeScript declarations generated by `tsup` serve as the primary API reference — keep your interface fields descriptive enough that hover-over docs in an editor are useful.

```typescript title="src/types.ts" lineNumbers
/** Configuration for the Matrix adapter */
export interface MatrixAdapterConfig {
  /** Matrix homeserver URL (e.g., "https://matrix.example.com") */
  homeserverUrl: string;
  /** Access token for the bot account */
  accessToken: string;
  /** Override the bot display name (default: "matrix-bot") */
  userName?: string;
}
```

<Callout type="info">
  TSDoc comments on exported interfaces and functions appear in IDE tooltips and generated `.d.ts` files. Keep them concise and factual.
</Callout>

### What not to document

* Internal/private methods — they're implementation details
* Re-exported types from `chat` or `@chat-adapter/shared` — link to the upstream docs instead
* Obvious behavior — `postMessage` posts a message, no need to elaborate

## Sample messages file

Include a `sample-messages.md` file in your package root with real webhook payloads from the platform. This is invaluable for other contributors debugging edge cases.

````markdown title="sample-messages.md"
# Matrix sample messages

## Text message

```json
{
  "type": "m.room.message",
  "room_id": "!abc123:matrix.org",
  "event_id": "$evt456",
  "sender": "@alice:matrix.org",
  "content": {
    "msgtype": "m.text",
    "body": "Hello world"
  },
  "origin_server_ts": 1700000000000
}
```

## Bot mention

```json
{
  "type": "m.room.message",
  "room_id": "!abc123:matrix.org",
  "event_id": "$evt789",
  "sender": "@alice:matrix.org",
  "content": {
    "msgtype": "m.text",
    "body": "@bot help me",
    "format": "org.matrix.custom.html",
    "formatted_body": "<a href=\"https://matrix.to/#/@bot:matrix.org\">bot</a> help me"
  },
  "origin_server_ts": 1700000001000
}
```
````

Existing Vercel-maintained adapters include `sample-messages.md` files in their package roots — check those for format reference.

## Checklist

Before publishing, verify your documentation covers:

* [ ] README with badges, install, quick start, env vars, config reference, platform setup
* [ ] TSDoc comments on all exported interfaces and factory functions
* [ ] `sample-messages.md` with real platform webhook payloads
* [ ] Links to Chat SDK docs (`chat-sdk.dev`) where relevant


---
title: Publishing your adapter
description: Package, version, and publish your community Chat SDK adapter to npm.
type: guide
prerequisites:
  - /docs/contributing/building
  - /docs/contributing/testing
  - /docs/contributing/documenting
related:
  - /docs/adapters
---

# Publishing your adapter



## Package checklist

Before publishing, verify your `package.json` meets these requirements:

```json title="package.json" lineNumbers
{
  "name": "chat-adapter-matrix",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.js",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  },
  "files": ["dist"],
  "peerDependencies": {
    "chat": "^4.0.0"
  },
  "publishConfig": {
    "access": "public"
  },
  "keywords": ["chat-sdk", "chat-adapter", "matrix"],
  "license": "MIT"
}
```

| Field                | Why it matters                                                |
| -------------------- | ------------------------------------------------------------- |
| `"type": "module"`   | ESM-only — matches the Chat SDK ecosystem                     |
| `"files": ["dist"]`  | Only publish compiled output, keeping the package lean        |
| `"exports"`          | Explicit entry points for bundlers and Node.js                |
| `"peerDependencies"` | Consumers provide their own `chat` instance                   |
| `"publishConfig"`    | Required for scoped packages (`@your-scope/chat-adapter-*`)   |
| `"keywords"`         | Include `chat-sdk` and `chat-adapter` for npm discoverability |

## Naming conventions

| Convention | Example                         | When to use                                                |
| ---------- | ------------------------------- | ---------------------------------------------------------- |
| Unscoped   | `chat-adapter-matrix`           | Most community adapters                                    |
| Scoped     | `@your-org/chat-adapter-matrix` | Optional — if you prefer publishing under your org's scope |

<Callout type="warn">
  The `@chat-adapter/` npm scope is reserved for Vercel-maintained adapters. Do not publish under this scope.
</Callout>

## Build and verify

Run a full build and verify everything compiles before publishing.

```sh title="Terminal"
# Build
npm run build

# Type-check
npm run typecheck

# Run tests
npm test
```

Inspect the package contents to make sure only `dist/` is included:

```sh title="Terminal"
npm pack --dry-run
```

You should see output like:

```
dist/index.js
dist/index.d.ts
dist/index.js.map
package.json
README.md
LICENSE
```

If you see `src/`, `node_modules/`, or test files in the output, update your `"files"` field or add a `.npmignore`.

## Versioning

Follow [semver](https://semver.org):

| Change                                              | Bump    | Example           |
| --------------------------------------------------- | ------- | ----------------- |
| Bug fix, internal refactor                          | `patch` | `1.0.0` → `1.0.1` |
| New feature, new export, new config option          | `minor` | `1.0.0` → `1.1.0` |
| Breaking change (removed export, changed signature) | `major` | `1.0.0` → `2.0.0` |

When the Chat SDK releases a new major version, you'll need a major bump too if your adapter's peer dependency range changes.

## Publish to npm

```sh title="Terminal"
npm publish
```

For scoped packages published for the first time:

```sh title="Terminal"
npm publish --access public
```

## Peer dependency compatibility

Your adapter should declare `chat` as a peer dependency with a caret range:

```json
{
  "peerDependencies": {
    "chat": "^4.0.0"
  }
}
```

This means your adapter works with any `4.x` release. When the Chat SDK ships a new major version:

1. Test your adapter against the new version
2. Update the peer dependency range
3. Publish a new major version of your adapter

## Post-publish verification

After publishing, verify the package works for consumers:

```sh title="Terminal"
# Create a temp directory
mkdir /tmp/test-adapter && cd /tmp/test-adapter
npm init -y

# Install your adapter
npm install chat chat-adapter-matrix

# Verify the import works
node -e "import('chat-adapter-matrix').then(m => console.log(Object.keys(m)))"
```

You should see your exported symbols (`createMatrixAdapter`, `MatrixAdapter`, etc.).

## Keeping your adapter up to date

* Watch the [Chat SDK changelog](https://github.com/vercel/chat/releases) for new features and breaking changes
* Run your test suite against new Chat SDK releases before they ship to catch compatibility issues early
* When the `Adapter` interface adds new optional methods, consider implementing them to keep your adapter feature-complete

## Listing on chat-sdk.dev

Community adapters can be listed on the [Adapters](https://chat-sdk.dev/adapters) page by opening a PR that adds an entry to `apps/docs/adapters.json` in the [Chat SDK repo](https://github.com/vercel/chat). Your adapter's README is fetched from GitHub at build time and rendered on its dedicated page.

### Pin your README to a commit or tag

<Callout type="warn">
  The `readme` field **must** reference a specific commit SHA or tag — not a branch name like `main`.
</Callout>

The docs site re-renders on every deploy, so an unpinned `readme` would serve whatever currently sits at your default branch — including edits made after the listing PR was reviewed. Pinning freezes the rendered content at the state we approved; new content goes live through a follow-up PR that bumps the ref.

```json title="apps/docs/adapters.json"
{
  "name": "My Adapter",
  "slug": "my-adapter",
  "type": "platform",
  "community": true,
  "packageName": "chat-adapter-my-thing",
  "readme": "https://github.com/your-org/chat-adapter-my-thing/tree/v1.2.0"
}
```

Accepted `readme` formats:

| Format                | Example                                                     |
| --------------------- | ----------------------------------------------------------- |
| Repo root at a tag    | `https://github.com/owner/repo/tree/v1.0.0`                 |
| Repo root at a commit | `https://github.com/owner/repo/tree/abc1234...`             |
| Subpath in a monorepo | `https://github.com/owner/repo/tree/<ref>/packages/adapter` |

Unpinned refs (e.g., `tree/main`, or omitting `/tree/<ref>` entirely) will emit a build warning and are rejected during PR review.


---
title: Testing adapters
description: Write unit tests, integration tests, and replay tests for community Chat SDK adapters.
type: guide
prerequisites:
  - /docs/contributing/building
related:
  - /docs/adapters
---

# Testing adapters



## Testing philosophy

Chat SDK adapters are the trust boundary between your application and a platform. High test coverage is expected: unit tests for every public method, and integration tests that wire up the full `Chat` → `Adapter` → handler pipeline.

All adapters in this repo use [vitest](https://vitest.dev) with `@vitest/coverage-v8`. Community adapters should follow the same convention.

<Callout type="info">
  This page covers the hand-rolled patterns used inside this repo's `packages/`. If you're testing a bot or a custom adapter as a **consumer** of Chat SDK, use [`@chat-adapter/tests`](/docs/testing) — it ships factories and Vitest matchers that cover most of these patterns in a few lines.
</Callout>

## Unit tests

### Factory function

Verify that the factory validates config, reads environment variables, and sets defaults.

```typescript title="src/factory.test.ts" lineNumbers
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createMatrixAdapter } from "./factory";

describe("createMatrixAdapter", () => {
  const originalEnv = process.env;

  beforeEach(() => {
    process.env = { ...originalEnv };
  });

  afterEach(() => {
    process.env = originalEnv;
  });

  it("creates adapter from explicit config", () => {
    const adapter = createMatrixAdapter({
      homeserverUrl: "https://matrix.example.com",
      accessToken: "syt_test_token",
    });

    expect(adapter.name).toBe("matrix");
  });

  it("reads environment variables as fallback", () => {
    process.env.MATRIX_HOMESERVER_URL = "https://matrix.example.com";
    process.env.MATRIX_ACCESS_TOKEN = "syt_test_token";

    const adapter = createMatrixAdapter();
    expect(adapter.name).toBe("matrix");
  });

  it("throws when homeserver URL is missing", () => {
    expect(() => createMatrixAdapter({ accessToken: "tok" } as never))
      .toThrow("homeserver URL");
  });

  it("throws when access token is missing", () => {
    expect(() =>
      createMatrixAdapter({
        homeserverUrl: "https://matrix.example.com",
      } as never)
    ).toThrow("access token");
  });
});
```

### Thread ID encode/decode

Verify that `encodeThreadId` and `decodeThreadId` roundtrip consistently and reject invalid formats.

```typescript title="src/thread-id.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import { MatrixAdapter } from "./adapter";

const adapter = new MatrixAdapter({
  homeserverUrl: "https://matrix.example.com",
  accessToken: "syt_test_token",
});

describe("thread ID encoding", () => {
  it("roundtrips room-only thread ID", () => {
    const data = { roomId: "!abc123:matrix.org" };
    const encoded = adapter.encodeThreadId(data);
    const decoded = adapter.decodeThreadId(encoded);

    expect(decoded.roomId).toBe(data.roomId);
    expect(encoded).toMatch(/^matrix:/);
  });

  it("roundtrips thread ID with event", () => {
    const data = {
      roomId: "!abc123:matrix.org",
      eventId: "$event456",
    };
    const encoded = adapter.encodeThreadId(data);
    const decoded = adapter.decodeThreadId(encoded);

    expect(decoded.roomId).toBe(data.roomId);
    expect(decoded.eventId).toBe(data.eventId);
  });

  it("throws on invalid format", () => {
    expect(() => adapter.decodeThreadId("invalid")).toThrow();
    expect(() => adapter.decodeThreadId("slack:C123:ts")).toThrow();
  });
});
```

### Webhook signature verification

Test the three key scenarios: missing headers, invalid signature, and valid signature.

```typescript title="src/webhook.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import { MatrixAdapter } from "./adapter";

const adapter = new MatrixAdapter({
  homeserverUrl: "https://matrix.example.com",
  accessToken: "syt_test_token",
});

describe("handleWebhook", () => {
  it("returns 401 when signature header is missing", async () => {
    const request = new Request("https://example.com/webhook", {
      method: "POST",
      body: "{}",
    });

    const response = await adapter.handleWebhook(request);
    expect(response.status).toBe(401);
  });

  it("returns 401 when signature is invalid", async () => {
    const request = new Request("https://example.com/webhook", {
      method: "POST",
      headers: { "x-matrix-signature": "invalid" },
      body: "{}",
    });

    const response = await adapter.handleWebhook(request);
    expect(response.status).toBe(401);
  });

  it("returns 200 for valid signed request", async () => {
    const body = JSON.stringify({ type: "m.room.message" });
    const request = createSignedRequest(body); // Your signing helper

    const response = await adapter.handleWebhook(request);
    expect(response.status).toBe(200);
  });
});
```

### Message parsing

Cover the main message types: plain text, bot messages, DMs, edited messages, attachments, and formatted text.

```typescript title="src/parse-message.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import { MatrixAdapter } from "./adapter";

const adapter = new MatrixAdapter({
  homeserverUrl: "https://matrix.example.com",
  accessToken: "syt_test_token",
});

describe("parseMessage", () => {
  it("parses a plain text message", () => {
    const raw = {
      event_id: "$evt1",
      room_id: "!room1:matrix.org",
      body: "Hello world",
      sender: "@alice:matrix.org",
      sender_display_name: "Alice",
      origin_server_ts: 1700000000000,
    };

    const message = adapter.parseMessage(raw);
    expect(message.text).toBe("Hello world");
    expect(message.author.userId).toBe("@alice:matrix.org");
    expect(message.author.isBot).toBe(false);
  });

  it("detects bot messages", () => {
    const raw = {
      event_id: "$evt2",
      room_id: "!room1:matrix.org",
      body: "Automated response",
      sender: "@bot:matrix.org",
      origin_server_ts: 1700000000000,
    };

    const message = adapter.parseMessage(raw);
    expect(message.author.isBot).toBe(true);
  });
});
```

### Format converter

Test `toAst()` and `fromAst()` for each node type your platform supports, plus `renderPostable()` for all message variants.

```typescript title="src/format-converter.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import { MatrixFormatConverter } from "./format-converter";

const converter = new MatrixFormatConverter();

describe("MatrixFormatConverter", () => {
  describe("toAst", () => {
    it("parses plain text", () => {
      const ast = converter.toAst("Hello world");
      expect(ast.type).toBe("root");
    });

    it("parses bold text", () => {
      const ast = converter.toAst("**bold**");
      // Verify the AST contains a strong node
      const paragraph = ast.children[0];
      expect(paragraph.children[0].type).toBe("strong");
    });
  });

  describe("fromAst", () => {
    it("renders bold text", () => {
      const ast = converter.toAst("**bold**");
      const result = converter.fromAst(ast);
      expect(result).toContain("**bold**");
    });
  });

  describe("renderPostable", () => {
    it("renders plain text", () => {
      const result = converter.renderPostable({ text: "Hello" });
      expect(result).toBe("Hello");
    });

    it("renders card fallback", () => {
      const result = converter.renderPostable({
        card: {
          type: "card",
          title: "Test Card",
          children: [],
        },
      });
      expect(result).toContain("Test Card");
    });
  });
});
```

## Integration testing

Integration tests wire your adapter into a full `Chat` instance and verify end-to-end message handling.

### Test context factory

Create a factory that sets up the full test environment:

```typescript title="src/test-utils.ts" lineNumbers
import { Chat, type Message, type Thread, type ActionEvent, type ReactionEvent } from "chat";
import { createMemoryState } from "@chat-adapter/state-memory";
import { createMatrixAdapter } from "./factory";

interface CapturedMessages {
  mentionMessage: Message | null;
  mentionThread: Thread | null;
  followUpMessage: Message | null;
  followUpThread: Thread | null;
}

interface WaitUntilTracker {
  waitUntil: (task: Promise<unknown>) => void;
  waitForAll: () => Promise<void>;
}

function createWaitUntilTracker(): WaitUntilTracker {
  const tasks: Promise<unknown>[] = [];

  return {
    waitUntil: (task) => {
      tasks.push(task);
    },
    waitForAll: async () => {
      await Promise.all(tasks);
      tasks.length = 0;
    },
  };
}

export function createMatrixTestContext(handlers: {
  onMention?: (thread: Thread, message: Message) => void | Promise<void>;
  onSubscribed?: (thread: Thread, message: Message) => void | Promise<void>;
  onAction?: (event: ActionEvent) => void | Promise<void>;
  onReaction?: (event: ReactionEvent) => void | Promise<void>;
}) {
  const adapter = createMatrixAdapter({
    homeserverUrl: "https://matrix.example.com",
    accessToken: "syt_test_token",
  });

  const state = createMemoryState();
  const chat = new Chat({
    userName: "matrix-bot",
    adapters: { matrix: adapter },
    state,
    logger: "error",
  });

  const captured: CapturedMessages = {
    mentionMessage: null,
    mentionThread: null,
    followUpMessage: null,
    followUpThread: null,
  };

  if (handlers.onMention) {
    const handler = handlers.onMention;
    chat.onNewMention(async (thread, message) => {
      captured.mentionMessage = message;
      captured.mentionThread = thread;
      await handler(thread, message);
    });
  }

  if (handlers.onSubscribed) {
    const handler = handlers.onSubscribed;
    chat.onSubscribedMessage(async (thread, message) => {
      captured.followUpMessage = message;
      captured.followUpThread = thread;
      await handler(thread, message);
    });
  }

  if (handlers.onAction) {
    chat.onAction(handlers.onAction);
  }

  if (handlers.onReaction) {
    chat.onReaction(handlers.onReaction);
  }

  const tracker = createWaitUntilTracker();

  return {
    chat,
    adapter,
    state,
    tracker,
    captured,
    sendWebhook: async (fixture: unknown) => {
      const request = createSignedMatrixRequest(fixture); // Your signing helper
      await chat.webhooks.matrix(request, {
        waitUntil: tracker.waitUntil,
      });
      await tracker.waitForAll();
    },
  };
}

function createSignedMatrixRequest(payload: unknown): Request {
  const body = JSON.stringify(payload);
  // Add platform-specific signature headers
  return new Request("https://example.com/webhook/matrix", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-matrix-signature": computeSignature(body),
    },
    body,
  });
}

function computeSignature(body: string): string {
  // Platform-specific signature computation
  return "valid-signature";
}
```

### Writing integration tests

Use the test context to verify the full message flow:

```typescript title="src/integration.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import { createMatrixTestContext } from "./test-utils";

describe("Matrix adapter integration", () => {
  it("handles mention → subscribe → follow-up flow", async () => {
    const ctx = createMatrixTestContext({
      onMention: async (thread) => {
        await thread.subscribe();
        await thread.post("Got it!");
      },
      onSubscribed: async (thread, message) => {
        await thread.post(`Echo: ${message.text}`);
      },
    });

    // Send a mention
    await ctx.sendWebhook({
      type: "m.room.message",
      room_id: "!room1:matrix.org",
      event_id: "$evt1",
      body: "@bot hello",
      sender: "@alice:matrix.org",
      origin_server_ts: Date.now(),
    });

    expect(ctx.captured.mentionMessage).not.toBeNull();
    expect(ctx.captured.mentionMessage?.text).toBe("@bot hello");

    // Send a follow-up in the same thread
    await ctx.sendWebhook({
      type: "m.room.message",
      room_id: "!room1:matrix.org",
      thread_root_id: "$evt1",
      event_id: "$evt2",
      body: "follow up",
      sender: "@alice:matrix.org",
      origin_server_ts: Date.now(),
    });

    expect(ctx.captured.followUpMessage).not.toBeNull();
    expect(ctx.captured.followUpMessage?.text).toBe("follow up");
  });
});
```

## What to test end-to-end

Cover these flows in your integration tests:

| Flow                       | What to verify                                                                |
| -------------------------- | ----------------------------------------------------------------------------- |
| **Mention**                | Bot detects @mention, handler fires, `mentionMessage` is captured             |
| **Subscribe + follow-up**  | After `thread.subscribe()`, subsequent messages trigger `onSubscribedMessage` |
| **Actions**                | Button clicks fire `onAction` with correct action ID and user info            |
| **Reactions**              | Emoji reactions fire `onReaction` with correct emoji and message ID           |
| **Self-message filtering** | Messages from the bot itself are ignored                                      |
| **DM flow**                | Direct messages are detected and routed correctly                             |

## Recording and replay tests (advanced)

For production debugging, Chat SDK supports recording webhook interactions and replaying them as test fixtures.

1. **Enable recording** — Set `RECORDING_ENABLED=true` in your deployed environment. Recordings are tagged with the current git SHA.

2. **Interact with the bot** — Send messages, click buttons, add reactions — each interaction is recorded.

3. **Export recordings**

```sh title="Terminal"
pnpm recording:list
pnpm recording:export session-<id>
```

4. **Create test fixtures** — Extract webhook payloads from the exported recording and save them as JSON fixtures:

```json title="fixtures/replay/matrix-mention.json"
{
  "botName": "matrix-bot",
  "botUserId": "@bot:matrix.org",
  "mention": { "type": "m.room.message", "body": "@bot help", "..." : "..." },
  "followUp": { "type": "m.room.message", "body": "thanks", "..." : "..." }
}
```

5. **Write replay tests** — Use the fixtures in your test context:

```typescript title="src/replay.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import fixture from "../fixtures/replay/matrix-mention.json";
import { createMatrixTestContext } from "./test-utils";

describe("replay: mention flow", () => {
  it("handles recorded mention interaction", async () => {
    const ctx = createMatrixTestContext({
      onMention: async (thread) => {
        await thread.subscribe();
      },
    });

    await ctx.sendWebhook(fixture.mention);
    expect(ctx.captured.mentionMessage).not.toBeNull();

    await ctx.sendWebhook(fixture.followUp);
    expect(ctx.captured.followUpMessage).not.toBeNull();
  });
});
```
