클라이언트 도구
클라이언트 도구는 브라우저에서 실행되므로 UI 업데이트, 로컬 저장소 액세스 및 브라우저 API 상호작용이 가능합니다. 서버 도구와 달리 클라이언트 도구는 서버 정의에 execute 함수가 없습니다.
sequenceDiagram
participant LLM Service
participant Server
participant Browser
participant ClientTool
LLM Service->>Server: tool_call chunk<br/>{name: "updateUI", args: {...}}
Server->>Server: Check if tool has<br/>server execute
Note over Server: No execute function<br/>= client tool
Server->>Browser: RUN_FINISHED client-tool<br/>interrupt via SSE/HTTP
Browser->>Browser: Find registered<br/>client tool
Browser->>ClientTool: execute(args)
ClientTool->>ClientTool: Update UI,<br/>localStorage, etc.
ClientTool-->>Browser: Return result
Browser->>Server: POST tool result
Server->>LLM Service: Add tool_result<br/>to conversation
Note over LLM Service: Model uses result<br/>to continue
LLM Service-->>Server: Stream response
Server-->>Browser: Forward chunks
클라이언트 도구를 사용하는 경우
- UI 업데이트: 알림을 표시하고, 양식을 업데이트하며, 표시 여부를 전환합니다.
- 로컬 저장소: 사용자 기본 설정을 저장하고 데이터를 캐시합니다.
- 브라우저 API: 위치 정보, 카메라, 클립보드에 액세스합니다.
- 상태 관리: React/Vue/Solid 상태를 업데이트합니다.
- 탐색: 경로를 변경하고 섹션으로 스크롤합니다.
작동 방식
- LLM의 도구 호출: LLM이 클라이언트 도구를 호출하기로 결정합니다.
- 서버 감지: 서버가 도구에
execute함수가 없음을 확인합니다. - 클라이언트 알림: 서버가 인터럽트 와이어에서 내부
client-tool-execution일시 중지를 내보냅니다(interrupts의 공개 항목은 아님). - 클라이언트 실행: 브라우저가 도구 이름으로 등록된
.client()구현을 찾아 파싱된 입력으로 실행합니다. - 결과 반환: 클라이언트가 재개 배치를 통해 결과를 자동 제출합니다.
- 서버 업데이트: 결과를 검증하고 대화에 추가합니다.
- LLM의 계속 실행: LLM이 결과를 받고 대화를 계속합니다.
네이티브 클라이언트 도구 실행은 원자적 인터럽트 배치 수명 주기를 공유하지만(여러 항목의 제출을 게이트할 수 있음) 자동으로 해결됩니다. 따라서 이에 대해 resolveInterrupt를 호출하지 않습니다. 일시적 수명 주기, 배치 및 과거의 tool-input-available 사용자 지정 이벤트에서의 마이그레이션은 인터럽트를 참조하세요. hydration 후에는 보류 중인 클라이언트 도구 실행이 복원되지만 다시 실행되지는 않습니다. 복구 정책은 클라이언트 영속성을 참조하세요.
승인은 별도의 축입니다
클라이언트 도구에 승인이 필요할 수도 있으며, 승인은 브라우저 결과와 별개입니다. needsApproval: true를 추가하면 도구가 먼저 tool-approval 인터럽트에서 일시 중지됩니다. 해당 결정만 해결하면 됩니다. 승인되면 클라이언트가 .client() 구현을 자동으로 실행하고 결과를 반환합니다.
const approval = interrupts.find(
(interrupt) =>
interrupt.kind === 'tool-approval' &&
interrupt.toolName === 'delete_local_data',
)
if (
approval?.kind === 'tool-approval' &&
approval.toolName === 'delete_local_data'
) {
approval.resolveInterrupt(true)
}
실행을 수동으로 해결하지 않습니다. 이를 위해 .client() 구현이 사용됩니다. .client() 구현 없이 도구를 등록하고 결과를 직접 제공하려면 addToolResult를 사용하세요(도구의 출력 스키마에 따라 검증됨). 이 방법은 레거시 스트림의 과거 경로도 유지합니다. 승인 양식은 도구 승인 흐름을, 승인 수명 주기는 인터럽트를 참조하세요.
클라이언트 도구 정의
클라이언트 도구는 동일한 toolDefinition() API를 사용하지만 .client() 메서드를 추가합니다.
// tools/definitions.ts - Shared between server and client
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
export const updateUIDef = toolDefinition({
name: "update_ui",
description: "Update the UI with new information",
inputSchema: z.object({
message: z.string().meta({ description: "Message to display" }),
type: z.enum(["success", "error", "info"]).meta({ description: "Message type" }),
}),
outputSchema: z.object({
success: z.boolean(),
}),
});
export const saveToLocalStorageDef = toolDefinition({
name: "save_to_local_storage",
description: "Save data to browser local storage",
inputSchema: z.object({
key: z.string().meta({ description: "Storage key" }),
value: z.string().meta({ description: "Value to store" }),
}),
outputSchema: z.object({
saved: z.boolean(),
}),
});
클라이언트 도구 사용
서버 측
LLM이 클라이언트 도구에 액세스하도록 하려면 채팅을 생성할 때 도구 구현이 아닌 도구 정의를 서버에 전달합니다.
// api/chat/route.ts
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { updateUIDef, saveToLocalStorageDef } from "./tools/definitions";
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages,
tools: [updateUIDef, saveToLocalStorageDef], // Pass definitions
});
return toServerSentEventsResponse(stream);
}
보안: 위와 같이 정의를 정적으로 등록하는 것이 안전한 기본값입니다. 서버만 모델에 표시할 도구를 결정하므로 클라이언트가 승인하지 않은 도구를 알릴 수 없습니다. 대신 클라이언트가 AG-UI
RunAgentInput.tools를 통해 요청마다 도구를 선언하도록 하려면mergeAgentTools를 사용하세요.params.tools는 클라이언트가 제어하므로 먼저 보안 참고 사항을 읽어야 합니다.
클라이언트 측
자동 실행과 완전한 타입 안전성을 갖춘 클라이언트 구현을 생성합니다.
// app/chat.tsx
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import {
createChatClientOptions,
type InferChatMessages,
type ToolCallPart,
type MessagePart,
} from "@tanstack/ai-client";
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
const updateUIDef = toolDefinition({
name: "update_ui",
description: "Update the UI with new information",
inputSchema: z.object({
message: z.string().meta({ description: "Message to display" }),
type: z.enum(["success", "error", "info"]).meta({ description: "Message type" }),
}),
outputSchema: z.object({ success: z.boolean() }),
});
const saveToLocalStorageDef = toolDefinition({
name: "save_to_local_storage",
description: "Save data to browser local storage",
inputSchema: z.object({
key: z.string().meta({ description: "Storage key" }),
value: z.string().meta({ description: "Value to store" }),
}),
outputSchema: z.object({ saved: z.boolean() }),
});
// Step 1: Create client implementations (module scope)
const updateUI = updateUIDef.client((input) => {
// Update UI state - fully typed!
console.log(input.message, input.type);
return { success: true };
});
const saveToLocalStorage = saveToLocalStorageDef.client((input) => {
localStorage.setItem(input.key, input.value);
return { saved: true };
});
// Step 2: A plain array is all you need — literal tool names, inputs and
// outputs are inferred without any wrapper or `as const`.
const tools = [updateUI, saveToLocalStorage];
const chatOptions = createChatClientOptions({
connection: fetchServerSentEvents("/api/chat"),
tools,
});
// Step 3: Infer message types for full type safety
type ChatMessages = InferChatMessages<typeof chatOptions>;
function ChatComponent() {
const { messages, sendMessage, isLoading } = useChat(chatOptions);
// Step 4: Render with full type safety
return (
<div>
{messages.map((message) => (
<MessageComponent key={message.id} message={message} />
))}
</div>
);
}
// Messages component with full type safety
function MessageComponent({ message }: { message: ChatMessages[number] }) {
return (
<div>
{message.parts.map((part: MessagePart) => {
if (part.type === "text") {
return <p>{part.content}</p>;
}
if (part.type === "tool-call") {
// ✅ part.name is narrowed to specific tool names
if (part.name === "update_ui") {
// ✅ part.input is typed as { message: string, type: "success" | "error" | "info" }
// ✅ part.output is typed as { success: boolean } | undefined
return (
<div>
Tool: {part.name}
{part.output && <span>✓ Success</span>}
</div>
);
}
}
return null;
})}
</div>
);
}
자동 실행
모델이 클라이언트 도구를 호출하면 클라이언트 도구가 자동으로 실행됩니다. 흐름은 다음과 같습니다.
- LLM이 클라이언트 도구를 호출합니다.
- 서버가 브라우저에
client-tool-execution인터럽트를 보냅니다. - 클라이언트가 일치하는 도구 구현을 자동으로 실행합니다.
- 결과가 서버로 다시 전송됩니다.
- 대화가 결과와 함께 계속됩니다.
클라이언트 런타임 컨텍스트
클라이언트 도구는 두 번째 인수로 타입이 지정된 런타임 컨텍스트를 받을 수 있습니다. 이 컨텍스트는 ChatClient 또는 프레임워크 훅 인스턴스에 로컬이며 서버로 직렬화되지 않습니다.
import { createChatClientOptions } from "@tanstack/ai-client";
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { toolDefinition } from "@tanstack/ai";
import { toast } from "./toast";
const activeProjectId = "";
type ClientContext = {
activeProjectId: string;
toast(message: string): void;
};
const showToast = toolDefinition({
name: "show_toast",
description: "Show a browser notification",
}).client<ClientContext>((_input, ctx) => {
ctx.context.toast(`Project ${ctx.context.activeProjectId} updated`);
return { ok: true };
});
const chatOptions = createChatClientOptions({
connection: fetchServerSentEvents("/api/chat"),
tools: [showToast],
context: {
activeProjectId,
toast: (message) => toast(message),
},
});
const chat = useChat(chatOptions);
로컬 브라우저 종속성에는 context를 사용합니다. 서버에도 클라이언트의 값이 필요한 경우 forwardedProps로 전송하고, 라우트에서 검증한 후 서버의 chat({ context })에 명시적으로 매핑합니다. 전체 패턴은 런타임 컨텍스트를 참조하세요.
clientTools() 헬퍼(선택 사항)
일반 배열인 tools: [toolA, toolB]만 전달하면 됩니다. 래퍼나 as const 없이 도구 이름, 입력 및 출력이 추론됩니다. clientTools()는 동일한 캡처를 명시적으로 수행하는 선택적 identity 헬퍼입니다. 훅/옵션 호출 외부에서 공유 가능하고 재사용할 수 있는 도구 튜플을 빌드하려는 경우에만 사용하세요.
import { clientTools } from "@tanstack/ai-client";
import { toolDefinition } from "@tanstack/ai";
const notify = toolDefinition({
name: "notify",
description: "Show a notification",
}).client(() => ({ ok: true }));
// Equivalent to `const tools = [notify]` — just captured explicitly.
const tools = clientTools(notify);
타입 안전성의 이점
동형 아키텍처는 종단 간 완전한 타입 안전성을 제공합니다.
import type { UIMessage } from "@tanstack/ai-client";
const messages: UIMessage[] = [];
messages.forEach((message) => {
message.parts.forEach((part) => {
if (part.type === "tool-call" && part.name === "update_ui") {
// ✅ TypeScript knows part.name is literally "update_ui"
// ✅ part.input is typed as { message: string, type: "success" | "error" | "info" }
// ✅ part.output is typed as { success: boolean } | undefined
console.log(part.input.message); // ✅ Fully typed!
if (part.output) {
console.log(part.output.success); // ✅ Fully typed!
}
}
});
});
도구 상태
tool-call 파트는 UI에 진행 상태를 표시할 수 있도록 관찰 가능한 소수의 ToolCallState 값을 거칩니다:
awaiting-input— 모델이 도구를 호출하려 하지만 아직 인수가 도착하지 않았습니다.input-streaming— 모델이 도구 인수를 스트리밍하고 있습니다(일부 입력을 사용할 수 있을 수 있음).input-complete— 모든 인수를 수신했으며 도구를 실행할 수 있습니다.approval-requested— 도구가 실행되기 전에 사용자 승인을 기다리고 있습니다.approval-responded— 사용자가 도구 호출을 승인하거나 거부했습니다.
ToolCallState 유니온에는 complete 값이 포함되지만 런타임은 tool-call 파트를 이 값으로 전환하지 않습니다. 완료된 호출은 input-complete 상태로 마무리됩니다. 도구가 실행되면 결과가 두 가지 방식으로 나타납니다. tool-call 파트에 part.output이 채워지고, 자체 state가 complete 또는 error인 형제 tool-result 파트가 생성됩니다(error인 경우 part.error를 포함합니다). 로딩/스트리밍 진행 상태에는 tool-call 상태를 사용하고, 최종 성공/오류 피드백에는 tool-result 파트를 사용하세요.
import type { ToolCallPart } from "@tanstack/ai-client";
function ToolCallDisplay({ part }: { part: ToolCallPart }) {
if (part.state === "awaiting-input") {
return <div>🔄 Waiting for arguments...</div>;
}
if (part.state === "input-streaming") {
return <div>📥 Receiving arguments...</div>;
}
if (part.state === "input-complete") {
return <div>✓ Arguments received, running tool...</div>;
}
// Completion shows up as a populated `part.output` (and as a sibling
// `tool-result` part whose state is `complete` / `error`).
if (part.output) {
return <div>✅ Tool complete</div>;
}
return null;
}
하이브리드 도구
도구는 서버와 클라이언트 모두에 구현하여 유연하게 실행할 수 있습니다.
import { toolDefinition, chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
import { db } from "./db";
// Define once
const addToCartDef = toolDefinition({
name: "add_to_cart",
description: "Add item to shopping cart",
inputSchema: z.object({
itemId: z.string(),
quantity: z.number(),
}),
outputSchema: z.object({
success: z.boolean(),
cartId: z.string(),
}),
});
// Server implementation - Store in database
const addToCartServer = addToCartDef.server(async (input) => {
const cart = await db.carts.create({
data: { itemId: input.itemId, quantity: input.quantity },
});
return { success: true, cartId: cart.id };
});
// Client implementation - Update local wishlist
const addToCartClient = addToCartDef.client((input) => {
const wishlist = JSON.parse(localStorage.getItem("wishlist") || "[]");
wishlist.push(input.itemId);
localStorage.setItem("wishlist", JSON.stringify(wishlist));
return { success: true, cartId: "local" };
});
// Server: Pass definition for client execution
chat({ adapter: openaiText('gpt-5.5'), messages: [], tools: [addToCartDef] }); // Client will execute
// Or pass server implementation for server execution
chat({ adapter: openaiText('gpt-5.5'), messages: [], tools: [addToCartServer] }); // Server will execute
모범 사례
- 클라이언트 도구를 단순하게 유지 - 클라이언트 도구는 브라우저에서 실행되므로 번들 크기를 불필요하게 키울 수 있는 무거운 계산이나 큰 종속성을 피합니다.
- 오류를 적절하게 처리 - 도구 구현에서 명확한 오류 처리를 정의하고 출력 스키마에 의미 있는 오류 메시지를 반환합니다.
- UI를 반응형으로 업데이트 - 도구 실행에 따라 UI를 업데이트하려면 프레임워크의 상태 관리(예: React/Vue/Solid)를 사용합니다.
- 민감한 데이터 보호 - API 키나 개인 정보와 같은 민감한 데이터를 로컬 저장소에 저장하거나 클라이언트 도구를 통해 노출하지 않습니다.
- 피드백 제공 - 도구 상태를 사용하여 진행 중인 작업과 클라이언트 도구 실행 결과(로딩 스피너, 성공 메시지, 오류 알림)를 사용자에게 알립니다.
- 모든 항목에 타입 지정 - 도구 정의부터 구현 및 사용까지 완전한 타입 안전성을 위해 TypeScript와 Zod 스키마를 활용합니다.
일반적인 사용 사례
- UI 업데이트 - 알림 표시, 양식 업데이트, 표시 여부 전환
- 로컬 저장소 - 사용자 기본 설정 저장, 데이터 캐시
- 브라우저 API - 위치 정보, 카메라, 클립보드 액세스
- 상태 관리 - React/Vue/Solid 상태 업데이트
- 탐색 - 경로 변경, 섹션으로 스크롤
- 분석 - 사용자 상호작용 추적