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
Persistence (Advanced)
Embeddings
Reranking
Evaluate
Sandboxes (Advanced)
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
CodeModeSnippetCallEventcode_mode:snippet_call{ snippet: string; input: unknown; timestamp: number }Code Mode with Snippets, before a snippet runs
CodeModeSnippetResultEventcode_mode:snippet_result{ snippet: string; result: unknown; duration: number; timestamp: number }Code Mode with Snippets, after a successful snippet run
CodeModeSnippetErrorEventcode_mode:snippet_error{ snippet: string; error: string; duration: number; timestamp: number }Code Mode with Snippets, when a snippet throws
SnippetRegisteredEventsnippet:registered{ id: string; name: string; description: string; timestamp: number }when a snippet 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)

These stay CUSTOM

Harness *.session-id events and structured-output.start / structured-output.complete stay CUSTOM. They are not fields on RUN_FINISHED.

Read them with the same chunk.type === "CUSTOM" && chunk.name === "..." branch as the rest of this page. See Streaming structured output for the complete event.

Your own custom events aren't in this union

Tools and chat middleware can emit application-defined events through emitCustomEvent.

A server tool receives emitCustomEvent on its execution context:

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

Chat middleware calls the same helper on ChatMiddlewareContext. The engine yields the chunk while the hook is still running, so a long onConfig can send started before the work finishes:

ts
import { type ChatMiddleware } from "@tanstack/ai";

async function prepare() {
  await new Promise<void>((resolve) => {
    setTimeout(resolve, 1);
  });
}

const progress: ChatMiddleware = {
  name: "progress",
  async onConfig(ctx) {
    if (ctx.phase !== "beforeModel") return;
    ctx.emitCustomEvent("my-app:progress", { step: "prepare" });
    await prepare();
    ctx.emitCustomEvent("my-app:progress", { step: "ready" });
  },
};
import { type ChatMiddleware } from "@tanstack/ai";

async function prepare() {
  await new Promise<void>((resolve) => {
    setTimeout(resolve, 1);
  });
}

const progress: ChatMiddleware = {
  name: "progress",
  async onConfig(ctx) {
    if (ctx.phase !== "beforeModel") return;
    ctx.emitCustomEvent("my-app:progress", { step: "prepare" });
    await prepare();
    ctx.emitCustomEvent("my-app:progress", { step: "ready" });
  },
};

With durability on the response, each of these events flushes as soon as it is emitted, so prepare reaches the client while prepare() is still running. High-volume names stay in the durability batch: process.stdout, process.stderr, sandbox.file, and sandbox.file.diff. Pass { batch: true } to keep one of your own events in that batch:

ts
import { type ChatMiddleware } from "@tanstack/ai";

const noisy: ChatMiddleware = {
  name: "noisy",
  async onConfig(ctx) {
    ctx.emitCustomEvent("my-app:ticks", { n: 1 }, { batch: true });
  },
};
import { type ChatMiddleware } from "@tanstack/ai";

const noisy: ChatMiddleware = {
  name: "noisy",
  async onConfig(ctx) {
    ctx.emitCustomEvent("my-app:ticks", { n: 1 }, { batch: true });
  },
};

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.

  • Event metadata — metadata.tanstack fields a custom AG-UI server must send so useChat gets finishReason and model.
  • 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.
  • Stream Events for the standard AG-UI StreamChunk lifecycle this union extends.