- MCP Server ToolsUpdated
- Managed MCP with chat()Updated
- Manual MCPUpdated
- MCP Type GenerationUpdated
- MCP Client InputUpdated
- MCP Server Tools: AdvancedNew
- MCP EventsNew
- MCP AppsUpdated
- Serve Tools over HTTPUpdated
- Serve Resources and PromptsUpdated
- Ask for InputUpdated
- Draft Text from a ToolUpdated
- MCP Server TasksUpdated
- MCP Server AuthUpdated
- MCP Server SessionsUpdated
- MCP Server on stdioUpdated
Persistence (Advanced)
- GenerationsUpdated
- Realtime Voice ChatUpdated
- Text-to-SpeechUpdated
- TranscriptionUpdated
- Transcription: AdvancedNew
- Voice CreationUpdated
- Audio RecordingUpdated
- Audio GenerationUpdated
- Image GenerationUpdated
- Image Generation: AdvancedNew
- Video GenerationUpdated
- Video Generation: AdvancedNew
- Live GenerationUpdated
- World GenerationUpdated
- Generation HooksUpdated
- OverviewUpdated
- Quick StartUpdated
- ProvidersUpdated
- HarnessesUpdated
- Harness AuthUpdated
- WorkspaceUpdated
- ToolsUpdated
- PolicyUpdated
- Lifecycle & SnapshotsUpdated
- Portable SnapshotsUpdated
- Keep Files After ReloadUpdated
- Save a Named VersionUpdated
- Branch From a VersionUpdated
- Send a Frozen FileUpdated
- Let the Agent Save and ForkUpdated
- Pick Which Files to KeepUpdated
- What a Snapshot StoresUpdated
- Instance DurabilityUpdated
- Durable RunsUpdated
- EventsUpdated
Sandboxes (Advanced)
- Runtime ContextUpdated
- Bring Your Own KeyUpdated
- Debug LoggingUpdated
- Multimodal ContentUpdated
- Files APIUpdated
- Per-Model Type SafetyUpdated
- Runtime Adapter SwitchingUpdated
- Tree-ShakingUpdated
- Extend AdapterUpdated
- Extend Adapter: AdvancedNew
- Typed Pre-Configured OptionsUpdated
- Test with a Fake ModelUpdated
- Prompt CachingUpdated
- Mid-Conversation ChangesUpdated
- Approval Flow ProcessingUpdated
Events
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.
Read the events#
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.
The stream#
A harness run sends the standard AG-UI StreamChunks:
- Text: the assistant output, piece by piece.
- Tool calls, also bridged tools. They show up as normal tool-call chunks when the agent calls them.
- Reasoning: the agent's thinking, when the harness exposes it.
- Run lifecycle: run started, run finished, and other boundaries.
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.
Custom events#
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 |
- *.session-id: pass the id back with the adapter's modelOptions.sessionId to resume the harness session on a later run.
- sandbox.file: sent whenever a sandbox is active and file watching is on. You need no hooks.
- sandbox.file.diff: off by default, because a diff on every change has a cost. Turn it on with fileEvents: { diff: true } on defineSandbox when the client must show the change, not only know that it happened.
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.
What gets stored#
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:
- All of the harness text arrives as one final assistant message. A restored thread shows the tool cards in the order they ran, then the full text.
- A stored result is the string that the harness reported. Opening a thread again never runs a tool again.
Advanced#
Bridged tools send their own events#
A chat() tool that runs through the tool bridge can stream CUSTOM events during its run. Code mode, for example, sends:
- code_mode:execution_started
- code_mode:console
- code_mode:external_call, code_mode:external_result, and code_mode:external_error
Read them with the same literal-name pattern to show progress live.
Session-id events with any adapter#
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.
Trim what you keep#
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.