본문으로 건너뛰기

도구 승인

사람이 승인할 때까지 실행되어서는 안 되는 도구가 있습니다. 예를 들어 자금 이체, 레코드 삭제, 메시지 전송입니다. 모델이 호출을 계획한 후 실제 작업이 일어나기 전에 사람의 승인을 기다리도록 할 수 있습니다.

이 페이지를 마치면 채팅이 해당 호출에서 일시 중지되고, 인라인으로 승인 또는 거부 프롬프트를 표시하며, 사용자의 결정으로 실행을 계속합니다.

도구 정의

needsApproval: true는 호출을 승인 대기를 위한 일시 중지로 전환합니다. 서버와 브라우저가 같은 타입을 추론하도록 도구를 한 번 정의해 공유합니다.

// tools/transfer.ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'

export const transferTool = toolDefinition({
name: 'transfer',
description: 'Transfer funds to a recipient',
needsApproval: true,
inputSchema: z.object({
amount: z.number().positive(),
recipient: z.string().min(1),
}),
outputSchema: z.object({ receiptId: z.string() }),
})

제공

서버는 사용자가 승인한 후에만 도구를 실행합니다. 데이터베이스는 필요하지 않습니다. 브라우저가 메시지 기록과 resume 결정을 다시 보내므로 parentRunIdresumechat()에 전달하면 일시 중지된 호출을 재구성합니다.

// app/api/chat/route.ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { transferTool } from '../../../tools/transfer'

const transfer = transferTool.server(
async (input: { amount: number; recipient: string }) => ({
receiptId: `${input.recipient}-${input.amount}-${crypto.randomUUID()}`,
}),
)

export async function POST(request: Request) {
const params = await chatParamsFromRequest(request)
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
parentRunId: params.parentRunId,
...(params.resume ? { resume: params.resume } : {}),
tools: [transfer],
})
return toServerSentEventsResponse(stream)
}

채팅에 렌더링

toolNameoriginalArgs에 타입이 지정되도록 공유한 도구를 useChat에 전달합니다. 평소처럼 메시지를 렌더링하면 실행이 일시 중지될 때 보류 중인 승인이 대화와 함께 interrupts에 표시됩니다. interrupt.resolveInterrupt(...)로 항목에서 직접 해결합니다.

// app/transfer-chat.tsx
import { useState } from 'react'
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import { transferTool } from '../tools/transfer'

export function TransferChat() {
const { messages, sendMessage, interrupts, resuming } = useChat({
threadId: 'account-42',
connection: fetchServerSentEvents('/api/chat'),
tools: [transferTool] as const,
})
const [input, setInput] = useState('')

return (
<div>
{messages.map((message) => (
<div key={message.id}>
<strong>{message.role}: </strong>
{message.parts.map((part, i) =>
part.type === 'text' ? <span key={i}>{part.content}</span> : null,
)}
</div>
))}

{interrupts.map((interrupt) => {
if (
interrupt.kind !== 'tool-approval' ||
interrupt.toolName !== 'transfer'
) {
return null
}
return (
<div key={interrupt.id} className="approval">
<p>
Send {interrupt.originalArgs.amount} to{' '}
{interrupt.originalArgs.recipient}?
</p>
<button
disabled={!interrupt.canResolve || resuming}
onClick={() => interrupt.resolveInterrupt(true)}
>
Approve
</button>
<button
disabled={!interrupt.canResolve || resuming}
onClick={() => interrupt.resolveInterrupt(false)}
>
Reject
</button>
</div>
)
})}

<form
onSubmit={(event) => {
event.preventDefault()
void sendMessage(input)
setInput('')
}}
>
<input value={input} onChange={(event) => setInput(event.target.value)} />
<button type="submit">Send</button>
</form>
</div>
)
}

승인 및 거부 버튼은 항목 자체에서 resolveInterrupt를 호출합니다. 단일 보류 결정을 즉시 제출하므로 추가 단계가 필요하지 않습니다. 여러 항목을 한 번에 해결하는 방법은 여러 인터럽트에서 다룹니다.

서버 도구와 클라이언트 도구

승인 방식은 두 도구에서 동일합니다. 차이는 사용자가 승인한 후 도구가 실행되는 위치뿐입니다.

  • 서버 도구(.server())는 승인되면 서버에서 실행됩니다.
  • 클라이언트 도구(.client())는 승인되면 브라우저에서 실행됩니다.

