Building Agents

PreviousNext

Learn how to create agents that think, act, and decide using loops, tools, and large language models.


Agents: The Kitchen Brigade, Explained

Walk into a busy kitchen and ask for “something light, seasonal, and memorable.” The head chef checks what’s fresh, consults pairings, sketches a plating, then adjusts on the fly. Decide → act → taste/compare → repeat—until the dish is ready. That loop is the essence of an agent.

What is an Agent?

Agents are LLMs that use tools in a loop to accomplish tasks.

Model decides → Tool acts → Result feeds back → Repeat until the model returns text (final answer) or a stop condition triggers.

Parts:

  • LLM — plans the next action
  • Tools — do work beyond text (APIs, files, databases)
  • Loop — curates context and enforces stop rules

Build Your First Agent (MDX)

Let’s build a Tasting Menu Planner—a chef agent that checks the pantry, considers pairings, and composes a short menu with a tiny ASCII plating sketch. All tools are deterministic so demos and tests don’t flap.

import { Experimental_Agent as Agent, stepCountIs, tool } from "ai"
import { z } from "zod"
 
export const chefAgent = new Agent({
  model: "openai/gpt-4o",
  system: "You are a head chef creating a light, seasonal tasting menu.",
  tools: {
    checkPantry: tool({
      description: "Return available seasonal ingredients by section",
      inputSchema: z.object({
        sections: z
          .array(z.enum(["veg", "seafood", "dairy", "aromatics", "all"]))
          .default(["all"]),
      }),
      execute: async ({ sections }) => ({
        veg: ["asparagus", "pea shoots", "lemon"],
        seafood: ["scallops", "halibut"],
        dairy: ["crème fraîche"],
        aromatics: ["mint", "chive"],
      }),
    }),
 
    flavorPairings: tool({
      description: "Suggest classic flavor pairings for given ingredients",
      inputSchema: z.object({ items: z.array(z.string()) }),
      execute: async ({ items }) => ({
        suggestions: [
          { base: "scallops", pairs: ["lemon", "pea shoots", "mint"] },
          { base: "asparagus", pairs: ["lemon", "crème fraîche", "chive"] },
        ],
      }),
    }),
 
    composeMenu: tool({
      description: "Compose a 3-course tasting menu from pairings",
      inputSchema: z.object({
        theme: z.string(),
        courses: z.number().min(3).max(5).default(3),
      }),
      execute: async ({ theme, courses }) => ({
        courses: [
          { name: "Scallop crudo", note: "lemon · mint · pea shoots" },
          { name: "Asparagus velouté", note: "crème fraîche · chive" },
          { name: "Halibut en papillote", note: "lemon · herbs" },
        ].slice(0, courses),
      }),
    }),
 
    renderAsciiPlating: tool({
      description: "Render a tiny ASCII plating sketch for a course list",
      inputSchema: z.object({ names: z.array(z.string()) }),
      execute: async ({ names }) => ({
        sketch: `
${names.map((n) => `• ${n}  —  [○  ●  ○]`).join("\n")}`,
      }),
    }),
  },
  stopWhen: stepCountIs(20), // loop ends when model emits text or a stop rule fires
})
 
const result = await chefAgent.generate({
  prompt:
    "Create a light, seasonal 3-course tasting menu and include a tiny plating sketch.",
})
 
console.log(result.text) // final menu in Markdown
console.log(result.steps) // tool-call trace (observability)

What happens automatically:

  1. Calls checkPantry → 2) calls flavorPairings → 3) calls composeMenu → 4) optionally calls renderAsciiPlating → 5) emits a Markdown menu. The Agent class runs the loop, curates context, and enforces stop rules.

Loop Control You’ll Actually Use

stopWhen

By default, agents use stepCountIs(1), which allows one generation step. Here's what that means:

A "step" = one model generation, which produces either:

  • Text output (agent stops immediately), OR
  • Tool call(s) (tools execute, then loop continues to step 2)

So with stepCountIs(1):

  • If the model generates text → done
  • If the model calls a tool → tool executes → step 2 happens → agent continues until it generates text or hits another stop condition
// Default behavior (single generation)
const agent = new Agent({
  model: "openai/gpt-4o",
  tools: { checkPantry, composeMenu },
  // stopWhen: stepCountIs(1) ← implicit default
})
 
// This agent:
// - Step 1: Model might call checkPantry → tool executes → continues
// - Step 2: Model generates final answer → stops
// The limit is on GENERATIONS, not tool calls

