When a harness adapter runs inside a sandbox, everything it does is observable on the chat() stream: the same AG-UI StreamChunks any chat() run produces, plus namespaced CUSTOM events for sandbox- and harness-specifics.
This page is about the stream the client reads. To run server-side callbacks on file changes, or to log sandbox internals, see Observability.
A harness run produces standard AG-UI StreamChunks:
Text — incremental assistant output.
Tool calls — including bridged tools, which surface as ordinary tool-call chunks the moment the in-sandbox agent invokes them.
Reasoning — the agent's thinking, where the harness exposes it.
Run lifecycle — run started / finished and related boundaries.
On top of the standard chunks, the sandbox and harness layers emit CUSTOM events (chunk.type === 'CUSTOM'), each with a name and a value:
| Event name | Emitted by | When | value |
|---|---|---|---|
| grok-build.session-id | Grok Build adapter | once, when the in-sandbox session is created or resumed | the resumable harness session id |
| claude-code.session-id | Claude Code adapter | once, when the in-sandbox session is created or resumed | the resumable harness session id |
| codex.session-id | Codex adapter | once, when the session is created or resumed | the resumable harness session id |
| 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. 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:
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 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 can stream CUSTOM events back mid-execution. Code mode, for example, emits code_mode:execution_started and code_mode:console (plus code_mode:external_call / …_result / …_error) so you can show its progress live. Read them with the same pattern below.
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:
import { stream } from "./my-run";
for await (const chunk of stream) {
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
}
}*.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:
import { resumeSession } from "./session";
import { stream } from "./my-run";
for await (const chunk of stream) {
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:
tsimport 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 for the full typed event taxonomy, the ChatStream type this narrowing relies on, and the tradeoff for your own emitCustomEvent calls.