---
title: Thread
description: Represents a conversation thread with methods for posting, subscribing, and state management.
type: reference
related:
  - /docs/threads-messages-channels
  - /docs/posting-messages
---

# Thread



A `Thread` represents a conversation thread on any platform. Event handlers receive one, and you can also create thread handles with `chat.thread()` or `chat.openDM()`.

## Properties

<TypeTable
  type={{
  id: {
    description: 'Full thread ID in `adapter:channel:thread` format.',
    type: 'string',
  },
  channelId: {
    description: 'Channel or 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 (AI SDK fullStream recommended for multi-step agents)
// TanStack AI chat() streams are also accepted
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 | ChatElement`

`ChatElement` covers card and modal elements, whether you write them in JSX or build them with functions such as `Card()`.

**Returns:** `Promise<SentMessage | PostableObject>`. Plain messages and streams return a `SentMessage` with `edit()`, `delete()`, `addReaction()`, and `removeReaction()` methods. `Plan` and `StreamingPlan` inputs return the same object so you can keep mutating it.

See [Posting Messages](/docs/posting-messages) for details on each format.

## reply

Post a message with a native reference to another message.

```typescript
await thread.reply(message, {
  markdown: "Thanks, I can help with that.",
});
```

**Parameters:** `target: string | Message`, `message: string | AdapterPostableMessage | AsyncIterable<string | StreamChunk | StreamEvent> | ChatElement`

**Returns:** `Promise<SentMessage>`

The target `Message` must belong to the same thread. Streams are buffered before sending. Unlike `post()`, `reply()` does not accept `Plan` or `StreamingPlan`. Adapters without native message reply support throw `NotImplementedError`.

Prefer passing the `Message` over its ID. The `Message` is checked against this thread and carried through to `SentMessage.replyTo` and cached thread history. A bare ID is only resolved to a `Message` when the thread already holds it in `recentMessages`; otherwise the reply is still sent, but `replyTo` is left undefined.

## 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. Streams are not supported.',
    type: 'AdapterPostableMessage | ChatElement',
  },
  '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. Only the Slack adapter supports this. 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: AdapterPostableMessage | ChatElement`, `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 and excludes all bots. Use it to subscribe only to 1:1 conversations and unsubscribe 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, which takes multiple platform API calls on threads with long history. 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 through 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. With Slack Agent messaging, calling this without a custom status sets the
session to `processing` and identifies the initiating user. Slack's native stop
button also requires the `agent_session_stopped` event subscription.

A custom status uses the legacy `assistant.threads.setStatus` compatibility
bridge for loading labels instead. That endpoint cannot receive the initiating
user, and an existing native processing indicator can take precedence over the
label. Use the default indicator when you need native session behavior.

```typescript
await thread.startTyping();

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

## signal

An `AbortSignal` for the active turn. Slack aborts it when the user clicks the
native agent-session stop button, including when the stop webhook reaches a
different serverless instance sharing the same state adapter.

Pass it to model and tool APIs so cancellation stops upstream work:

```typescript
const result = await agent.stream({
  prompt: message.text,
  abortSignal: thread.signal,
});
await thread.post(result.fullStream);
```

Threads created outside an incoming handler expose a signal that remains
unaborted.

## markAsRead

Mark an inbound message as read. WhatsApp, Messenger, and XChat support this capability. Other adapters throw `NotImplementedError`.

Inside a message handler, omit the argument to mark the current message:

```typescript
bot.onDirectMessage(async (thread) => {
  await thread.markAsRead();
});
```

Pass a `Message` or message ID when targeting a message explicitly:

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

A `Message` must belong to the thread, and passing one from another thread throws. A bare message ID is sent to the adapter as given, since there is nothing to check it against. Calling `markAsRead()` without an argument outside a message handler throws because there is no current message.

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

## 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 when 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!");
```

The reviver calls `ThreadImpl.fromJSON()` and `Message.fromJSON()` for any serialized objects it encounters.

When a restored thread starts streaming, it uses its Chat instance's `fallbackStreamingPlaceholderText` and `streamingUpdateIntervalMs` defaults. `bot.reviver()` binds restored threads to that bot; standalone and Workflow deserialization bind to the registered singleton on first runtime access. These settings are not stored in the serialized payload, so resumed work uses the receiving bot's configuration. An explicit `StreamingPlan` update interval still takes precedence.

If you pass an adapter directly to `ThreadImpl.fromJSON(data, adapter)`, streaming can work without a registered Chat instance, using the default settings. Streaming defaults come only from a Chat instance that has that exact adapter registered, under any key. If the registered instance doesn't own the adapter, state still comes from the registered singleton, as in earlier versions. Prefer `bot.reviver()` to bind ownership explicitly.

## 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 | ChatElement) => 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>',
  },
}}
/>


---

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)