Docs
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
Class References
Function References
Interface References
Type Alias References
Variable References
Protocol

Custom Events Reference

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.

The type: ChatStream

chat() returns ChatStream by default (no outputSchema, stream not explicitly false):

ts
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" }],
});
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:

ts
type ChatStream = AsyncIterable<Exclude<StreamChunk, CustomEvent> | KnownCustomEvent>
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.

Reading events: the plain narrowing pattern

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:

ts
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
  }
}
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
  }
}

Full taxonomy

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.

InterfacenamevalueEmitted when
SandboxFileCustomEventsandbox.file{ type: 'create' | 'change' | 'delete'; path: string; timestamp: number }per file create/change/delete in an active sandbox
SandboxFileDiffEventsandbox.file.diff{ path: string; diff: string }per file change, opt-in via fileEvents: { diff: true }
FileChangedEventfile.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
CodeModeExecutionStartedEventcode_mode:execution_started{ timestamp: number; codeLength: number }Code Mode, when sandbox execution begins
CodeModeConsoleEventcode_mode:console{ level: 'log' | 'warn' | 'error' | 'info'; message: string; timestamp: number }Code Mode, per console.* call inside the sandbox
CodeModeExternalCallEventcode_mode:external_call{ function: string; args: unknown; timestamp: number }Code Mode, before a bound external_* function runs
CodeModeExternalResultEventcode_mode:external_result{ function: string; result: unknown; duration: number }Code Mode, after a successful external_* call
CodeModeExternalErrorEventcode_mode:external_error{ function: string; error: string; duration: number }Code Mode, when an external_* call throws
CodeModeSkillCallEventcode_mode:skill_call{ skill: string; input: unknown; timestamp: number }Code Mode with Skills, before a skill runs
CodeModeSkillResultEventcode_mode:skill_result{ skill: string; result: unknown; duration: number; timestamp: number }Code Mode with Skills, after a successful skill run
CodeModeSkillErrorEventcode_mode:skill_error{ skill: string; error: string; duration: number; timestamp: number }Code Mode with Skills, when a skill throws
SkillRegisteredEventskill:registered{ id: string; name: string; description: string; timestamp: number }when a skill is registered into the tool registry
StructuredOutputStartEventstructured-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
ApprovalRequestedEventapproval-requested{ toolCallId: string; toolName: string; input: unknown; approval: { id: string; needsApproval: true } }a server tool needs approval — the run pauses; see Tool Approval Flow
ToolInputAvailableEventtool-input-available{ toolCallId: string; toolName: string; input: unknown }a client tool is invoked — the run pauses; see Client Tools
UIResourceEventui-resource{ resource; serverId?: string; toolCallId: string; toolName: string; meta?: Record<string, unknown> }an MCP tool returns a ui:// resource (MCP Apps)

Your own custom events aren't in this union

Tools can emit arbitrary, application-defined events through the emitCustomEvent context API:

ts
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 };
});
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:

ts
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
  }
}
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.

  • Sandbox Events — the sandbox- and harness-specific rows of this table, in context, plus sandbox.file.diff's opt-in.
  • Observability — the server-side hook accessors (before()/after()/diff()) that back sandbox.file.diff.
  • Showing Code Mode in the UI — rendering the code_mode:* events live.
  • Streaming UIs — reading structured-output.complete end to end.
  • Streaming — the standard AG-UI StreamChunk lifecycle this union extends.