본문으로 건너뛰기

하네스 구조화된 출력

코딩 에이전트에게 저장소를 검사하도록 요청했습니다. 에이전트는 도구 호출과 산문을 스트리밍합니다. 파싱해야 하는 긴 텍스트가 아니라 저장하거나 렌더링할 수 있는 타입이 지정된 객체가 필요합니다.

동일한 chat() 호출에 outputSchema를 전달합니다. 하네스가 네이티브 도구를 실행합니다. 그러면 await chat() 또는 useChat().final에서 검증된 객체를 얻습니다.

이 페이지는 샌드박스 하네스 어댑터를 위한 것입니다.

프롬프트에서 JSON만 추출하고 샌드박스가 필요하지 않다면 HTTP 어댑터와 함께 원샷 추출을 사용합니다.

스키마 정의

import { z } from "zod";

export const ReportSchema = z.object({
name: z.string(),
oneLiner: z.string(),
audience: z.string(),
mainPackages: z.array(
z.object({
name: z.string(),
role: z.string(),
}),
),
howToRun: z.string(),
});

반환 타입은 스키마에서 추론됩니다. 캐스트가 필요하지 않습니다.

서버: 샌드박스와 outputSchema

하네스에는 샌드박스가 필요합니다. withSandbox(...)를 전달합니다. 클라이언트가 스트림을 읽는다면 stream: true를 전달합니다. stream: true가 없으면 chat()은 SSE가 아니라 Promise를 반환합니다.

import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { claudeCodeText } from "@tanstack/ai-claude-code";
import {
defineSandbox,
defineWorkspace,
githubRepo,
withSandbox,
} from "@tanstack/ai-sandbox";
import { dockerSandbox } from "@tanstack/ai-sandbox-docker";

const sandbox = defineSandbox({
id: "repo-report",
provider: dockerSandbox({ image: "node:22" }),
workspace: defineWorkspace({
source: githubRepo({ repo: "TanStack/ai" }),
}),
});

export async function POST(request: Request) {
const body: unknown = await request.json();
const messages =
typeof body === "object" &&
body !== null &&
"messages" in body &&
Array.isArray(body.messages)
? body.messages
: [];

const stream = chat({
adapter: claudeCodeText("claude-opus-4-8"),
messages,
outputSchema: ReportSchema,
stream: true,
middleware: [withSandbox(sandbox)],
});

return toServerSentEventsResponse(stream);
}

어댑터를 교체해 에이전트를 변경합니다.

  • codexText("gpt-5.3-codex")
  • opencodeText("anthropic/claude-opus-4-5")
  • grokBuildText("composer-2.5")
  • 모든 ACP CLI에는 acpCompatibleText(...)를 사용할 수 있습니다. ACP 호환을 참고합니다.

타입이 지정된 객체는 structured-output.complete 이벤트로 도착합니다. 도구 활동이 먼저 스트리밍됩니다.

클라이언트: partsfinal 읽기

어시스턴트 메시지에 현재 실행이 담깁니다. 도구 호출, 추론, 타입이 지정된 객체를 확인하려면 messages[].parts를 순회합니다. useChat().final은 최신 structured-output 파트의 바로 가기입니다.

import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";

function RepoReport() {
const { messages, sendMessage, isLoading, final } = useChat({
connection: fetchServerSentEvents("/api/repo-report"),
outputSchema: ReportSchema,
});

return (
<>
<button
disabled={isLoading}
onClick={() => sendMessage("What is this repository about?")}
>
Run report
</button>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, index) => {
if (part.type === "thinking") {
return <p key={index}>{part.content}</p>;
}
if (part.type === "tool-call") {
return (
<p key={part.id}>
{part.name} ({part.state})
</p>
);
}
if (part.type === "text") {
return <p key={index}>{part.content}</p>;
}
if (part.type === "structured-output") {
const report = part.data ?? part.partial;
return report?.name ? <h2 key={index}>{report.name}</h2> : null;
}
return null;
})}
</div>
))}
{final ? <p>{final.oneLiner}</p> : null}
</>
);
}

각 파트 타입은 다음과 같습니다.

  • thinking: 에이전트가 내보내는 경우의 하네스 추론
  • tool-call: Read 또는 Bash와 같은 네이티브 하네스 도구
  • text: 에이전트가 JSON 앞에 작성하는 산문
  • structured-output: 스키마 객체. part.data는 검증된 값입니다. part.partial은 어댑터가 JSON 텍스트를 스트리밍할 때의 점진적 파싱 결과입니다. part.raw는 원본 문자열입니다.

final의 타입은 스키마로 지정됩니다. structured-output.complete가 도착할 때까지 null로 유지됩니다. 항상 최신 어시스턴트 턴과 일치합니다. 이전 턴은 각자의 structured-output 파트에 유지됩니다.

하네스 어댑터에서는 partial이 비어 있습니다. 객체는 필드별로 스트리밍되지 않습니다. 기다리는 동안 messages에서 도구 호출을 렌더링합니다. partial / final 형태는 스트리밍 UI를 참고합니다.

타입이 지정된 객체 영속화

