Sandboxes

Events

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.

The stream

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.

Custom events

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 nameEmitted byWhenvalue
grok-build.session-idGrok Build adapteronce, when the in-sandbox session is created or resumedthe resumable harness session id
claude-code.session-idClaude Code adapteronce, when the in-sandbox session is created or resumedthe resumable harness session id
codex.session-idCodex adapteronce, when the session is created or resumedthe resumable harness session id
opencode.session-idOpenCode adapteronce, when the session is created or resumedthe resumable harness session id
file.changedharness 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.filethe engine, automaticallyper file create / change / delete while a sandbox is active{ type: 'create' | 'change' | 'delete'; path: string; timestamp: number }
sandbox.file.diffthe 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:

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

Reading CUSTOM events on the client

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";

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 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 { 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:

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 for the full typed event taxonomy, the ChatStream type this narrowing relies on, and the tradeoff for your own emitCustomEvent calls.

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