Conversation History
Persist messages per user across every platform — for LLM context, audit, or compliance.
Bots that hold context across a user's conversations need somewhere to store it. The platform's own message history won't do — a user might talk to your bot in Slack today and Discord tomorrow, and you want the same memory to follow them.
bot.transcripts keeps a per-user transcript in your state adapter, keyed by a stable identifier you choose (an email, an internal user ID, anything that's the same person no matter where they are).
Setup
You opt in by setting two fields on ChatConfig:
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! }),
// Resolve the cross-platform identifier for an inbound message.
// Return null for messages you don't want to remember.
identity: ({ author }) => author.email ?? null,
// Storage tuning. retention is the list TTL, refreshed on every append.
transcripts: {
retention: "30d",
maxPerUser: 200,
},
});transcripts and identity are paired — set one without the other and the constructor throws. This keeps the API loud rather than silently no-op'ing on every call.
Building LLM context
The most common pattern: append the user's message, build a prompt from recent transcript entries, post the reply, append the reply too.
bot.onSubscribedMessage(async (thread, msg) => {
await bot.transcripts.append(thread, msg);
const recent = await bot.transcripts.list({
userKey: msg.userKey!,
limit: 20,
});
const reply = await generateReply(recent, msg);
await thread.post(reply);
await bot.transcripts.append(
thread,
{ role: "assistant", text: reply },
{ userKey: msg.userKey! }
);
});A few things worth knowing:
msg.userKeyis set automatically from youridentityresolver before your handler runs. If the resolver returnednull, it staysundefinedand theappendcall no-ops.- Bot replies are explicit. The SDK doesn't auto-capture
thread.post()output — you decide what gets remembered. That's important for retries, intermediate streaming chunks, and anything you don't want feeding back into the model later. - Order is chronological.
listreturns oldest-first, ready to feed into a model. Setlimitto keep prompts bounded.
Identity resolution
identity runs once per inbound message during dispatch. The author, message, and adapter name are all available:
identity: async ({ adapter, author, message }) => {
// Look up by email when the platform exposes it
if (author.email) {
return author.email;
}
// Or map a platform user to an internal ID
return await lookupUser(adapter, author.userId);
}Return null when you can't resolve a key. The SDK won't fall back to a platform-specific ID — that would silently fragment a user's transcript across platforms, which is exactly what this feature is here to prevent.
If your resolver throws, the SDK logs a warning and dispatches the message without a userKey. Handlers still run; only the persistence is skipped.
Filtering entries
list accepts a few filters. They compose, and they're applied after getList — useful for narrowing prompts without restructuring storage.
// Recent N across all platforms
await bot.transcripts.list({ userKey: "mike@acme.com", limit: 50 });
// Single platform
await bot.transcripts.list({ userKey: "mike@acme.com", platforms: ["slack"] });
// Single thread
await bot.transcripts.list({
userKey: "mike@acme.com",
threadId: "slack:C123:1234.5678",
});
// Only the user's own messages
await bot.transcripts.list({ userKey: "mike@acme.com", roles: ["user"] });Deleting a user's transcript
For data-subject requests or simple "forget me" flows:
await bot.transcripts.delete({ userKey: "mike@acme.com" });
// → { deleted: 47 }This wipes every entry stored under the key. Single-entry and time-range deletes aren't part of the API — appendToList doesn't support them safely under concurrent writes.
Where it's stored
bot.transcripts is backed by StateAdapter.appendToList / getList / delete. Every built-in state adapter (memory, redis, ioredis, pg) supports these primitives, so this works on whichever one you've already configured.
Entries are written under the key transcripts:user:{userKey} as a capped list. appendToList is atomic, so concurrent inbound messages on the same user don't race.
Reference
See Transcripts for full type signatures, configuration options, and the entry shape.
Read more
Handling Events
Register handlers for mentions, messages, reactions, member joins, and platform-specific events.
Transcripts
Cross-platform per-user transcript persistence — configuration, methods, and entry shape.
State Adapters
Pluggable state adapters for thread subscriptions, distributed locking, and caching.
Message Subject
Fetch the parent resource that a message is about.