본문으로 건너뛰기

도구 승인 흐름

도구 승인 흐름을 사용하면 민감한 도구를 실행하기 전에 사용자 승인을 요구할 수 있으므로, 이메일 전송, 구매, 데이터 삭제와 같은 작업을 사용자가 제어할 수 있습니다. 도구 호출은 ToolCallState 수명 주기를 거칩니다.

현재 클라이언트 API는 승인을 바인딩된 AG-UI 인터럽트로 노출합니다. 전체 서버/클라이언트 수명 주기, 원자적 일괄 제어, 일반 인터럽트 및 복구에 대해서는 인터럽트를 참고하세요. 더 이상 권장되지 않는 API 매핑은 AG-UI 인터럽트로 마이그레이션을 참고하세요.

  1. awaiting-input — 도구 호출이 시작되었으며 아직 인수가 없습니다.
  2. input-streaming — 인수가 점진적으로 도착합니다.
  3. input-complete — 모든 인수를 받았습니다.
  4. approval-requested — 사용자 승인을 기다립니다(needsApproval: true인 경우에만 해당).
  5. approval-responded — 사용자가 승인하거나 거부했습니다.

approval-responded 후 호출이 승인되었다면 실행됩니다. ToolCallState 유니온에 complete가 존재하지만 런타임은 도구 호출 파트를 해당 상태로 전환하지 않습니다. 결과는 값이 채워진 part.output과 자체 상태가 complete 또는 error인 형제 tool-result 파트로 나타납니다.

승인은 일시적으로 실행됩니다. 브라우저가 다시 보내는 전체 클라이언트 메시지 기록에서 실행이 재개되므로, 상태 비저장 라우트는 일시 중지된 호출을 다시 구성하기 위한 서버 저장소가 필요하지 않습니다.

도구에 승인이 필요할 때 일반적인 흐름은 다음과 같습니다.

  1. 모델이 도구를 호출합니다.
  2. 도구 실행이 일시 중지됩니다.
  3. 사용자에게 승인 또는 거부를 요청합니다.
  4. 승인되면 도구가 실행되고, 거부되면 취소됩니다.
  5. 결과와 함께 대화를 계속합니다.

승인 인터럽트 해결

approvalSchema가 없으면 부울 축약형을 사용합니다. 승인은 기본적으로 원래 도구 입력을 사용합니다.

const approval = interrupts.find(
(interrupt) => interrupt.kind === 'tool-approval',
)

if (approval?.kind === 'tool-approval') {
approval.resolveInterrupt(true)
}

approvalSchema를 사용하면 승인과 거부에 별도의 애플리케이션 페이로드를 정의할 수 있습니다.

import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'

const transferDefinition = toolDefinition({
name: 'transfer',
description: 'Transfer funds',
needsApproval: true,
inputSchema: z.object({
amount: z.number().positive(),
recipient: z.string(),
}),
approvalSchema: {
approve: z.object({ note: z.string() }),
reject: z.object({ reason: z.string() }),
},
})

분기 데이터는 payload 아래에 둡니다. 승인된 인수는 editedArgs로 전체를 선택적으로 바꿀 수 있지만, 거부에서는 수정 사항을 받지 않습니다.

approval.resolveInterrupt(true, {
editedArgs: { amount: 12, recipient: 'Ada' },
payload: { note: 'Reviewed' },
})

approval.resolveInterrupt(false, {
payload: { reason: 'Policy limit' },
})

거부와 취소는 서로 다릅니다. resolveInterrupt(false, ...)는 이어지는 실행을 위해 해결된 거부를 기록합니다. cancel()은 페이로드가 없으며 거부 스키마를 선택하지 않습니다.

approval.cancel()

단일 항목은 유효하게 해결된 후 제출됩니다. 여러 항목은 모두 유효해질 때까지 준비 상태로 있다가 원자적으로 제출됩니다. 하나의 동기 일괄 트랜잭션에는 루트 resolveInterrupts(...)를 사용합니다. 여러 인터럽트를 참고하세요.

승인 활성화

도구는 정의에서 needsApproval: true 를 설정하여 승인 필요로 표시할 수 있습니다:

import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
import { emailService } from './email-service'

// Step 1: Define tool with approval requirement
const sendEmailDef = toolDefinition({
name: 'send_email',
description: 'Send an email to a recipient',
inputSchema: z.object({
to: z.string().email(),
subject: z.string(),
body: z.string(),
}),
outputSchema: z.object({
success: z.boolean(),
messageId: z.string(),
}),
needsApproval: true, // This tool requires approval
})

// Step 2: Create server implementation
const sendEmail = sendEmailDef.server(async ({ to, subject, body }) => {
// Only executes if approved
await emailService.send({ to, subject, body })
return { success: true, messageId: '...' }
})

서버 측 승인

서버에서 needsApproval: true인 도구는 실행을 일시 중지하고 승인을 기다립니다.

