하네스 구조화된 출력
코딩 에이전트에게 저장소를 검사하도록 요청했습니다. 에이전트는 도구 호출과 산문을 스트리밍합니다. 파싱해야 하는 긴 텍스트가 아니라 저장하거나 렌더링할 수 있는 타입이 지정된 객체가 필요합니다.
동일한 chat() 호출에 outputSchema를 전달합니다. 하네스가 네이티브 도구를 실행합니다. 그러면 await chat() 또는 useChat().final에서 검증된 객체를 얻습니다.
이 페이지는 샌드박스 하네스 어댑터를 위한 것입니다.
- Claude Code
- Codex
- OpenCode
- Grok Build
- ACP-Compatible (
acpCompatible)
프롬프트에서 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 이벤트로 도착합니다. 도구 활동이 먼저 스트리밍됩니다.
클라이언트: parts와 final 읽기
어시스턴트 메시지에 현재 실행이 담깁니다. 도구 호출, 추론, 타입이 지정된 객체를 확인하려면 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 페이지가 포함되어 있습니다.
examples/ts-react-chat을 엽니다./repo-report를 엽니다.- Claude Code, Grok Build, ACP compatible 또는 Codex를 선택합니다.
- Auth를 선택합니다. 기본값은 API key입니다. 컴퓨터에서 이미
claude login,grok login또는codex login을 실행했다면 Host login을 사용합니다. - 보고서를 실행합니다. 페이지는
messages[].parts에서 도구 호출과 추론을 렌더링합니다.structured-output파트와useChat().final에서 타입이 지정된 객체를 읽습니다.
페이지는 TanStack/ai를 샌드박스에 복제하고, 에이전트에게 이를 검사하도록 요청한 다음 검증된 보고서를 표시합니다.
Claude Code에서는 해당 복제본에 대한 신뢰 대화상자를 수락할 필요가 없습니다. 어댑터는 사용자 설정만 로드하므로 복제본의 .claude/settings.json이 헤드리스 -p를 차단하지 않습니다. Host login은 호스트의 claude login을 사용합니다. 샌드박스 타입은 이를 선택하지 않습니다. 하네스 인증을 참고합니다.