chat 4.27.0 → 4.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/ai/index.d.ts +501 -0
  2. package/dist/ai/index.js +500 -0
  3. package/dist/chat-D9UYaaNO.d.ts +3156 -0
  4. package/dist/chunk-HD375J7S.js +128 -0
  5. package/dist/{chunk-AN7MRAVW.js → chunk-V25FKIIL.js} +5 -1
  6. package/dist/index.d.ts +35 -2934
  7. package/dist/index.js +567 -210
  8. package/dist/{jsx-runtime-Co9uV6l7.d.ts → jsx-runtime-CFq1K_Ve.d.ts} +11 -1
  9. package/dist/jsx-runtime.d.ts +1 -1
  10. package/dist/jsx-runtime.js +1 -1
  11. package/docs/actions.mdx +52 -1
  12. package/docs/adapters.mdx +72 -36
  13. package/docs/ai/ai-sdk-tools.mdx +227 -0
  14. package/docs/ai/index.mdx +63 -0
  15. package/docs/ai/meta.json +4 -0
  16. package/docs/{api → ai}/to-ai-messages.mdx +16 -3
  17. package/docs/ai/types.mdx +243 -0
  18. package/docs/api/cards.mdx +4 -0
  19. package/docs/api/chat.mdx +132 -10
  20. package/docs/api/index.mdx +6 -6
  21. package/docs/api/markdown.mdx +28 -5
  22. package/docs/api/message.mdx +54 -1
  23. package/docs/api/meta.json +1 -0
  24. package/docs/api/modals.mdx +50 -0
  25. package/docs/api/postable-message.mdx +58 -4
  26. package/docs/api/thread.mdx +11 -3
  27. package/docs/api/transcripts.mdx +220 -0
  28. package/docs/cards.mdx +6 -0
  29. package/docs/concurrency.mdx +58 -15
  30. package/docs/contributing/building.mdx +74 -2
  31. package/docs/contributing/testing.mdx +4 -0
  32. package/docs/conversation-history.mdx +137 -0
  33. package/docs/direct-messages.mdx +23 -5
  34. package/docs/ephemeral-messages.mdx +1 -1
  35. package/docs/error-handling.mdx +15 -3
  36. package/docs/files.mdx +21 -1
  37. package/docs/handling-events.mdx +10 -7
  38. package/docs/index.mdx +8 -5
  39. package/docs/meta.json +17 -3
  40. package/docs/modals.mdx +24 -0
  41. package/docs/posting-messages.mdx +10 -4
  42. package/docs/slash-commands.mdx +4 -4
  43. package/docs/streaming.mdx +75 -27
  44. package/docs/subject.mdx +53 -0
  45. package/docs/testing.mdx +142 -0
  46. package/docs/threads-messages-channels.mdx +10 -1
  47. package/docs/usage.mdx +15 -2
  48. package/package.json +23 -2
  49. package/resources/guides/how-to-build-an-ai-agent-for-slack-with-chat-sdk-and-ai-sdk.md +1 -1
@@ -7,9 +7,14 @@ type: reference
7
7
  `PostableMessage` is the union of all message formats accepted by `thread.post()` and `sent.edit()`.
8
8
 
9
9
  ```typescript
10
- type PostableMessage = AdapterPostableMessage | AsyncIterable<string | StreamChunk | StreamEvent>;
10
+ type PostableMessage =
11
+ | AdapterPostableMessage
12
+ | AsyncIterable<string | StreamChunk | StreamEvent>
13
+ | PostableObject;
11
14
  ```
12
15
 
16
+ `PostableObject` covers `Plan` (mutable task lists) and `StreamingPlan` (streams with platform-specific options) — both documented below.
17
+
13
18
  ## String
14
19
 
15
20
  Raw text passed through as-is to the platform.
@@ -33,7 +38,7 @@ await thread.post({ raw: "Hello world" });
33
38
  type: 'string',
34
39
  },
35
40
  attachments: {
36
- description: 'File/image attachments.',
41
+ description: 'Typed media attachments for adapters that support outgoing attachments.',
37
42
  type: 'Attachment[]',
38
43
  },
39
44
  files: {
@@ -58,7 +63,7 @@ await thread.post({ markdown: "**Bold** and _italic_" });
58
63
  type: 'string',
59
64
  },
