Skip to main content

Documentation Index

Fetch the complete documentation index at: https://trigger-docs-tri-7532-ai-sdk-chat-transport-and-chat-task-s.mintlify.app/llms.txt

Use this file to discover all available pages before exploring further.

Durable chat runs can span hours and many turns. You usually want:
  1. Conversation state — full UIMessage[] (or equivalent) keyed by chatId, so reloads and history views work.
  2. Live session state — a scoped access token for the session and optionally lastEventId for stream resume.
This page describes a hook mapping that works with any database. Adapt table and column names to your stack.

Conceptual data model

You can use one table or two; the important split is semantic:
ConceptPurposeTypical fields
ConversationDurable transcript + display metadataStable id (same as chatId), serialized uiMessages, title, model choice, owner/user id, timestamps
Active sessionHydrate the transport on page reloadSame chatId as key (or FK), publicAccessToken, optional lastEventId
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. 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.
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.

Where each hook writes

onPreload (optional)

When the user triggers preload, the run starts before the first user message.
  • Ensure the conversation row exists (create or no-op).
  • Upsert session: chatAccessToken from the event (a session-scoped PAT covering both read:sessions:{chatId} and write:sessions:{chatId}).
  • Load any user / tenant context you need for prompts (clientData).
If you skip preload, do the equivalent in onChatStart when preloaded is false.

onChatStart (turn 0, non-preloaded path)

  • If preloaded is true, return early — onPreload already ran.
  • Otherwise mirror preload: user/context, conversation create, session upsert.
  • If continuation is true, the conversation row usually already exists (previous run ended or timed out); only update session fields so the new PAT and lastEventId are stored.

onTurnStart

  • 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”.
Don’t use chat.defer() 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.
// ❌ Bad — non-blocking write, mid-stream refresh drops the user message.
onTurnStart: async ({ chatId, uiMessages }) => {
  chat.defer(db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } }));
},

// ✅ Good — awaited, durable before the model starts.
onTurnStart: async ({ chatId, uiMessages }) => {
  await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } });
},
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.

onTurnComplete

  • Persist uiMessages again with the assistant reply finalized.
  • Upsert session with the fresh chatAccessToken and lastEventId from the event.
lastEventId lets the frontend resume 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.
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.
// ✅ Atomic — refresh on the next page load reads both writes consistently.
await db.$transaction([
  db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } }),
  db.chatSession.upsert({
    where: { id: chatId },
    create: { id: chatId, publicAccessToken: chatAccessToken, lastEventId },
    update: { publicAccessToken: chatAccessToken, lastEventId },
  }),
]);

// ❌ Two awaits — narrow race window where messages are post-write but
// lastEventId is still pre-write. A page refresh that lands here will
// duplicate the assistant message on resume.
await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } });
await db.chatSession.upsert({ /* ... */ });

Token renewal (app server)

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). Your accessToken callback typically just wraps auth.createPublicToken:
"use server";
import { auth } from "@trigger.dev/sdk";

export async function mintChatAccessToken(chatId: string) {
  return auth.createPublicToken({
    scopes: { read: { sessions: chatId }, write: { sessions: chatId } },
    expirationTime: "1h",
  });
}
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. No Trigger task code needs to run for renewal.

Minimal pseudocode

// Pseudocode — replace saveConversation / saveSession with your DB layer.

chat.agent({
  id: "my-chat",
  clientDataSchema: z.object({ userId: z.string() }),

  onPreload: async ({ chatId, chatAccessToken, clientData }) => {
    if (!clientData) return;
    await ensureUser(clientData.userId);
    await upsertConversation({ id: chatId, userId: clientData.userId /* ... */ });
    await upsertSession({ chatId, publicAccessToken: chatAccessToken });
  },

  onChatStart: async ({ chatId, chatAccessToken, clientData, continuation, preloaded }) => {
    if (preloaded) return;
    await ensureUser(clientData.userId);
    if (!continuation) {
      await upsertConversation({ id: chatId, userId: clientData.userId /* ... */ });
    }
    await upsertSession({ chatId, publicAccessToken: chatAccessToken });
  },

  onTurnStart: async ({ chatId, uiMessages }) => {
    // Awaited, not chat.defer — see the warning in `onTurnStart` above.
    await saveConversationMessages(chatId, uiMessages);
  },

  onTurnComplete: async ({ chatId, uiMessages, chatAccessToken, lastEventId }) => {
    // Atomic: messages + lastEventId must be readable consistently on resume.
    // See the warning above for why a non-atomic write causes duplicate renders.
    await db.$transaction([
      saveConversationMessagesQuery(chatId, uiMessages),
      upsertSessionQuery({ chatId, publicAccessToken: chatAccessToken, lastEventId }),
    ]);
  },

  run: async ({ messages, signal }) => {
    /* streamText, etc. */
  },
});

Alternative: hydrateMessages

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 instead of relying on the frontend’s accumulated state. 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):
export const myChat = chat.agent({
  id: "my-chat",
  hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
    const record = await db.chat.findUnique({ where: { id: chatId } });
    const stored = record?.messages ?? [];

    if (trigger === "submit-message" && incomingMessages.length > 0) {
      stored.push(incomingMessages[incomingMessages.length - 1]!);
      await db.chat.update({ where: { id: chatId }, data: { messages: stored } });
    }

    return stored;
  },
  onTurnComplete: async ({ chatId, uiMessages, chatAccessToken, lastEventId }) => {
    // Persist the response and refresh session state atomically — see the
    // warning in the previous section for why these two writes have to be
    // in the same transaction.
    await db.$transaction([
      db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } }),
      db.chatSession.upsert({
        where: { id: chatId },
        create: { id: chatId, publicAccessToken: chatAccessToken, lastEventId },
        update: { publicAccessToken: chatAccessToken, lastEventId },
      }),
    ]);
  },
  run: async ({ messages, signal }) => {
    return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal });
  },
});
This replaces the onTurnStart persistence pattern — the hook handles both loading and persisting the new message in one place.

Design notes

  • 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.
  • continuation: true means “same logical chat, new run” — refresh the persisted PAT, don’t assume an empty conversation.
  • 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.
  • 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).

See also