Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2ca2997
feat(ai): type all framework CUSTOM events into KnownCustomEvent union
AlemTuzlak Jul 3, 2026
48dcb5e
feat(ai): add SandboxFileHookEvent with lazy content accessors
AlemTuzlak Jul 3, 2026
f0a8c5c
feat(ai): retype chat stream as ChatStream so custom-event narrowing …
AlemTuzlak Jul 3, 2026
074d34d
feat(ai): thread SandboxFileHookEvent through the sandbox runtime seam
AlemTuzlak Jul 3, 2026
fe625e0
feat(ai-sandbox): git-backed buildFileHookEvent + resolveFileEvents
AlemTuzlak Jul 3, 2026
03f624a
fix(ai-sandbox): shell-quote git exec args (command injection) + tigh…
AlemTuzlak Jul 3, 2026
12b8435
feat(ai-sandbox): wire git-backed file hook accessors into the watcher
AlemTuzlak Jul 3, 2026
f1515a8
feat(ai): add emitFileDiff runtime seam for sandbox.file.diff
AlemTuzlak Jul 3, 2026
187ea79
test(ai): guard indexed access in sandbox-runtime-emit assertion
AlemTuzlak Jul 3, 2026
41b83b1
feat(ai-sandbox): opt-in live sandbox.file.diff stream event
AlemTuzlak Jul 3, 2026
7b684ce
test(ai-sandbox): integration coverage for sandbox.file + sandbox.fil…
AlemTuzlak Jul 3, 2026
6b348aa
docs(sandbox): typed custom events, sandbox.file.diff, and hook diff …
AlemTuzlak Jul 3, 2026
cbc18b7
docs(skills): typed custom-event narrowing + sandbox hook diff accessors
AlemTuzlak Jul 3, 2026
9aa0a2b
docs(openrouter): tag cost snippet ignore for kiira (ChatStream Exclu…
AlemTuzlak Jul 3, 2026
fefca25
ci: apply automated fixes
autofix-ci[bot] Jul 3, 2026
ff7c803
fix(ai): use function-property signatures on SandboxFileHookEvent (es…
AlemTuzlak Jul 3, 2026
40f4295
fix(ai-sandbox): exec-poll watcher searches cwd-relative so it works …
AlemTuzlak Jul 3, 2026
0e20cf3
fix(ai-sandbox): address review — diff() delete handling, await pendi…
AlemTuzlak Jul 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/adapters/openrouter.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ accounts for routing, fallback providers, BYOK, and cached-token pricing. See
OpenRouter's [Usage Accounting](https://openrouter.ai/docs/use-cases/usage-accounting)
docs for the meaning and units of these fields.

```typescript
```typescript ignore
import { chat, type RunFinishedEvent, type StreamChunk } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";

Expand Down
1 change: 1 addition & 0 deletions docs/chat/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ TanStack AI implements the [AG-UI Protocol](https://docs.ag-ui.com/introduction)
- **TEXT_MESSAGE_START/CONTENT/END** - Text content streaming lifecycle
- **TOOL_CALL_START/ARGS/END** - Tool invocation lifecycle
- **STEP_STARTED/STEP_FINISHED** - Thinking/reasoning steps
- **CUSTOM** - Namespaced extension events (sandbox file changes, Code Mode progress, structured-output completion, and your own `emitCustomEvent` calls) — see the [Custom Events Reference](../protocol/custom-events) for the full typed taxonomy and how to narrow `chunk.value` with a plain `if`
- **RUN_FINISHED** - Run completion with finish reason and usage
- **RUN_ERROR** - Error occurred during the run

Expand Down
19 changes: 16 additions & 3 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,8 @@
{
"label": "Streaming",
"to": "chat/streaming",
"addedAt": "2026-04-15"
"addedAt": "2026-04-15",
"updatedAt": "2026-07-03"
},
{
"label": "Connection Adapters",
Expand All @@ -175,6 +176,16 @@
}
]
},
{
"label": "Protocol",
"children": [
{
"label": "Custom Events Reference",
"to": "protocol/custom-events",
"addedAt": "2026-07-03"
}
]
},
{
"label": "Structured Outputs",
"children": [
Expand Down Expand Up @@ -368,12 +379,14 @@
{
"label": "Events",
"to": "sandbox/events",
"addedAt": "2026-06-29"
"addedAt": "2026-06-29",
"updatedAt": "2026-07-03"
},
{
"label": "Observability",
"to": "sandbox/observability",
"addedAt": "2026-06-29"
"addedAt": "2026-06-29",
"updatedAt": "2026-07-03"
},
{
"label": "Cloudflare (Edge)",
Expand Down
174 changes: 174 additions & 0 deletions docs/protocol/custom-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
---
title: Custom Events Reference
id: custom-events
order: 1
description: "Every CUSTOM event TanStack AI itself emits, unified as KnownCustomEvent, and the ChatStream type that lets a plain `if (chunk.name === '…')` narrow chunk.value — no helper, no cast."
keywords:
- tanstack ai
- custom events
- KnownCustomEvent
- ChatStream
- ag-ui protocol
- CUSTOM event
- stream narrowing
---

You're reading a `chat()` stream and you've hit a `CUSTOM` event — maybe
[`sandbox.file.diff`](../sandbox/events), 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 ignore
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 ignore
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.

| 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](../sandbox/events) |
| `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](../code-mode/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](../code-mode/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 })`](../structured-outputs/streaming), 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](../tools/tool-approval) |
| `ToolInputAvailableEvent` | `tool-input-available` | `{ toolCallId: string; toolName: string; input: unknown }` | a client tool is invoked — the run pauses; see [Client Tools](../tools/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](../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 ignore
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.

## Related

- [Sandbox Events](../sandbox/events) — the sandbox- and harness-specific rows of this table, in context, plus `sandbox.file.diff`'s opt-in.
- [Observability](../sandbox/observability) — the server-side hook accessors (`before()`/`after()`/`diff()`) that back `sandbox.file.diff`.
- [Showing Code Mode in the UI](../code-mode/client-integration) — rendering the `code_mode:*` events live.
- [Streaming UIs](../structured-outputs/streaming) — reading `structured-output.complete` end to end.
- [Streaming](../chat/streaming) — the standard AG-UI `StreamChunk` lifecycle this union extends.
103 changes: 80 additions & 23 deletions docs/sandbox/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,30 @@ events (`chunk.type === 'CUSTOM'`), each with a `name` and a `value`:
| `opencode.session-id` | OpenCode adapter | once, when the session is created or resumed | the resumable harness session id |
| `file.changed` | harness adapter (e.g. Grok Build, Claude Code) | after the run completes | `{ path: string; diff: string }` — the whole working-tree `git diff` (`path` is always `'.'`, the tree root) |
| `sandbox.file` | the engine, automatically | per file create / change / delete while a sandbox is active | `{ type: 'create' \| 'change' \| 'delete'; path: string; timestamp: number }` |
| `sandbox.file.diff` | the engine, opt-in via `fileEvents: { diff: true }` | per file create / change / delete, after the matching `sandbox.file` | `{ path: string; diff: string }` — a unified patch of that one file vs the session's git baseline |

The `*.session-id` event lets you resume a harness session on a follow-up run
(pass it back via the adapter's `modelOptions.sessionId`). `sandbox.file` is
emitted automatically whenever a sandbox is active and file watching is on —
no hooks required; see [Observability](./observability) to also handle these
server-side or to turn the watcher off.
no hooks required. `sandbox.file.diff` is off by default (computing a diff on
every change has a cost); turn it on with `fileEvents: { diff: true }` on
`defineSandbox` when the client needs to render the change itself, not just
know it happened:

```ts
import { defineSandbox } from "@tanstack/ai-sandbox";
import { dockerSandbox } from "@tanstack/ai-sandbox-docker";

const repoSandbox = defineSandbox({
id: "repo-agent",
provider: dockerSandbox({ image: "node:22" }),
fileEvents: { diff: true }, // also emit sandbox.file.diff per change
});
```

See [Observability](./observability) to also handle file changes server-side
via hooks (which get the same diff, plus `before()`/`after()`), or to turn
the watcher off entirely.

> **Bridged tools emit their own events too.** A `chat()` tool that runs through
> the [tool bridge](./tools) can stream `CUSTOM` events back mid-execution. Code
Expand All @@ -50,44 +68,83 @@ server-side or to turn the watcher off.

## Reading CUSTOM events on the client

A `CUSTOM` chunk's `value` is of unknown shape, so narrow it with `typeof` / `in`
checks before reading its fields — never cast:
Every `CUSTOM` event TanStack AI itself emits — `sandbox.file`,
`sandbox.file.diff`, `file.changed`, the `*.session-id` events, and more — has
a fixed `name` and a concrete `value` shape, unified as `KnownCustomEvent`.
`chat()`'s return type narrows accordingly: check `chunk.type === 'CUSTOM'`
and then compare `chunk.name` to a literal string. No helper, no cast — the
plain `if` types `chunk.value` for you:

```ts
import { stream } from './my-run'
import { stream } from "./my-run";

for await (const chunk of stream) {
if (chunk.type === 'CUSTOM' && chunk.name === 'file.changed') {
const value = chunk.value
if (value !== null && typeof value === 'object' && 'diff' in value) {
console.log(value.diff)
}
if (chunk.type === "CUSTOM" && chunk.name === "sandbox.file") {
console.log(chunk.value.type, chunk.value.path); // typed, no cast
} else if (chunk.type === "CUSTOM" && chunk.name === "sandbox.file.diff") {
console.log(chunk.value.path, chunk.value.diff); // typed, no cast
} else if (chunk.type === "CUSTOM" && chunk.name === "file.changed") {
console.log(chunk.value.diff); // typed, no cast
}
}
```

The same pattern reads the auto-emitted `sandbox.file` events:
### Session-id events aren't one literal name

`*.session-id` is emitted per-adapter (`claude-code.session-id`,
`codex.session-id`, `grok-build.session-id`, `opencode.session-id`), so its
type is the template-literal name `` `${string}.session-id` ``, not a single
string. If you know which adapter you're running, compare the exact literal
— it narrows `chunk.value` the same as any other event:

```ts
import { stream } from './my-run'
import { resumeSession } from "./session";
import { stream } from "./my-run";

for await (const chunk of stream) {
if (chunk.type === 'CUSTOM' && chunk.name === 'sandbox.file') {
const value = chunk.value
if (
value !== null &&
typeof value === 'object' &&
'type' in value &&
'path' in value
) {
console.log('file event', value) // { type, path, timestamp }
}
if (chunk.type === "CUSTOM" && chunk.name === "claude-code.session-id") {
resumeSession(chunk.value.sessionId); // typed as string, no cast
}
}
```

> **`chunk.name.endsWith('.session-id')` does *not* narrow.** It's a plain
> boolean expression, not something TypeScript can attach to a type — so
> `chunk.value` stays whatever it was before the check (effectively
> `unknown`), even though the check happens to be correct at runtime. If you
> need to handle *any* adapter's session id without listing every adapter's
> literal name, write a small type predicate instead:
>
> ```ts
> import type { KnownCustomEvent, SessionIdEvent } from "@tanstack/ai";
> import { resumeSession } from "./session";
> import { stream } from "./my-run";
>
> function isSessionIdEvent(
> chunk: KnownCustomEvent,
> ): chunk is SessionIdEvent {
> return chunk.name.endsWith(".session-id");
> }
>
> for await (const chunk of stream) {
> if (chunk.type === "CUSTOM" && isSessionIdEvent(chunk)) {
> resumeSession(chunk.value.sessionId); // typed as string, no cast
> }
> }
> ```
>
> This predicate is something you write yourself when you need it — TanStack
> AI doesn't ship a guard API. Plain literal-`name` narrowing (as above) is
> the primary, no-helper pattern; reach for a predicate only for this
> "any adapter" case.

See [Custom Events Reference](../protocol/custom-events) for the full typed
event taxonomy, the `ChatStream` type this narrowing relies on, and the
tradeoff for your own `emitCustomEvent` calls.

## Related

- [Observability](./observability) — server-side file-event hooks, debug logging, and the low-level watcher.
- [Observability](./observability) — server-side file-event hooks (with `before()`/`after()`/`diff()`), debug logging, and the low-level watcher.
- [Custom Events Reference](../protocol/custom-events) — the full `KnownCustomEvent` taxonomy and the `ChatStream` type.
- [Tools](./tools) — bridged host tools that surface as tool-call (and CUSTOM) chunks.
- [Quick Start](./quick-start) — read the `file.changed` diff end to end.
Loading
Loading