DEV Community

Chichebe John
Chichebe John

Posted on

Build an AI Agent With Tool Calling in a Next.js Route Handler (Claude API)

A step-by-step guide to the pattern behind AutoValue's listing agent: a conversational AI that calls real functions, keeps state across turns, and lives entirely in one Next.js route.

Most "build a chatbot" tutorials stop at text in, text out. Production agents are different: they call your functions, wait for real data, and decide what to do next based on the results. I run one in production at AutoValue, where a conversational agent walks Nigerian car sellers through a full listing flow: identifying the car from a photo, reading the odometer, pulling live market prices, and publishing the finished listing to the marketplace. This tutorial builds the core of that pattern from scratch: a tool-calling agent inside a single Next.js route handler.

You'll need Node 18+, a Next.js App Router project, and an Anthropic API key.

Step 1: Install the SDK and set your key

npm install @anthropic-ai/sdk
Enter fullscreen mode Exit fullscreen mode

Put your key in .env.local so Next.js keeps it server-side:

ANTHROPIC_API_KEY=sk-ant-...
Enter fullscreen mode Exit fullscreen mode

The SDK reads that variable automatically, so the client needs no arguments.

Step 2: Define a tool

A tool is a JSON Schema description of a function the model may call. The model never executes anything itself; it returns a structured request, and your code runs the function. Here's a simplified version of AutoValue's pricing tool:

import Anthropic from "@anthropic-ai/sdk";

const tools: Anthropic.Tool[] = [
  {
    name: "get_market_price",
    description:
      "Get the current Nigerian market price range for a specific car. " +
      "Call this whenever the user asks what a car is worth, or before " +
      "suggesting a listing price. Do not estimate prices from memory.",
    input_schema: {
      type: "object",
      properties: {
        make: { type: "string", description: "e.g. Toyota" },
        model: { type: "string", description: "e.g. Camry" },
        year: { type: "integer", description: "e.g. 2015" },
        mileage_km: {
          type: "integer",
          description: "Odometer reading in kilometres, if known",
        },
      },
      required: ["make", "model", "year"],
    },
  },
];
Enter fullscreen mode Exit fullscreen mode

Two lessons from production hiding in that description. First, say when to call the tool, not just what it does. Models decide from the description, and "call this whenever the user asks what a car is worth" measurably beats a bare "gets car prices." Second, the line "do not estimate prices from memory" exists because a model's memorized prices can be years stale. In a volatile economy that's a 3x error. Make the tool the only allowed source of truth for anything that changes fast.

Step 3: The route handler with the agent loop

The Claude API is stateless: you send the full conversation every time. The agent loop is simple in shape: call the model, and if it stops because it wants a tool (stop_reason: "tool_use"), run the tool, append the result, and call again. When it stops for any other reason, you have your answer.

app/api/agent/route.ts:

import Anthropic from "@anthropic-ai/sdk";
import { NextResponse } from "next/server";

const client = new Anthropic();

const SYSTEM_PROMPT =
  "You are AutoValue's listing assistant. You help Nigerian car sellers " +
  "price and list their cars. Always base prices on the get_market_price " +
  "tool, never on memory. Be concise and friendly.";

async function runTool(name: string, input: unknown): Promise<string> {
  if (name === "get_market_price") {
    const { make, model, year } = input as {
      make: string;
      model: string;
      year: number;
    };
    // In production this hits our search pipeline (Serper → Google CSE)
    // and a Supabase anchor table. Stubbed here:
    return JSON.stringify({
      currency: "NGN",
      low: 8_500_000,
      high: 10_200_000,
      source: "live market listings",
      car: `${year} ${make} ${model}`,
    });
  }
  throw new Error(`Unknown tool: ${name}`);
}

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

  const history: Anthropic.MessageParam[] = [...messages];

  while (true) {
    const response = await client.messages.create({
      model: "claude-opus-4-8",
      max_tokens: 16000,
      thinking: { type: "adaptive" },
      system: SYSTEM_PROMPT,
      tools,
      messages: history,
    });

    // Append the FULL content array, not just the text.
    // It contains tool_use and thinking blocks the API needs back.
    history.push({ role: "assistant", content: response.content });

    if (response.stop_reason !== "tool_use") {
      const reply = response.content
        .filter((b): b is Anthropic.TextBlock => b.type === "text")
        .map((b) => b.text)
        .join("");
      return NextResponse.json({ reply, messages: history });
    }

    // The model may request several tools in one turn. Run them all,
    // then return ALL results in a single user message.
    const toolResults: Anthropic.ToolResultBlockParam[] = [];
    for (const block of response.content) {
      if (block.type !== "tool_use") continue;
      try {
        const result = await runTool(block.name, block.input);
        toolResults.push({
          type: "tool_result",
          tool_use_id: block.id,
          content: result,
        });
      } catch (err) {
        toolResults.push({
          type: "tool_result",
          tool_use_id: block.id,
          content: err instanceof Error ? err.message : String(err),
          is_error: true,
        });
      }
    }
    history.push({ role: "user", content: toolResults });
  }
}
Enter fullscreen mode Exit fullscreen mode

The frontend just POSTs { messages } and stores the returned messages array for the next turn. That's the whole state model: the client holds the transcript, the server holds nothing.

Step 4: The four bugs you will hit (because I hit them)

1. Appending only the text. If you push { role: "assistant", content: replyText } instead of the full response.content, you drop the tool_use blocks, and the API rejects the next request because your tool_result references an ID that no longer exists in history. Always append the whole content array.

2. Splitting parallel tool results. The model can request three tools in one turn. If you send each result as its own user message, the request may fail on role alternation, and worse, you quietly teach the model to stop parallelizing. All results go in one user message.

3. Swallowing tool errors. If a tool throws and you skip its result, the API errors on the dangling tool_use. Return a tool_result with is_error: true instead; the model reads the error and adapts, often by asking the user a clarifying question, which is exactly what you want.

4. Trusting the model to carry state. In AutoValue's six-phase flow we learned that data the model collected in phase one (like body type) doesn't reliably survive to a tool call in phase three if you expect the model to re-emit it as a tool argument. Accumulate authoritative state in your own code and inject it into tool executions yourself. The model drives the conversation; your code owns the facts.

Where to go from here

  • The SDK can run the loop for you. client.beta.messages.toolRunner() with betaZodTool handles the call-execute-append cycle automatically. I've shown the manual loop because you should understand what the runner does before you let it do it.
  • Streaming: swap create for client.messages.stream() and forward text deltas to the client for a typing effect; stream.finalMessage() gives you the same object the loop needs.
  • Cost: for high-traffic routes, a smaller model like claude-haiku-4-5 handles narrow, well-prompted tool flows at a fraction of the cost. AutoValue runs different models for different jobs: a big model for the conversational agent, a small one for the pricing calculator.

The full production version of this pattern (vision-based car identification, odometer OCR, photo phase management, publish flow) runs at autovalue.tech. I wrote about the pricing engine behind the get_market_price tool, and the currency bug that nearly sank it, in my previous article.

Top comments (0)