---
title: History
description: Store and retrieve message history across user, thread, and channel scopes.
type: guide
prerequisites:
  - /docs/state-adapters
related:
  - /docs/handling-events
  - /docs/api/history
---

# History



The History API reads and persists message history in three scopes, keyed by user, thread, or channel. The user scope is stored in your state adapter. Thread history comes from platform APIs, with an optional cache in the state adapter. Channel history reads from the platform through adapter methods.

| Scope   | Access                | Keyed by                | Typical use                                       |
| ------- | --------------------- | ----------------------- | ------------------------------------------------- |
| User    | `bot.history.user`    | Cross-platform user key | LLM context, audit trail, GDPR                    |
| Thread  | `bot.history.thread`  | Thread ID               | Backfill for adapters without server-side history |
| Channel | `bot.history.channel` | Channel ID              | Channel-level summaries, moderation               |

<Callout type="info">
  `bot.history` is the unified successor to `bot.transcripts`. The user scope is a drop-in replacement. See [Migrating from `bot.transcripts`](#migrating-from-bottranscripts) below.
</Callout>

## Setup

All three scopes are available once you configure a state adapter. The user scope also needs an `identity` resolver:

```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! }),

  history: {
    user: {
      // Required. Maps each inbound message to a stable cross-platform key.
      // Return null to skip persistence for that message.
      identity: ({ author }) => author.email ?? null,

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

`history.user` requires an identity resolver. Set `history.user.identity`, or keep the deprecated top-level `identity` field during migration. Omitting both when `history.user` (or legacy `transcripts`) is set throws at construction.

Set `history.user.maxPerUser: false` to disable count-based eviction. Omit `retention` as well to keep entries without a TTL. The state adapter must use durable storage to retain history across restarts. The legacy `transcripts.maxPerUser` field also accepts `false`.

Thread and channel scopes are always available on `bot.history`. Use `history.thread` to tune the thread cache, which is used by adapters with `persistThreadHistory: true` such as Telegram and WhatsApp:

```typescript
const bot = new Chat({
  // ...
  history: {
    thread: {
      maxMessages: 200,
      ttlMs: 14 * 24 * 60 * 60 * 1000, // 14 days
    },
  },
});
```

## User history

`bot.history.user` stores a per-user transcript keyed by a stable cross-platform identifier. The same user can talk to your bot on Slack and Discord and see the same accumulated history.

### Building LLM context

The most common pattern is to append the user's message, build a prompt from recent history, post the reply, and then append the reply.

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

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

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

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

A few details about this pattern:

* The SDK sets `msg.userKey` from your `identity` resolver before your handler runs. If the resolver returned `null`, `msg.userKey` stays `undefined` and `append` does nothing.
* Bot replies are stored only when you append them. The SDK does not capture `thread.post()` output, so you decide what gets persisted. That matters for retries and intermediate streaming chunks.
* `list` returns entries oldest first, the order a model expects. Use `limit` to keep prompts bounded.

### Filtering entries

```typescript
// Recent 50 across all platforms (default)
await bot.history.user.list({ userKey: "mike@acme.com" });

// Newest 20 only
await bot.history.user.list({ userKey: "mike@acme.com", limit: 20 });

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

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

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

### Deleting a user's history

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

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

This deletes every entry stored under the key. Single-entry and time-range deletes are not supported, because the underlying `appendToList` primitive can't do them safely under concurrent writes.

## Thread history

`bot.history.thread` reads thread messages through `adapter.fetchMessages` and keeps a per-thread cache for adapters that lack server-side message history APIs and set `persistThreadHistory: true`, such as Telegram and WhatsApp. For those adapters, `list()` falls back to the cache when the adapter returns an empty first page. For every other adapter, the platform response is authoritative.

```typescript title="lib/bot.ts" lineNumbers
bot.onSubscribedMessage(async (thread, message) => {
  // bot.history.thread.list() handles both cases: it delegates to
  // adapter.fetchMessages, and on adapters that persist history in the
  // SDK-maintained cache (persistThreadHistory) falls back to that cache
  // when the adapter returns an empty first page.
  const { messages } = await bot.history.thread.list(thread.id, { limit: 20 });

  const reply = await generateReply(messages, message);
  await thread.post(reply);
});
```

<Callout type="info">
  Thread history is populated automatically when `adapter.persistThreadHistory` is `true`. You don't need to append manually. For adapters with server-side history (Slack, Teams, Discord), prefer `thread.messages` or `adapter.fetchMessages`.
</Callout>

## Channel history

`bot.history.channel` reads top-level channel messages and lists threads. It delegates to the adapter, and its methods throw when the adapter doesn't implement the underlying capability, such as `listThreads` on WhatsApp.

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

  const { messages } = await bot.history.channel.listMessages(channelId, {
    limit: 10,
  });

  const { threads } = await bot.history.channel.listThreads(channelId, {
    limit: 20,
  });

  const reply = await generateChannelSummary(messages, threads);
  await thread.post(reply);
});
```

## Identity resolution

The identity resolver can live on `history.user.identity` (preferred) or the deprecated top-level `ChatConfig.identity` field. It runs once per inbound message:

```typescript
history: {
  user: {
    identity: async ({ adapter, author, message }) => {
      if (author.email) {
        return author.email;
      }
      // Map a platform user to an internal ID
      return await lookupUser(adapter, author.userId);
    },
    retention: "30d",
  },
}
```

Return `null` when you can't resolve a key. The SDK won't fall back to a platform-specific ID, because that would split a user's history across platforms without any warning.

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

## Migrating from `bot.transcripts`

`bot.transcripts` is deprecated in favor of `bot.history.user`. The API is identical, so you rename the config key and the call sites:

```typescript
// Before
const bot = new Chat({
  identity: ({ author }) => author.email ?? null,
  transcripts: { retention: "30d", maxPerUser: 200 },
});

