본문으로 건너뛰기

이벤트

하네스 어댑터가 샌드박스 내부에서 실행되면 해당 작업은 모두 chat() 스트림에서 관찰할 수 있습니다. 모든 chat() 실행이 생성하는 동일한 AG-UI StreamChunk와 샌드박스 및 하네스 전용 네임스페이스 CUSTOM 이벤트가 함께 전달됩니다.

이 페이지에서는 클라이언트가 읽는 스트림을 설명합니다. 파일 변경 시 서버 측 콜백을 실행하거나 샌드박스 내부를 로깅하려면 관찰 가능성을 참조하세요.

스트림

하네스 실행은 표준 AG-UI StreamChunk를 생성합니다.

  • 텍스트: 어시스턴트의 증분 출력입니다.
  • 도구 호출: 연결된 도구를 포함하며, 샌드박스 내부 에이전트가 호출하는 즉시 일반 도구 호출 청크로 표시됩니다.
  • 추론: 하네스가 노출하는 경우 에이전트의 사고 과정입니다.
  • 실행 수명 주기: 실행 시작, 실행 완료 및 관련 경계입니다.

사용자 지정 이벤트

표준 청크 외에도 샌드박스 및 하네스 계층은 각각 namevalue를 포함하는 CUSTOM 이벤트(chunk.type === 'CUSTOM')를 내보냅니다.

이벤트 name생성 주체시점value
grok-build.session-idGrok Build 어댑터샌드박스 내부 세션이 생성되거나 재개될 때 한 번재개할 수 있는 하네스 세션 ID
claude-code.session-idClaude Code 어댑터샌드박스 내부 세션이 생성되거나 재개될 때 한 번재개할 수 있는 하네스 세션 ID
codex.session-idCodex 어댑터세션이 생성되거나 재개될 때 한 번재개할 수 있는 하네스 세션 ID
opencode.session-idOpenCode 어댑터세션이 생성되거나 재개될 때 한 번재개할 수 있는 하네스 세션 ID
file.changed하네스 어댑터(예: Grok Build, Claude Code)실행 완료 후{ path: string; diff: string }: 전체 작업 트리의 git diff입니다(path는 항상 트리 루트인 '.'입니다).
sandbox.file엔진, 자동 생성샌드박스가 활성 상태인 동안 파일을 생성·변경·삭제할 때마다{ type: 'create' | 'change' | 'delete'; path: string; timestamp: number }
sandbox.file.difffileEvents: { diff: true }로 선택적으로 활성화한 엔진해당 sandbox.file 후 파일을 생성·변경·삭제할 때마다{ path: string; diff: string }: 세션의 git 기준과 비교한 해당 파일 하나의 통합 패치입니다.

*.session-id 이벤트를 사용하면 후속 실행에서 하네스 세션을 재개할 수 있습니다(어댑터의 modelOptions.sessionId를 통해 다시 전달합니다). sandbox.file은 샌드박스가 활성 상태이고 파일 감시가 켜져 있으면 훅 없이 자동으로 생성됩니다. sandbox.file.diff는 기본적으로 꺼져 있습니다(변경할 때마다 diff를 계산하는 데 비용이 들기 때문입니다). 클라이언트가 변경이 발생했다는 사실만 아는 것이 아니라 직접 렌더링해야 할 때는 defineSandbox에서 fileEvents: { diff: true }를 사용해 켤 수 있습니다:

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
});

파일 변경을 훅을 통해 서버 측에서 처리하는 방법(동일한 diff와 before()/after()를 함께 받습니다)이나 감시 기능을 완전히 끄는 방법은 관찰 가능성을 참조하세요.

연결된 도구도 자체 이벤트를 생성합니다. 도구 브리지를 통해 실행되는 chat() 도구는 실행 중간에 CUSTOM 이벤트를 다시 스트리밍할 수 있습니다. 예를 들어 코드 모드는 code_mode:execution_startedcode_mode:console(그리고 code_mode:external_call / …_result / …_error)을 생성하므로 진행 상황을 실시간으로 표시할 수 있습니다. 아래와 같은 패턴으로 읽으세요.

클라이언트에서 CUSTOM 이벤트 읽기

TanStack AI 가 자체적으로 발생하는 모든 CUSTOM 이벤트, sandbox.file, sandbox.file.diff, file.changed, *.session-id 이벤트 및 기타 이벤트는 고정된 name 와 구체적인 value 모양을 가지며, KnownCustomEvent 로 통합됩니다. chat() 의 반환 타입은 이에 따라 좁아집니다: chunk.type === 'CUSTOM' 를 확인한 후 chunk.name 을 리터럴 문자열과 비교합니다. 별도의 헬퍼나 캐스팅 없이, 평범한 if 타입 chunk.value 을 제공합니다:

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

세션 ID 이벤트는 하나의 리터럴 이름이 아닙니다

