docs/ai-chat/error-handling.mdx
chat.agent errors fall into four layers, each with different recovery semantics. The default behavior is conversation-preserving: a thrown error in a hook or run() does not kill the chat. The current turn ends with an error chunk, and the agent waits for the user's next message.
| Layer | Source | Default behavior | Recovery |
|---|---|---|---|
| Stream | streamText errors mid-response (rate limits, model API failures) | onError callback converts to error chunk | Sanitize message via uiMessageStreamOptions.onError |
| Hook / turn | Throws in onValidateMessages, onTurnStart, run, etc. | Error chunk + turn-complete written to stream; conversation continues | Catch in your hook, or rely on default |
| Run | Unhandled exception escapes the run | Run fails. No retry by default. Standard task onFailure fires. | onFailure task hook |
| Frontend | Stream delivers { type: "error", errorText } | useChat exposes via error field and onError callback | Show toast, retry button, etc. |
When the model API errors mid-response (rate limits, network failures, malformed output), the AI SDK's streamText calls the onError callback. Use uiMessageStreamOptions.onError to convert the error to a user-friendly string. The string is sent to the frontend as an error chunk.
import { chat } from "@trigger.dev/sdk/ai";
export const myChat = chat.agent({
id: "my-chat",
uiMessageStreamOptions: {
onError: (error) => {
console.error("Stream error:", error);
if (error instanceof Error && error.message.includes("rate limit")) {
return "Rate limited. Please wait a moment and try again.";
}
if (error instanceof Error && error.message.includes("context_length")) {
return "This conversation is too long. Please start a new chat.";
}
return "Something went wrong while generating a response. Please try again.";
},
},
run: async ({ messages, signal }) => {
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
},
});
The frontend receives this as an error chunk that useChat exposes via its error field:
const { messages, error } = useChat({ transport });
{error && <div className="text-red-600">{error.message}</div>}
If any lifecycle hook (onValidateMessages, onChatStart, onTurnStart, hydrateMessages, onAction, prepareMessages, onBeforeTurnComplete, onTurnComplete) or run() throws an unhandled exception, the turn loop catches it:
{ type: "error", errorText: error.message } to the streamThe conversation stays alive. The user can send another message and continue.
export const myChat = chat.agent({
id: "my-chat",
onTurnStart: async ({ chatId, uiMessages }) => {
// If this throws, the turn ends with an error chunk
// and the agent waits for the next message
await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } });
},
run: async ({ messages, signal }) => {
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
},
});
For granular control, wrap your hook code in try/catch and decide what to do. Common patterns:
onValidateMessages: async ({ messages }) => {
try {
return await validateUIMessages({ messages, tools: chatTools });
} catch (err) {
// Log to your error tracking service
Sentry.captureException(err);
// Throw a user-facing error message — this becomes the error chunk
throw new Error("Your message contains invalid data and could not be sent.");
}
},
run()run() is your code — wrap it in try/catch for full control. This is the right place to save partial state to your DB before the error chunk goes out:
run: async ({ messages, chatId, signal }) => {
try {
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
} catch (err) {
// Save the failed turn for debugging / undo
await db.failedTurn.create({
data: {
chatId,
error: err instanceof Error ? err.message : String(err),
messages,
},
});
throw err; // Re-throw to trigger the error chunk
}
},
To persist errors for debugging or undo, use onTurnComplete (which fires even after errors) or the standard task onComplete hook.
onTurnCompleteonTurnComplete fires after every turn — successful or errored. On an errored turn responseMessage is undefined or partial and error carries the thrown value (with finishReason set to "error"). Use this to mark the turn as failed:
onTurnComplete: async ({ chatId, uiMessages, responseMessage, stopped, error }) => {
// Persist the messages regardless of error state
await db.chat.update({
where: { id: chatId },
data: {
messages: uiMessages,
// `error` is set when the turn threw
lastTurnStatus: error ? "errored" : stopped ? "stopped" : "ok",
},
});
},
onFailure task hookFor run-level failures (the entire run dies), use the standard task onFailure hook. This fires when the run terminates with an unhandled exception:
chat.agent({
id: "my-chat",
onFailure: async ({ error, ctx }) => {
// Log run-level failure to your monitoring service
await monitoring.recordRunFailure({
runId: ctx.run.id,
chatId: ctx.run.tags.find(t => t.startsWith("chat:"))?.slice(5),
error: error.message,
});
},
run: async ({ messages, signal }) => {
return streamText({ ... });
},
});
A common pattern is to let the user "undo" the failed turn and try again. Combine chat.history.rollbackTo with a custom action:
chat.agent({
id: "my-chat",
actionSchema: z.discriminatedUnion("type", [
z.object({ type: z.literal("undo") }),
]),
onAction: async ({ action, uiMessages }) => {
if (action.type === "undo") {
// Find the last user message and roll back to it
const lastUserIdx = [...uiMessages].reverse().findIndex(m => m.role === "user");
if (lastUserIdx !== -1) {
const targetIdx = uiMessages.length - 1 - lastUserIdx - 1;
const target = uiMessages[targetIdx];
if (target) chat.history.rollbackTo(target.id);
}
}
},
run: async ({ messages, signal }) => {
return streamText({ ... });
},
});
On the frontend, show an "Undo" button when an error occurs:
{error && (
<button onClick={() => transport.sendAction(chatId, { type: "undo" })}>
Undo and try again
</button>
)}
For transient errors (network blips, rate limits), the simplest recovery is to re-send the last user message. The AI SDK's useChat provides regenerate():
const { messages, error, regenerate } = useChat({ transport });
{error && (
<button onClick={() => regenerate()}>Retry</button>
)}
regenerate() removes the last assistant response and re-sends. Combined with onValidateMessages or hydrateMessages, you can reload the canonical state from your DB before retrying.
When a stream errors mid-response, the responseMessage in onBeforeTurnComplete and onTurnComplete contains the partial output. Save it as a "draft" so the user can see what was generated before the error:
onBeforeTurnComplete: async ({ chatId, responseMessage, stopped }) => {
if (responseMessage && responseMessage.parts.length > 0) {
// Save partial response — user can manually accept or discard
await db.partialResponse.create({
data: {
chatId,
message: responseMessage,
reason: stopped ? "stopped" : "errored",
},
});
}
},
If the primary model errors, try a fallback model in the same turn:
run: async ({ messages, signal }) => {
try {
return streamText({
model: anthropic("claude-sonnet-4-5"),
messages,
abortSignal: signal,
stopWhen: stepCountIs(15),
});
} catch (err) {
console.warn("Primary model failed, falling back:", err);
return streamText({
model: anthropic("claude-sonnet-4-6"),
messages,
abortSignal: signal,
stopWhen: stepCountIs(15),
});
}
},
When an error occurs at any layer, the frontend's UIMessageChunk stream surfaces an error chunk:
{ "type": "error", "errorText": "Rate limited. Please wait a moment and try again." }
A turn-complete control record follows on session.out (header-form, not a data chunk — see turn-complete control record for the wire format) to mark the turn as done.
The AI SDK's useChat processes this and:
useChat's error field to an Error with message = errorTextonError callback (if set)status returns to "ready")const { messages, error, status } = useChat({
transport,
onError: (err) => {
toast.error(err.message);
},
});
function Chat() {
const transport = useTriggerChatTransport({
task: "my-chat",
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
startSession: ({ chatId, clientData }) =>
startChatSession({ chatId, clientData }),
});
const { messages, error, sendMessage } = useChat({ transport });
return (
<div>
{messages.map(m => /* ... */)}
{error && (
<div className="rounded border border-red-300 bg-red-50 p-3">
<p className="text-red-700">{error.message}</p>
</div>
)}
<form onSubmit={(e) => { e.preventDefault(); sendMessage(/* ... */); }}>
</form>
</div>
);
}
The errorText is just a string, so distinguish error types via prefixes or codes:
// Backend
uiMessageStreamOptions: {
onError: (error) => {
if (error.message.includes("rate limit")) return "RATE_LIMIT: Please wait and try again.";
if (error.message.includes("context_length")) return "CONTEXT_TOO_LONG: Start a new chat.";
return "UNKNOWN: Something went wrong.";
},
},
// Frontend
{error?.message.startsWith("RATE_LIMIT") && <RateLimitNotice />}
{error?.message.startsWith("CONTEXT_TOO_LONG") && <NewChatPrompt />}
accessToken / startSessionIf your accessToken or startSession callback throws (auth failure, DB write failure, network error), the rejection surfaces through useChat's error state — same as a stream error. The transport doesn't retry the callback automatically; the customer is responsible for handling it.
const transport = useTriggerChatTransport({
task: "my-chat",
accessToken: async ({ chatId }) => {
try {
return await mintChatAccessToken(chatId);
} catch (err) {
// Customer's server action failed (e.g. user lost auth).
// Re-throw to surface as a useChat error, or return a sentinel
// your UI can detect and prompt re-auth.
throw new Error(`AUTH_REFRESH: ${err.message}`);
}
},
startSession: ({ chatId, clientData }) =>
startChatSession({ chatId, clientData }),
});
startSession failures most commonly mean the customer's authorization layer rejected the request (no plan, quota exceeded, user not allowed to chat with this agent). The customer's server should produce a meaningful error message; the transport propagates it verbatim to useChat's error state.
chat.agent uses retry: { maxAttempts: 1 } — the run never retries on unhandled failure. This is intentional: each turn is conversation-preserving, so a true run failure is severe and shouldn't silently retry (which could send duplicate API calls or mutate state twice).
To add retry-like behavior:
run() with try/catch and a fallback modelsendMessage or regenerate again)chat.agent with a parent task that has retry configured, and call the agent's task internallyuiMessageStreamOptions.onError to sanitize stream errors before they reach the user.onTurnStart so a mid-stream failure still leaves the user's message visible.onTurnComplete to mark turn status in your DB (ok / errored / stopped).onFailure for run-level monitoring (Sentry, monitoring dashboards).run() instead of failing the turn.ChatChunkTooLargeErrorA specific run-failing error worth flagging on its own. Anything written through the chat output is one record on the underlying realtime stream, capped at ~1 MiB per record. A single chunk over the cap throws ChatChunkTooLargeError (named export from @trigger.dev/sdk). The most common trigger is a tool whose result object is large enough to overflow as one tool-output-available chunk.
The error carries chunkType, chunkSize, and maxSize. Catch with the isChatChunkTooLargeError guard and route oversized values out-of-band.
See Large payloads in chat.agent for the ID-reference pattern that works around the cap, plus guidance on transient data parts and out-of-band logging.
uiMessageStreamOptions.onError — stream error handler detailschat.history — rollback to a previous messageonFailure, onComplete, onWait, etc.