import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { sendEmail } from './tools'

export async function POST(request: Request) {
const { messages } = await request.json()

const stream = chat({
adapter: openaiText('gpt-5.5'),
messages,
tools: [sendEmail],
})

return toServerSentEventsResponse(stream)
}

승인 UI

훅의 interrupts 배열에서 대기 중인 승인을 렌더링합니다. 각 tool-approval 인터럽트에는 도구 이름, 원래 인수, 사용자 결정을 전달해 호출하는 resolveInterrupt가 포함됩니다. 이 배열은 이미 도구와 무관하므로 하나의 블록으로 needsApproval: true로 표시된 모든 도구를 처리할 수 있습니다. 도구별 part.name 분기나 혼합 유니온에서 part.approval을 읽을 필요가 없습니다.

import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
import { sendEmail } from './tools'

function ChatComponent() {
const { messages, sendMessage, interrupts, resuming } = useChat({
connection: fetchServerSentEvents('/api/chat'),
tools: [sendEmail],
})

return (
<div>
{/* ...render messages... */}
{interrupts.map((interrupt) =>
interrupt.kind === 'tool-approval' ? (
<div key={interrupt.id} className="approval-prompt">
<p>🔒 Approve {interrupt.toolName}?</p>
<pre>{JSON.stringify(interrupt.originalArgs, null, 2)}</pre>
<button
disabled={!interrupt.canResolve || resuming}
onClick={() => interrupt.resolveInterrupt(true)}
>
Approve
</button>
<button
disabled={!interrupt.canResolve || resuming}
onClick={() => interrupt.resolveInterrupt(false)}
>
Deny
</button>
</div>
) : null,
)}
</div>
)
}

인터럽트가 바인딩되고 준비될 때까지 canResolvefalse입니다. 해결 요청이 진행 중인 동안에는 resumingtrue이므로 두 값 모두를 기준으로 버튼을 활성화해야 합니다.

addToolApprovalResponse에서 마이그레이션

이전 UI는 도구 호출 파트에서 part.approval을 읽고 addToolApprovalResponse({ id, approved })를 호출했습니다. 이 API는 더 이상 권장되지 않습니다. 대신 interrupts 배열에서 렌더링하고 resolveInterrupt를 호출합니다(위의 승인 UI 참고). 기본적으로 도구와 무관하므로 파트 기반 패턴에 필요했던 도구별 타입 좁히기가 사라집니다. 전체 매핑은 AG-UI 인터럽트로 마이그레이션을 참고하세요.

승인을 사용하는 클라이언트 도구

클라이언트 도구도 승인을 필요로 할 수 있습니다:

import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'

// tools/definitions.ts
const deleteLocalDataDef = toolDefinition({
name: 'delete_local_data',
description: 'Delete data from local storage',
inputSchema: z.object({
key: z.string(),
}),
outputSchema: z.object({
deleted: z.boolean(),
}),
needsApproval: true, // Requires approval even on client
})

// Client: Create implementation
const deleteLocalData = deleteLocalDataDef.client((input) => {
// This will only execute after approval
localStorage.removeItem(input.key)
return { deleted: true }
})

const { messages, interrupts } = useChat({
connection: fetchServerSentEvents('/api/chat'),
// Pass client tools as a plain array — literal tool-name inference works
// without a wrapper. The approval surfaces as a `tool-approval` interrupt you
// resolve from `interrupts` (see Approval UI); the tool runs on approval.
tools: [deleteLocalData], // Automatic execution after approval
})

예시: 전자상거래 구매

import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
import { createOrder } from './orders'

// Define tool with approval requirement
const purchaseItemDef = toolDefinition({
name: 'purchase_item',
description: 'Purchase an item from the store',
inputSchema: z.object({
itemId: z.string(),
quantity: z.number(),
price: z.number(),
}),
outputSchema: z.object({
orderId: z.string(),
total: z.number(),
}),
needsApproval: true,
})

// Create server implementation
const purchaseItem = purchaseItemDef.server(
async ({ itemId, quantity, price }) => {
const order = await createOrder({ itemId, quantity, price })
return { orderId: order.id, total: price * quantity }
},
)

구매 전에 사용자에게 항목, 수량, 가격을 표시하는 승인 메시지가 나타납니다. 도구는 사용자가 승인한 후에만 실행됩니다.

모범 사례

  • 민감한 작업에 승인 사용 - 이메일 전송, 결제, 데이터 삭제
  • 명확한 정보 표시 - 승인 전에 도구가 수행할 작업을 표시합니다.
  • 맥락 제공 - 도구 인수를 읽기 쉬운 형식으로 표시합니다.
  • 거부를 원활하게 처리 - 도구가 거부되어도 대화를 중단하지 않습니다.
  • 타임아웃 처리 - 승인 요청에 타임아웃을 고려합니다.

다음 단계