승인 인터럽트는 두 경우에 동일하므로 위 UI는 변경되지 않습니다. needsApproval이 없는 클라이언트 도구는 자체적으로 실행되며 일시 중지되지 않습니다. 클라이언트 도구를 참조하세요.

결정에 데이터 전달

검토 메모나 거부 사유처럼 결정 자체에 타입이 지정된 데이터가 필요하면 approvalSchema를 연결합니다. 도구 정의에 추가합니다. 두 분기에 하나의 스키마를 사용하거나, 서로 다른 페이로드에는 { approve, reject } 맵을 사용합니다.

export const transferTool = toolDefinition({
name: 'transfer',
// ...same inputSchema and outputSchema as above
needsApproval: true,
approvalSchema: {
approve: z.object({ note: z.string().min(1) }),
reject: z.object({ reason: z.string().min(1) }),
},
})

이제 결정에 페이로드가 포함되며, 승인 시 인수도 바꿀 수 있습니다.

// Approve as-is, with the approve-branch payload.
interrupt.resolveInterrupt(true, { payload: { note: 'Reviewed' } })

// Approve, but replace the arguments first. editedArgs is a full replacement,
// not a merge, and is validated against the tool's inputSchema.
interrupt.resolveInterrupt(true, {
editedArgs: { amount: 12, recipient: 'Ada' },
payload: { note: 'Capped to policy' },
})

// Reject, with the reject-branch payload.
interrupt.resolveInterrupt(false, { payload: { reason: 'Too large' } })

editedArgs는 승인에서만 허용됩니다. approvalSchema가 없으면 불리언 축약형 resolveInterrupt(true) / resolveInterrupt(false)만 사용하면 됩니다. 서버는 도구를 실행하기 전에 전체 결정을 다시 검증합니다.

서버에서 결정 사용

전송한 두 필드는 서로 다른 위치에 전달되므로 필요한 항목을 선택합니다.

editedArgs는 도구가 실행될 때 사용할 인수가 됩니다. 이를 통해 사람은 도구가 실행되기 전에 작업 내용을 조정할 수 있습니다. 별도로 연결할 작업은 없습니다. execute는 수정 여부와 관계없이 inputSchema에 대해 이미 검증된 최종 입력을 항상 받습니다.

// server/transfer-tool.ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'

const transferTool = toolDefinition({
name: 'transfer',
description: 'Move money between accounts',
needsApproval: true,
inputSchema: z.object({
recipient: z.string(),
amount: z.number(),
}),
outputSchema: z.object({ receiptId: z.string() }),
})

export const transfer = transferTool.server(async (input) => {
// input.amount / input.recipient are the model's arguments, or the
// approver's editedArgs when they changed them. Same code either way.
return {
receiptId: `${input.recipient}-${input.amount}-${crypto.randomUUID()}`,
}
})

payload는 도구 입력이 아닌 결정 데이터이며, 두 분기에서 다르게 사용합니다.

  • 거부 페이로드는 도구의 실패한 결과로 돌아오므로 모델이 거부된 이유를 읽고 응답할 수 있습니다. resolveInterrupt(false, { payload: &#123; reason: 'Too large' &#125; &#125;) hands &#123; reason: 'Too large' &#125;를 해당 호출의 결과로 모델에 전달합니다.
  • 승인 페이로드는 애플리케이션에서 사용하는 검증된 결정 데이터입니다. 감사 로그, "검토자" 기록, 분석 이벤트 등에 사용하는 데이터입니다. execute에 전달되지 않습니다. 도구 자체에 승인자의 값이 필요하면 페이로드가 아니라 editedArgs(도구 입력의 일부)에 넣습니다.

거부는 취소가 아닙니다

resolveInterrupt(false, ...)는 해결된 아니오입니다. 실행은 계속되고 모델은 거부(및 도구 결과로 전달된 거부 페이로드)를 확인하므로 이에 응답할 수 있습니다.

interrupt.cancel()은 일시 중지를 포기합니다. 페이로드를 전달하지 않으며 거부 분기를 선택하지도 않습니다. 사용자가 아니오라고 답했으면 거부하고, 답변 없이 워크플로를 중단했으면 취소합니다.

승인 대기열을 함께 해결하려면 여러 인터럽트를 참조하세요.