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

ChatStream is defined as:

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

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

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

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.