@trigger.dev/sdk 4.5.0-rc.5 → 4.5.0-rc.7

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 (213) hide show
  1. package/dist/commonjs/v3/ai.d.ts +178 -5
  2. package/dist/commonjs/v3/ai.js +603 -119
  3. package/dist/commonjs/v3/ai.js.map +1 -1
  4. package/dist/commonjs/v3/chat-client.js +3 -0
  5. package/dist/commonjs/v3/chat-client.js.map +1 -1
  6. package/dist/commonjs/v3/chat-react.js +10 -7
  7. package/dist/commonjs/v3/chat-react.js.map +1 -1
  8. package/dist/commonjs/v3/chat-server.d.ts +8 -0
  9. package/dist/commonjs/v3/chat-server.js +32 -10
  10. package/dist/commonjs/v3/chat-server.js.map +1 -1
  11. package/dist/commonjs/v3/chat-server.test.js +51 -0
  12. package/dist/commonjs/v3/chat-server.test.js.map +1 -1
  13. package/dist/commonjs/v3/chat.js +34 -6
  14. package/dist/commonjs/v3/chat.js.map +1 -1
  15. package/dist/commonjs/v3/chat.test.js +53 -0
  16. package/dist/commonjs/v3/chat.test.js.map +1 -1
  17. package/dist/commonjs/v3/createStartSessionAction.test.js +30 -0
  18. package/dist/commonjs/v3/createStartSessionAction.test.js.map +1 -1
  19. package/dist/commonjs/v3/sessions.d.ts +11 -6
  20. package/dist/commonjs/v3/sessions.js +10 -5
  21. package/dist/commonjs/v3/sessions.js.map +1 -1
  22. package/dist/commonjs/v3/test/mock-chat-agent.d.ts +6 -0
  23. package/dist/commonjs/v3/test/mock-chat-agent.js +1 -0
  24. package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
  25. package/dist/commonjs/version.js +1 -1
  26. package/dist/esm/v3/ai.d.ts +178 -5
  27. package/dist/esm/v3/ai.js +603 -120
  28. package/dist/esm/v3/ai.js.map +1 -1
  29. package/dist/esm/v3/chat-client.js +3 -0
  30. package/dist/esm/v3/chat-client.js.map +1 -1
  31. package/dist/esm/v3/chat-react.js +10 -7
  32. package/dist/esm/v3/chat-react.js.map +1 -1
  33. package/dist/esm/v3/chat-server.d.ts +8 -0
  34. package/dist/esm/v3/chat-server.js +32 -10
  35. package/dist/esm/v3/chat-server.js.map +1 -1
  36. package/dist/esm/v3/chat-server.test.js +51 -0
  37. package/dist/esm/v3/chat-server.test.js.map +1 -1
  38. package/dist/esm/v3/chat.js +34 -6
  39. package/dist/esm/v3/chat.js.map +1 -1
  40. package/dist/esm/v3/chat.test.js +53 -0
  41. package/dist/esm/v3/chat.test.js.map +1 -1
  42. package/dist/esm/v3/createStartSessionAction.test.js +30 -0
  43. package/dist/esm/v3/createStartSessionAction.test.js.map +1 -1
  44. package/dist/esm/v3/sessions.d.ts +11 -6
  45. package/dist/esm/v3/sessions.js +10 -5
  46. package/dist/esm/v3/sessions.js.map +1 -1
  47. package/dist/esm/v3/test/mock-chat-agent.d.ts +6 -0
  48. package/dist/esm/v3/test/mock-chat-agent.js +1 -0
  49. package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
  50. package/dist/esm/version.js +1 -1
  51. package/docs/ai/prompts.mdx +430 -0
  52. package/docs/ai-chat/actions.mdx +115 -0
  53. package/docs/ai-chat/anatomy.mdx +71 -0
  54. package/docs/ai-chat/backend.mdx +817 -0
  55. package/docs/ai-chat/background-injection.mdx +221 -0
  56. package/docs/ai-chat/changelog.mdx +850 -0
  57. package/docs/ai-chat/chat-local.mdx +174 -0
  58. package/docs/ai-chat/client-protocol.mdx +1081 -0
  59. package/docs/ai-chat/compaction.mdx +411 -0
  60. package/docs/ai-chat/custom-agents.mdx +364 -0
  61. package/docs/ai-chat/error-handling.mdx +415 -0
  62. package/docs/ai-chat/fast-starts.mdx +672 -0
  63. package/docs/ai-chat/frontend.mdx +580 -0
  64. package/docs/ai-chat/how-it-works.mdx +230 -0
  65. package/docs/ai-chat/lifecycle-hooks.mdx +530 -0
  66. package/docs/ai-chat/mcp.mdx +101 -0
  67. package/docs/ai-chat/overview.mdx +90 -0
  68. package/docs/ai-chat/patterns/branching-conversations.mdx +284 -0
  69. package/docs/ai-chat/patterns/code-sandbox.mdx +126 -0
  70. package/docs/ai-chat/patterns/database-persistence.mdx +414 -0
  71. package/docs/ai-chat/patterns/human-in-the-loop.mdx +275 -0
  72. package/docs/ai-chat/patterns/large-payloads.mdx +169 -0
  73. package/docs/ai-chat/patterns/oom-resilience.mdx +120 -0
  74. package/docs/ai-chat/patterns/persistence-and-replay.mdx +211 -0
  75. package/docs/ai-chat/patterns/recovery-boot.mdx +230 -0
  76. package/docs/ai-chat/patterns/skills.mdx +221 -0
  77. package/docs/ai-chat/patterns/sub-agents.mdx +383 -0
  78. package/docs/ai-chat/patterns/tool-result-auditing.mdx +148 -0
  79. package/docs/ai-chat/patterns/trusted-edge-signals.mdx +337 -0
  80. package/docs/ai-chat/patterns/version-upgrades.mdx +172 -0
  81. package/docs/ai-chat/pending-messages.mdx +343 -0
  82. package/docs/ai-chat/prompt-caching.mdx +206 -0
  83. package/docs/ai-chat/quick-start.mdx +161 -0
  84. package/docs/ai-chat/reference.mdx +909 -0
  85. package/docs/ai-chat/server-chat.mdx +263 -0
  86. package/docs/ai-chat/sessions.mdx +333 -0
  87. package/docs/ai-chat/testing.mdx +682 -0
  88. package/docs/ai-chat/tools.mdx +191 -0
  89. package/docs/ai-chat/types.mdx +242 -0
  90. package/docs/ai-chat/upgrade-guide.mdx +515 -0
  91. package/docs/apikeys.mdx +54 -0
  92. package/docs/building-with-ai.mdx +261 -0
  93. package/docs/bulk-actions.mdx +49 -0
  94. package/docs/changelog.mdx +6 -0
  95. package/docs/cli-deploy-commands.mdx +9 -0
  96. package/docs/cli-dev-commands.mdx +9 -0
  97. package/docs/cli-dev.mdx +8 -0
  98. package/docs/cli-init-commands.mdx +58 -0
  99. package/docs/cli-introduction.mdx +25 -0
  100. package/docs/cli-list-profiles-commands.mdx +42 -0
  101. package/docs/cli-login-commands.mdx +33 -0
  102. package/docs/cli-logout-commands.mdx +33 -0
  103. package/docs/cli-preview-archive.mdx +59 -0
  104. package/docs/cli-promote-commands.mdx +9 -0
  105. package/docs/cli-switch.mdx +43 -0
  106. package/docs/cli-update-commands.mdx +42 -0
  107. package/docs/cli-whoami-commands.mdx +33 -0
  108. package/docs/community.mdx +6 -0
  109. package/docs/config/config-file.mdx +602 -0
  110. package/docs/config/extensions/additionalFiles.mdx +38 -0
  111. package/docs/config/extensions/additionalPackages.mdx +40 -0
  112. package/docs/config/extensions/aptGet.mdx +34 -0
  113. package/docs/config/extensions/audioWaveform.mdx +20 -0
  114. package/docs/config/extensions/custom.mdx +380 -0
  115. package/docs/config/extensions/emitDecoratorMetadata.mdx +29 -0
  116. package/docs/config/extensions/esbuildPlugin.mdx +31 -0
  117. package/docs/config/extensions/ffmpeg.mdx +45 -0
  118. package/docs/config/extensions/lightpanda.mdx +56 -0
  119. package/docs/config/extensions/overview.mdx +67 -0
  120. package/docs/config/extensions/playwright.mdx +195 -0
  121. package/docs/config/extensions/prismaExtension.mdx +1014 -0
  122. package/docs/config/extensions/puppeteer.mdx +30 -0
  123. package/docs/config/extensions/pythonExtension.mdx +182 -0
  124. package/docs/config/extensions/syncEnvVars.mdx +291 -0
  125. package/docs/context.mdx +235 -0
  126. package/docs/database-connections.mdx +213 -0
  127. package/docs/deploy-environment-variables.mdx +435 -0
  128. package/docs/deployment/atomic-deployment.mdx +172 -0
  129. package/docs/deployment/overview.mdx +257 -0
  130. package/docs/deployment/preview-branches.mdx +224 -0
  131. package/docs/errors-retrying.mdx +379 -0
  132. package/docs/github-actions.mdx +222 -0
  133. package/docs/github-integration.mdx +136 -0
  134. package/docs/github-repo.mdx +8 -0
  135. package/docs/help-email.mdx +6 -0
  136. package/docs/help-slack.mdx +11 -0
  137. package/docs/hidden-tasks.mdx +56 -0
  138. package/docs/how-it-works.mdx +454 -0
  139. package/docs/how-to-reduce-your-spend.mdx +217 -0
  140. package/docs/idempotency.mdx +504 -0
  141. package/docs/introduction.mdx +223 -0
  142. package/docs/limits.mdx +241 -0
  143. package/docs/logging.mdx +195 -0
  144. package/docs/machines.mdx +952 -0
  145. package/docs/manual-setup.mdx +632 -0
  146. package/docs/mcp-agent-rules.mdx +41 -0
  147. package/docs/mcp-introduction.mdx +385 -0
  148. package/docs/mcp-tools.mdx +273 -0
  149. package/docs/migrating-from-v3.mdx +334 -0
  150. package/docs/observability/dashboards.mdx +102 -0
  151. package/docs/observability/query.mdx +585 -0
  152. package/docs/open-source-contributing.mdx +16 -0
  153. package/docs/open-source-self-hosting.mdx +541 -0
  154. package/docs/private-networking/aws-console-setup.mdx +304 -0
  155. package/docs/private-networking/overview.mdx +144 -0
  156. package/docs/private-networking/troubleshooting.mdx +78 -0
  157. package/docs/queue-concurrency.mdx +354 -0
  158. package/docs/quick-start.mdx +97 -0
  159. package/docs/realtime/auth.mdx +208 -0
  160. package/docs/realtime/backend/overview.mdx +45 -0
  161. package/docs/realtime/backend/streams.mdx +418 -0
  162. package/docs/realtime/backend/subscribe.mdx +225 -0
  163. package/docs/realtime/how-it-works.mdx +94 -0
  164. package/docs/realtime/overview.mdx +63 -0
  165. package/docs/realtime/react-hooks/overview.mdx +73 -0
  166. package/docs/realtime/react-hooks/streams.mdx +449 -0
  167. package/docs/realtime/react-hooks/subscribe.mdx +674 -0
  168. package/docs/realtime/react-hooks/swr.mdx +87 -0
  169. package/docs/realtime/react-hooks/triggering.mdx +194 -0
  170. package/docs/realtime/react-hooks/use-wait-token.mdx +34 -0
  171. package/docs/realtime/run-object.mdx +174 -0
  172. package/docs/replaying.mdx +72 -0
  173. package/docs/request-feature.mdx +6 -0
  174. package/docs/roadmap.mdx +6 -0
  175. package/docs/run-tests.mdx +20 -0
  176. package/docs/run-usage.mdx +113 -0
  177. package/docs/runs/heartbeats.mdx +38 -0
  178. package/docs/runs/max-duration.mdx +139 -0
  179. package/docs/runs/metadata.mdx +734 -0
  180. package/docs/runs/priority.mdx +31 -0
  181. package/docs/runs.mdx +396 -0
  182. package/docs/self-hosting/docker.mdx +458 -0
  183. package/docs/self-hosting/env/supervisor.mdx +74 -0
  184. package/docs/self-hosting/env/webapp.mdx +276 -0
  185. package/docs/self-hosting/kubernetes.mdx +601 -0
  186. package/docs/self-hosting/overview.mdx +108 -0
  187. package/docs/skills.mdx +85 -0
  188. package/docs/tags.mdx +120 -0
  189. package/docs/tasks/overview.mdx +697 -0
  190. package/docs/tasks/scheduled.mdx +382 -0
  191. package/docs/tasks/schemaTask.mdx +413 -0
  192. package/docs/tasks/streams.mdx +884 -0
  193. package/docs/triggering.mdx +1320 -0
  194. package/docs/troubleshooting-alerts.mdx +385 -0
  195. package/docs/troubleshooting-debugging-in-vscode.mdx +8 -0
  196. package/docs/troubleshooting-github-issues.mdx +6 -0
  197. package/docs/troubleshooting-uptime-status.mdx +6 -0
  198. package/docs/troubleshooting.mdx +398 -0
  199. package/docs/upgrading-packages.mdx +80 -0
  200. package/docs/vercel-integration.mdx +207 -0
  201. package/docs/versioning.mdx +56 -0
  202. package/docs/video-walkthrough.mdx +23 -0
  203. package/docs/wait-for-token.mdx +540 -0
  204. package/docs/wait-for.mdx +42 -0
  205. package/docs/wait-until.mdx +53 -0
  206. package/docs/wait.mdx +18 -0
  207. package/docs/writing-tasks-introduction.mdx +33 -0
  208. package/package.json +10 -6
  209. package/skills/trigger-authoring-chat-agent/SKILL.md +296 -0
  210. package/skills/trigger-authoring-tasks/SKILL.md +254 -0
  211. package/skills/trigger-chat-agent-advanced/SKILL.md +368 -0
  212. package/skills/trigger-cost-savings/SKILL.md +116 -0
  213. package/skills/trigger-realtime-and-frontend/SKILL.md +276 -0
