Build a Human-in-the-Loop AI Agent with Vercel AI SDK

Saturday 21/03/2026

·11 min read
Share:

Your AI agent can search for flights, draft emails, and book meetings. Great. But you probably don't want it actually sending that email or charging that credit card without asking first. The moment you give an AI real-world side effects, you need a human-in-the-loop — a confirmation step where the user reviews what the agent wants to do before it does it.

The problem is most agent tutorials skip this entirely. They show a loop where the AI calls tools automatically, and you're left wondering how to wedge a "are you sure?" dialog into a streaming response. Vercel's AI SDK solves this with a clean pattern: you mark certain tools as requiring confirmation, the client renders an approval UI, and the agent pauses until the user says yes or no.

Here's how to build a booking assistant in Next.js that searches availability, drafts confirmation emails, and waits for your approval before sending anything.

How the confirmation flow works

The AI SDK separates tool execution into two phases: invocation and result. Normally, tools execute server-side and results stream back automatically. But when a tool requires confirmation, execution pauses after invocation. The client receives the tool call with its arguments, renders them for the user, and waits. The user either approves (and the tool executes) or rejects (and the agent gets told "no").

The flow looks like this:

  1. User sends a message ("Book me a room at The Grand for March 28")
  2. The agent calls searchAvailability — this runs automatically (read-only, no side effects)
  3. The agent calls sendBookingConfirmation — this pauses and shows an approval dialog
  4. User reviews the booking details and clicks Approve or Reject
  5. If approved, the tool executes and the agent continues
  6. If rejected, the agent receives a rejection message and adjusts

This pattern lets you mix auto-executing tools (safe, read-only) with gated tools (side effects, cost, external actions) in the same agent conversation.

Setting up the project

npx create-next-app@latest booking-agent --typescript --tailwind --app
cd booking-agent
pnpm add ai @ai-sdk/anthropic zod

You'll need an Anthropic API key. Add it to .env.local:

# .env.local
ANTHROPIC_API_KEY=your-key-here

Defining tools with confirmation gates

The core idea: each tool declares whether it needs user approval. Tools that just read data run automatically. Tools that take actions pause for confirmation.

// src/tools.ts
import { tool } from 'ai'
import { z } from 'zod'

// This tool runs automatically — it's read-only
export const searchAvailability = tool({
    description: 'Search hotel room availability for given dates and location',
    parameters: z.object({
        location: z.string().describe('City or hotel name'),
        checkIn: z.string().describe('Check-in date in YYYY-MM-DD format'),
        checkOut: z.string().describe('Check-out date in YYYY-MM-DD format'),
        guests: z.number().describe('Number of guests'),
    }),
    execute: async ({ location, checkIn, checkOut, guests }) => {
        // In a real app, this calls your hotel API
        const results = [
            {
                hotel: 'The Grand',
                room: 'Deluxe King',
                price: 189,
                currency: 'USD',
                available: true,
                checkIn,
                checkOut,
                guests,
            },
            {
                hotel: 'The Grand',
                room: 'Suite',
                price: 349,
                currency: 'USD',
                available: true,
                checkIn,
                checkOut,
                guests,
            },
            {
                hotel: 'City Inn',
                room: 'Standard Double',
                price: 99,
                currency: 'USD',
                available: true,
                checkIn,
                checkOut,
                guests,
            },
        ]
        return { results, query: { location, checkIn, checkOut, guests } }
    },
})

// This tool requires confirmation — it sends a real email
export const sendBookingConfirmation = tool({
    description:
        'Send a booking confirmation email to the user with reservation details',
    parameters: z.object({
        hotel: z.string(),
        room: z.string(),
        checkIn: z.string(),
        checkOut: z.string(),
        guests: z.number(),
        totalPrice: z.number(),
        currency: z.string(),
        guestEmail: z.string().email(),
    }),
    // No execute function here — we handle execution on the client
    // after the user approves
})

// Another auto-executing read-only tool
export const calculateTotal = tool({
    description:
        'Calculate the total price for a stay including taxes and fees',
    parameters: z.object({
        nightlyRate: z.number(),
        nights: z.number(),
        taxRate: z.number().default(0.12),
    }),
    execute: async ({ nightlyRate, nights, taxRate }) => {
        const subtotal = nightlyRate * nights
        const tax = subtotal * taxRate
        const total = subtotal + tax
        return {
            subtotal,
            tax: Math.round(tax * 100) / 100,
            total: Math.round(total * 100) / 100,
            nights,
        }
    },
})

Notice that sendBookingConfirmation has no execute function. That's the key. When a tool has no execute, the AI SDK sends the tool call to the client instead of running it server-side. The client is responsible for providing the result — either by executing the action after approval or by returning a rejection.

The API route

// src/app/api/chat/route.ts
import { streamText } from 'ai'
import { anthropic } from '@ai-sdk/anthropic'
import {
    searchAvailability,
    sendBookingConfirmation,
    calculateTotal,
} from '@/src/tools'