*.session-id는 어댑터별로 생성되므로(claude-code.session-id, codex.session-id, grok-build.session-id, opencode.session-id) 타입은 하나의 문자열이 아니라 템플릿 리터럴 이름 `${string}.session-id`입니다. 실행 중인 어댑터를 알고 있다면 정확한 리터럴과 비교하세요. 그러면 다른 이벤트와 마찬가지로 chunk.value의 타입이 좁혀집니다:

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')는 타입을 좁히지 않습니다. 이는 TypeScript가 타입에 연결할 수 없는 일반 불리언 표현식이므로 chunk.value는 검사 전 타입(사실상 unknown)을 그대로 유지합니다. 런타임에서 올바른 검사라도 마찬가지입니다. 모든 어댑터의 리터럴 이름을 나열하지 않고 어떤 어댑터의 세션 ID든 처리해야 한다면 대신 작은 타입 술어를 작성하세요:

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

이 술어는 필요할 때 직접 작성하는 것입니다. TanStack AI는 가드 API를 제공하지 않습니다. 일반적인 리터럴-name 좁히기(위와 같음)가 기본적인 헬퍼 없는 패턴이며, "어떤 어댑터든" 처리해야 하는 경우에만 술어를 사용하세요.

타입이 지정된 전체 이벤트 분류, 이 좁히기에 사용되는 ChatStream 타입 및 직접 emitCustomEvent를 호출할 때의 트레이드오프는 사용자 지정 이벤트 참조를 확인하세요.

저장되는 항목

사용자가 다음 날 아침 스레드를 다시 열고 이전에 본 내용을 확인하려고 합니다. 에이전트가 실행한 명령과 그 결과입니다. 이 페이지의 스트림은 실시간 출력이며, 앱이 보관하는 대화 기록은 별개의 항목입니다.

채팅 영속성withSandbox와 함께 연결하면 완료된 실행은 메시지 저장소에 다음 내용을 남깁니다:

하네스가 생성한 항목저장소의 내용
텍스트예, 어시스턴트 메시지로 저장됩니다.
도구 호출(이름 및 인수)예, 어시스턴트 메시지의 toolCalls로 저장됩니다.
도구 결과예, role: 'tool' 메시지로 저장됩니다.
추론아니요.
CUSTOM 이벤트(파일 이벤트, 코드 모드 콘솔, 세션 ID)아니요.

따라서 어떤 기기에서든 스레드를 다시 열면 결과가 포함된 도구 카드가 다시 구성됩니다.

CUSTOM 이벤트는 메시지가 아니므로, UI가 이벤트를 바탕으로 구성하는 파일 목록이나 콘솔 패널은 해당 방문 중에만 유지됩니다. 다시 표시하려면 그 상태를 직접 보관해야 합니다.

알아 두어야 할 제한은 두 가지입니다:

  • 하네스의 모든 텍스트는 하나의 최종 어시스턴트 메시지로 도착합니다. 복원된 스레드에서는 도구 카드가 실행 순서대로 표시된 다음 전체 텍스트가 표시됩니다.
  • 저장된 결과는 하네스가 보고한 문자열입니다. 스레드를 다시 열어도 도구가 다시 실행되지는 않습니다.

보관할 항목 줄이기

대화 기록을 저장할지 결정하려면 샌드박스 어댑터 빌드를 참조하세요. 이 섹션에서는 더 세밀하게 조정합니다. 대화 기록을 저장하되 더 작게 만들 수 있습니다.

한 번의 실행에서 도구 출력이 수백 KB에 이를 수 있으며, 결과는 전체가 저장소에 도달합니다. 무엇을 보관할지는 MessageStore가 결정합니다. isSandboxToolCall은 하네스가 샌드박스 내부에서 실행한 호출을 알려주므로, 해당 호출이 어떻게 표시되는지 알 필요가 없습니다. 렌더링된 tool-call 파트도 읽으므로 클라이언트 측 뷰를 필터링할 때 편리합니다.

각 결과에 상한을 설정하면 카드를 유지하면서 크기를 제한할 수 있습니다:

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),
};

대화 기록을 삭제하려면 각 결과를 해당 호출과 함께 제거하세요. 호출이 없는 결과는 프로바이더가 거부하므로 둘 중 어느 쪽보다 좋지 않습니다.

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),
};

이들을 삭제해도 안전합니다. 이들은 표시용 기록이며 여기서 재개되는 항목도 없습니다. 다음 턴의 모델 요청에도 전달되지 않는데, 프로바이더에 제공된 적이 없는 도구의 이름을 포함하기 때문입니다.

  • 관찰 가능성, 서버 측 파일 이벤트 훅(before()/after()/diff()), 디버그 로깅 및 저수준 감시 기능입니다.
  • 사용자 지정 이벤트 참조, 전체 KnownCustomEvent 분류와 ChatStream 타입입니다.
  • 도구, 도구 호출(및 CUSTOM) 청크로 표시되는 연결된 호스트 도구입니다.
  • 빠른 시작, file.changed diff를 처음부터 끝까지 읽는 방법입니다.