withSandbox 옆에 withPersistence를 추가합니다. 엔진은 완료된 structured-output 파트를 트랜스크립트에 저장합니다. 새 메시지 id를 사용하면 하네스 산문과 구조화된 객체가 두 개의 어시스턴트 메시지로 유지됩니다. 마지막 텍스트 메시지 id를 사용하면 둘 다 하나의 메시지에 유지됩니다. 다시 로드하면 다음을 통해 해당 트랜스크립트를 하이드레이션합니다. reconstructChat. 채팅 영속성을 참고합니다.

import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { claudeCodeText } from "@tanstack/ai-claude-code";
import { withPersistence } from "@tanstack/ai-persistence";
import {
defineSandbox,
defineWorkspace,
githubRepo,
withSandbox,
} from "@tanstack/ai-sandbox";
import { dockerSandbox } from "@tanstack/ai-sandbox-docker";
import { persistence } from "./persistence";
import { z } from "zod";

const ReportSchema = z.object({
name: z.string(),
oneLiner: z.string(),
});

const sandbox = defineSandbox({
id: "repo-report",
provider: dockerSandbox({ image: "node:22" }),
workspace: defineWorkspace({
source: githubRepo({ repo: "TanStack/ai" }),
}),
});

export async function POST(request: Request) {
const body: unknown = await request.json();
const messages =
typeof body === "object" &&
body !== null &&
"messages" in body &&
Array.isArray(body.messages)
? body.messages
: [];
const threadId =
typeof body === "object" &&
body !== null &&
"threadId" in body &&
typeof body.threadId === "string"
? body.threadId
: undefined;
const runId =
typeof body === "object" &&
body !== null &&
"runId" in body &&
typeof body.runId === "string"
? body.runId
: undefined;

const stream = chat({
adapter: claudeCodeText("claude-opus-4-8"),
messages,
outputSchema: ReportSchema,
stream: true,
threadId,
runId,
middleware: [withSandbox(sandbox), withPersistence(persistence)],
});

return toServerSentEventsResponse(stream);
}

각 하네스가 스키마를 적용하는 방법

어댑터스키마 적용 방법
Claude Code동일한 턴에서 네이티브 --json-schema 플래그를 사용합니다. 값은 파일 경로가 아니라 인라인 JSON입니다.
Codex동일한 턴에서 네이티브 --output-schema 플래그를 사용합니다. Codex가 작성하는 대로 어시스턴트 텍스트가 스트리밍됩니다. 마지막 메시지도 스키마 객체입니다.
OpenCode프롬프트에 스키마를 추가합니다. 어댑터가 마지막 어시스턴트 텍스트를 파싱합니다.
Grok Build프롬프트에 스키마를 추가합니다. 어댑터가 마지막 어시스턴트 텍스트를 파싱합니다.
ACP compatible프롬프트에 스키마를 추가합니다. 어댑터가 마지막 어시스턴트 텍스트를 파싱합니다.

OpenCode, Grok Build, acpCompatible는 마지막 어시스턴트 메시지에서 JSON을 파싱합니다. 메시지가 JSON이 아니면 파싱이 실패합니다. 작업이 추출 전용이고 샌드박스가 필요하지 않다면 @tanstack/ai-openai 또는 @tanstack/ai-grok을 사용합니다.

승인 게이트와 클라이언트 도구

하네스 어댑터는 샌드박스 내부에서 도구를 실행합니다. 브라우저 왕복을 위해 일시 중지하지 않습니다.

  • 서버 execute()가 없는 도구는 즉시 실패합니다.
  • needsApproval이 있는 도구는 즉시 실패합니다.

승인 게이트 또는 클라이언트 도구가 필요하다면 HTTP 어댑터와 함께 도구 사용을 사용합니다.

UI 없이 스크립트 실행

브라우저로 스트리밍하지 않는다면 stream: true를 생략합니다. promise가 타입이 지정된 객체와 함께 확인됩니다.

const report = await chat({
adapter: claudeCodeText("claude-opus-4-8"),
messages: [{ role: "user", content: "What is this repository about?" }],
outputSchema: ReportSchema,
middleware: [withSandbox(sandbox)],
});

report.name;
report.oneLiner;

사용해 보기

React 채팅 예제에는 repo-report 페이지가 포함되어 있습니다.

  1. examples/ts-react-chat을 엽니다.
  2. /repo-report를 엽니다.
  3. Claude Code, Grok Build, ACP compatible 또는 Codex를 선택합니다.
  4. Auth를 선택합니다. 기본값은 API key입니다. 컴퓨터에서 이미 claude login, grok login 또는 codex login을 실행했다면 Host login을 사용합니다.
  5. 보고서를 실행합니다. 페이지는 messages[].parts에서 도구 호출과 추론을 렌더링합니다. structured-output 파트와 useChat().final에서 타입이 지정된 객체를 읽습니다.

페이지는 TanStack/ai를 샌드박스에 복제하고, 에이전트에게 이를 검사하도록 요청한 다음 검증된 보고서를 표시합니다.

Claude Code에서는 해당 복제본에 대한 신뢰 대화상자를 수락할 필요가 없습니다. 어댑터는 사용자 설정만 로드하므로 복제본의 .claude/settings.json이 헤드리스 -p를 차단하지 않습니다. Host login은 호스트의 claude login을 사용합니다. 샌드박스 타입은 이를 선택하지 않습니다. 하네스 인증을 참고합니다.