@@ -0,0 +1,414 @@
1
+ ---
2
+ title: "Database persistence for chat"
3
+ sidebarTitle: "Database persistence"
4
+ description: "Split conversation state and live session metadata across hooks — preload, turn start, turn complete — without tying the pattern to a specific ORM or schema."
5
+ ---
6
+
7
+ import RcBanner from "/snippets/ai-chat-rc-banner.mdx";
8
+
9
+ <RcBanner />
10
+
11
+ Durable chat runs can span **hours** and **many turns**. You usually want:
12
+
13
+ 1. **Conversation state** — full **`UIMessage[]`** (or equivalent) keyed by **`chatId`**, so reloads and history views work.
14
+ 2. **Live session state** — a **scoped access token** for the session and optionally **`lastEventId`** for stream resume.
15
+
16
+ This page describes a **hook mapping** that works with any database. Adapt table and column names to your stack.
17
+
18
+ ## Conceptual data model
19
+
20
+ You can use one table or two; the important split is **semantic**:
21
+
22
+ | Concept | Purpose | Typical fields |
23
+ | ------- | ------- | -------------- |
24
+ | **Conversation** | Durable transcript + display metadata | Stable id (same as **`chatId`**), serialized **`uiMessages`**, title, model choice, owner/user id, timestamps |
25
+ | **Active session** | Hydrate the transport on page reload | Same **`chatId`** as key (or FK), **`publicAccessToken`**, optional **`lastEventId`** |
26
+
27
+ The **conversation** row is what your UI lists as "chats." The **session** row is what the **transport** needs after a refresh: a session-scoped PAT (so the transport doesn't have to re-mint on first paint) and the SSE resume cursor.
28
+
29
+ Storing the current **`runId`** is optional — useful for telemetry / dashboard linking ("View this run") but not required for resume. The Session row owns its current run server-side; the transport reads from `session.out` keyed on `chatId`, so a run swap (continuation, upgrade) is invisible to your DB schema.
30
+
31
+ <Note>
32
+ Store **`UIMessage[]`** in a JSON-compatible column, or normalize to a messages table — the pattern is *when* you read/write, not *how* you encode rows.
33
+ </Note>
34
+
35
+ ## Where each hook writes
36
+
37
+ This pattern covers **durable DB rows** (the conversation and the active session). Per-process in-memory state ([`chat.local`](/ai-chat/chat-local), [DB connection pools](/database-connections), sandboxes, etc.) belongs in [`onBoot`](/ai-chat/lifecycle-hooks#onboot) — it fires on every fresh worker including continuation runs, where `onPreload` and `onChatStart` do not.
38
+
39
+ ### `onPreload` (optional)
40
+
41
+ When the user triggers [preload](/ai-chat/fast-starts#preload), the run starts **before** the first user message.
42
+
43
+ - Ensure the **conversation** row exists (create or no-op).
44
+ - **Upsert session**: **`chatAccessToken`** from the event (a session-scoped PAT covering both `read:sessions:{chatId}` and `write:sessions:{chatId}`).
45
+ - Load any **user / tenant context** you need for prompts (`clientData`).
46
+
47
+ If you skip preload, do the equivalent in **`onChatStart`** when **`preloaded`** is false.
48
+
49
+ ### `onChatStart` (chat's first message, non-preloaded path)
50
+
51
+ - Fires **once per chat**, on the very first user message. Does NOT fire on continuation runs (post-`endRun`, post-waitpoint-timeout, post-`chat.requestUpgrade`) or on OOM-retry attempts.
52
+ - If **`preloaded`** is true, return early — **`onPreload`** already ran.
53
+ - Otherwise mirror preload: user/context, conversation create, session upsert.
54
+ - No need to gate the conversation create on `continuation` — it's always a brand-new chat at this point.
55
+ - For continuation runs that need to refresh per-run state (new PAT, new `lastEventId`), do it in **`onTurnStart`** / **`onTurnComplete`** — both fire on every turn including the first turn of a continuation run.
56
+
57
+ ### `onTurnStart`
58
+
59
+ - **`await`** persist **`uiMessages`** (full accumulated history including the new user turn) **before** the hook returns — `chat.agent` does not begin streaming until `onTurnStart` resolves, so this is what bounds "user message is durable before the stream".
60
+
61
+ <Warning>
62
+ **Don't use [`chat.defer()`](/ai-chat/background-injection#chat-defer-standalone) for the message write here.** `chat.defer` is fire-and-forget — the hook resolves before the write lands and the stream starts immediately. If the user refreshes mid-stream, the next page load reads `[]` from your DB, the resumed SSE stream pushes the assistant into an empty array, and the user's message disappears from the rendered conversation forever.
63
+
64
+ ```ts
65
+ // ❌ Bad — non-blocking write, mid-stream refresh drops the user message.
66
+ onTurnStart: async ({ chatId, uiMessages }) => {
67
+ chat.defer(db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } }));
68
+ },
69
+
70
+ // ✅ Good — awaited, durable before the model starts.
71
+ onTurnStart: async ({ chatId, uiMessages }) => {
72
+ await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } });
73
+ },
74
+ ```
75
+
76
+ `chat.defer` is for writes whose timing doesn't matter for resume — analytics, audit logs, search-index updates, etc. Anything the next page load reads needs to land before the stream begins.
77
+ </Warning>
78
+
79
+ ### `onTurnComplete`
80
+
81
+ - Persist **`uiMessages`** again with the **assistant** reply finalized.
82
+ - **Upsert session** with the fresh **`chatAccessToken`** and **`lastEventId`** from the event.
83
+
84
+ **`lastEventId`** lets the frontend [resume](/ai-chat/frontend) without replaying SSE events it already applied. Treat it as part of session state, not optional polish, if you care about duplicate chunks after refresh.
85
+
86
+ <Warning>
87
+ **Write the messages and `lastEventId` in a single transaction.** Both values are read in parallel on the next page load (one fetches the conversation, the other fetches the session). If a refresh races between the two writes, the page can see the assistant message persisted (full history) but a stale `lastEventId` from the previous turn. The transport then resumes from that stale cursor and replays this turn's chunks on top of the already-persisted assistant message, producing a duplicated render.
88
+
89
+ ```ts
90
+ // ✅ Atomic — refresh on the next page load reads both writes consistently.
91
+ await db.$transaction([
92
+ db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } }),
93
+ db.chatSession.upsert({
94
+ where: { id: chatId },
95
+ create: { id: chatId, publicAccessToken: chatAccessToken, lastEventId },
96
+ update: { publicAccessToken: chatAccessToken, lastEventId },
97
+ }),
98
+ ]);
99
+
100
+ // ❌ Two awaits — narrow race window where messages are post-write but
101
+ // lastEventId is still pre-write. A page refresh that lands here will
102
+ // duplicate the assistant message on resume.
103
+ await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } });
104
+ await db.chatSession.upsert({ /* ... */ });
105
+ ```
106
+ </Warning>
107
+
108
+ ## Token renewal (app server)
109
+
110
+ The persisted PAT has a TTL (see **`chatAccessTokenTTL`** on **`chat.agent`**, default 1h). When the transport gets a **401** on a session-PAT-authed request, it calls your **`accessToken`** callback to mint a fresh PAT — no DB lookup required, since the session is keyed on `chatId` (which the transport already has).
111
+
112
+ Your `accessToken` callback typically just wraps `auth.createPublicToken`:
113
+
114
+ ```ts
115
+ "use server";
116
+ import { auth } from "@trigger.dev/sdk";
117
+
118
+ export async function mintChatAccessToken(chatId: string) {
119
+ return auth.createPublicToken({
120
+ scopes: { read: { sessions: chatId }, write: { sessions: chatId } },
121
+ expirationTime: "1h",
122
+ });
123
+ }
124
+ ```
125
+
126
+ If you want to keep your DB session row in sync, the transport's **`onSessionChange`** callback fires every time the cached PAT changes — persist the new value there.
127
+
128
+ No Trigger task code needs to run for renewal.
129
+
130
+ ## Minimal pseudocode
131
+
132
+ ```typescript
133
+ // Pseudocode — replace saveConversation / saveSession with your DB layer.
134
+
135
+ chat.agent({
136
+ id: "my-chat",
137
+ clientDataSchema: z.object({ userId: z.string() }),
138
+
139
+ onPreload: async ({ chatId, chatAccessToken, clientData }) => {
140
+ if (!clientData) return;
141
+ await ensureUser(clientData.userId);
142
+ await upsertConversation({ id: chatId, userId: clientData.userId /* ... */ });
143
+ await upsertSession({ chatId, publicAccessToken: chatAccessToken });
144
+ },
145
+
146
+ onChatStart: async ({ chatId, chatAccessToken, clientData, preloaded }) => {
147
+ if (preloaded) return;
148
+ // Fires once per chat — no continuation gate needed.
149
+ await ensureUser(clientData.userId);
150
+ await upsertConversation({ id: chatId, userId: clientData.userId /* ... */ });
151
+ await upsertSession({ chatId, publicAccessToken: chatAccessToken });
152
+ },
153
+
154
+ onTurnStart: async ({ chatId, uiMessages }) => {
155
+ // Awaited, not chat.defer — see the warning in `onTurnStart` above.
156
+ await saveConversationMessages(chatId, uiMessages);
157
+ },
158
+
159
+ onTurnComplete: async ({ chatId, uiMessages, chatAccessToken, lastEventId }) => {
160
+ // Atomic: messages + lastEventId must be readable consistently on resume.
161
+ // See the warning above for why a non-atomic write causes duplicate renders.
162
+ await db.$transaction([
163
+ saveConversationMessagesQuery(chatId, uiMessages),
164
+ upsertSessionQuery({ chatId, publicAccessToken: chatAccessToken, lastEventId }),
165
+ ]);
166
+ },
167
+
168
+ run: async ({ messages, signal }) => {
169
+ /* streamText, etc. */
170
+ },
171
+ });
172
+ ```
173
+
174
+ ## Alternative: `hydrateMessages`
175
+
176
+ For apps that need the backend to be the single source of truth for message history — abuse prevention, branching conversations, or rollback support — use [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) instead of relying on the frontend's accumulated state.
177
+
178
+ With hydration, the hook loads messages from your database on every turn. The frontend's messages are ignored (except for the new user message, which arrives in `incomingMessages`):
179
+
180
+ ```ts
181
+ import { chat, upsertIncomingMessage } from "@trigger.dev/sdk/ai";
182
+
183
+ export const myChat = chat.agent({
184
+ id: "my-chat",
185
+ hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
186
+ const record = await db.chat.findUnique({ where: { id: chatId } });
187
+ const stored = record?.messages ?? [];
188
+
189
+ // `upsertIncomingMessage` pushes a fresh user message and no-ops
190
+ // on HITL continuations (the runtime overlays the new tool-state
191
+ // advance onto the existing entry). See lifecycle hooks for the
192
+ // full pattern: /ai-chat/lifecycle-hooks#hydratemessages
193
+ if (upsertIncomingMessage(stored, { trigger, incomingMessages })) {
194
+ // Upsert, not update: on a head-start first turn no preload ran,
195
+ // so the row may not exist yet when this hook fires.
196
+ await db.chat.upsert({
197
+ where: { id: chatId },
198
+ create: { id: chatId, messages: stored },
199
+ update: { messages: stored },
200
+ });
201
+ }
202
+
203
+ return stored;
204
+ },
205
+ onTurnComplete: async ({ chatId, uiMessages, chatAccessToken, lastEventId }) => {
206
+ // Persist the response and refresh session state atomically — see the
207
+ // warning in the previous section for why these two writes have to be
208
+ // in the same transaction.
209
+ await db.$transaction([
210
+ db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } }),
211
+ db.chatSession.upsert({
212
+ where: { id: chatId },
213
+ create: { id: chatId, publicAccessToken: chatAccessToken, lastEventId },
214
+ update: { publicAccessToken: chatAccessToken, lastEventId },
215
+ }),
216
+ ]);
217
+ },
218
+ run: async ({ messages, signal }) => {
219
+ return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
220
+ },
221
+ });
222
+ ```
223
+
224
+ This replaces the `onTurnStart` persistence pattern — the hook handles both loading and persisting the new message in one place.
225
+
226
+ Hydration composes with [Head Start](/ai-chat/fast-starts#with-hydratemessages): on a head-start first turn the route handler's history arrives as `incomingMessages`, and the write path must be an upsert because no preload ran to create the row.
227
+
228
+ ## Design notes
229
+
230
+ - **`chatId`** is stable for the life of a thread and is the only identifier the transport persists. Runs come and go (idle continuation, upgrade, cancel/restart) but the chat keeps its identity.
231
+ - **`continuation: true`** means "same logical chat, new run" — refresh the persisted PAT, don't assume an empty conversation.
232
+ - The current `runId` is available on every hook event for telemetry / dashboard linking ("View this run"), but you don't need to persist it for resume to work — the transport addresses by `chatId`.
233
+ - Keep **task modules** that perform writes **out of** browser bundles; the pattern assumes persistence runs **in the worker** (or your BFF that the task calls).
234
+
235
+ ## Complete example
236
+
237
+ End-to-end implementation across the three files involved: agent task, server actions, and React component.
238
+
239
+ <Warning>
240
+ The example below trusts raw `chatId` and returns rows without filtering by user. In a real multi-user app, **scope every query by the authenticated user** — read the user from your auth/session in each server action and add `where: { userId }` to all `db.chat.*` and `db.chatSession.*` queries. Without that, one client could read or delete another user's chat state, and `getAllSessions()` would leak other users' `publicAccessToken`s. The snippet keeps auth out of the way to focus on the persistence shape.
241
+ </Warning>
242
+
243
+ <CodeGroup>
244
+ ```ts trigger/chat.ts
245
+ import { chat } from "@trigger.dev/sdk/ai";
246
+ import { streamText, stepCountIs } from "ai";
247
+ import { anthropic } from "@ai-sdk/anthropic";
248
+ import { z } from "zod";
249
+ import { db } from "@/lib/db";
250
+
251
+ export const myChat = chat.agent({
252
+ id: "my-chat",
253
+ clientDataSchema: z.object({
254
+ userId: z.string(),
255
+ }),
256
+ onChatStart: async ({ chatId, clientData }) => {
257
+ await db.chat.create({
258
+ data: { id: chatId, userId: clientData.userId, title: "New chat", messages: [] },
259
+ });
260
+ },
261
+ onTurnStart: async ({ chatId, uiMessages, runId, chatAccessToken }) => {
262
+ // Persist messages + session before streaming
263
+ await db.chat.update({
264
+ where: { id: chatId },
265
+ data: { messages: uiMessages },
266
+ });
267
+ await db.chatSession.upsert({
268
+ where: { id: chatId },
269
+ create: { id: chatId, runId, publicAccessToken: chatAccessToken },
270
+ update: { runId, publicAccessToken: chatAccessToken },
271
+ });
272
+ },
273
+ onTurnComplete: async ({ chatId, uiMessages, runId, chatAccessToken, lastEventId }) => {
274
+ // Persist assistant response + stream position atomically — see the
275
+ // race-condition warning earlier on this page.
276
+ await db.$transaction([
277
+ db.chat.update({
278
+ where: { id: chatId },
279
+ data: { messages: uiMessages },
280
+ }),
281
+ db.chatSession.upsert({
282
+ where: { id: chatId },
283
+ create: { id: chatId, runId, publicAccessToken: chatAccessToken, lastEventId },
284
+ update: { runId, publicAccessToken: chatAccessToken, lastEventId },
285
+ }),
286
+ ]);
287
+ },
288
+ run: async ({ messages, signal }) => {
289
+ return streamText({
290
+ model: anthropic("claude-sonnet-4-5"),
291
+ messages,
292
+ abortSignal: signal,
293
+ stopWhen: stepCountIs(15),
294
+ });
295
+ },
296
+ });
297
+ ```
298
+
299
+ ```ts app/actions.ts
300
+ "use server";
301
+
302
+ import { auth } from "@trigger.dev/sdk";
303
+ import { chat } from "@trigger.dev/sdk/ai";
304
+ import { db } from "@/lib/db";
305
+
306
+ export const startChatSession = chat.createStartSessionAction("my-chat");
307
+
308
+ export async function mintChatAccessToken(chatId: string) {
309
+ return auth.createPublicToken({
310
+ scopes: { read: { sessions: chatId }, write: { sessions: chatId } },
311
+ expirationTime: "1h",
312
+ });
313
+ }
314
+
315
+ export async function getChatMessages(chatId: string) {
316
+ const found = await db.chat.findUnique({ where: { id: chatId } });
317
+ return found?.messages ?? [];
318
+ }
319
+
320
+ export async function getAllSessions() {
321
+ const sessions = await db.chatSession.findMany();
322
+ const result: Record<
323
+ string,
324
+ {
325
+ publicAccessToken: string;
326
+ lastEventId?: string;
327
+ }
328
+ > = {};
329
+ for (const s of sessions) {
330
+ result[s.id] = {
331
+ publicAccessToken: s.publicAccessToken,
332
+ lastEventId: s.lastEventId ?? undefined,
333
+ };
334
+ }
335
+ return result;
336
+ }
337
+
338
+ export async function deleteSession(chatId: string) {
339
+ await db.chatSession.delete({ where: { id: chatId } }).catch(() => {});
340
+ }
341
+ ```
342
+
343
+ ```tsx app/components/chat.tsx
344
+ "use client";
345
+
346
+ import { useChat } from "@ai-sdk/react";
347
+ import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
348
+ import type { myChat } from "@/trigger/chat";
349
+ import { mintChatAccessToken, startChatSession, deleteSession } from "@/app/actions";
350
+
351
+ export function Chat({ chatId, initialMessages, initialSessions }) {
352
+ const transport = useTriggerChatTransport<typeof myChat>({
353
+ task: "my-chat",
354
+ accessToken: ({ chatId }) => mintChatAccessToken(chatId),
355
+ startSession: ({ chatId, clientData }) =>
356
+ startChatSession({ chatId, clientData }),
357
+ clientData: { userId: currentUser.id }, // Type-checked against clientDataSchema
358
+ sessions: initialSessions,
359
+ onSessionChange: (id, session) => {
360
+ if (!session) deleteSession(id);
361
+ },
362
+ });
363
+
364
+ const { messages, sendMessage, stop, status } = useChat({
365
+ id: chatId,
366
+ messages: initialMessages,
367
+ transport,
368
+ resume: initialMessages.length > 0,
369
+ });
370
+
371
+ return (
372
+ <div>
373
+ {messages.map((m) => (
374
+ <div key={m.id}>
375
+ <strong>{m.role}:</strong>
376
+ {m.parts.map((part, i) =>
377
+ part.type === "text" ? <span key={i}>{part.text}</span> : null
378
+ )}
379
+ </div>
380
+ ))}
381
+
382
+ <form
383
+ onSubmit={(e) => {
384
+ e.preventDefault();
385
+ const input = e.currentTarget.querySelector("input");
386
+ if (input?.value) {
387
+ sendMessage({ text: input.value });
388
+ input.value = "";
389
+ }
390
+ }}
391
+ >
392
+ <input placeholder="Type a message..." />
393
+ <button type="submit" disabled={status === "streaming"}>
394
+ Send
395
+ </button>
396
+ {status === "streaming" && (
397
+ <button type="button" onClick={stop}>
398
+ Stop
399
+ </button>
400
+ )}
401
+ </form>
402
+ </div>
403
+ );
404
+ }
405
+ ```
406
+
407
+ </CodeGroup>
408
+
409
+ ## See also
410
+
411
+ - [Lifecycle hooks](/ai-chat/lifecycle-hooks)
412
+ - [Session management](/ai-chat/frontend#session-management) — `resume`, `lastEventId`, transport
413
+ - [`chat.defer()`](/ai-chat/background-injection#chat-defer-standalone) — non-blocking writes during a turn
414
+ - [Code execution sandbox](/ai-chat/patterns/code-sandbox) — combines **`onWait`** / **`onComplete`** with this persistence model
@@ -0,0 +1,275 @@
1
+ ---
2
+ title: "Human-in-the-loop"
3
+ sidebarTitle: "Human-in-the-loop"
4
+ description: "Pause the agent mid-response to ask the user a clarifying question, then resume with their answer."
5
+ ---
6
+
7
+ import RcBanner from "/snippets/ai-chat-rc-banner.mdx";
8
+
9
+ <RcBanner />
10
+
11
+ Some turns need to stop and ask the user something before they can finish — picking between options, confirming a destructive action, or clarifying an ambiguous request. The AI SDK calls this **human-in-the-loop** (HITL), and the building block is a tool with no `execute` function.
12
+
13
+ When the LLM calls a tool that has no `execute`, `streamText` ends with the tool call still pending. The turn completes cleanly, the frontend renders UI to collect the answer, and when the user responds, a new turn resumes with the answer merged into the same assistant message.
14
+
15
+ ## How it works
16
+
17
+ ```
18
+ Turn N:
19
+ User message → run()
20
+ LLM streams text → calls askUser tool (no execute)
21
+ streamText ends with tool-call in `input-available` state
22
+ onTurnComplete fires (finishReason = "tool-calls")
23
+ Agent idle
24
+
25
+ Frontend:
26
+ Renders question + option buttons from tool input
27
+ User clicks → addToolOutput({ tool, toolCallId, output })
28
+ sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls
29
+ → sendMessage() fires next turn
30
+
31
+ Turn N+1:
32
+ hydrateMessages / accumulator sees the updated assistant message
33
+ run() is called, LLM continues from the tool result
34
+ onTurnComplete fires (finishReason = "stop", responseMessage is the FULL merged message)
35
+ ```
36
+
37
+ The AI SDK's `toUIMessageStream` automatically reuses the assistant message ID across the pause (we pass `originalMessages` internally), so `responseMessage` in the post-resume `onTurnComplete` is the **full merged message** — the original text, the completed tool call, and any follow-up content — not just the new parts.
38
+
39
+ ## Backend: define the tool
40
+
41
+ A HITL tool has an `inputSchema` describing what the model can ask, but **no `execute` function**. When the LLM calls it, `streamText` returns control to your agent.
42
+
43
+ ```ts trigger/my-chat.ts
44
+ import { chat } from "@trigger.dev/sdk/ai";
45
+ import { streamText, tool, stepCountIs } from "ai";
46
+ import { anthropic } from "@ai-sdk/anthropic";
47
+ import { z } from "zod";
48
+
49
+ const askUser = tool({
50
+ description:
51
+ "Ask the user a clarifying question when you need their input. " +
52
+ "Present 2-4 options for them to pick from.",
53
+ inputSchema: z.object({
54
+ question: z.string(),
55
+ options: z
56
+ .array(
57
+ z.object({
58
+ id: z.string(),
59
+ label: z.string(),
60
+ description: z.string().optional(),
61
+ })
62
+ )
63
+ .min(2)
64
+ .max(4),
65
+ }),
66
+ // No execute function — streamText ends, the frontend supplies the output
67
+ // via addToolOutput, and the next turn continues from the result.
68
+ });
69
+
70
+ export const myChat = chat.agent({
71
+ id: "my-chat",
72
+ tools: { askUser },
73
+ run: async ({ messages, tools, signal }) => {
74
+ return streamText({
75
+ model: anthropic("claude-sonnet-4-5"),
76
+ messages,
77
+ tools,
78
+ abortSignal: signal,
79
+ stopWhen: stepCountIs(15),
80
+ });
81
+ },
82
+ });
83
+ ```
84
+
85
+ Declaring `tools` on the config (and reading them back from the payload) is the recommended shape for any agent with tools. See [Tools](/ai-chat/tools).
86
+
87
+ ## Frontend: render the question and collect the answer
88
+
89
+ Two pieces on the client:
90
+
91
+ 1. **UI for the pending tool call** — render when the tool part is in `input-available` state, i.e. the LLM has called the tool but there's no output yet.
92
+ 2. **Auto-send on resolution** — use `sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls` so answering kicks off the next turn without the user having to hit "send."
93
+
94
+ ```tsx
95
+ import { useChat, lastAssistantMessageIsCompleteWithToolCalls } from "@ai-sdk/react";
96
+ import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
97
+
98
+ function ChatView({ chatId }: { chatId: string }) {
99
+ const transport = useTriggerChatTransport({
100
+ task: "my-chat",
101
+ accessToken: ({ chatId }) => mintChatAccessToken(chatId),
102
+ startSession: ({ chatId, clientData }) =>
103
+ startChatSession({ chatId, clientData }),
104
+ });
105
+ const { messages, sendMessage, addToolOutput } = useChat({
106
+ id: chatId,
107
+ transport,
108
+ sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
109
+ });
110
+
111
+ return (
112
+ <>
113
+ {messages.map((m) =>
114
+ m.parts.map((part, i) => {
115
+ if (part.type === "tool-askUser" && part.state === "input-available") {
116
+ return (
117
+ <AskUserCard
118
+ key={i}
119
+ question={part.input.question}
120
+ options={part.input.options}
121
+ onAnswer={(opt) =>
122
+ addToolOutput({
123
+ tool: "askUser",
124
+ toolCallId: part.toolCallId,
125
+ output: { optionId: opt.id, label: opt.label },
126
+ })
127
+ }
128
+ />
129
+ );
130
+ }
131
+ if (part.type === "text") return <Markdown key={i}>{part.text}</Markdown>;
132
+ return null;
133
+ })
134
+ )}
135
+ </>
136
+ );
137
+ }
138
+ ```
139
+
140
+ `addToolOutput` patches the assistant message locally with `state: "output-available"` and fills in `output`. `lastAssistantMessageIsCompleteWithToolCalls` detects that every pending tool call now has a result, and `useChat` fires a new `sendMessage` — the backend picks it up as the next turn.
141
+
142
+ ## Detecting a paused turn in `onTurnComplete`
143
+
144
+ Two ways to detect "this turn paused for user input" vs "this turn finished normally":
145
+
146
+ ### Via `finishReason` (recommended)
147
+
148
+ The AI SDK's finish reason is surfaced on every `onTurnComplete` event. If the model stopped on tool calls, it's `"tool-calls"`:
149
+
150
+ ```ts
151
+ onTurnComplete: async ({ finishReason, responseMessage }) => {
152
+ if (finishReason === "tool-calls") {
153
+ // Turn paused — assistant message has pending tool call(s)
154
+ const pending = responseMessage?.parts.filter(
155
+ (p) => p.type.startsWith("tool-") && p.state === "input-available"
156
+ );
157
+ // Persist as a checkpoint / partial turn
158
+ } else {
159
+ // finishReason === "stop" — normal completion
160
+ // Persist as a completed turn
161
+ }
162
+ };
163
+ ```
164
+
165
+ <Note>
166
+ `finishReason` is only undefined for manual `chat.pipe()` flows or aborted streams. For the common `run() → return streamText(...)` pattern it's always populated.
167
+ </Note>
168
+
169
+ ### Via response parts
170
+
171
+ If you need more nuance (e.g. which specific tool is pending), use `chat.history.getPendingToolCalls()`:
172
+
173
+ ```ts
174
+ const pending = chat.history.getPendingToolCalls();
175
+ // [{ toolCallId, toolName, messageId }]
176
+ ```
177
+
178
+ The result reflects the most recent assistant message: the one waiting on `addToolOutput`. Use it from `onAction` to gate fresh user turns ("can't send a new message while a HITL is open"), or from `onTurnComplete` to decide what to persist.
179
+
180
+ Both `finishReason === "tool-calls"` and `chat.history.getPendingToolCalls().length > 0` are equivalent in practice. Use `finishReason` for dispatch, the helper for detail.
181
+
182
+ ### Acting once per net-new tool result
183
+
184
+ When the user's `addToolOutput` round-trips a tool answer back to the agent, the wire message carries the resolved tool part. If you want to fire side-effects (audit log, billing, notifications) exactly once per resolved tool call, do it in `hydrateMessages` before the runtime merges. `chat.history.extractNewToolResults(message)` returns only the parts whose `toolCallId` isn't already resolved on the chain:
185
+
186
+ ```ts
187
+ hydrateMessages: async ({ incomingMessages }) => {
188
+ for (const msg of incomingMessages) {
189
+ if (msg.role !== "assistant") continue;
190
+ for (const r of chat.history.extractNewToolResults(msg)) {
191
+ await auditLog.record({
192
+ toolCallId: r.toolCallId,
193
+ toolName: r.toolName,
194
+ output: r.output,
195
+ errorText: r.errorText, // set only for output-error parts
196
+ });
197
+ }
198
+ }
199
+ return incomingMessages;
200
+ },
201
+ ```
202
+
203
+ `extractNewToolResults` compares against the current `chat.history`. By the time `onTurnComplete` fires, the chain already contains `responseMessage`, so the helper returns `[]` there. Use it where the message is from outside the accumulator: `hydrateMessages`, `onAction` if the action carries a message, or any custom pre-merge code path.
204
+
205
+ ## Persistence: one message vs one record per pause
206
+
207
+ Because the AI SDK reuses the assistant message ID across the pause, the "same turn" from the user's perspective maps to **two `onTurnComplete` firings** on the server — but both receive a `responseMessage` with the **same `id`**, and the second firing's `responseMessage` contains the fully merged content.
208
+
209
+ Two common persistence patterns:
210
+
211
+ ### Overwrite on every turn (simplest)
212
+
213
+ Just store the latest `uiMessages` array on every `onTurnComplete`. The paused-turn write is overwritten by the resume-turn write; the final DB state has the full merged message.
214
+
215
+ ```ts
216
+ onTurnComplete: async ({ chatId, uiMessages }) => {
217
+ await db.chat.update({
218
+ where: { id: chatId },
219
+ data: { messages: uiMessages },
220
+ });
221
+ },
222
+ ```
223
+
224
+ Use this unless you specifically need an audit trail.
225
+
226
+ ### Checkpoint nodes (immutable history)
227
+
228
+ For apps that want every pause point recorded as its own immutable snapshot (branching, replay, diff review), save a checkpoint when paused and a sibling when complete:
229
+
230
+ ```ts
231
+ onTurnComplete: async ({ chatId, responseMessage, finishReason, uiMessages }) => {
232
+ if (!responseMessage) return;
233
+
234
+ if (finishReason === "tool-calls") {
235
+ // Paused — save a checkpoint
236
+ await db.turnCheckpoint.create({
237
+ data: {
238
+ chatId,
239
+ messageId: responseMessage.id,
240
+ parts: responseMessage.parts,
241
+ kind: "partial",
242
+ },
243
+ });
244
+ } else {
245
+ // Completed — save a sibling with the merged full message
246
+ await db.turnCheckpoint.create({
247
+ data: {
248
+ chatId,
249
+ messageId: responseMessage.id,
250
+ parts: responseMessage.parts,
251
+ kind: "final",
252
+ },
253
+ });
254
+ }
255
+
256
+ // Always update the canonical chat record for `hydrateMessages` to load
257
+ await db.chat.update({
258
+ where: { id: chatId },
259
+ data: { messages: uiMessages },
260
+ });
261
+ };
262
+ ```
263
+
264
+ Both writes see `responseMessage.id` as the same value — they're checkpoints of the same logical message. Grouping by `messageId` + ordering by `createdAt` gives you the progression.
265
+
266
+ ## Multi-pause turns
267
+
268
+ A single logical turn can pause more than once — the LLM asks question A, gets the answer, thinks, then asks question B before finishing. Each pause fires its own `onTurnComplete` with `finishReason === "tool-calls"`; only the last firing has `finishReason === "stop"`. The checkpoint pattern above handles this naturally — each pause adds a new checkpoint sharing the same `responseMessage.id`.
269
+
270
+ ## Gotchas
271
+
272
+ - **Don't set an `execute` function on the HITL tool.** If it has one, `streamText` will call it immediately instead of handing control back.
273
+ - **The frontend must use `sendAutomaticallyWhen`.** Without it, the user has to press Enter after answering — `addToolOutput` updates local state but doesn't fire a new turn by itself.
274
+ - **Don't mutate `responseMessage` in `onTurnComplete`.** It's the captured snapshot. To add custom parts, use `chat.response.append()` in `onBeforeTurnComplete` (while the stream is open).
275
+ - **Stop handling.** If the user stops the run while a pause is active (`chat.stop()` on the transport), `onTurnComplete` fires with `stopped: true` and `finishReason` reflecting the last successful step. Treat stopped paused turns the same as stopped normal turns.