To enable multi-tool reasoning chains, increase the step limit:

const agent = new Agent({
  model: "openai/gpt-4o",
  tools: { checkPantry, flavorPairings, composeMenu, renderPlating },
  stopWhen: stepCountIs(20), // Allow up to 20 generations
})
 
// Now the agent can:
// Step 1: call checkPantry → executes
// Step 2: call flavorPairings → executes
// Step 3: call composeMenu → executes
// Step 4: call renderPlating → executes
// Step 5: generate final text → stops
// (5 steps total, well under the 20-step limit)

Key insight: The loop automatically continues after tool execution. stopWhen controls when to stop if there are tool results in the last step—it prevents infinite loops while allowing the agent to chain multiple tool calls together.

Cost guard (production):

// Stop when estimated cost exceeds $0.50
const budgetExceeded = ({ steps }) => {
  const usage = steps.reduce(
    (t, s) => ({
      in: t.in + (s.usage?.inputTokens ?? 0),
      out: t.out + (s.usage?.outputTokens ?? 0),
    }),
    { in: 0, out: 0 }
  )
  const usd = (usage.in * 0.01 + usage.out * 0.03) / 1000
  return usd > 0.5
}

Then:

const agent = new Agent({
  model: "openai/gpt-4o",
  stopWhen: [stepCountIs(20), budgetExceeded],
})

prepareStep

Runs before each iteration. Trim history and optionally escalate the model as tasks get complex.

const agent = new Agent({
  model: "openai/gpt-4o-mini",
  tools,
  prepareStep: async ({ stepNumber, messages }) => {
    const trimmed = messages.slice(-12) // keep it lean
    if (stepNumber >= 3) {
      return { model: "openai/gpt-4o", messages: trimmed }
    }
    return { messages: trimmed }
  },
})

When Not to Use an Agent

Agents are flexible but non-deterministic. Prefer structured workflows (core functions) when you need:

  • Deterministic control (billing, policy checks)
  • Fixed pipelines (ETL, batch transforms)
  • Tight SLAs (hard latency budgets)
import { generateText } from "ai"
 
const res = await generateText({
  model: "openai/gpt-4o-mini",
  system: "You verify return eligibility using company policy.",
  messages: [{ role: "user", content: details }],
})
 
if (!policy.allows(details)) return deny()
return approve()

Learn more: /docs/agents/workflows


Production Patterns: Tools That Don’t Bite

Design tools like public APIs—clear contracts, safe outputs.

  • Timeouts & retries: set per-tool caps; retry idempotent ops only.
  • Idempotency: separate reads from writes; add idempotency keys for writes.
  • Input validation: strict inputSchema; reject unknowns.
  • Output curation: summarize big payloads before feeding back to the model.
  • Prompt-injection resistance: sanitize external text; structure data; avoid raw passthrough.
  • Auth & tenancy: tools derive user/tenant from server context, not model text.
const searchDocs = tool({
  description: "Search product docs",
  inputSchema: z.object({
    q: z.string().min(2),
    limit: z.number().int().min(1).max(5).default(3),
  }),
  execute: async ({ q, limit }, ctx) => {
    const results = await docs.search(q, { limit, userId: ctx.user.id })
    return {
      hits: results.map((r) => ({
        id: r.id,
        title: r.title,
        summary: r.snippet,
      })),
    }
  },
})

Observability, Cost, and Safety

  • Log steps: persist result.steps (tool name, args, duration, tokens) with PII redaction.
  • Budget & latency guards: add cost/latency stops; fail fast if any step > N ms.
  • Context curation: keep only necessary facts.
  • Postmortems: inspect step traces—wrong tool choice or bad input?
const slowStepExceeded = ({ steps }) =>
  steps.some((s) => (s.timing?.ms ?? 0) > 4000)
const agent = new Agent({
  model: "openai/gpt-4o",
  stopWhen: [stepCountIs(25), slowStepExceeded],
})

Evaluate Your Agent

Don’t just snapshot text—assert on the tool sequence.

import { strict as assert } from "assert"
 
const run = await signupAgent.generate({ prompt: "Explain the Friday dip" })
const calls = run.steps.flatMap(
  (s) => s.toolCalls?.map((c) => c.toolName) ?? []
)
 
assert.deepEqual(calls, ["sqlQuery", "fetchDeploys", "renderAsciiChart"])
assert.match(run.text ?? "", /friday|deploy|chart/i)

Sidebar: The Kitchen Brigade (A sticky mental model)

