Skip to main content
The Chat SDK organizes conversations around three core concepts: Threads, Messages, and Channels. Understanding how these work together is essential for building chat applications.

Thread

A Thread represents a conversation context where messages are exchanged. It’s the primary interface for posting messages, managing state, and subscribing to updates.
interface Thread<TState = Record<string, unknown>, TRawMessage = unknown> {
  readonly id: string;           // Unique thread ID
  readonly channelId: string;    // Parent channel ID
  readonly isDM: boolean;        // Whether this is a DM conversation
  readonly adapter: Adapter;     // Platform adapter
  
  // State management
  readonly state: Promise<TState | null>;
  setState(state: Partial<TState>, options?: { replace?: boolean }): Promise<void>;
  
  // Message operations
  post(message: string | PostableMessage | CardJSXElement): Promise<SentMessage>;
  postEphemeral(user: string | Author, message: AdapterPostableMessage, options: PostEphemeralOptions): Promise<EphemeralMessage | null>;
  startTyping(status?: string): Promise<void>;
  
  // Subscription
  isSubscribed(): Promise<boolean>;
  subscribe(): Promise<void>;
  unsubscribe(): Promise<void>;
  
  // Message iteration
  readonly messages: AsyncIterable<Message>;      // Newest first
  readonly allMessages: AsyncIterable<Message>;   // Oldest first (all history)
  readonly recentMessages: Message[];             // Cached recent messages
  
  // Parent channel
  readonly channel: Channel<TState, TRawMessage>;
}

Thread ID Format

Thread IDs follow the pattern: {adapter}:{channel}:{thread}
// Format: slack:C123ABC:1234567890.123456
const threadId = "slack:C123ABC:1234567890.123456";

Custom Thread State

Threads support type-safe custom state with automatic persistence:
interface MyThreadState {
  aiMode?: boolean;
  userName?: string;
  conversationId?: string;
}

const chat = new Chat<typeof adapters, MyThreadState>({
  userName: "mybot",
  adapters: { slack: slackAdapter },
  state: redisState,
});

chat.onNewMention(async (thread, message) => {
  // Set state (merges by default)
  await thread.setState({ aiMode: true, userName: message.author.userName });
  
  // Get state (type-safe)
  const state = await thread.state; // Type: MyThreadState | null
  if (state?.aiMode) {
    await thread.post("AI mode is enabled!");
  }
});
State is persisted for 30 days by default using THREAD_STATE_TTL_MS.

Message

A Message represents a single chat message with normalized content across platforms.
class Message<TRawMessage = unknown> {
  readonly id: string;           // Unique message ID
  readonly threadId: string;     // Parent thread ID
  
  text: string;                  // Plain text (all formatting stripped)
  formatted: FormattedContent;   // mdast AST representation
  raw: TRawMessage;              // Platform-specific payload
  
  author: Author;                // Message author info
  metadata: MessageMetadata;     // Timestamps and edit status
  attachments: Attachment[];     // Files, images, etc.
  
  isMention?: boolean;           // Whether the bot is @-mentioned
}

Author Information

interface Author {
  userId: string;        // Unique user ID
  userName: string;      // Username/handle for @-mentions
  fullName: string;      // Display name
  isBot: boolean | "unknown";
  isMe: boolean;         // Whether this is the bot itself
}

Message Metadata

interface MessageMetadata {
  dateSent: Date;        // When the message was sent
  edited: boolean;       // Whether the message has been edited
  editedAt?: Date;       // When the message was last edited
}

Formatted Content

Messages use mdast (Markdown AST) as the canonical format:
import { toPlainText, stringifyMarkdown } from "chat";

chat.onSubscribedMessage(async (thread, message) => {
  // Access plain text
  console.log(message.text);
  
  // Convert formatted AST to markdown string
  const markdown = stringifyMarkdown(message.formatted);
  
  // Or convert to plain text
  const plain = toPlainText(message.formatted);
});

Attachments

interface Attachment {
  type: "image" | "file" | "video" | "audio";
  url?: string;          // URL to the file
  name?: string;         // Filename
  mimeType?: string;     // MIME type
  size?: number;         // File size in bytes
  width?: number;        // Image/video width
  height?: number;       // Image/video height
  
  // Binary data (for uploading or if already fetched)
  data?: Buffer | Blob;
  
  // Fetch attachment data with authentication
  fetchData?: () => Promise<Buffer>;
}
For platforms like Slack with private URLs, use fetchData() to automatically handle authentication.

Channel

A Channel represents a conversation container that holds threads and messages.
interface Channel<TState = Record<string, unknown>, TRawMessage = unknown> {
  readonly id: string;           // Channel ID
  readonly adapter: Adapter;     // Platform adapter
  readonly isDM: boolean;        // Whether this is a DM
  readonly name: string | null;  // Channel name (after fetchMetadata)
  
  // State management (same as Thread)
  readonly state: Promise<TState | null>;
  setState(state: Partial<TState>, options?: { replace?: boolean }): Promise<void>;
  
  // Message operations (same as Thread)
  post(message: string | PostableMessage | CardJSXElement): Promise<SentMessage>;
  postEphemeral(user: string | Author, message: AdapterPostableMessage, options: PostEphemeralOptions): Promise<EphemeralMessage | null>;
  startTyping(status?: string): Promise<void>;
  mentionUser(userId: string): string;
  
  // Iteration
  readonly messages: AsyncIterable<Message>;        // Channel-level messages
  threads(): AsyncIterable<ThreadSummary>;          // List threads in channel
  
  // Metadata
  fetchMetadata(): Promise<ChannelInfo>;
}

Getting a Channel

// Get channel by ID (adapter inferred from prefix)
const channel = chat.channel("slack:C123ABC");

// Or access from a thread
chat.onNewMention(async (thread, message) => {
  const channel = thread.channel;
  await channel.post("Hello channel!");
});

Listing Threads

// Iterate through all threads in a channel
for await (const threadSummary of channel.threads()) {
  console.log(threadSummary.rootMessage.text);
  console.log(`Replies: ${threadSummary.replyCount}`);
  console.log(`Last reply: ${threadSummary.lastReplyAt}`);
}
On platforms without native threading (like standalone channels), threads() returns an empty iterable.

Channel Metadata

interface ChannelInfo {
  id: string;
  name?: string;         // Channel name (e.g., "#general")
  isDM?: boolean;
  memberCount?: number;  // Number of members
  metadata: Record<string, unknown>; // Platform-specific data
}

// Fetch channel info
const info = await channel.fetchMetadata();
console.log(`Channel: ${info.name}, Members: ${info.memberCount}`);

Relationship Between Concepts

chat.onNewMention(async (thread, message) => {
  // Thread -> Channel
  const channel = thread.channel;
  
  // Thread -> Messages
  for await (const msg of thread.messages) {
    console.log(msg.text);
  }
  
  // Message -> Author
  console.log(message.author.userName);
});

Message Iteration Patterns

Thread Messages (Newest First)

// Default: iterate from newest to oldest
for await (const message of thread.messages) {
  console.log(message.text);
  // Auto-paginates lazily
}

Thread Messages (Oldest First)

// Iterate from beginning of thread history
for await (const message of thread.allMessages) {
  console.log(message.text);
  // Auto-paginates forward through history
}

Recent Messages (Cached)

// Access recently fetched messages (no API call)
const recent = thread.recentMessages;
console.log(`Last message: ${recent[recent.length - 1]?.text}`);

// Refresh cache from API
await thread.refresh();
Use recentMessages for quick access without pagination. Messages are cached when the thread is created or after calling refresh().