---
title: Threads, Messages, and Channels
description: Work with threads, messages, and channels across platforms.
docs_index: /llms.txt
lastUpdated: 2026-10-17
type: guide
prerequisites:
  - /docs/usage
related:
  - /docs/handling-events
  - /docs/posting-messages
  - /docs/api/thread
  - /docs/api/channel
  - /docs/subject
  - /docs/history
---

> For an index of all documentation, see [/llms.txt](/llms.txt).

A channel holds threads, and each thread holds the messages in one conversation. Your event handlers receive the `Thread` and `Message` for each event, and `thread.channel` returns the `Channel` that contains the thread.

## Threads

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

Your event handlers usually receive a `Thread` from the SDK. You can also construct one from a thread ID, which is useful in cron jobs, workflow steps, or any other code that runs 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({ markdown: "**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 are stored in your state adapter, so they persist across restarts. 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

`getParticipants()` returns the unique human authors in a thread and excludes bots. Use it to decide whether to subscribe based on how many people 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...
});
```

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

### Typing indicator

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

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

### Mark as read

Inside a message handler, mark the current inbound message as read without passing platform-specific IDs:

```typescript title="lib/bot.ts"
bot.onDirectMessage(async (thread) => {
  await thread.markAsRead();
});
```

You can pass a `Message` or message ID when you need an explicit target:

```typescript
await thread.markAsRead(message);
await thread.markAsRead(message.id);
```

Platforms may advance the conversation's read state through the target message, which also marks earlier messages as read.

> WhatsApp, Messenger, and XChat support marking messages as read. Other adapters throw `NotImplementedError`. See the [adapter feature matrix](/docs/platform-adapters) for details.

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

For adapters without a server-side history API, such as Telegram and WhatsApp, the SDK keeps a per-thread cache in your state adapter. Read it with `bot.history.thread`:

```typescript title="lib/bot.ts" lineNumbers
// Platform API first, SDK cache fallback when the adapter returns nothing
const { messages } = await bot.history.thread.list(thread.id, { limit: 20 });
```

To persist a cross-platform transcript for each user, for LLM context, auditing, or GDPR requests, see the [History guide](/docs/history).

### 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();
```

> Scheduled messages are currently only supported by the Slack adapter. Other adapters throw `NotImplementedError`. See the [feature matrix](/docs/platform-adapters) for details.

## 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                                                                                                     |
| `replyTo`     | `Message` (optional)      | Normalized message this message replies to, when provided by the adapter                                             |
| `isMention`   | `boolean` (optional)      | The adapter's mention decision. `true` and `false` are final; `undefined` makes Chat SDK match `@username` in `text` |

### 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` is the container that holds threads, such as a Slack channel or a Teams conversation. Get a channel from a thread or look one up by ID:

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

### Channel history

Channel-level history comes from the platform adapter and isn't stored in your state adapter by default. Use `bot.history.channel` for promise-based pagination, or the `Channel` iterators above for async iteration.

For channel digests or moderation, the usual pattern is to list threads and then fetch the messages in each one:

```typescript title="lib/bot.ts" lineNumbers
const channelId = "slack:C123ABC";

// Step 1: list recent threads (ThreadSummary: id, rootMessage, replyCount, …)
const { threads, nextCursor } = await bot.history.channel.listThreads(channelId, {
  limit: 20,
});

for (const summary of threads) {
  // Step 2: fetch messages in each thread
  const { messages } = await bot.history.thread.list(summary.id, {
    limit: 50,
    direction: "forward",
  });

  console.log(summary.rootMessage.text, summary.replyCount, messages.length);
}

// Paginate with nextCursor from listThreads when you need more threads
```

The object-oriented equivalent uses the same underlying APIs:

```typescript title="lib/bot.ts" lineNumbers
const channel = bot.channel("slack:C123ABC");

for await (const summary of channel.threads()) {
  const thread = bot.thread(summary.id);

  for await (const msg of thread.allMessages) {
    console.log(msg.text);
  }
}
```

To read top-level channel messages without thread replies, use `listMessages`:

```typescript title="lib/bot.ts" lineNumbers
const { messages } = await bot.history.channel.listMessages(channelId, {
  limit: 20,
});
```

For a one-shot helper that lists threads and prefetches messages for each, see [`bot.history.channel.listThreadsWithMessages`](/docs/api/history#listthreadswithmessages) in the API reference.

> `listThreads` requires adapter support (Slack, Discord, Teams, Google Chat, GitHub). On threadless platforms (WhatsApp, Telegram), `listThreads` throws and `channel.threads()` yields nothing. See the [adapter feature matrix](/docs/adapters) and the [History guide](/docs/history#channel-history) for platform details.

## Thread ID format

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

- Slack: `slack:C123ABC:1234567890.123456`
- Teams: `teams:{base64(conversationId)}:{base64(serviceUrl)}[:{conversationType}]`
- Google Chat: `gchat:spaces/ABC123:{base64(threadName)}`
- Discord: `discord:{guildId}:{channelId}[:{threadId}]`

You rarely need to build these yourself, because the threads your event handlers receive already carry them.

## Logging

If you omit the `logger` option, Chat SDK uses `ConsoleLogger("info")`. Each adapter also creates its own child logger.

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

---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)