You're reading a chat() stream and you've hit a CUSTOM event — maybe sandbox.file.diff, maybe a Code Mode progress event, maybe structured-output.complete. Each feature page documents its own events in context. This page is the map: every CUSTOM event TanStack AI itself emits, in one table, plus the type mechanism that lets you read any of them with a plain if — no helper function, no cast.
chat() returns ChatStream by default (no outputSchema, stream not explicitly false):
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import type { ChatStream } from "@tanstack/ai";
const stream: ChatStream = chat({
adapter: openaiText("gpt-5.5"),
messages: [{ role: "user", content: "Hello" }],
});ChatStream is defined as:
type ChatStream = AsyncIterable<Exclude<StreamChunk, CustomEvent> | KnownCustomEvent>StreamChunk (the raw AG-UI event union) has exactly one CUSTOM-shaped member: the generic CustomEvent interface, with name: string and value: any. Left in the union unchanged, that single any "poisons" every narrow — even if (chunk.type === 'CUSTOM' && chunk.name === 'sandbox.file') would still leave chunk.value typed any, because TypeScript can't distinguish the generic member from a specific one once they're merged. ChatStream fixes this in two steps: Exclude<StreamChunk, CustomEvent> removes that generic member, and | KnownCustomEvent adds back a discriminated union of every event TanStack AI actually emits — each with a literal name and a concrete value. The result is a stream where CUSTOM events narrow like anything else.
Check chunk.type === 'CUSTOM', then compare chunk.name to a literal string. That's the entire client-side API — there is no isCustomEvent or isSandboxEvent guard to import:
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages: [{ role: "user", content: "Hello" }],
});
for await (const chunk of stream) {
if (chunk.type === "CUSTOM" && chunk.name === "sandbox.file.diff") {
console.log(chunk.value.path, chunk.value.diff); // typed, no helper, no cast
} else if (chunk.type === "CUSTOM" && chunk.name === "structured-output.complete") {
console.log(chunk.value.object); // typed, no helper, no cast
}
}Every interface below extends the base CustomEvent (type: 'CUSTOM', plus an optional model?) with a literal name and a concrete value. All are unioned as KnownCustomEvent, exported from @tanstack/ai alongside each individual interface.
| Interface | name | value | Emitted when |
|---|---|---|---|
| SandboxFileCustomEvent | sandbox.file | { type: 'create' | 'change' | 'delete'; path: string; timestamp: number } | per file create/change/delete in an active sandbox |
| SandboxFileDiffEvent | sandbox.file.diff | { path: string; diff: string } | per file change, opt-in via fileEvents: { diff: true } |
| FileChangedEvent | file.changed | { path: string; diff: string } | a harness adapter (Grok Build, Claude Code, …), once after the run completes |
| SessionIdEvent | `${string}.session-id` | { sessionId: string } | a harness adapter, once when its in-sandbox session is created or resumed |
| CodeModeExecutionStartedEvent | code_mode:execution_started | { timestamp: number; codeLength: number } | Code Mode, when sandbox execution begins |
| CodeModeConsoleEvent | code_mode:console | { level: 'log' | 'warn' | 'error' | 'info'; message: string; timestamp: number } | Code Mode, per console.* call inside the sandbox |
| CodeModeExternalCallEvent | code_mode:external_call | { function: string; args: unknown; timestamp: number } | Code Mode, before a bound external_* function runs |
| CodeModeExternalResultEvent | code_mode:external_result | { function: string; result: unknown; duration: number } | Code Mode, after a successful external_* call |
| CodeModeExternalErrorEvent | code_mode:external_error | { function: string; error: string; duration: number } | Code Mode, when an external_* call throws |
| CodeModeSkillCallEvent | code_mode:skill_call | { skill: string; input: unknown; timestamp: number } | Code Mode with Skills, before a skill runs |
| CodeModeSkillResultEvent | code_mode:skill_result | { skill: string; result: unknown; duration: number; timestamp: number } | Code Mode with Skills, after a successful skill run |
| CodeModeSkillErrorEvent | code_mode:skill_error | { skill: string; error: string; duration: number; timestamp: number } | Code Mode with Skills, when a skill throws |
| SkillRegisteredEvent | skill:registered | { id: string; name: string; description: string; timestamp: number } | when a skill is registered into the tool registry |
| StructuredOutputStartEvent | structured-output.start | { messageId: string } | chat({ outputSchema, stream: true }), once per structured message |
| StructuredOutputCompleteEvent<T> | structured-output.complete | { object: T; raw: string; reasoning?: string } | structured-output streaming, once with the validated object |
| ApprovalRequestedEvent | approval-requested | { toolCallId: string; toolName: string; input: unknown; approval: { id: string; needsApproval: true } } | a server tool needs approval — the run pauses; see Tool Approval Flow |
| ToolInputAvailableEvent | tool-input-available | { toolCallId: string; toolName: string; input: unknown } | a client tool is invoked — the run pauses; see Client Tools |
| UIResourceEvent | ui-resource | { resource; serverId?: string; toolCallId: string; toolName: string; meta?: Record<string, unknown> } | an MCP tool returns a ui:// resource (MCP Apps) |
Tools can emit arbitrary, application-defined events through the emitCustomEvent context API:
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
const importRows = toolDefinition({
name: "importRows",
description: "Import rows into the dataset, reporting progress as it runs",
inputSchema: z.object({ rows: z.array(z.string()) }),
}).server(async ({ rows }, context) => {
for (let i = 0; i < rows.length; i++) {
context?.emitCustomEvent("my-app:progress", {
done: i + 1,
total: rows.length,
});
}
return { imported: rows.length };
});These flow over the wire exactly like the built-in events — same CUSTOM chunk shape, same runtime behavior. But 'my-app:progress' isn't one of the literal names in KnownCustomEvent, so it's intentionally absent from ChatStream's type. This is the same tradeoff StructuredOutputStream already made: including a generic fallback member would reintroduce the value: any poison for every other event on the stream.
To read your own event's value, don't rely on ChatStream's narrowed union for that branch — annotate the stream as the wider StreamChunk type instead, where the generic CUSTOM member's value: any needs no cast:
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import type { StreamChunk } from "@tanstack/ai";
import { importRows } from "./tools";
const stream: AsyncIterable<StreamChunk> = chat({
adapter: openaiText("gpt-5.5"),
messages: [{ role: "user", content: "Import these rows" }],
tools: [importRows],
});
for await (const chunk of stream) {
if (chunk.type === "CUSTOM" && chunk.name === "my-app:progress") {
console.log(chunk.value.done, chunk.value.total); // value: any — your event, your shape
}
}The event still arrives at runtime either way — this only changes what TypeScript will let you write. ChatStream is the right default for reading TanStack AI's own events with full type safety; fall back to StreamChunk for the branches that read your own.