export const maxDuration = 60

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

    const result = streamText({
        model: anthropic('claude-sonnet-4-6'),
        system: `You are a helpful hotel booking assistant. You help users find and book hotel rooms.

When the user wants to book a room:
1. Search for availability first
2. Calculate the total price including taxes
3. Present the options clearly
4. When the user picks a room, use sendBookingConfirmation to send the confirmation email
5. Always confirm the email address before sending

Be concise and helpful. Format prices clearly.`,
        messages,
        tools: {
            searchAvailability,
            sendBookingConfirmation,
            calculateTotal,
        },
        maxSteps: 5,
    })

    return result.toDataStreamResponse()
}

The maxSteps: 5 lets the agent chain multiple tool calls in one turn — search availability, calculate total, then attempt the booking. When it hits sendBookingConfirmation, the stream pauses because that tool has no server-side execute.

Building the approval UI

This is where it gets interesting. The useChat hook gives you access to tool invocations in the message stream. You check if a tool call is waiting for confirmation, render the details, and use addToolResult to send back the user's decision.

// src/app/page.tsx
'use client'

import { useChat } from '@ai-sdk/react'
import { useState } from 'react'

export default function BookingChat() {
    const { messages, input, handleInputChange, handleSubmit, addToolResult } =
        useChat({ maxSteps: 5 })

    return (
        <div className="mx-auto flex min-h-screen max-w-2xl flex-col p-4">
            <h1 className="mb-4 text-2xl font-bold">Hotel Booking Assistant</h1>

            <div className="mb-4 flex-1 space-y-4 overflow-y-auto">
                {messages.map((message) => (
                    <div key={message.id}>
                        {/* Regular text content */}
                        {message.content && (
                            <div
                                className={`rounded-lg p-3 ${
                                    message.role === 'user'
                                        ? 'ml-auto max-w-md bg-blue-600 text-white'
                                        : 'max-w-lg bg-gray-100'
                                }`}
                            >
                                {message.content}
                            </div>
                        )}

                        {/* Tool invocations */}
                        {message.toolInvocations?.map((invocation) => {
                            if (invocation.toolName === 'sendBookingConfirmation') {
                                return (
                                    <ApprovalCard
                                        key={invocation.toolCallId}
                                        invocation={invocation}
                                        addToolResult={addToolResult}
                                    />
                                )
                            }

                            // Auto-executed tools: show a subtle status
                            if (invocation.state === 'result') {
                                return (
                                    <div
                                        key={invocation.toolCallId}
                                        className="py-1 text-sm text-gray-400"
                                    >
                                        Used {invocation.toolName}
                                    </div>
                                )
                            }

                            return (
                                <div
                                    key={invocation.toolCallId}
                                    className="py-1 text-sm text-gray-400"
                                >
                                    Running {invocation.toolName}...
                                </div>
                            )
                        })}
                    </div>
                ))}
            </div>

            <form onSubmit={handleSubmit} className="flex gap-2">
                <input
                    value={input}
                    onChange={handleInputChange}
                    placeholder="Search for a hotel room..."
                    className="flex-1 rounded-lg border p-3"
                />
                <button
                    type="submit"
                    className="rounded-lg bg-blue-600 px-6 py-3 text-white"
                >
                    Send
                </button>
            </form>
        </div>
    )
}

Now the approval card component — the piece that makes the human-in-the-loop pattern work:

// src/components/ApprovalCard.tsx
'use client'

interface BookingArgs {
    hotel: string
    room: string
    checkIn: string
    checkOut: string
    guests: number
    totalPrice: number
    currency: string
    guestEmail: string
}

interface ToolInvocation {
    toolCallId: string
    toolName: string
    state: string
    args: BookingArgs
    result?: Record<string, unknown>
}

interface ApprovalCardProps {
    invocation: ToolInvocation
    addToolResult: (params: {
        toolCallId: string
        result: Record<string, unknown>
    }) => void
}

