How to build agentic loops with tools, maxSteps, 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 maxSteps option.

Defining tools

Each tool needs a description (what the LLM reads to decide when to use it) and a Zod schema for its parameters.

import { tool } from 'ai';
import { z } from 'zod';
 
const getWeather = tool({
  description: 'Get the current weather for a city.',
  parameters: 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.',
  parameters: 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', args: { city: 'Paris' } }]
console.log(toolResults); // [{ toolName: 'getWeather', result: { city: 'Paris', ... } }]
console.log(text);        // Final answer after tool results
 

Multi-step agent with maxSteps

Set maxSteps to allow the LLM to call multiple tools across multiple turns. The SDK loops automatically until the LLM produces a text-only response or maxSteps is reached.

// app/api/agent/route.ts
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
 
export async function POST(req: Request) {
  const { messages } = await req.json();
 
  const result = streamText({
    model: openai('gpt-4o'),
    system: 'You are a research assistant. Use your tools to answer thoroughly.',
    messages,
    tools: { getWeather, searchWeb },
    maxSteps: 5,           // allow up to 5 tool-call rounds
    onStepFinish: ({ stepType, toolCalls, toolResults }) => {
      console.log(`Step: ${stepType}`, toolCalls?.length ?? 0, 'tool calls');
    },
  });
 
  return result.toDataStreamResponse();
}
 
maxSteps is the safety valve — without it, a confused agent could loop indefinitely. Start with maxSteps: 5 for most agents. Research agents may need up to 10.

Streaming tool progress to the client

useChat automatically streams tool call events to the client. You can intercept them to show progress indicators.

'use client';
import { useChat } from 'ai/react';
 
export default function AgentChat() {
  const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
    api: '/api/agent',
    onToolCall: ({ toolCall }) => {
      console.log('Tool called:', toolCall.toolName, toolCall.args);
      // Show a status indicator in the UI
    },
  });
 
  return (
    <div>
      {messages.map(m => (
        <div key={m.id}>
          {m.role === 'assistant' && m.content && <p>{m.content}</p>}
          {/* Render tool invocations */}
          {m.toolInvocations?.map(t => (
            <div key={t.toolCallId} style={{color: 'gray'}}>
              Using {t.toolName}...
              {t.state === 'result' && <span> done</span>}
            </div>
          ))}
        </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.',
  parameters: 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 { addToolResult } = useChat(...);
// When you see a toolInvocation with state === 'call' and toolName === 'askUserConfirmation':
// Show a dialog, then:
// addToolResult({ toolCallId: t.toolCallId, result: { 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.',
  parameters: 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

const { text, usage, steps } = await generateText({
  model: openai('gpt-4o'),
  tools: { searchWeb },
  maxSteps: 5,
  prompt: 'Research the top 3 AI frameworks in 2026.',
});
 
// Total usage across all steps
console.log('Total tokens:', usage.totalTokens);
console.log('Prompt tokens:', usage.promptTokens);
console.log('Completion tokens:', usage.completionTokens);
 
// Per-step breakdown
steps.forEach((step, i) => {
  console.log(`Step ${i}: ${step.usage.totalTokens} tokens, ${step.toolCalls?.length ?? 0} tool calls`);
});