Sandbox events stream the agent text, tool calls, and file edits to the UI while the run is in progress.
Everything a harness does in a sandbox comes back on the chat() stream. You get the normal AG-UI chunks, plus CUSTOM events with a name for sandbox-specific things such as file edits.
This sample prints the agent's text and every file it touches:
import { chat } from '@tanstack/ai'
import { GROK_CLI_INSTALL_COMMAND, grokBuildText } from '@tanstack/ai-grok-build'
import {
createSecrets,
defineSandbox,
defineWorkspace,
githubRepo,
withSandbox,
} from '@tanstack/ai-sandbox'
import { dockerSandbox } from '@tanstack/ai-sandbox-docker'
const repoSandbox = defineSandbox({
id: 'repo-agent',
provider: dockerSandbox({ image: 'node:22' }),
workspace: defineWorkspace({
source: githubRepo({ repo: 'owner/app' }),
setup: [GROK_CLI_INSTALL_COMMAND],
secrets: createSecrets({ XAI_API_KEY: process.env.XAI_API_KEY ?? '' }),
}),
fileEvents: { diff: true }, // also send sandbox.file.diff for each change
})
const stream = chat({
threadId: 'thread-1',
adapter: grokBuildText('grok-build'),
messages: [{ role: 'user', content: 'Add a README section on testing.' }],
middleware: [withSandbox(repoSandbox)],
})
for await (const chunk of stream) {
if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
process.stdout.write(chunk.delta)
} else if (chunk.type === 'CUSTOM' && chunk.name === 'sandbox.file') {
console.log(chunk.value.type, chunk.value.path)
} else if (chunk.type === 'CUSTOM' && chunk.name === 'sandbox.file.diff') {
console.log(chunk.value.path, chunk.value.diff)
} else if (chunk.type === 'CUSTOM' && chunk.name === 'file.changed') {
console.log(chunk.value.diff)
}
}You see create, change, and delete lines while the agent works. When the run ends, you see the full git diff.
Compare chunk.name to a literal string, and TypeScript types chunk.value for you. You need no helper and no cast.
A harness run sends the standard AG-UI StreamChunks:
This page is about the stream that the client reads. To run server-side callbacks on file changes, or to log sandbox internals, see Observability.
On top of the standard chunks, the sandbox and harness layers send CUSTOM events (chunk.type === 'CUSTOM'). Each one has a name and a value:
| Event name | Sent by | When | value |
|---|---|---|---|
| grok-build.session-id | Grok Build adapter | Once, when the session is created or resumed | { sessionId: string } |
| claude-code.session-id | Claude Code adapter | Once, when the session is created or resumed | { sessionId: string } |
| codex.session-id | Codex adapter | Once, when the session is created or resumed | { sessionId: string } |
| opencode.session-id | OpenCode adapter | Once, when the session is created or resumed | { sessionId: string } |
| file.changed | Harness adapter (for example 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 | For each file create, change, or delete while a sandbox is active | { type: 'create' | 'change' | 'delete'; path: string; timestamp: number } |
| sandbox.file.diff | The engine, with fileEvents: { diff: true } | For each file create, change, or delete, after the matching sandbox.file | { path: string; diff: string }: a unified patch of that one file against the git baseline of the session |
To handle file changes on the server with hooks, or to turn the watcher off, see Observability. Hooks get the same diff, plus before() and after().
For the full typed event list and the ChatStream type, see Custom Events Reference.
Your user opens a thread the next morning and wants to see the commands the agent ran and their results. The stream on this page is live output. The transcript your app keeps is a different thing.
Add chat persistence next to withSandbox. A finished run then leaves this in the message store:
| The harness produced | In the store |
|---|---|
| Text | Yes, as the assistant message. |
| Tool calls (name and arguments) | Yes, as toolCalls on an assistant message. |
| Tool results | Yes, as a role: 'tool' message. |
| Reasoning | No. |
| CUSTOM events (file events, code-mode console, session ids) | No. |
So when the user opens the thread again, on any device, the tool cards come back with their results.
CUSTOM events are not messages. Anything your UI builds from them (a file list, a console panel) lasts only for that visit. If you want it back later, store that state yourself.
Two limits:
A chat() tool that runs through the tool bridge can stream CUSTOM events during its run. Code mode, for example, sends:
Read them with the same literal-name pattern to show progress live.
Each adapter sends its own name (claude-code.session-id, codex.session-id, grok-build.session-id, opencode.session-id). So the type of the name is the template literal `${string}.session-id`, not one string.
If you know the adapter, compare the exact name. It types chunk.value like 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
}
}chunk.name.endsWith('.session-id') does not narrow the type. It is a plain boolean, so chunk.value stays untyped, even though the check is correct at runtime.
To handle the session id of any adapter without a list of names, write a small type predicate:
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
}
}TanStack AI does not ship this guard. You write it yourself, only for this "any adapter" case.
To decide if you store a transcript at all, see Build a Sandbox Adapter. This section is for when you store one and want it smaller.
The tool output of one run can be hundreds of kilobytes, and results reach your store whole. Your MessageStore decides what to keep.
To keep the cards but limit the size, cap each result:
import type { ModelMessage } from '@tanstack/ai'
import type { MessageStore } from '@tanstack/ai-persistence'
import { db } from './db'
const MAX_RESULT = 8_000
const store: MessageStore = {
async saveThread(threadId, messages) {
const capped = messages.map((message: ModelMessage) =>
message.role === 'tool' && typeof message.content === 'string'
? { ...message, content: message.content.slice(0, MAX_RESULT) }
: message,
)
await db.saveThread(threadId, capped)
},
loadThread: (threadId) => db.loadThread(threadId),
}To drop the history, remove each result together with its call. A result without its call is worse than both options, because providers reject it.
isSandboxToolCall tells you which calls the harness ran inside the sandbox, so you do not need to know how they are marked. It also reads a rendered tool-call part, which helps when you filter a client-side view.
import { isSandboxToolCall } from '@tanstack/ai-sandbox'
import type { ModelMessage } from '@tanstack/ai'
import type { MessageStore } from '@tanstack/ai-persistence'
import { db } from './db'
const store: MessageStore = {
async saveThread(threadId, messages) {
const dropped = new Set<string>()
const kept: Array<ModelMessage> = []
for (const message of messages) {
const calls = message.toolCalls
if (calls && calls.length > 0 && calls.every(isSandboxToolCall)) {
for (const call of calls) dropped.add(call.id)
continue
}
if (
message.role === 'tool' &&
message.toolCallId !== undefined &&
dropped.has(message.toolCallId)
) {
continue
}
kept.push(message)
}
await db.saveThread(threadId, kept)
},
loadThread: (threadId) => db.loadThread(threadId),
}Dropping them is safe. They are display history, and nothing resumes from them. The next request to the model never carries them either, because they name tools that the provider never got.