How to build agentic loops with tools, stopWhen, and server/client execution

From chatbot to agent: what changes

A chatbot calls an LLM and returns the response. An agent calls an LLM, which decides to call a tool, runs the tool, feeds the result back to the LLM, and repeats until the task is done. The Vercel AI SDK supports this loop natively via the tools parameter and a stopWhen stop condition (for example stopWhen: stepCountIs(5)).

Defining tools

Each tool needs a description (what the LLM reads to decide when to use it) and a Zod schema for its inputs, supplied via the inputSchema field.

import { tool } from 'ai';
import { z } from 'zod';

const getWeather = tool({
  description: 'Get the current weather for a city.',
  inputSchema: z.object({
    city: z.string().describe('The city name, e.g. London or New York'),
    units: z.enum(['celsius', 'fahrenheit']).default('celsius'),
  }),
  execute: async ({ city, units }) => {
    // Replace with a real weather API call
    return { city, temperature: 18, condition: 'Partly cloudy', units };
  },
});

const searchWeb = tool({
  description: 'Search the web for current information on a topic.',
  inputSchema: z.object({
    query: z.string().describe('The search query'),
  }),
  execute: async ({ query }) => {
    // Replace with Tavily, Bing, or DuckDuckGo API
    return { results: [`Result for: ${query}`] };
  },
});

Single-step tool call

import { generateText } from 'ai';
import { openai } from '@ai-sdk/openai';

const { text, toolCalls, toolResults } = await generateText({
  model: openai('gpt-4o'),
  tools: { getWeather, searchWeb },
  prompt: 'What is the weather in Paris today?',
});

console.log(toolCalls);   // [{ toolName: 'getWeather', input: { city: 'Paris' } }]
console.log(toolResults); // [{ toolName: 'getWeather', output: { city: 'Paris', ... } }]
console.log(text);        // Final answer after tool results

Multi-step agent with stopWhen

Set a stop condition with stopWhen (for example stopWhen: stepCountIs(5), importing stepCountIs from 'ai') to allow the LLM to call multiple tools across multiple turns. The SDK loops automatically until the LLM produces a text-only response or the stop condition is reached. Forward note: AI SDK v6 also adds a higher-level Agent class (also called ToolLoopAgent) that wraps this same tool-calling loop in a single reusable abstraction, while the stopWhen approach shown here remains fully supported in v6.

// app/api/agent/route.ts
import { streamText, convertToModelMessages, stepCountIs, UIMessage } from 'ai';
import { openai } from '@ai-sdk/openai';

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

  const result = streamText({
    model: openai('gpt-4o'),
    system: 'You are a research assistant. Use your tools to answer thoroughly.',
    messages: convertToModelMessages(messages),
    tools: { getWeather, searchWeb },
    stopWhen: stepCountIs(5),   // allow up to 5 tool-call rounds
    onStepFinish: ({ toolCalls }) => {
      console.log('Step finished:', toolCalls?.length ?? 0, 'tool calls');
    },
  });

  return result.toUIMessageStreamResponse();
}
The stop condition is the safety valve — without it, a confused agent could loop indefinitely. Start with stopWhen: stepCountIs(5) for most agents. Research agents may need stepCountIs(10).

Streaming tool progress to the client

useChat streams tool-call events to the client as typed parts on each message. Read them from message.parts (parts of type tool-<toolName>) to show progress indicators.

'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';

export default function AgentChat() {
  const { messages } = useChat({
    transport: new DefaultChatTransport({ api: '/api/agent' }),
  });

  return (
    <div>
      {messages.map(m => (
        <div key={m.id}>
          {m.parts.map((part, i) => {
            if (part.type === 'text') return <p key={i}>{part.text}</p>;
            // Tool parts are typed `tool-<toolName>` with a state and input/output
            if (part.type.startsWith('tool-')) {
              return (
                <div key={i} style={{ color: 'gray' }}>
                  Using {part.type.replace('tool-', '')}...
                  {part.state === 'output-available' && <span> done</span>}
                </div>
              );
            }
            return null;
          })}
        </div>
      ))}
    </div>
  );
}

Client-side tools (human-in-the-loop)

Some tools should not execute on the server — they need user confirmation or access to browser APIs. Define them without an execute function and handle them client-side.

// Server: define a tool with no execute (client-side only)
const askUserConfirmation = tool({
  description: 'Ask the user to confirm before taking an action.',
  inputSchema: z.object({
    action: z.string().describe('The action to confirm'),
    details: z.string(),
  }),
  // No execute -- handled client-side
});

// Client: handle the pending tool call
// const { addToolOutput } = useChat(...);
// When a message part has type === 'tool-askUserConfirmation'
// and state === 'input-available', show a dialog, then:
// addToolOutput({
//   tool: 'askUserConfirmation',
//   toolCallId: part.toolCallId,
//   output: { confirmed: true },
// });
// This resumes the agent loop with the user's answer.

Error handling in tool calls

const robustTool = tool({
  description: 'Fetches data from an external API.',
  inputSchema: z.object({ id: z.string() }),
  execute: async ({ id }) => {
    try {
      const data = await fetchExternalApi(id);
      return { success: true, data };
    } catch (error) {
      // Return error as a value -- do not throw
      // The LLM will see the error and can decide how to proceed
      return {
        success: false,
        error: error instanceof Error ? error.message : 'Unknown error',
      };
    }
  },
});
Do not throw errors from tool execute functions. The SDK will catch them but the LLM loses the ability to reason about what went wrong. Return structured error objects instead.

Tool choice control

const result = await generateText({
  model: openai('gpt-4o'),
  tools: { getWeather, searchWeb },
  toolChoice: 'auto',          // default: LLM decides when to use tools
  // toolChoice: 'required',   // LLM must call at least one tool
  // toolChoice: { type: 'tool', toolName: 'getWeather' },  // force specific tool
  // toolChoice: 'none',       // disable tools for this call
  prompt: 'What is the weather in Tokyo?',
});
 

Token usage and cost tracking

import { generateText, stepCountIs } from 'ai';
import { openai } from '@ai-sdk/openai';

const { text, usage, steps } = await generateText({
  model: openai('gpt-4o'),
  tools: { searchWeb },
  stopWhen: stepCountIs(5),
  prompt: 'Research the top 3 AI frameworks in 2026.',
});

// Total usage across all steps
console.log('Total tokens:', usage.totalTokens);
console.log('Input tokens:', usage.inputTokens);
console.log('Output tokens:', usage.outputTokens);

// Per-step breakdown
steps.forEach((step, i) => {
  console.log(`Step ${i}: ${step.usage.totalTokens} tokens, ${step.toolCalls?.length ?? 0} tool calls`);
});