export function ApprovalCard({ invocation, addToolResult }: ApprovalCardProps) {
    // Already handled — show what happened
    if (invocation.state === 'result') {
        const wasApproved = (invocation.result as Record<string, unknown>)
            ?.approved
        return (
            <div
                className={`my-2 rounded-lg border p-4 ${
                    wasApproved
                        ? 'border-green-200 bg-green-50'
                        : 'border-red-200 bg-red-50'
                }`}
            >
                <p className="font-medium">
                    {wasApproved
                        ? 'Booking confirmed!'
                        : 'Booking cancelled by user'}
                </p>
            </div>
        )
    }

    // Waiting for approval — show the details and buttons
    const args = invocation.args
    return (
        <div className="my-2 rounded-lg border border-yellow-200 bg-yellow-50 p-4">
            <h3 className="mb-2 font-bold">Confirm Booking</h3>
            <div className="mb-3 space-y-1 text-sm">
                <p>
                    <span className="font-medium">Hotel:</span> {args.hotel} —{' '}
                    {args.room}
                </p>
                <p>
                    <span className="font-medium">Dates:</span> {args.checkIn}{' '}
                    to {args.checkOut}
                </p>
                <p>
                    <span className="font-medium">Guests:</span> {args.guests}
                </p>
                <p>
                    <span className="font-medium">Total:</span>{' '}
                    {args.currency} {args.totalPrice}
                </p>
                <p>
                    <span className="font-medium">Confirmation email to:</span>{' '}
                    {args.guestEmail}
                </p>
            </div>
            <div className="flex gap-2">
                <button
                    onClick={async () => {
                        // Execute the actual booking
                        const bookingResult = await fetch('/api/book', {
                            method: 'POST',
                            headers: { 'Content-Type': 'application/json' },
                            body: JSON.stringify(args),
                        }).then((r) => r.json())

                        addToolResult({
                            toolCallId: invocation.toolCallId,
                            result: { approved: true, ...bookingResult },
                        })
                    }}
                    className="rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700"
                >
                    Approve & Send
                </button>
                <button
                    onClick={() => {
                        addToolResult({
                            toolCallId: invocation.toolCallId,
                            result: {
                                approved: false,
                                reason: 'User cancelled the booking',
                            },
                        })
                    }}
                    className="rounded bg-red-100 px-4 py-2 text-red-700 hover:bg-red-200"
                >
                    Reject
                </button>
            </div>
        </div>
    )
}

The critical pattern here: addToolResult sends the result back into the conversation. The AI SDK picks it up, feeds it to the model as a tool result, and the agent continues — either confirming the booking was sent or handling the rejection gracefully.

The booking API endpoint

When the user approves, the client calls your actual booking API before sending the result back to the agent:

// src/app/api/book/route.ts
import { NextResponse } from 'next/server'

export async function POST(req: Request) {
    const booking = await req.json()

    // In production, this calls your hotel booking API,
    // charges the card, sends the email, etc.
    console.log('Processing booking:', booking)

    // Simulate API call
    const confirmationNumber = `BK-${Date.now().toString(36).toUpperCase()}`

    // Here you'd also send the actual email
    // await sendEmail({ to: booking.guestEmail, ... })

    return NextResponse.json({
        confirmationNumber,
        status: 'confirmed',
        message: `Booking confirmed. Confirmation email sent to ${booking.guestEmail}`,
    })
}

Gotchas and things that tripped me up

Tool invocation states matter. A tool invocation goes through states: partial-call (streaming args), call (args complete, waiting for execution), and result (done). For client-side tools without execute, the invocation stays in the call state until you call addToolResult. I initially tried checking for a custom "pending" state that doesn't exist — just check that state !== 'result' to know if it needs approval.

maxSteps applies to the full round-trip. If you set maxSteps: 5 and the agent uses 3 steps before hitting the approval tool, it only has 2 steps left after the user approves. For complex agents, set this higher than you think you need.

Rejection needs a useful message. When the user rejects, don't just send { approved: false }. Include a reason string so the agent can respond intelligently — "User wants to change the dates" is way more helpful than "rejected."

Don't forget error handling on the approval action. If the booking API call fails after the user clicks Approve, you need to handle that before calling addToolResult. Once you send the result, the agent assumes the action succeeded or failed based on what you told it:

// src/components/ApprovalCard.tsx (error handling addition)
onClick={async () => {
    try {
        const bookingResult = await fetch('/api/book', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(args),
        })

        if (!bookingResult.ok) {
            throw new Error('Booking API failed')
        }

        const data = await bookingResult.json()
        addToolResult({
            toolCallId: invocation.toolCallId,
            result: { approved: true, ...data },
        })
    } catch (error) {
        addToolResult({
            toolCallId: invocation.toolCallId,
            result: {
                approved: false,
                reason: 'Booking failed due to a server error. Please try again.',
            },
        })
    }
}}

Deciding which tools need confirmation

Not every tool needs a gate. Here's a quick framework:

| Tool type | Confirmation needed? | Example | |-----------|---------------------|---------| | Read-only queries | No | Search, calculate, look up | | Reversible writes | Depends on cost | Save draft, update preferences | | Irreversible actions | Yes | Send email, charge card, delete data | | External API calls | Yes if they cost money | Book hotel, place order |

The general rule: if the action has consequences the user can't easily undo, gate it. If it's just reading data or doing math, let it fly.

Extending this pattern

This same approach works for any agent that needs guardrails. A few ideas:

  • Code deployment agent — searches logs and writes fixes automatically, but pauses before deploying to production
  • Email assistant — drafts replies and schedules meetings, but waits for approval before sending
  • Data migration tool — analyzes schema differences automatically, but confirms before running destructive migrations

The pattern is always the same: omit the execute function from dangerous tools, render the args in an approval UI, and use addToolResult to resume the agent.

What's next

If you're building agents that need to coordinate multiple specialized AI models, check out the upcoming post on Vercel AI SDK vs Mastra vs LangChain.js — a practical comparison of TypeScript AI frameworks for building production agent systems.

Share:
VA

Vadim Alakhverdov

Software developer writing about JavaScript, web development, and developer tools.

Related Posts