Errors
Safe server errors, chat.error vs state.error, retry.
PAI never sends a stack trace to the browser. The client gets a code, a safe message, and sometimes a diagnostic id for support.
Map exceptions on the server. Render them in the chat. Retry when the run failed.
Server
createPai({
mapError: (error) => {
if (error instanceof WorkspaceNotFound) {
return { code: "workspace_not_found", message: "Workspace not found." };
}
},
onRuntimeError(event) {
// Original exception — stacks, names. Never sent to the browser.
reportIncident(event);
},
});mapError returning undefined uses PAI's generic fallback. HTTP adapters have the same pair as mapHttpError / onHttpError on createPaiExpressReceiver().
Logging
That is not OpenTelemetry. Traces are Observability. Operational records are logger / logLevel on createPai (receivers inherit them, or pass their own).
Records are content-free: a level, a code, a timestamp, and opaque ids in context. Conversation text, error messages and raw errors are excluded. An error record also says what failed:
| Field | Holds |
|---|---|
errorChain | The error and its causes, outermost first, each as { type, code?, statusCode? }. It follows cause, and an AI SDK RetryError's last attempt. A chain longer than eight keeps its first seven errors and its deepest cause, and sets errorChainTruncated. |
context.errorCode | The client-safe error's code, such as tool_failed. |
context.agentName, context.toolName | The registered agent and tool, on runtime errors. |
A type, code or name that isn't a short identifier is left out, so free text such as a message can't reach the log.
{
"level": "error",
"code": "runtime_error",
"timestamp": 1760505600000,
"errorChain": [
{ "type": "Error" },
{ "type": "SandboxUnavailableError" },
{ "type": "FetchError", "code": "ECONNRESET" }
],
"context": {
"source": "tool",
"operation": "execute",
"errorId": "PAI-…",
"errorCode": "tool_failed",
"agentName": "assistant",
"toolName": "bash"
}
}A logger that forwards only context should forward errorChain and errorChainTruncated too:
createPai({
logger: ({ level, code, context, errorChain, errorChainTruncated }) => {
log[level]({ event: code, context, errorChain, errorChainTruncated });
},
logLevel: "warn",
});Omit logger for PAI's default server reporter. In development and tests (NODE_ENV is development or test) it prints the raw event, error included, which is the quickest way to debug. Anywhere else, NODE_ENV unset included, it prints the same content-free record a logger would receive, as one JSON line. Pass false to silence. Default logLevel for a supplied logger is "warn". onWarning is the hook for stored-record and checkpoint warnings; omit it to use the same logger.
A supplied onRuntimeError replaces this reporting for runtime errors and receives the raw event instead, in every environment; onHttpError does the same on a receiver. That event is not content-free. Its error can carry request data: a failed model call's error holds the request body, prompt included. Its publicError is safe to show the user but not necessarily to log, because a thrown tool's own message passes through to publicError.message. To log one without conversation data, log toRuntimeDiagnostic(event) from @pai/core (or toHttpDiagnostic(event) from @pai/receiver-http): the content-free record a configured logger receives, errorChain included.
PAI never lets the AI SDK print a failed model call itself: the SDK's default prints the call's request body, so PAI handles the error through these channels instead.
React error fields
These are the error/loading surfaces on useChat(). isRunning, send, retry, and the rest of the controller are on useChat().
| Field | Meaning | What to show |
|---|---|---|
chat.loading | First snapshot not here | Skeleton |
chat.state.error | Durable failed run | Safe message + Retry |
chat.error | Local send/stop threw | Toast, then Retry if a run exists |
chat.watchError | Live stream is dead | “Reconnecting failed” — not a model failure |
function ErrorBar() {
const chat = Assistant.useChat();
const failed = chat.state.error;
if (!failed && !chat.watchError) return null;
return (
<div>
<p>{failed?.message ?? "Lost the live connection."}</p>
{failed ? (
<button type="button" onClick={() => void chat.retry()}>
Retry
</button>
) : null}
</div>
);
}retry() re-runs the failed turn. regenerate(...) re-runs from a chosen assistant message. If retry is not available, the client throws ThreadRetryUnavailableError.
Stop is not an error: chat.stop() cancels the active run. Tools must honor ctx.signal.
Next
- Chat
- Serve
- Observability — traces, not logs
useChat()createPai()