60
65
  attachments: {
61
- description: 'File/image attachments.',
66
+ description: 'Typed media attachments for adapters that support outgoing attachments.',
62
67
  type: 'Attachment[]',
63
68
  },
64
69
  files: {
@@ -87,7 +92,7 @@ await thread.post({
87
92
  type: 'Root',
88
93
  },
89
94
  attachments: {
90
- description: 'File/image attachments.',
95
+ description: 'Typed media attachments for adapters that support outgoing attachments.',
91
96
  type: 'Attachment[]',
92
97
  },
93
98
  files: {
@@ -133,6 +138,55 @@ await thread.post({
133
138
  }}
134
139
  />
135
140
 
141
+ ## Plan
142
+
143
+ A `Plan` is a step-by-step task list that mutates after posting. Each `addTask` / `updateTask` / `complete` call re-renders the same message in place. See [Plan API](/docs/streaming#plan-api) for full usage.
144
+
145
+ ```typescript
146
+ import { Plan } from "chat";
147
+
148
+ const plan = new Plan({ initialMessage: "Researching options..." });
149
+ await thread.post(plan);
150
+ await plan.addTask({ title: "Look up records" });
151
+ await plan.complete({ completeMessage: "Done!" });
152
+ ```
153
+
154
+ Adapters that don't support `PostableObject` editing render the plan as fallback text and ignore subsequent mutations.
155
+
156
+ ## StreamingPlan
157
+
158
+ Wraps an async iterable with platform-specific streaming options. Use this when you need to pass options like task grouping or stop blocks through `thread.post()`. See [Streaming with options](/docs/streaming#streaming-with-options).
159
+
160
+ ```typescript
161
+ import { StreamingPlan } from "chat";
162
+
163
+ const planned = new StreamingPlan(stream, {
164
+ groupTasks: "plan",
165
+ endWith: [feedbackBlock],
166
+ updateIntervalMs: 750,
167
+ });
168
+
169
+ await thread.post(planned);
170
+ ```
171
+
172
+ <TypeTable
173
+ type={{
174
+ groupTasks: {
175
+ description: 'Slack: render task_update chunks as `"plan"` (single grouped block) or `"timeline"` (inline cards, default).',
176
+ type: '"plan" | "timeline" | undefined',
177
+ },
178
+ endWith: {
179
+ description: 'Slack: Block Kit elements appended when the stream stops (e.g. retry / feedback buttons).',
180
+ type: 'unknown[] | undefined',
181
+ },
182
+ updateIntervalMs: {
183
+ description: 'Post+edit adapters: minimum interval between update cycles in ms.',
184
+ type: 'number | undefined',
185
+ default: '500',
186
+ },
187
+ }}
188
+ />
189
+
136
190
  ## AsyncIterable (streaming)
137
191
 
138
192
  An async iterable of strings, `StreamChunk` objects, or stream events. The SDK streams the message in real time using platform-native APIs where available.
@@ -39,7 +39,7 @@ A `Thread` is provided to your event handlers and represents a conversation thre
39
39
 
40
40
  ## post
41
41
 
42
- Post a message to the thread. Accepts strings, structured messages, cards, and streams.
42
+ Post a message to the thread. Accepts strings, structured messages, cards, streams, and `PostableObject` instances (`Plan`, `StreamingPlan`).
43
43
 
44
44
  ```typescript
45
45
  // Plain text
@@ -56,11 +56,19 @@ await thread.post(Card({ title: "Hi", children: [Text("Hello")] }));
56
56
 
57
57
  // Stream (fullStream recommended for multi-step agents)
58
58
  await thread.post(result.fullStream);
59
+
60
+ // Plan (mutable task list)
61
+ const plan = new Plan({ initialMessage: "Working..." });
62
+ await thread.post(plan);
63
+ await plan.addTask({ title: "Step 1" });
64
+
65
+ // Streaming with platform options
66
+ await thread.post(new StreamingPlan(stream, { groupTasks: "plan" }));
59
67
  ```
60
68
 
61
69
  **Parameters:** `message: string | PostableMessage | CardJSXElement`
62
70
 
63
- **Returns:** `Promise<SentMessage>` — the sent message with `edit()`, `delete()`, `addReaction()`, and `removeReaction()` methods.
71
+ **Returns:** `Promise<SentMessage | PostableObject>` — for plain messages and streams, a `SentMessage` with `edit()`, `delete()`, `addReaction()`, and `removeReaction()` methods; for `Plan` / `StreamingPlan` inputs, the same object is returned so you can keep mutating it.
64
72
 
65
73
  See [Posting Messages](/docs/posting-messages) for details on each format.
66
74
 
@@ -138,7 +146,7 @@ if (participants.length > 1) {
138
146
 
139
147
  ## subscribe / unsubscribe
140
148
 
141
- Manage thread subscriptions. Subscribed threads route all messages to `onSubscribedMessage` handlers.
149
+ 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.
142
150
 
143
151
  ```typescript
144
152
  await thread.subscribe();
@@ -0,0 +1,220 @@
1
+ ---
2
+ title: Transcripts
3
+ description: Cross-platform per-user transcript persistence — configuration, methods, and entry shape.
4
+ type: reference
5
+ ---
6
+
7
+ `bot.transcripts` provides per-user message persistence keyed by a stable cross-platform identifier. See the [Conversation history](/docs/conversation-history) guide for usage patterns.
8
+
9
+ ```typescript
10
+ import { Chat } from "chat";
11
+ ```
12
+
13
+ ## Configuration
14
+
15
+ `transcripts` and `identity` are configured on `ChatConfig`. Both must be set together — passing `transcripts` without `identity` throws at construction.
16
+
17
+ ### ChatConfig.transcripts
18
+
19
+ <TypeTable
20
+ type={{
21
+ retention: {
22
+ description: 'List TTL, refreshed on every append. Accepts ms or a duration string ("45s", "30m", "6h", "7d"). Omit for no expiry.',
23
+ type: 'number | DurationString | undefined',
24
+ },
25
+ maxPerUser: {
26
+ description: 'Hard cap per user. Older entries are evicted on append.',
27
+ type: 'number',
28
+ default: '200',
29
+ },
30
+ storeFormatted: {
31
+ description: 'Persist the mdast `formatted` field alongside `text`. Off by default to keep storage small.',
32
+ type: 'boolean',
33
+ default: 'false',
34
+ },
35
+ }}
36
+ />
37
+
38
+ ### ChatConfig.identity
39
+
40
+ ```typescript
41
+ identity: (context: IdentityContext) => string | null | Promise<string | null>;
42
+ ```
43
+
44
+ Called once per inbound message during dispatch. The result is attached to the `Message` instance as `message.userKey`. Return `null` to skip persistence for an event.
45
+
46
+ #### IdentityContext
47
+
48
+ <TypeTable
49
+ type={{
50
+ adapter: {
51
+ description: 'Adapter name (e.g. "slack", "discord").',
52
+ type: 'string',
53
+ },
54
+ author: {
55
+ description: 'Message author info.',
56
+ type: 'Author',
57
+ },
58
+ message: {
59
+ description: 'The inbound message.',
60
+ type: 'Message',
61
+ },
62
+ }}
63
+ />
64
+
65
+ ## Methods
66
+
67
+ Access via `bot.transcripts`. Throws if `transcripts` was not configured on the `Chat` instance.
68
+
69
+ ### append
70
+
71
+ Persist a `Message` (typically the inbound user message) or an `AppendInput` (typically a bot reply you just posted).
72
+
73
+ ```typescript
74
+ append(
75
+ thread: Postable,
76
+ message: Message | AppendInput,
77
+ options?: AppendOptions,
78
+ ): Promise<TranscriptEntry | null>;
79
+ ```
80
+
81
+ When `message` is a `Message`, `userKey` is read from the instance. If it's `undefined` (the resolver returned `null`), the call is a no-op and returns `null`. When `message` is an `AppendInput`, `options.userKey` is required.
82
+
83
+ #### AppendInput
84
+
85
+ <TypeTable
86
+ type={{
87
+ role: {
88
+ description: 'Role tag for the entry.',
89
+ type: '"user" | "assistant" | "system"',
90
+ },
91
+ text: {
92
+ description: 'Plain-text body.',
93
+ type: 'string',
94
+ },
95
+ formatted: {
96
+ description: 'Optional mdast AST. Only stored when `transcripts.storeFormatted` is true.',
97
+ type: 'FormattedContent | undefined',
98
+ },
99
+ platformMessageId: {
100
+ description: 'Platform-native message ID, when known.',
101
+ type: 'string | undefined',
102
+ },
103
+ }}
104
+ />
105
+
106
+ #### AppendOptions
107
+
108
+ <TypeTable
109
+ type={{
110
+ userKey: {
111
+ description: 'Required when appending an `AppendInput` (assistant or system role); ignored when appending a `Message`.',
112
+ type: 'string | undefined',
113
+ },
114
+ }}
115
+ />
116
+
117
+ ### list
118
+
119
+ Returns entries in chronological order (oldest first). When `limit` is set, returns the newest `N` entries — still chronologically.
120
+
121
+ ```typescript
122
+ list(query: ListQuery): Promise<TranscriptEntry[]>;
123
+ ```
124
+
125
+ #### ListQuery
126
+
127
+ <TypeTable
128
+ type={{
129
+ userKey: {
130
+ description: 'Cross-platform user key.',
131
+ type: 'string',
132
+ },
133
+ limit: {
134
+ description: 'Maximum entries returned. Cannot exceed `maxPerUser` because that is the storage cap.',
135
+ type: 'number',
136
+ default: '50',
137
+ },
138
+ platforms: {
139
+ description: 'Filter to a subset of adapter names.',
140
+ type: 'string[] | undefined',
141
+ },
142
+ threadId: {
143
+ description: 'Filter to a single thread.',
144
+ type: 'string | undefined',
145
+ },
146
+ roles: {
147
+ description: 'Filter to specific roles.',
148
+ type: '("user" | "assistant" | "system")[] | undefined',
149
+ },
150
+ }}
151
+ />
152
+
153
+ ### count
154
+
155
+ ```typescript
156
+ count(query: CountQuery): Promise<number>;
157
+ ```
158
+
159
+ Returns the total number of entries stored under the user key. `CountQuery` has a single field, `userKey: string`.
160
+
161
+ ### delete
162
+
163
+ ```typescript
164
+ delete(target: { userKey: string }): Promise<{ deleted: number }>;
165
+ ```
166
+
167
+ Wipes every entry stored under the user key. Returns the count that was removed. Single-entry and time-range deletes are not supported — the underlying `appendToList` primitive can't support them safely under concurrent writes.
168
+
169
+ ## TranscriptEntry
170
+
171
+ Returned by `append` and `list`.
172
+
173
+ <TypeTable
174
+ type={{
175
+ id: {
176
+ description: 'UUID assigned by the SDK at append time.',
177
+ type: 'string',
178
+ },
179
+ userKey: {
180
+ description: 'Cross-platform user key from the IdentityResolver.',
181
+ type: 'string',
182
+ },
183
+ role: {
184
+ description: 'Role tag.',
185
+ type: '"user" | "assistant" | "system"',
186
+ },
187
+ text: {
188
+ description: 'Plain-text body — canonical for prompt building.',
189
+ type: 'string',
190
+ },
191
+ formatted: {
192
+ description: 'mdast AST. Only present when `transcripts.storeFormatted` is true.',
193
+ type: 'FormattedContent | undefined',
194
+ },
195
+ platform: {
196
+ description: 'Originating adapter name.',
197
+ type: 'string',
198
+ },
199
+ threadId: {
200
+ description: 'Originating thread ID.',
201
+ type: 'string',
202
+ },
203
+ platformMessageId: {
204
+ description: 'Platform-native message ID, when known.',
205
+ type: 'string | undefined',
206
+ },
207
+ timestamp: {
208
+ description: 'ms-since-epoch, set at append time on the SDK side.',
209
+ type: 'number',
210
+ },
211
+ }}
212
+ />
213
+
214
+ ## Storage
215
+
216
+ Backed by `StateAdapter.appendToList` / `getList` / `delete`. Every built-in state adapter (`memory`, `redis`, `ioredis`, `pg`) supports these primitives.
217
+
218
+ Entries are stored under `transcripts:user:{userKey}` as a capped list. `appendToList` is atomic, so concurrent inbound messages don't race.
219
+
220
+ The `retention` value is applied as the list TTL and refreshed on every append. With `retention: "30d"`, a user who hasn't talked to the bot in 30 days has their transcript expire automatically.
package/docs/cards.mdx CHANGED
@@ -115,6 +115,12 @@ Set `actionType="modal"` to indicate the button opens a [modal](/docs/modals). T
115
115
  <Button id="open-feedback" actionType="modal">Give Feedback</Button>
116
116
  ```
117
117
 
118
+ Optional `callbackUrl` causes the action data to be POSTed to a URL when clicked. See [Callback URLs](/docs/actions#callback-urls) for details.
119
+
120
+ ```tsx title="lib/bot.tsx"
121
+ <Button callbackUrl={webhook.url} id="approve" style="primary">Approve</Button>
122
+ ```
123
+
118
124
  ### CardLink
119
125
 
120
126
  Inline hyperlink rendered as text. Unlike `LinkButton` (which must be inside `Actions`), `CardLink` can be placed directly in a card alongside other content.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Overlapping Messages
3
- description: Control how overlapping messages on the same thread are handled queue, debounce, drop, or process concurrently.
3
+ description: Control how overlapping messages on the same thread are handled - burst, queue, debounce, drop, or process concurrently.
4
4
  type: guide
5
5
  prerequisites:
6
6
  - /docs/handling-events
@@ -57,11 +57,49 @@ A done → drain: [B, C, D] → handler(D, { skipped: [B, C] })
57
57
  D done → queue empty → release lock
58
58
  ```
59
59
 
60
+ ### Burst
61
+
62
+ Waits for `debounceMs` before the first handler on an idle thread, then drains the collected burst like `queue`. The latest message is dispatched, and earlier messages in the burst are available as `context.skipped`.
63
+
64
+ Use this for assistant-style bots where users often send one logical turn as several short messages inside a small window and you want one response with full context. Compared to `debounce`, `burst` keeps the earlier messages in `context.skipped` instead of dropping them, and it flushes queued messages that arrive while the handler is running.
65
+
66
+ Choose `debounce` when the latest message replaces earlier ones, like rapid corrections. Choose `burst` when earlier messages still matter, like "hey", "quick question", and then the actual question. The tradeoff is that even a lone message waits for `debounceMs` before the handler runs.
67
+
68
+ ```typescript title="lib/bot.ts" lineNumbers
69
+ const bot = new Chat({
70
+ concurrency: { strategy: "burst", debounceMs: 1000 },
71
+ // ...
72
+ });
73
+
74
+ bot.onNewMention(async (thread, message, context) => {
75
+ const turn = [...(context?.skipped ?? []), message]
76
+ .map((m) => m.text)
77
+ .join("\n\n");
78
+
79
+ const response = await generateAIResponse(turn);
80
+ await thread.post(response);
81
+ });
82
+ ```
83
+
84
+ **Flow:**
85
+
86
+ ```
87
+ A arrives → acquire lock → enqueue A → sleep(debounceMs)
88
+ B arrives → lock busy → enqueue B
89
+ C arrives → lock busy → enqueue C
90
+ ... debounceMs elapses ...
91
+ → drain: [A, B, C] → handler(C, { skipped: [A, B] })
92
+ D arrives while C is running → lock busy → enqueue D
93
+ E arrives while C is running → lock busy → enqueue E
94
+ C done → drain: [D, E] → handler(E, { skipped: [D] })
95
+ E done → queue empty → release lock
96
+ ```
97
+
60
98
  ### Debounce
61
99
 
62
- Every message starts or resets a debounce timer. Only the **final message in a burst** is processed.
100
+ The first message waits for `debounceMs`. Messages that arrive during that window replace the pending message, so only the **final message in the burst window** is processed.
63
101
 
64
- This is particularly useful for platforms like **WhatsApp** and **Telegram** where users tend to send a flurry of short messages in quick succession instead of composing a single message "hey", "quick question", "how do I reset my password?" arriving as three separate webhooks within a few seconds. Without debounce, the bot would respond to "hey" before the actual question even arrives. With debounce, the SDK waits for a pause in the conversation and processes only the final message.
102
+ This is particularly useful for platforms like **WhatsApp** and **Telegram** where users tend to send a flurry of short messages in quick succession instead of composing a single message - "hey", "quick question", "how do I reset my password?" arriving as three separate webhooks within a few seconds. Without debounce, the bot would respond to "hey" before the actual question even arrives. With debounce, the SDK waits briefly and processes only the final message in the window.
65
103
 
66
104
  ```typescript title="lib/bot.ts" lineNumbers
67
105
  const bot = new Chat({
@@ -118,15 +156,19 @@ const bot = new Chat({
118
156
  | Option | Strategies | Default | Description |
119
157
  |--------|-----------|---------|-------------|
120
158
  | `strategy` | all | `"drop"` | The concurrency strategy to use |
121
- | `maxQueueSize` | queue, debounce | `10` | Maximum queued messages per thread |
122
- | `onQueueFull` | queue, debounce | `"drop-oldest"` | Whether to evict the oldest or reject the newest message when the queue is full |
123
- | `queueEntryTtlMs` | queue, debounce | `90000` | TTL for queued entries in milliseconds. Expired entries are discarded on dequeue |
124
- | `debounceMs` | debounce | `1500` | Debounce window in milliseconds |
159
+ | `maxQueueSize` | queue, burst | `10` | Maximum queued messages per thread |
160
+ | `onQueueFull` | queue, burst | `"drop-oldest"` | Whether to evict the oldest or reject the newest message when the queue is full |
161
+ | `queueEntryTtlMs` | queue, debounce, burst | `90000` | TTL for queued entries in milliseconds. Expired entries are discarded on dequeue |
162
+ | `debounceMs` | debounce, burst | `1500` | Debounce window in milliseconds |
125
163
  | `maxConcurrent` | concurrent | `Infinity` | Max concurrent handlers per thread |
126
164
 
165
+ <Callout type="warn">
166
+ `maxConcurrent` only applies to the `concurrent` strategy. Pairing it with any other strategy logs a warning and the value is ignored. Setting `maxConcurrent` to a value less than `1` throws at construction time — `0` would deadlock the strategy and is rejected up front.
167
+ </Callout>
168
+
127
169
  ## MessageContext
128
170
 
129
- All handler types (`onNewMention`, `onSubscribedMessage`, `onNewMessage`) accept an optional `MessageContext` as their last parameter. It is only populated when using the `queue` strategy and messages were skipped.
171
+ All handler types (`onNewMention`, `onSubscribedMessage`, `onNewMessage`) accept an optional `MessageContext` as their last parameter. It is populated when using the `queue` strategy for queued messages, and when using `burst` for a collapsed turn. A lone `burst` message receives `skipped: []` and `totalSinceLastHandler: 1`.
130
172
 
131
173
  ```typescript
132
174
  interface MessageContext {
@@ -182,7 +224,7 @@ const bot = new Chat({
182
224
 
183
225
  ## State adapter requirements
184
226
 
185
- The `queue` and `debounce` strategies require three additional methods on your state adapter:
227
+ The `queue`, `debounce`, and `burst` strategies require three additional methods on your state adapter:
186
228
 
187
229
  | Method | Description |
188
230
  |--------|-------------|
@@ -198,12 +240,12 @@ All strategies emit structured log events at `info` level:
198
240
 
199
241
  | Event | Strategy | Data |
200
242
  |-------|----------|------|
201
- | `message-queued` | queue | threadId, messageId, queueDepth |
202
- | `message-dequeued` | queue, debounce | threadId, messageId, skippedCount |
203
- | `message-dropped` | drop, queue | threadId, messageId, reason |
204
- | `message-expired` | queue, debounce | threadId, messageId |
243
+ | `message-queued` | queue, burst | threadId, messageId, queueDepth |
244
+ | `message-dequeued` | queue, debounce, burst | threadId, messageId, skippedCount for queue/burst |
245
+ | `message-dropped` | drop, queue, burst | threadId, messageId, reason |
246
+ | `message-expired` | queue, debounce, burst | threadId, messageId |
205
247
  | `message-superseded` | debounce | threadId, droppedId |
206
- | `message-debouncing` | debounce | threadId, messageId, debounceMs |
248
+ | `message-debouncing` | debounce, burst | threadId, messageId, debounceMs |
207
249
  | `message-debounce-reset` | debounce | threadId, messageId |
208
250
 
209
251
  ## Choosing a strategy
@@ -212,7 +254,8 @@ All strategies emit structured log events at `info` level:
212
254
  |----------|----------|-----|
213
255
  | Simple bots, one-shot commands | `drop` | No complexity, no queue overhead |
214
256
  | AI chatbots, customer support | `queue` | Never lose messages; handler sees full conversation context |
215
- | WhatsApp/Telegram bots, rapid corrections | `debounce` | Users send many short messages in quick succession; wait for a pause before responding |
257
+ | AI chatbots with multi-message user turns | `burst` | Wait for the idle burst window, then respond once with every message in that window |
258
+ | WhatsApp/Telegram bots, rapid corrections | `debounce` | Users send many short messages in quick succession; wait briefly and keep only the latest |
216
259
  | Stateless lookups, translations | `concurrent` | Maximum throughput, no ordering needed |
217
260
 
218
261
  ## Backward compatibility
@@ -370,7 +370,7 @@ parseMessage(raw: unknown): Message<unknown> {
370
370
 
371
371
  ### Sending messages
372
372
 
373
- Use `extractCard()` and `extractFiles()` from `@chat-adapter/shared` to check for rich content. Use your format converter's `renderPostable()` to convert the message to platform format.
373
+ Use `extractCard()` and `extractFiles()` from `@chat-adapter/shared` to check for rich content. Use `extractPostableAttachments()` if your adapter maps normalized `Attachment` objects to platform-native media uploads. Use your format converter's `renderPostable()` to convert the message to platform format.
374
374
 
375
375
  ```typescript title="src/adapter.ts" lineNumbers
376
376
  async postMessage(
@@ -414,6 +414,62 @@ async deleteMessage(threadId: string, messageId: string): Promise<void> {
414
414
  }
415
415
  ```
416
416
 
417
+ ### Buttons and callback URLs
418
+
419
+ When the host app passes a `Card` with `<Button callbackUrl={...}>`, the SDK rewrites each such button **before** your adapter sees the postable: the `callbackUrl` is stored in the state adapter under a short token, and the button's `value` field is replaced with `__cb:<16-hex-chars>` (21 characters total). Your adapter does not need to know this happens — just round-trip `button.value` through your platform's button payload.
420
+
421
+ What this means in practice:
422
+
423
+ 1. **Send side**: when rendering a `ButtonElement` to your platform's button payload, encode both `button.id` (the action ID) and `button.value` (which may be `undefined`, a user-supplied value, or a callback token). Pick a delimiter that cannot appear in either, and validate the encoded string fits the platform's limit.
424
+ 2. **Receive side**: when the user clicks a button, decode the platform payload back into `actionId` and `value`, and pass them to `chat.processAction({ actionId, value, ... })`. The SDK will detect the `__cb:` prefix, look up the stored callback URL, POST to it, and pass the original value (if any) to user `onAction` handlers.
425
+
426
+ Discord's adapter is a good reference — it joins the action ID and value with `\n` and validates against Discord's 100-character `custom_id` limit:
427
+
428
+ ```typescript title="src/cards.ts (discord)" lineNumbers
429
+ const DISCORD_CUSTOM_ID_DELIMITER = "\n";
430
+ const DISCORD_CUSTOM_ID_MAX_LENGTH = 100;
431
+
432
+ export function encodeDiscordCustomId(
433
+ actionId: string,
434
+ value?: string
435
+ ): string {
436
+ if (value == null || value === "") {
437
+ validateLength(actionId);
438
+ return actionId;
439
+ }
440
+ const encoded = `${actionId}${DISCORD_CUSTOM_ID_DELIMITER}${value}`;
441
+ validateLength(encoded);
442
+ return encoded;
443
+ }
444
+
445
+ export function decodeDiscordCustomId(customId: string): {
446
+ actionId: string;
447
+ value: string | undefined;
448
+ } {
449
+ const idx = customId.indexOf(DISCORD_CUSTOM_ID_DELIMITER);
450
+ if (idx === -1) {
451
+ return { actionId: customId, value: undefined };
452
+ }
453
+ // Use the FIRST delimiter only — values may legitimately contain "\n".
454
+ return {
455
+ actionId: customId.slice(0, idx),
456
+ value: customId.slice(idx + 1),
457
+ };
458
+ }
459
+ ```
460
+
461
+ <Callout type="info">
462
+ Platform button-data limits are the main constraint to plan for. Discord's
463
+ `custom_id` is 100 chars; Telegram's `callback_data` is 64 bytes. The SDK's
464
+ callback token is fixed at 21 chars (`__cb:` + 16 hex), so the worst-case
465
+ payload your encoding must fit is `actionId + delimiter + 21`. If the
466
+ encoded string exceeds the platform limit, throw a `ValidationError` from
467
+ `@chat-adapter/shared` so the host app fails fast at post time rather than
468
+ silently truncating.
469
+ </Callout>
470
+
471
+ Modals with `callbackUrl` are handled entirely inside the SDK via stored modal context — your adapter does not need any special handling. Just call `chat.processModalSubmit(event, contextId, { waitUntil })` from your webhook handler and the SDK will POST to the modal's `callbackUrl` (using `waitUntil` if you provide it, so the response is not blocked).
472
+
417
473
  ### Reactions
418
474
 
419
475
  Handle both `EmojiValue` objects and plain strings. `EmojiValue` has a `name` property and `toString()` method — there is no `unicode` field.
@@ -535,7 +591,7 @@ export class MatrixFormatConverter extends BaseFormatConverter {
535
591
  }
536
592
  ```
537
593
 
538
- For platforms with non-standard formatting (e.g., Slack's `mrkdwn`), implement custom parsing in `toAst()` and rendering in `fromAst()`. See the [Discord adapter](https://github.com/vercel/chat/blob/main/packages/adapter-discord/src/markdown.ts) for an example of handling platform-specific mention syntax.
594
+ For platforms with non-standard formatting, implement custom parsing in `toAst()` and rendering in `fromAst()`. See the [Discord adapter](https://github.com/vercel/chat/blob/main/packages/adapter-discord/src/markdown.ts) for an example of handling platform-specific mention syntax.
539
595
 
540
596
  ## Optional methods
541
597
 
@@ -639,3 +695,19 @@ import {
639
695
  cardToFallbackText, // Convert card to plain text
640
696
  } from "@chat-adapter/shared";
641
697
  ```
698
+
699
+ ### Token encryption
700
+
701
+ If your adapter persists OAuth tokens to a `StateAdapter`, encrypt them at rest with the shared AES-256-GCM helpers instead of rolling your own:
702
+
703
+ ```typescript
704
+ import {
705
+ encryptToken, // Encrypt a string into an EncryptedTokenData envelope
706
+ decryptToken, // Decrypt an envelope back to the original string
707
+ decodeKey, // Decode a hex-64 or base64-44 32-byte key (throws on wrong length)
708
+ isEncryptedTokenData, // Type guard for tolerating legacy plaintext records
709
+ type EncryptedTokenData,
710
+ } from "@chat-adapter/shared";
711
+ ```
712
+
713
+ Accept the key as an optional `encryptionKey` config field (auto-detected from a `*_ENCRYPTION_KEY` env var), encrypt on `setInstallation()`, decrypt on `getInstallation()`, and use `isEncryptedTokenData` to keep accepting plaintext records so operators can roll the key in without flushing existing installs. See `@chat-adapter/slack` and `@chat-adapter/linear` for reference implementations.
@@ -14,6 +14,10 @@ Chat SDK adapters are the trust boundary between your application and a platform
14
14
 
15
15
  All adapters in this repo use [vitest](https://vitest.dev) with `@vitest/coverage-v8`. Community adapters should follow the same convention.
16
16
 
17
+ <Callout type="info">
18
+ This page covers the hand-rolled patterns used inside this repo's `packages/`. If you're testing a bot or a custom adapter as a **consumer** of Chat SDK, use [`@chat-adapter/tests`](/docs/testing) — it ships factories and Vitest matchers that cover most of these patterns in a few lines.
19
+ </Callout>
20
+
17
21
  ## Unit tests
18
22
 
19
23
  ### Factory function