await bot.transcripts.append(thread, msg);
const entries = await bot.transcripts.list({ userKey, limit: 20 });
await bot.transcripts.delete({ userKey });

// After: identity on history.user (top-level identity still works during migration)
const bot = new Chat({
  history: {
    user: {
      identity: ({ author }) => author.email ?? null,
      retention: "30d",
      maxPerUser: 200,
    },
  },
});

await bot.history.user.append(thread, msg);
const entries = await bot.history.user.list({ userKey, limit: 20 });
await bot.history.user.delete({ userKey });
```

`bot.transcripts` continues to work and will not be removed in the current major version.

You can migrate one field at a time: when both `history.user` and the legacy `transcripts` block are set, they merge, with `history.user` winning field by field. Settings like `retention` and `maxPerUser` left on `transcripts` keep applying until you move them.

## Storage

The user scope and the thread cache use `StateAdapter.appendToList` and `getList`. Channel scope reads from platform APIs and is not persisted in state by default.

| Scope                  | Storage / source                                  | Key or API                                                     |
| ---------------------- | ------------------------------------------------- | -------------------------------------------------------------- |
| User                   | State adapter                                     | `transcripts:user:{userKey}`                                   |
| Thread cache           | State adapter (when `persistThreadHistory: true`) | `msg-history:{threadId}`                                       |
| Thread / channel reads | Platform adapter                                  | `adapter.fetchMessages`, `fetchChannelMessages`, `listThreads` |

On channel-scoped platforms such as WhatsApp and Telegram, inbound messages may also be appended under `msg-history:{channelId}`. `bot.history.channel.listMessages` reads from that cache only when the adapter doesn't implement `fetchChannelMessages`.

Appends to user and thread cache keys are atomic, so concurrent inbound messages on the same key don't race.

## Reference

See [History API reference](/docs/api/history) for full type signatures, configuration options, and entry shapes.


---

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)