You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
`clientData` contains custom data from the frontend — either the `metadata` option on the transport constructor (sent with every message) or the `metadata` option on `sendMessage()` (per-message). See [Client data and metadata](#client-data-and-metadata).
259
260
</Tip>
260
261
262
+
### onTurnStart
263
+
264
+
Fires at the start of every turn, after message accumulation and `onChatStart` (turn 0), but **before**`run()` executes. Use it to persist messages before streaming begins — so a mid-stream page refresh still shows the user's message.
265
+
266
+
| Field | Type | Description |
267
+
|-------|------|-------------|
268
+
|`chatId`|`string`| Chat session ID |
269
+
|`messages`|`ModelMessage[]`| Full accumulated conversation (model format) |
270
+
|`uiMessages`|`UIMessage[]`| Full accumulated conversation (UI format) |
271
+
|`turn`|`number`| Turn number (0-indexed) |
272
+
|`runId`|`string`| The Trigger.dev run ID |
273
+
|`chatAccessToken`|`string`| Scoped access token for this run |
returnstreamText({ model: openai("gpt-4o"), messages, abortSignal: signal });
291
+
},
292
+
});
293
+
```
294
+
295
+
<Tip>
296
+
By persisting in `onTurnStart`, the user's message is saved to your database before the AI starts streaming. If the user refreshes mid-stream, the message is already there.
297
+
</Tip>
298
+
261
299
### onTurnComplete
262
300
263
-
Fires after each turn completes — after the response is captured, before waiting for the next message. This is the primary hook for persisting conversations.
301
+
Fires after each turn completes — after the response is captured, before waiting for the next message. This is the primary hook for persisting the assistant's response.
264
302
265
303
| Field | Type | Description |
266
304
|-------|------|-------------|
@@ -271,15 +309,23 @@ Fires after each turn completes — after the response is captured, before waiti
271
309
|`newUIMessages`|`UIMessage[]`| Only this turn's messages (UI format) |
272
310
|`responseMessage`|`UIMessage \| undefined`| The assistant's response for this turn |
273
311
|`turn`|`number`| Turn number (0-indexed) |
312
+
|`runId`|`string`| The Trigger.dev run ID |
313
+
|`chatAccessToken`|`string`| Scoped access token for this run |
314
+
|`lastEventId`|`string \| undefined`| Stream position for resumption. Persist this with the session. |
Use `uiMessages` to overwrite the full conversation each turn (simplest). Use `newUIMessages` if you prefer to store messages individually — for example, one database row per message.
292
338
</Tip>
293
339
340
+
<Tip>
341
+
Persist `lastEventId` alongside the session. When the transport reconnects after a page refresh, it uses this to skip past already-seen events — preventing duplicate messages.
342
+
</Tip>
343
+
294
344
## Persistence
295
345
296
346
### What needs to be persisted
297
347
298
348
To build a chat app that survives page refreshes, you need to persist two things:
299
349
300
-
1.**Messages** — The conversation history. Persisted **server-side** in the task via `onTurnComplete`.
301
-
2.**Sessions** — The transport's connection state (`runId`, `publicAccessToken`, `lastEventId`). Persisted **client-side** via `onSessionChange`.
350
+
1.**Messages** — The conversation history. Persisted **server-side** in the task via `onTurnStart` and `onTurnComplete`.
351
+
2.**Sessions** — The transport's connection state (`runId`, `publicAccessToken`, `lastEventId`). Persisted **server-side** via `onTurnStart` and `onTurnComplete`.
302
352
303
353
<Note>
304
354
Sessions let the transport reconnect to an existing run after a page refresh. Without them, every page load would start a new run — losing the conversation context that was accumulated in the previous run.
305
355
</Note>
306
356
307
-
### Persisting messages (server-side)
357
+
### Persisting messages and sessions (server-side)
308
358
309
-
Messages are stored inside the task itself, so they're durable even if the frontend disconnects mid-conversation.
359
+
Both messages and sessions are persisted server-side in the lifecycle hooks. `onTurnStart` saves the user's message before streaming begins, while `onTurnComplete` saves the assistant's response and the `lastEventId` for stream resumption.
310
360
311
361
```ts trigger/chat.ts
312
362
import { chat } from"@trigger.dev/sdk/ai";
@@ -316,16 +366,34 @@ import { db } from "@/lib/db";
316
366
317
367
exportconst myChat =chat.task({
318
368
id: "my-chat",
319
-
onChatStart: async ({ chatId, clientData }) => {
369
+
onChatStart: async ({ chatId }) => {
320
370
awaitdb.chat.create({
321
371
data: { id: chatId, title: "New chat", messages: [] },
The `onSessionChange` callback on the transport fires whenever a session's state changes:
408
+
### Session cleanup (frontend)
343
409
344
-
-**Session created** — After triggering a new task run
345
-
-**Turn completed** — The `lastEventId` is updated (used for stream resumption)
346
-
-**Session removed** — The run ended or failed. `session` is `null`.
410
+
Since session creation and updates are handled server-side, the frontend only needs to handle session deletion when a run ends:
347
411
348
412
```tsx
349
413
const transport =useTriggerChatTransport<typeofmyChat>({
350
414
task: "my-chat",
351
415
accessToken: getChatToken,
352
416
sessions: loadedSessions, // Restored from DB on page load
353
417
onSessionChange: (chatId, session) => {
354
-
if (session) {
355
-
saveSession(chatId, session); // Server action
356
-
} else {
357
-
deleteSession(chatId); // Server action
418
+
if (!session) {
419
+
deleteSession(chatId); // Server action — run ended
358
420
}
359
421
},
360
422
});
361
423
```
362
424
363
425
### Restoring on page load
364
426
365
-
On page load, fetch both the messages and the session from your database, then pass them to `useChat` and the transport:
427
+
On page load, fetch both the messages and the session from your database, then pass them to `useChat` and the transport. Pass `resume: true` to `useChat` when there's an existing conversation — this tells the AI SDK to reconnect to the stream via the transport.
const { messages, sendMessage, stop, status } =useChat({
416
477
id: chatId,
417
478
messages: initialMessages,
418
479
transport,
480
+
resume: initialMessages.length>0, // Resume if there's an existing conversation
419
481
});
420
482
421
483
// ... render UI
422
484
}
423
485
```
424
486
487
+
<Info>
488
+
`resume: true` causes `useChat` to call `reconnectToStream` on the transport when the component mounts. The transport uses the session's `lastEventId` to skip past already-seen stream events, so the frontend only receives new data. Only enable `resume` when there are existing messages — for brand new chats, there's nothing to reconnect to.
489
+
</Info>
490
+
425
491
### Full example
426
492
427
-
Putting it all together — a complete chat app with server-side message persistence and session reconnection:
493
+
Putting it all together — a complete chat app with server-side persistence, session reconnection, and stream resumption:
0 commit comments