Transport setup
Use the useTriggerChatTransport hook from @trigger.dev/sdk/chat/react to create a memoized transport instance, then pass it to useChat:
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import { useChat } from "@ai-sdk/react";
import type { myChat } from "@/trigger/chat";
import { getChatToken } from "@/app/actions";
export function Chat() {
const transport = useTriggerChatTransport<typeof myChat>({
task: "my-chat",
accessToken: getChatToken,
});
const { messages, sendMessage, stop, status } = useChat({ transport });
// ... render UI
}
The transport is created once on first render and reused across re-renders. Pass a type parameter for compile-time validation of the task ID.
The hook keeps onSessionChange up to date via a ref internally, so you don’t need to memoize the callback or worry about stale closures.
Dynamic access tokens
For token refresh, pass a function instead of a string. It’s called on each sendMessage:
const transport = useTriggerChatTransport({
task: "my-chat",
accessToken: async () => {
const res = await fetch("/api/chat-token");
return res.text();
},
});
Session management
Session cleanup (frontend)
Since session creation and updates are handled server-side, the frontend only needs to handle session deletion when a run ends:
const transport = useTriggerChatTransport<typeof myChat>({
task: "my-chat",
accessToken: getChatToken,
sessions: loadedSessions, // Restored from DB on page load
onSessionChange: (chatId, session) => {
if (!session) {
deleteSession(chatId); // Server action — run ended
}
},
});
Restoring on page load
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.
"use client";
import { useEffect, useState } from "react";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import { useChat } from "@ai-sdk/react";
import { getChatToken, getChatMessages, getSession, deleteSession } from "@/app/actions";
export default function ChatPage({ chatId }: { chatId: string }) {
const [initialMessages, setInitialMessages] = useState([]);
const [initialSession, setInitialSession] = useState(undefined);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
async function load() {
const [messages, session] = await Promise.all([
getChatMessages(chatId),
getSession(chatId),
]);
setInitialMessages(messages);
setInitialSession(session ? { [chatId]: session } : undefined);
setLoaded(true);
}
load();
}, [chatId]);
if (!loaded) return null;
return (
<ChatClient
chatId={chatId}
initialMessages={initialMessages}
initialSessions={initialSession}
/>
);
}
function ChatClient({ chatId, initialMessages, initialSessions }) {
const transport = useTriggerChatTransport({
task: "my-chat",
accessToken: getChatToken,
sessions: initialSessions,
onSessionChange: (id, session) => {
if (!session) deleteSession(id);
},
});
const { messages, sendMessage, stop, status } = useChat({
id: chatId,
messages: initialMessages,
transport,
resume: initialMessages.length > 0, // Resume if there's an existing conversation
});
// ... render UI
}
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.
In React strict mode (enabled by default in Next.js dev), you may see a TypeError: Cannot read properties of undefined (reading 'state') in the console when using resume. This is a known bug in the AI SDK caused by React strict mode double-firing the resume effect. The error is caught internally and does not affect functionality — streaming and message display work correctly. It only appears in development and will not occur in production builds.
Transport-level client data
Set default client data on the transport that’s included in every request. When the task uses clientDataSchema, this is type-checked to match:
const transport = useTriggerChatTransport<typeof myChat>({
task: "my-chat",
accessToken: getChatToken,
clientData: { userId: currentUser.id },
});
Pass metadata with individual messages via sendMessage. Per-message values are merged with transport-level client data (per-message wins on conflicts):
sendMessage(
{ text: "Hello" },
{ metadata: { model: "gpt-4o", priority: "high" } }
);
Typed client data with clientDataSchema
Instead of manually parsing clientData with Zod in every hook, pass a clientDataSchema to chat.task. The schema validates the data once per turn, and clientData is typed in all hooks and run:
import { chat } from "@trigger.dev/sdk/ai";
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
export const myChat = chat.task({
id: "my-chat",
clientDataSchema: z.object({
model: z.string().optional(),
userId: z.string(),
}),
onChatStart: async ({ chatId, clientData }) => {
// clientData is typed as { model?: string; userId: string }
await db.chat.create({
data: { id: chatId, userId: clientData.userId },
});
},
run: async ({ messages, clientData, signal }) => {
// Same typed clientData — no manual parsing needed
return streamText({
model: openai(clientData?.model ?? "gpt-4o"),
messages,
abortSignal: signal,
});
},
});
The schema also types the clientData option on the frontend transport:
// TypeScript enforces that clientData matches the schema
const transport = useTriggerChatTransport<typeof myChat>({
task: "my-chat",
accessToken: getChatToken,
clientData: { userId: currentUser.id },
});
Supports Zod, ArkType, Valibot, and other schema libraries supported by the SDK.
Stop generation
Calling stop() from useChat sends a stop signal to the running task via input streams. The task aborts the current streamText call, but the run stays alive for the next message:
const { messages, sendMessage, stop, status } = useChat({ transport });
{status === "streaming" && (
<button type="button" onClick={stop}>
Stop
</button>
)}
See Stop generation in the backend docs for how to handle stop signals in your task.
Self-hosting
If you’re self-hosting Trigger.dev, pass the baseURL option:
const transport = useTriggerChatTransport({
task: "my-chat",
accessToken,
baseURL: "https://your-trigger-instance.com",
});