chat
Version:
Unified chat abstraction for Slack, Teams, Google Chat, and Discord
1,231 lines (1,223 loc) • 123 kB
TypeScript
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@workflow/serde';
import { Root } from 'mdast';
import { Z as ChatElement, C as CardElement, ac as ModalElement, ah as SelectOptionElement } from './jsx-runtime-CFq1K_Ve.js';
interface ThreadHistoryConfig {
/** Maximum messages to keep per thread (default: 100) */
maxMessages?: number;
/** TTL for cached history in milliseconds (default: 7 days) */
ttlMs?: number;
}
/**
* Persistent per-thread history cache backed by the StateAdapter.
*
* Used by adapters that lack server-side message history APIs (e.g. WhatsApp,
* Telegram). Messages are atomically appended via `state.appendToList()`,
* which is safe without holding a thread lock.
*
* Distinct from the cross-platform per-user {@link TranscriptsApi} (see
* `transcripts.ts`) — this cache is keyed by thread, not user.
*/
declare class ThreadHistoryCache {
private readonly state;
private readonly maxMessages;
private readonly ttlMs;
constructor(state: StateAdapter, config?: ThreadHistoryConfig);
/**
* Atomically append a message to the history for a thread.
* Trims to maxMessages (keeps newest) and refreshes TTL.
*/
append(threadId: string, message: Message): Promise<void>;
/**
* Get messages for a thread in chronological order (oldest first).
*
* @param threadId - The thread ID
* @param limit - Optional limit on number of messages to return (returns newest N)
*/
getMessages(threadId: string, limit?: number): Promise<Message[]>;
}
/**
* Serialized channel data for passing to external systems (e.g., workflow engines).
*/
interface SerializedChannel {
_type: "chat:Channel";
adapterName: string;
channelVisibility?: ChannelVisibility;
id: string;
isDM: boolean;
}
/**
* Config for creating a ChannelImpl with explicit adapter/state instances.
*/
interface ChannelImplConfigWithAdapter {
adapter: Adapter;
channelVisibility?: ChannelVisibility;
id: string;
isDM?: boolean;
stateAdapter: StateAdapter;
threadHistory?: ThreadHistoryCache;
}
/**
* Config for creating a ChannelImpl with lazy adapter resolution.
*/
interface ChannelImplConfigLazy {
adapterName: string;
channelVisibility?: ChannelVisibility;
id: string;
isDM?: boolean;
}
type ChannelImplConfig = ChannelImplConfigWithAdapter | ChannelImplConfigLazy;
declare class ChannelImpl<TState = Record<string, unknown>> implements Channel<TState> {
readonly id: string;
readonly isDM: boolean;
readonly channelVisibility: ChannelVisibility;
private _adapter?;
private readonly _adapterName?;
private _stateAdapterInstance?;
private _name;
private readonly _threadHistory?;
constructor(config: ChannelImplConfig);
get adapter(): Adapter;
private get _stateAdapter();
get name(): string | null;
get state(): Promise<TState | null>;
setState(newState: Partial<TState>, options?: {
replace?: boolean;
}): Promise<void>;
/**
* Iterate messages newest first (backward from most recent).
* Uses adapter.fetchChannelMessages if available, otherwise falls back
* to adapter.fetchMessages with the channel ID.
*/
get messages(): AsyncIterable<Message>;
/**
* Iterate threads in this channel, most recently active first.
*/
threads(): AsyncIterable<ThreadSummary>;
fetchMetadata(): Promise<ChannelInfo>;
post<T extends PostableObject>(message: T): Promise<T>;
post(message: string | AdapterPostableMessage | AsyncIterable<string> | ChatElement): Promise<SentMessage>;
private handlePostableObject;
private postSingleMessage;
postEphemeral(user: string | Author, message: AdapterPostableMessage | ChatElement, options: PostEphemeralOptions): Promise<EphemeralMessage | null>;
schedule(message: AdapterPostableMessage | ChatElement, options: {
postAt: Date;
}): Promise<ScheduledMessage>;
private processCallbackUrls;
startTyping(status?: string): Promise<void>;
mentionUser(userId: string): string;
toJSON(): SerializedChannel;
static fromJSON<TState = Record<string, unknown>>(json: SerializedChannel, adapter?: Adapter): ChannelImpl<TState>;
static [WORKFLOW_SERIALIZE](instance: ChannelImpl): SerializedChannel;
static [WORKFLOW_DESERIALIZE](data: SerializedChannel): ChannelImpl;
private createSentMessage;
}
/**
* Derive the channel ID from a thread ID.
*/
declare function deriveChannelId(adapter: Adapter, threadId: string): string;
/**
* Logger types and implementations for chat-sdk
*/
type LogLevel = "debug" | "info" | "warn" | "error" | "silent";
interface Logger {
/** Create a sub-logger with a prefix */
child(prefix: string): Logger;
debug(message: string, ...args: unknown[]): void;
error(message: string, ...args: unknown[]): void;
info(message: string, ...args: unknown[]): void;
warn(message: string, ...args: unknown[]): void;
}
/**
* Default console logger implementation.
*/
declare class ConsoleLogger implements Logger {
private readonly prefix;
private readonly level;
constructor(level?: LogLevel, prefix?: string);
private shouldLog;
child(prefix: string): Logger;
debug(message: string, ...args: unknown[]): void;
info(message: string, ...args: unknown[]): void;
warn(message: string, ...args: unknown[]): void;
error(message: string, ...args: unknown[]): void;
}
/**
* Context provided to a PostableObject after it has been posted.
*/
interface PostableObjectContext {
adapter: Adapter;
logger?: Logger;
messageId: string;
threadId: string;
}
/**
* Base interface for objects that can be posted to threads/channels.
* Examples: Plan, Poll, etc.
*
* @template TData - The data type returned by getPostData()
*/
interface PostableObject<TData = unknown> {
/** Symbol identifying this as a postable object */
readonly $$typeof: symbol;
/**
* Get a fallback text representation for adapters that don't support this object type.
* This should return a human-readable string representation.
*/
getFallbackText(): string;
/** Get the data to send to the adapter */
getPostData(): TData;
/** Check if the adapter supports this object type */
isSupported(adapter: Adapter): boolean;
/** The kind of object - used by adapters to dispatch */
readonly kind: string;
/** Called after successful posting to bind the object to the thread */
onPosted(context: PostableObjectContext): void;
}
/**
* Type guard to check if a value is a PostableObject.
*/
declare function isPostableObject(value: unknown): value is PostableObject;
/**
* Serialized thread data for passing to external systems (e.g., workflow engines).
*/
interface SerializedThread {
_type: "chat:Thread";
adapterName: string;
channelId: string;
channelVisibility?: ChannelVisibility;
currentMessage?: SerializedMessage;
id: string;
isDM: boolean;
}
/**
* Config for creating a ThreadImpl with explicit adapter/state instances.
*/
interface ThreadImplConfigWithAdapter {
adapter: Adapter;
channelId: string;
channelVisibility?: ChannelVisibility;
currentMessage?: Message;
fallbackStreamingPlaceholderText?: string | null;
id: string;
initialMessage?: Message;
isDM?: boolean;
isSubscribedContext?: boolean;
logger?: Logger;
stateAdapter: StateAdapter;
streamingUpdateIntervalMs?: number;
threadHistory?: ThreadHistoryCache;
}
/**
* Config for creating a ThreadImpl with lazy adapter resolution.
* The adapter will be looked up from the Chat singleton on first access.
*/
interface ThreadImplConfigLazy {
adapterName: string;
channelId: string;
channelVisibility?: ChannelVisibility;
currentMessage?: Message;
fallbackStreamingPlaceholderText?: string | null;
id: string;
initialMessage?: Message;
isDM?: boolean;
isSubscribedContext?: boolean;
logger?: Logger;
streamingUpdateIntervalMs?: number;
}
type ThreadImplConfig = ThreadImplConfigWithAdapter | ThreadImplConfigLazy;
declare class ThreadImpl<TState = Record<string, unknown>> implements Thread<TState> {
readonly id: string;
readonly channelId: string;
readonly isDM: boolean;
readonly channelVisibility: ChannelVisibility;
/** Direct adapter instance (if provided) */
private _adapter?;
/** Adapter name for lazy resolution */
private readonly _adapterName?;
/** Direct state adapter instance (if provided) */
private _stateAdapterInstance?;
private _recentMessages;
private readonly _isSubscribedContext;
/** Current message context for streaming - provides userId/teamId */
private readonly _currentMessage?;
/** Update interval for fallback streaming */
private readonly _streamingUpdateIntervalMs;
/** Placeholder text for fallback streaming (post + edit) */
private readonly _fallbackStreamingPlaceholderText;
/** Cached channel instance */
private _channel?;
/** Thread history cache (set only for adapters with persistThreadHistory) */
private readonly _threadHistory?;
private readonly _logger?;
constructor(config: ThreadImplConfig);
/**
* Get the adapter for this thread.
* If created with lazy config, resolves from Chat singleton on first access.
*/
get adapter(): Adapter;
/**
* Get the state adapter for this thread.
* If created with lazy config, resolves from Chat singleton on first access.
*/
private get _stateAdapter();
get recentMessages(): Message[];
set recentMessages(messages: Message[]);
/**
* Get the current thread state.
* Returns null if no state has been set.
*/
get state(): Promise<TState | null>;
/**
* Set the thread state. Merges with existing state by default.
* State is persisted for 30 days.
*/
setState(newState: Partial<TState>, options?: {
replace?: boolean;
}): Promise<void>;
/**
* Get the Channel containing this thread.
* Lazy-created and cached.
*/
get channel(): Channel<TState>;
/**
* Iterate messages newest first (backward from most recent).
* Auto-paginates lazily.
*/
get messages(): AsyncIterable<Message>;
get allMessages(): AsyncIterable<Message>;
getParticipants(): Promise<Author[]>;
isSubscribed(): Promise<boolean>;
subscribe(): Promise<void>;
unsubscribe(): Promise<void>;
post<T extends PostableObject>(message: T): Promise<T>;
post(message: string | AdapterPostableMessage | AsyncIterable<string> | ChatElement): Promise<SentMessage>;
private handlePostableObject;
postEphemeral(user: string | Author, message: AdapterPostableMessage | ChatElement, options: PostEphemeralOptions): Promise<EphemeralMessage | null>;
private processCallbackUrls;
schedule(message: AdapterPostableMessage | ChatElement, options: {
postAt: Date;
}): Promise<ScheduledMessage>;
/**
* Handle streaming from an AsyncIterable.
* Normalizes the stream (supports both textStream and fullStream from AI SDK),
* then uses the adapter's stream implementation if available, otherwise falls back to post+edit.
*/
private handleStream;
/**
* Slack payloads carry the workspace ID in a few different shapes depending on
* the webhook type:
* - Message events: `team_id` or `team` as a string
* - `block_actions` payloads: `team.id` (object), with `user.team_id` as a fallback
*/
private extractSlackRecipientTeamId;
startTyping(status?: string): Promise<void>;
/**
* Fallback streaming implementation using post + edit.
* Used when adapter doesn't support native streaming.
* Uses recursive setTimeout to send updates every intervalMs (default 500ms).
* Schedules next update only after current edit completes to avoid overwhelming slow services.
*/
private fallbackStream;
refresh(): Promise<void>;
mentionUser(userId: string): string;
/**
* Serialize the thread to a plain JSON object.
* Use this to pass thread data to external systems like workflow engines.
*
* @example
* ```typescript
* // Pass to a workflow
* await workflow.start("my-workflow", {
* thread: thread.toJSON(),
* message: serializeMessage(message),
* });
* ```
*/
toJSON(): SerializedThread;
/**
* Reconstruct a Thread from serialized JSON data.
*
* Reconstructs a ThreadImpl from serialized data.
* Uses lazy resolution from Chat.getSingleton() for adapter and state.
*
* @param json - Serialized thread data
* @requires Call `chat.registerSingleton()` before deserializing threads
*
* @example
* ```typescript
* const thread = ThreadImpl.fromJSON(serializedThread);
* ```
*/
static fromJSON<TState = Record<string, unknown>>(json: SerializedThread, adapter?: Adapter): ThreadImpl<TState>;
/**
* Serialize a ThreadImpl instance for @workflow/serde.
* This static method is automatically called by workflow serialization.
*/
static [WORKFLOW_SERIALIZE](instance: ThreadImpl): SerializedThread;
/**
* Deserialize a ThreadImpl from @workflow/serde.
* Uses lazy adapter resolution from Chat.getSingleton().
* Requires chat.registerSingleton() to have been called.
*/
static [WORKFLOW_DESERIALIZE](data: SerializedThread): ThreadImpl;
private createSentMessage;
createSentMessageFromMessage(message: Message): SentMessage;
}
/**
* Represents the visibility scope of a channel.
*
* - `private`: Channel is only visible to invited members (e.g., private Slack channels)
* - `workspace`: Channel is visible to all workspace members (e.g., public Slack channels)
* - `external`: Channel is shared with external organizations (e.g., Slack Connect)
* - `unknown`: Visibility cannot be determined
*/
type ChannelVisibility = "private" | "workspace" | "external" | "unknown";
/**
* Chat configuration with type-safe adapter inference.
* @template TAdapters - Record of adapter name to adapter instance
*/
interface ChatConfig<TAdapters extends Record<string, Adapter> = Record<string, Adapter>> {
/** Map of adapter name to adapter instance */
adapters: TAdapters;
/**
* How to handle messages that arrive while a handler is already
* processing on the same thread.
*
* - `'drop'` (default) — discard the message (throw `LockError`)
* - `'queue'` — queue the message; when the current handler finishes,
* process only the latest queued message with `context.skipped` containing
* all intermediate messages
* - `'debounce'` — messages inside the debounce window replace the pending
* message; only the final message in that window is processed
* - `'burst'` — wait once before the first handler, then process the
* latest message with `context.skipped` containing earlier burst messages
* - `'concurrent'` — no locking; all messages processed in parallel
* - `ConcurrencyConfig` — fine-grained control over strategy and parameters
*/
concurrency?: ConcurrencyStrategy | ConcurrencyConfig;
/**
* TTL for message deduplication entries in milliseconds.
* Defaults to 300000 (5 minutes). Increase if your webhook cold starts
* cause platform retries that arrive after the default TTL expires.
*/
dedupeTtlMs?: number;
/**
* Placeholder text for fallback streaming (post + edit) adapters.
* Defaults to `"..."`.
*
* Set to `null` to avoid posting an initial placeholder message and instead
* wait until some real text has been streamed before creating the message.
*/
fallbackStreamingPlaceholderText?: string | null;
/**
* Resolves a stable cross-platform user key from inbound messages.
*
* Required when `transcripts` is configured. Called once per inbound
* message during dispatch; the result is attached to the Message
* instance as `message.userKey` for handlers to use.
*/
identity?: IdentityResolver;
/**
* Lock scope determines which messages contend for the same lock.
*
* - `'thread'`: lock per threadId (default for most adapters)
* - `'channel'`: lock per channelId (default for WhatsApp, Telegram)
* - function: resolve scope dynamically per message (async supported)
*
* When not set, falls back to the adapter's `lockScope` property,
* then to `'thread'`.
*/
lockScope?: LockScope | ((context: LockScopeContext) => LockScope | Promise<LockScope>);
/**
* Logger instance or log level.
* Pass "silent" to disable all logging.
*/
logger?: Logger | LogLevel;
/**
* @deprecated Renamed to {@link ChatConfig.threadHistory}. Both fields are
* read for backwards compatibility; `threadHistory` takes precedence when
* both are set.
*/
messageHistory?: {
maxMessages?: number;
ttlMs?: number;
};
/**
* @deprecated Use `concurrency` instead.
*
* Behavior when a thread lock cannot be acquired (another handler is processing).
* - `'drop'` (default) — throw `LockError`, preserving current behavior
* - `'force'` — force-release the existing lock and re-acquire
* - callback — custom logic receiving `(threadId, message)`, return `'force'` or `'drop'`
*
* When `'force'` is used, the previous handler continues executing — only the lock
* is released, not the handler itself. This means two handlers may run concurrently
* on the same thread. The old handler's `releaseLock()` call becomes a no-op since
* the token no longer matches.
*/
onLockConflict?: "force" | "drop" | ((threadId: string, message: Message) => "force" | "drop" | Promise<"force" | "drop">);
/** State adapter for subscriptions and locking */
state: StateAdapter;
/**
* Update interval for fallback streaming (post + edit) in milliseconds.
* Defaults to 500ms. Lower values provide smoother updates but may hit rate limits.
*/
streamingUpdateIntervalMs?: number;
/**
* Configuration for persistent per-thread message history backfill.
*
* Only used by adapters that set `persistThreadHistory: true` (e.g.
* Telegram, WhatsApp). Distinct from `transcripts` (the cross-platform
* per-user Transcripts API).
*/
threadHistory?: {
/** Maximum messages to store per thread (default: 100) */
maxMessages?: number;
/** TTL for cached history in milliseconds (default: 7 days) */
ttlMs?: number;
};
/**
* Cross-platform per-user message persistence.
*
* When set, `chat.transcripts` is available for append/list/count/delete
* keyed by a resolved cross-platform user key.
*
* Requires `identity` to also be set; the constructor throws otherwise.
*/
transcripts?: TranscriptsConfig;
/** Default bot username across all adapters */
userName: string;
}
/**
* Options for webhook handling.
*/
interface WebhookOptions {
/**
* Override the default modal-opening behavior to handle it inline
* within the current webhook response cycle.
* When provided, called instead of adapter.openModal().
* Used by Teams to return modal content in the HTTP invoke response.
*
* The returned `viewId` is platform-specific (e.g. Slack's view ID).
* Adapters that don't produce a view ID may return void.
*/
onOpenModal?: (modal: ModalElement, contextId: string) => Promise<{
viewId: string;
} | undefined>;
/**
* Function to run message handling in the background.
* Use this to ensure fast webhook responses while processing continues.
*
* @example
* // Next.js App Router
* import { after } from "next/server";
* chat.webhooks.slack(request, { waitUntil: (p) => after(() => p) });
*
* @example
* // Vercel Functions
* import { waitUntil } from "@vercel/functions";
* chat.webhooks.slack(request, { waitUntil });
*/
waitUntil?: (task: Promise<unknown>) => void;
}
/**
* Adapter interface with generics for platform-specific types.
* @template TThreadId - Platform-specific thread ID data type
* @template TRawMessage - Platform-specific raw message type
*/
interface Adapter<TThreadId = unknown, TRawMessage = unknown> {
/** Add a reaction to a message */
addReaction(threadId: string, messageId: string, emoji: EmojiValue | string): Promise<void>;
/** Bot user ID for platforms that use IDs in mentions (e.g., Slack's <@U123>) */
readonly botUserId?: string;
/**
* Derive channel ID from a thread ID.
* Default fallback: first two colon-separated parts (e.g., "slack:C123").
* Adapters with different structures should override this.
*/
channelIdFromThreadId(threadId: string): string;
/** Decode thread ID string back to platform-specific data */
decodeThreadId(threadId: string): TThreadId;
/** Delete a message */
deleteMessage(threadId: string, messageId: string): Promise<void>;
/** Cleanup hook called when Chat instance is shutdown */
disconnect?(): Promise<void>;
/** Edit an existing message */
editMessage(threadId: string, messageId: string, message: AdapterPostableMessage): Promise<RawMessage<TRawMessage>>;
/**
* Edit a previously posted object (Plan, Poll, etc.).
* If not implemented, object updates will throw PlanNotSupportedError.
*
* @param threadId - The thread containing the message
* @param messageId - The message ID to edit
* @param kind - The object kind (e.g., "plan")
* @param data - The object data (type depends on kind)
*/
editObject?(threadId: string, messageId: string, kind: string, data: unknown): Promise<RawMessage<TRawMessage>>;
/** Encode platform-specific data into a thread ID string */
encodeThreadId(platformData: TThreadId): string;
/**
* Fetch channel info/metadata.
*/
fetchChannelInfo?(channelId: string): Promise<ChannelInfo>;
/**
* Fetch channel-level messages (top-level, not thread replies).
* For example, Slack's conversations.history vs conversations.replies.
*/
fetchChannelMessages?(channelId: string, options?: FetchOptions): Promise<FetchResult<TRawMessage>>;
/**
* Fetch a single message by ID.
* Optional - adapters that don't implement this will return null.
*
* @param threadId - The thread ID containing the message
* @param messageId - The platform-specific message ID
* @returns The message, or null if not found/not supported
*/
fetchMessage?(threadId: string, messageId: string): Promise<Message<TRawMessage> | null>;
/**
* Fetch messages from a thread.
*
* **Direction behavior:**
* - `backward` (default): Fetches the most recent messages. Use this for loading
* a chat view. The `nextCursor` points to older messages.
* - `forward`: Fetches the oldest messages first. Use this for iterating through
* message history. The `nextCursor` points to newer messages.
*
* **Message ordering:**
* Messages within each page are always returned in chronological order (oldest first),
* regardless of direction. This is the natural reading order for chat messages.
*
* @example
* ```typescript
* // Load most recent 50 messages for display
* const recent = await adapter.fetchMessages(threadId, { limit: 50 });
* // recent.messages: [older, ..., newest] in chronological order
*
* // Paginate backward to load older messages
* const older = await adapter.fetchMessages(threadId, {
* limit: 50,
* cursor: recent.nextCursor,
* });
*
* // Iterate through all history from the beginning
* const history = await adapter.fetchMessages(threadId, {
* limit: 100,
* direction: 'forward',
* });
* ```
*/
fetchMessages(threadId: string, options?: FetchOptions): Promise<FetchResult<TRawMessage>>;
fetchSubject?(raw: TRawMessage): Promise<MessageSubject | null>;
/** Fetch thread metadata */
fetchThread(threadId: string): Promise<ThreadInfo>;
/**
* Get the visibility scope of a channel containing the thread.
*
* This distinguishes between private channels, workspace-visible channels,
* and externally shared channels (e.g., Slack Connect).
*
* @param threadId - The thread ID to check
* @returns The channel visibility scope
*/
getChannelVisibility?(threadId: string): ChannelVisibility;
/**
* Look up user information by user ID.
* Optional — not all platforms support this.
*
* @param userId - Platform-specific user ID
* @returns User info, or null if user not found
*/
getUser?(userId: string): Promise<UserInfo | null>;
/** Handle incoming webhook request */
handleWebhook(request: Request, options?: WebhookOptions): Promise<Response>;
/** Called when Chat instance is created (internal use) */
initialize(chat: ChatInstance): Promise<void>;
/**
* Check if a thread is a direct message conversation.
*
* @param threadId - The thread ID to check
* @returns True if the thread is a DM, false otherwise
*/
isDM?(threadId: string): boolean;
/**
* List threads in a channel.
*/
listThreads?(channelId: string, options?: ListThreadsOptions): Promise<ListThreadsResult<TRawMessage>>;
/**
* Default lock scope for this adapter.
* - `'thread'` (default): lock per threadId
* - `'channel'`: lock per channelId (for channel-based platforms like WhatsApp, Telegram)
*
* Can be overridden by `ChatConfig.lockScope`.
*/
readonly lockScope?: LockScope;
/** Unique name for this adapter (e.g., "slack", "teams") */
readonly name: string;
/**
* Optional hook called when a thread is subscribed to.
* Adapters can use this to set up platform-specific subscriptions
* (e.g., Google Chat Workspace Events).
*/
onThreadSubscribe?(threadId: string): Promise<void>;
/**
* Open a direct message conversation with a user.
*
* @param userId - The platform-specific user ID
* @returns The thread ID for the DM conversation
*
* @example
* ```typescript
* const dmThreadId = await adapter.openDM("U123456");
* await adapter.postMessage(dmThreadId, "Hello!");
* ```
*/
openDM?(userId: string): Promise<string>;
/**
* Open a modal/dialog form.
*
* @param triggerId - Platform-specific trigger ID from the action event
* @param modal - The modal element to display
* @param contextId - Optional context ID for server-side stored thread/message context
* @returns The view/dialog ID
*/
openModal?(triggerId: string, modal: ModalElement, contextId?: string): Promise<{
viewId: string;
}>;
/** Parse platform message format to normalized format */
parseMessage(raw: TRawMessage): Message<TRawMessage>;
/**
* @deprecated Renamed to {@link Adapter.persistThreadHistory}. Both flags
* are read for backwards compatibility; either being `true` enables
* persistence.
*/
readonly persistMessageHistory?: boolean;
/**
* When true, the SDK persists per-thread message history in the state
* adapter for this platform. Use for platforms that lack server-side
* message history APIs (e.g. WhatsApp, Telegram).
*/
readonly persistThreadHistory?: boolean;
/**
* Post a message to channel top-level (not in a thread).
*/
postChannelMessage?(channelId: string, message: AdapterPostableMessage): Promise<RawMessage<TRawMessage>>;
/**
* Post an ephemeral message visible only to a specific user.
*
* This is optional - if not implemented, Thread.postEphemeral will
* fall back to openDM + postMessage when fallbackToDM is true.
*
* @param threadId - The thread to post in
* @param userId - The user who should see the message
* @param message - The message content
* @returns EphemeralMessage with usedFallback: false
*/
postEphemeral?(threadId: string, userId: string, message: AdapterPostableMessage): Promise<EphemeralMessage<TRawMessage>>;
/** Post a message to a thread */
postMessage(threadId: string, message: AdapterPostableMessage): Promise<RawMessage<TRawMessage>>;
/**
* Post a special object (Plan, Poll, etc.) as a single message.
* If not implemented, posting such objects will throw PlanNotSupportedError.
*
* @param threadId - The thread to post to
* @param kind - The object kind (e.g., "plan")
* @param data - The object data (type depends on kind)
*/
postObject?(threadId: string, kind: string, data: unknown): Promise<RawMessage<TRawMessage>>;
/**
* Reconstruct fetchData on an attachment after deserialization.
* Called during message rehydration for queue/debounce strategies.
* Uses fetchMetadata and adapter auth context to rebuild the download closure.
*/
rehydrateAttachment?(attachment: Attachment): Attachment;
/** Remove a reaction from a message */
removeReaction(threadId: string, messageId: string, emoji: EmojiValue | string): Promise<void>;
/** Render formatted content to platform-specific string */
renderFormatted(content: FormattedContent): string;
/**
* Schedule a message for future delivery.
*
* Optional — only supported by adapters with native scheduling APIs (e.g., Slack).
* Thread.schedule() will throw NotImplementedError if this method is absent.
*
* @param threadId - The thread to post in
* @param message - The message content
* @param options - Scheduling options including the target delivery time
* @returns A ScheduledMessage with cancel() capability
*/
scheduleMessage?(threadId: string, message: AdapterPostableMessage, options: {
postAt: Date;
}): Promise<ScheduledMessage<TRawMessage>>;
/** Show typing indicator */
startTyping(threadId: string, status?: string): Promise<void>;
/**
* Stream a message using platform-native streaming APIs.
*
* The adapter consumes the async iterable and handles the entire streaming lifecycle.
* Only available on platforms with native streaming support (e.g., Slack).
*
* The stream can yield plain strings (text chunks) or {@link StreamChunk} objects
* for rich content like task progress cards. Adapters that don't support structured
* chunks will extract text from `markdown_text` chunks and ignore other types.
*
* @param threadId - The thread to stream to
* @param textStream - Async iterable of text chunks or structured StreamChunk objects
* @param options - Platform-specific streaming options
* @returns The raw message after streaming completes
*/
stream?(threadId: string, textStream: AsyncIterable<string | StreamChunk>, options?: StreamOptions): Promise<RawMessage<TRawMessage>>;
/** Bot username (can override global userName) */
readonly userName: string;
}
/**
* A structured streaming chunk for platform-native rich content.
*
* On Slack, these map directly to streaming chunk types:
* - `markdown_text`: Streamed text content
* - `task_update`: Tool/step progress cards (pending → in_progress → complete → error)
* - `plan_update`: Plan title updates
*
* Adapters that don't support structured chunks will extract `text` from
* `markdown_text` chunks and ignore other types gracefully.
*/
type StreamChunk = MarkdownTextChunk | TaskUpdateChunk | PlanUpdateChunk;
interface MarkdownTextChunk {
text: string;
type: "markdown_text";
}
interface TaskUpdateChunk {
details?: string;
id: string;
output?: string;
status: "pending" | "in_progress" | "complete" | "error";
title: string;
type: "task_update";
}
interface PlanUpdateChunk {
title: string;
type: "plan_update";
}
/**
* Options for streaming messages.
* Platform-specific options are passed through to the adapter.
*/
interface StreamOptions {
/** Slack: The team/workspace ID */
recipientTeamId?: string;
/** Slack: The user ID to stream to (for AI assistant context) */
recipientUserId?: string;
/** Block Kit elements to attach when stopping the stream (Slack only, via chat.stopStream) */
stopBlocks?: unknown[];
/**
* Slack: Controls how task_update chunks are displayed.
* - `"timeline"` — individual task cards shown inline with text (default)
* - `"plan"` — all tasks grouped into a single plan block
*/
taskDisplayMode?: "timeline" | "plan";
/** Minimum interval between updates in ms (default: 1000). Used for fallback mode (GChat/Teams). */
updateIntervalMs?: number;
}
/** Internal interface for Chat instance passed to adapters */
interface ChatInstance {
/** Get the configured logger, optionally with a child prefix */
getLogger(prefix?: string): Logger;
getState(): StateAdapter;
getUserName(): string;
/**
* @deprecated Use processMessage instead. This method is for internal use.
*/
handleIncomingMessage(adapter: Adapter, threadId: string, message: Message): Promise<void>;
/**
* Process an incoming action event (button click) from an adapter.
* Handles waitUntil registration and error catching internally.
*
* @param event - The action event (without thread field, will be added)
* @param options - Webhook options including waitUntil
*/
processAction(event: Omit<ActionEvent, "thread" | "openModal"> & {
adapter: Adapter;
}, options: WebhookOptions | undefined): Promise<void>;
processAppHomeOpened(event: AppHomeOpenedEvent, options?: WebhookOptions): void;
processAssistantContextChanged(event: AssistantContextChangedEvent, options?: WebhookOptions): void;
processAssistantThreadStarted(event: AssistantThreadStartedEvent, options?: WebhookOptions): void;
processMemberJoinedChannel(event: MemberJoinedChannelEvent, options?: WebhookOptions): void;
/**
* Process an incoming message from an adapter.
* Handles waitUntil registration and error catching internally.
*
* @param adapter - The adapter that received the message
* @param threadId - The thread ID
* @param message - Either a parsed message, or a factory function for lazy async parsing
* @param options - Webhook options including waitUntil
*/
processMessage(adapter: Adapter, threadId: string, message: Message | (() => Promise<Message>), options?: WebhookOptions): Promise<void>;
/**
* Process a modal close event from an adapter.
*
* @param event - The modal close event (without relatedThread/relatedMessage/relatedChannel)
* @param contextId - Context ID for retrieving stored thread/message/channel context
* @param options - Webhook options
*/
processModalClose(event: Omit<ModalCloseEvent, "relatedThread" | "relatedMessage" | "relatedChannel">, contextId?: string, options?: WebhookOptions): void;
/**
* Process a modal submit event from an adapter.
*
* @param event - The modal submit event (without relatedThread/relatedMessage/relatedChannel)
* @param contextId - Context ID for retrieving stored thread/message/channel context
* @param options - Webhook options
*/
processModalSubmit(event: Omit<ModalSubmitEvent, "relatedThread" | "relatedMessage" | "relatedChannel">, contextId?: string, options?: WebhookOptions): Promise<ModalResponse | undefined>;
/**
* Process an interactive options load event from an adapter.
* Returns normalized select options for the adapter to render.
*/
processOptionsLoad(event: OptionsLoadEvent, options?: WebhookOptions): Promise<OptionsLoadResult | undefined>;
/**
* Process an incoming reaction event from an adapter.
* Handles waitUntil registration and error catching internally.
*
* @param event - The reaction event (without adapter field, will be added)
* @param options - Webhook options including waitUntil
*/
processReaction(event: Omit<ReactionEvent, "adapter" | "thread"> & {
adapter?: Adapter;
}, options?: WebhookOptions): void;
/**
* Process an incoming slash command from an adapter.
* Handles waitUntil registration and error catching internally.
*
* @param event - The slash command event
* @param options - Webhook options including waitUntil
*/
processSlashCommand(event: Omit<SlashCommandEvent, "channel" | "openModal"> & {
adapter: Adapter;
channelId: string;
}, options: WebhookOptions | undefined): void;
/**
* Cross-platform per-user transcript store. Throws on access when
* `transcripts` is not configured on the Chat instance — callers should
* check `ChatConfig.transcripts` if they need a no-throw guard.
*/
readonly transcripts: TranscriptsApi;
}
/** Lock scope determines which messages contend for the same lock. */
type LockScope = "thread" | "channel";
/** Context provided to the lockScope resolver function. */
interface LockScopeContext {
adapter: Adapter;
channelId: string;
isDM: boolean;
threadId: string;
}
/** Concurrency strategy for overlapping messages on the same thread. */
type ConcurrencyStrategy = "drop" | "queue" | "debounce" | "burst" | "concurrent";
/** Fine-grained concurrency configuration. */
interface ConcurrencyConfig {
/** Debounce window in milliseconds (debounce/burst strategies). Default: 1500. */
debounceMs?: number;
/** Max concurrent handlers per thread (concurrent strategy). Default: Infinity. */
maxConcurrent?: number;
/** Max queued messages per thread (queue/burst strategy). Default: 10. */
maxQueueSize?: number;
/** What to do when queue is full. Default: 'drop-oldest'. */
onQueueFull?: "drop-oldest" | "drop-newest";
/** TTL for queued entries in milliseconds. Default: 90000 (90s). */
queueEntryTtlMs?: number;
/** The concurrency strategy to use. */
strategy: ConcurrencyStrategy;
}
/**
* An entry in the per-thread message queue.
* Used by the `queue`, `debounce`, and `burst` concurrency strategies.
*/
interface QueueEntry {
/** When this entry was enqueued (Unix ms). */
enqueuedAt: number;
/** When this entry expires (Unix ms). Stale entries are discarded on dequeue. */
expiresAt: number;
/** The queued message. */
message: Message;
}
/**
* Context provided to message handlers when messages were queued
* while a previous handler was running or while waiting for burst.
*/
interface MessageContext {
/**
* Messages that arrived while the previous handler was running,
* in chronological order, excluding the current message (which is the latest).
*/
skipped: Message[];
/** Total messages received since last handler ran (skipped.length + 1). */
totalSinceLastHandler: number;
}
interface StateAdapter {
/** Acquire a lock on a thread (returns null if already locked) */
acquireLock(threadId: string, ttlMs: number): Promise<Lock | null>;
/** Atomically append a value to a list. Trims to maxLength (keeping newest). Refreshes TTL. */
appendToList(key: string, value: unknown, options?: {
maxLength?: number;
ttlMs?: number;
}): Promise<void>;
/** Connect to the state backend */
connect(): Promise<void>;
/** Delete a cached value */
delete(key: string): Promise<void>;
/** Pop the next message from the thread's queue. Returns null if empty. */
dequeue(threadId: string): Promise<QueueEntry | null>;
/** Disconnect from the state backend */
disconnect(): Promise<void>;
/** Atomically append a message to the thread's pending queue. Returns new queue depth. */
enqueue(threadId: string, entry: QueueEntry, maxSize: number): Promise<number>;
/** Extend a lock's TTL */
extendLock(lock: Lock, ttlMs: number): Promise<boolean>;
/**
* Force-release a lock on a thread, regardless of ownership token.
* The previous lock holder's handler continues running — only the lock is released.
* The old handler's `releaseLock()` becomes a no-op (token mismatch).
*/
forceReleaseLock(threadId: string): Promise<void>;
/** Get a cached value by key */
get<T = unknown>(key: string): Promise<T | null>;
/** Read all values from a list in insertion order. Returns empty array if key does not exist. */
getList<T = unknown>(key: string): Promise<T[]>;
/** Check if subscribed to a thread */
isSubscribed(threadId: string): Promise<boolean>;
/** Get the current queue depth for a thread. */
queueDepth(threadId: string): Promise<number>;
/** Release a lock */
releaseLock(lock: Lock): Promise<void>;
/** Set a cached value with optional TTL in milliseconds */
set<T = unknown>(key: string, value: T, ttlMs?: number): Promise<void>;
/** Atomically set a value only if the key does not already exist. Returns true if set, false if key existed. */
setIfNotExists(key: string, value: unknown, ttlMs?: number): Promise<boolean>;
/** Subscribe to a thread (persists across restarts) */
subscribe(threadId: string): Promise<void>;
/** Unsubscribe from a thread */
unsubscribe(threadId: string): Promise<void>;
}
interface Lock {
expiresAt: number;
threadId: string;
token: string;
}
/**
* Base interface for entities that can receive messages.
* Both Thread and Channel extend this interface.
*
* @template TState - Custom state type stored per entity
* @template TRawMessage - Platform-specific raw message type
*/
interface Postable<TState = Record<string, unknown>, TRawMessage = unknown> {
/** The adapter this entity belongs to */
readonly adapter: Adapter;
/** The visibility scope of this channel */
readonly channelVisibility: ChannelVisibility;
/** Unique ID */
readonly id: string;
/** Whether this is a direct message conversation */
readonly isDM: boolean;
/**
* Get a platform-specific mention string for a user.
*/
mentionUser(userId: string): string;
/**
* Iterate messages newest first (backward from most recent).
* Auto-paginates lazily — only fetches pages as consumed.
*/
readonly messages: AsyncIterable<Message<TRawMessage>>;
/**
* Post a message.
*/
post<T extends PostableObject>(message: T): Promise<T>;
post(message: string | PostableMessage | ChatElement): Promise<SentMessage<TRawMessage>>;
/**
* Post an ephemeral message visible only to a specific user.
*/
postEphemeral(user: string | Author, message: AdapterPostableMessage | ChatElement, options: PostEphemeralOptions): Promise<EphemeralMessage<TRawMessage> | null>;
/**
* Schedule a message for future delivery.
*
* Currently only supported by the Slack adapter. Other adapters
* will throw NotImplementedError.
*
* @param message - The message content (streaming not supported)
* @param options - Scheduling options including the target delivery time
* @returns A ScheduledMessage with cancel() capability
*
* @example
* ```typescript
* const scheduled = await thread.schedule("Reminder: standup!", {
* postAt: new Date("2026-03-09T09:00:00Z"),
* });
*
* // Cancel before it's sent
* await scheduled.cancel();
* ```
*/
schedule(message: AdapterPostableMessage | ChatElement, options: {
postAt: Date;
}): Promise<ScheduledMessage<TRawMessage>>;
/**
* Set the state. Merges with existing state by default.
*/
setState(state: Partial<TState>, options?: {
replace?: boolean;
}): Promise<void>;
/** Show typing indicator */
startTyping(status?: string): Promise<void>;
/**
* Get the current state.
* Returns null if no state has been set.
*/
readonly state: Promise<TState | null>;
}
/**
* Represents a channel/conversation container that holds threads.
* Extends Postable for message posting capabilities.
*
* @template TState - Custom state type stored per channel
* @template TRawMessage - Platform-specific raw message type
*/
interface Channel<TState = Record<string, unknown>, TRawMessage = unknown> extends Postable<TState, TRawMessage> {
/** Fetch channel metadata from the platform */
fetchMetadata(): Promise<ChannelInfo>;
/** Channel name (e.g., "#general"). Null until fetchInfo() is called. */
readonly name: string | null;
/**
* Iterate threads in this channel, most recently active first.
* Returns ThreadSummary (lightweight) for efficiency.
* Empty iterable on threadless platforms.
*/
threads(): AsyncIterable<ThreadSummary<TRawMessage>>;
/**
* Serialize the channel to a plain JSON object.
* Use this to pass channel data to external systems like workflow engines.
*/
toJSON(): SerializedChannel;
}
/**
* Lightweight summary of a thread within a channel.
*/
interface ThreadSummary<TRawMessage = unknown> {
/** Full thread ID */
id: string;
/** Timestamp of most recent reply */
lastReplyAt?: Date;
/** Reply count (if available) */
replyCount?: number;
/** Root/first message of the thread */
rootMessage: Message<TRawMessage>;
}
/**
* Channel metadata returned by fetchInfo().
*/
interface ChannelInfo {
/** The visibility scope of this channel */
channelVisibility?: ChannelVisibility;
id: string;
isDM?: boolean;
memberCount?: number;
metadata: Record<string, unknown>;
name?: string;
}
/**
* Options for listing threads in a channel.
*/
interface ListThreadsOptions {
cursor?: string;
limit?: number;
}
/**
* Result of listing threads in a channel.
*/
interface ListThreadsResult<TRawMessage = unknown> {
nextCursor?: string;
threads: ThreadSummary<TRawMessage>[];
}
/** Default TTL for thread state (30 days in milliseconds) */
declare const THREAD_STATE_TTL_MS: number;
/**
* Thread interface with support for custom state.
* Extends Postable for shared message posting capabilities.
*
* @template TState - Custom state type stored per-thread (default: Record<string, unknown>)
* @template TRawMessage - Platform-specific raw message type
*/
interface Thread<TState = Record<string, unknown>, TRawMessage = unknown> extends Postable<TState, TRawMessage> {
/**
* Async iterator for all messages in the thread.
* Messages are yielded in chronological order (oldest first).
* Automatically handles pagination.
*/
allMessages: AsyncIterable<Message<TRawMessage>>;
/** Get the Channel containing this thread */
readonly channel: Channel<TState, TRawMessage>;
/** Channel/conversation ID */
readonly channelId: string;
/**
* Wrap a Message object as a SentMessage with edit/delete capabilities.
* Used internally for reconstructing messages from serialized data.
*/
createSentMessageFromMessage(message: Message<TRawMessage>): SentMessage<TRawMessage>;
/**
* Get the unique human participants in this thread.
*
* Scans all messages in the thread and returns deduplicated authors,
* excluding the bot itself. Useful for deciding whether to subscribe
* based on how many humans are participating — subscribe when it's a
* 1:1 conversation, unsubscribe when others join so humans can talk
* without the bot replying to every message.
*
* @returns Array of unique non-bot authors
*
* @example
* ```typescript
* // Subscribe only when one person is talking to the bot
* bot.onNewMention(async (thread, message) => {
* const participants = await thread.getParticipants();
* if (participants.length === 1) {
* await thread.subscribe();
* await thread.post("I'm here to help!");
* }
* });
*
* // Unsubscribe when the thread becomes a group conversation
* bot.onSubscribedMessage(async (thread, message) => {
* const participants = await thread.getParticipants();
* if (participants.length > 1) {
* await thread.unsubscribe();
* return;
* }
* await thread.post("Still here to help!");
* });
* ```
*/
getParticipants(): Promise<Author[]>;
/**
* Check if this thread is currently subscribed.
*
* In subscribed message handlers, this is optimized to return true immediately
* without a state lookup, since we already know we're in a subscribed context.
*
* @returns Promise resolving to true if subscribed, false otherwise
*/
isSubscribed(): Promise<boolean>;
/**
* Get a platform-specific mention string for a user.
* Use this to @-mention a user in a message.
* @example
* await thread.post(`Hey ${thread.mentionUser(userId)}, check this out!`);
*/
mentionUser(userId: string): string;
/**
* Post a message to this thread.
*
* Supports text, markdown, cards, and streaming from async iterables.
* When posting a stream (e.g., from AI SDK), uses platform-native streaming
* APIs when available (Slack), or falls back to post + edit with throttling.
*
* @param message - String, PostableMessage, JSX Card, or AsyncIterable<string>
* @returns A SentMessage with methods to edit, delete, or add reactions
*
* @example