If product forensics is too dry, carry this in your head: a head chef (LLM) with a brigade (tools) working a service (loop). The chef decides, the brigade acts, the tasting (comparison) guides the next move.

Tiny, safe example (deterministic tools):

import { Experimental_Agent as Agent, stepCountIs, tool } from "ai"
import { z } from "zod"
 
const chefAgent = new Agent({
  model: "openai/gpt-4o",
  system:
    "You are a helpful chef. Use tools to check ingredients and propose recipes.",
  tools: {
    checkFridge: tool({
      description: "Check available ingredients",
      inputSchema: z.object({
        section: z.enum(["proteins", "veg", "dairy", "all"]),
      }),
      execute: async ({ section }) => ({
        proteins: ["chicken", "eggs"],
        veg: ["peppers", "onions", "tomatoes"],
        dairy: ["parmesan", "yogurt"],
      }),
    }),
    suggestDish: tool({
      description: "Propose a dish from ingredients",
      inputSchema: z.object({ have: z.array(z.string()) }),
      execute: async ({ have }) => ({
        dish: "chicken peperonata",
        reason: "uses chicken + peppers + tomatoes",
      }),
    }),
  },
  stopWhen: stepCountIs(10),
})

Use the metaphor to think, ship the product pattern to win.


Using the Chef Agent

Once defined, you can use your chef in three ways.

Generate once

const menu = await chefAgent.generate({
  prompt: "Design a light, seasonal 3-course tasting menu.",
})
console.log(menu.text)

Stream as it thinks

const stream = chefAgent.stream({
  prompt: "Narrate the prep for each course as you plan it.",
})
for await (const chunk of stream.textStream) {
  process.stdout.write(chunk)
}

Respond in an API route

import { chefAgent } from "@/tasting-menu-agent"
import { validateUIMessages } from "ai"
 
export async function POST(req: Request) {
  const { messages } = await req.json()
  return chefAgent.respond({
    messages: await validateUIMessages({ messages }),
  })
}

Tool Choice (picking the right station)

Sometimes you want to force a station to go first (e.g., pantry check) or require tools in certain phases.

import { Experimental_Agent as Agent, tool } from "ai"
 
const chef = new Agent({
  model: "openai/gpt-4o",
  tools: { checkPantry, flavorPairings, composeMenu },
  toolChoice: "auto", // default; model decides
})
 
// Force the first step to check the pantry
const pantryFirst = new Agent({
  model: "openai/gpt-4o",
  tools: { checkPantry, flavorPairings, composeMenu },
  toolChoice: { type: "tool", toolName: "checkPantry" },
})
 
// Require tool use (no free-text answers until a tool has run)
const mustUseTools = new Agent({
  model: "openai/gpt-4o",
  tools: { checkPantry, flavorPairings, composeMenu },
  toolChoice: "required",
})

End-to-end Type Safety (UI messages)

import { chefAgent } from "@/tasting-menu-agent"
import { Experimental_InferAgentUIMessage as InferAgentUIMessage } from "ai"
 
export type ChefUIMessage = InferAgentUIMessage<typeof chefAgent>

Use with your client component:

"use client"
 
import type { ChefUIMessage } from "@/types"
import { useChat } from "@ai-sdk/react"
 
export function ChefChat() {
  const { messages } = useChat<ChefUIMessage>()
  return (
    <div>
      {messages.map((m) => (
        <p key={m.id}>
          {m.role}: {m.content}
        </p>
      ))}
    </div>
  )
}

Manual Loop Control (when you need a precise recipe)

When you need strict, auditable steps, use core functions and write your own service flow.

import { generateText, ModelMessage } from "ai"
 
const messages: ModelMessage[] = [
  {
    role: "system",
    content: "You are a head chef. Always check pantry first.",
  },
  { role: "user", content: "Create a seasonal menu for 2 courses." },
]
 
let step = 0
const maxSteps = 6
 
while (step < maxSteps) {
  const result = await generateText({
    model: "openai/gpt-4o",
    messages,
    tools: { checkPantry, flavorPairings, composeMenu },
  })
 
  messages.push(...result.response.messages)
  if (result.text) break
  step++
}

Next Steps

  • /docs/agents/building-agents — Deeper guide to the Agent class
  • /docs/agents/workflows — Structured, predictable control flows
  • /docs/agents/loop-controlstopWhen, prepareStep, advanced execution control

Agents aren’t magic—they’re disciplined loops. Keep the loop small, the tools sharp, and the stop rules honest. The rest is engineering.