Skip to content
agenoverse
AI Development

Build the first version: the agent loop

Write the whole agent in about sixty lines: a search tool, a loop that lets the model use it, and a step limit so it cannot run forever. TypeScript and Python versions.

Lesson 3 of 5Build your first AI agent

Updated

Reviewed

  • Level: Intermediate
  • 45 min to complete

// Before you start

This guide builds on this one:

This is the lesson where it comes together. You will write one file that does everything from lesson 1: a model, a loop and a tool. Read the code first, then we go through it piece by piece.

Give it something to look at#

The agent needs notes to search. A short list in the file is plenty for now; later you could swap it for files or a database without touching the loop.

Describe the tool, then write it#

A tool has two halves. The description is what the model sees: a name, a plain sentence about what it does, and the shape of its input. The function is your own code, which the model never sees.

Write the description as if you were explaining the tool to a new colleague. A vague description is the most common reason an agent picks the wrong tool, or searches with a whole sentence instead of keywords.

Tip

Put guidance in the description, not only in the prompt. "A few keywords, not a full sentence" next to the query field changes how the model fills it in.

Write the loop#

Save this as agent.ts (or the Python version as agent.py). Notice the three things that keep it honest: it stops when the model stops asking for tools, it caps the number of steps, and it never lets the model run anything directly.

typescript
import Anthropic from '@anthropic-ai/sdk'const client = new Anthropic()const MODEL = 'claude-sonnet-5-5'const MAX_STEPS = 6const SYSTEM =  "You answer questions using the user's notes. Search the notes before you answer. " +  'If the notes do not contain the answer, say so instead of guessing.'const notes = [  { id: 1, title: 'Team standup', text: 'Standup moved to 9:30 on Mondays. Priya runs it.' },  { id: 2, title: 'Launch checklist', text: 'Before launch: run the tests, check the contact form, update the sitemap.' },  { id: 3, title: 'Book list', text: 'To read: The Pragmatic Programmer, Designing Data-Intensive Applications.' },]// The tool itself is plain code. The model never sees this function, only its description.function searchNotes(query: string) {  const words = query.toLowerCase().split(/\s+/).filter(Boolean)  return notes    .filter((note) => words.some((word) => `${note.title} ${note.text}`.toLowerCase().includes(word)))    .slice(0, 3)}const tools: Anthropic.Tool[] = [  {    name: 'search_notes',    description:      "Search the user's notes. Returns up to 3 notes whose title or text contains any of the words in the query.",    input_schema: {      type: 'object',      properties: {        query: { type: 'string', description: 'A few keywords, not a full sentence.' },      },      required: ['query'],    },  },]export async function ask(question: string) {  const messages: Anthropic.MessageParam[] = [{ role: 'user', content: question }]  for (let step = 0; step < MAX_STEPS; step++) {    const response = await client.messages.create({      model: MODEL,      max_tokens: 1024,      system: SYSTEM,      tools,      messages,    })    // No tool request means the model has its answer.    if (response.stop_reason !== 'tool_use') {      const text = response.content.find((block) => block.type === 'text')      return text?.type === 'text' ? text.text : '(no answer)'    }    // Keep the model's turn in the history, then answer each tool request.    messages.push({ role: 'assistant', content: response.content })    const results: Anthropic.ToolResultBlockParam[] = []    for (const block of response.content) {      if (block.type !== 'tool_use') continue      const { query } = block.input as { query: string }      console.log(`step ${step + 1}: search_notes("${query}")`)      results.push({        type: 'tool_result',        tool_use_id: block.id,        content: JSON.stringify(searchNotes(query)),      })    }    messages.push({ role: 'user', content: results })  }  throw new Error(`Gave up after ${MAX_STEPS} steps`)}

Run it and read the trace#

For TypeScript, add a tiny run.ts that calls the function with a question from the command line:

run.ts
import { ask } from './agent.ts'const question = process.argv[2] ?? 'When is standup, and who runs it?'console.log(await ask(question))

Then run npx tsx run.ts "When is standup, and who runs it?". You should see one or two lines like step 1: search_notes("standup") followed by an answer that mentions 9:30 and Priya. Those lines are your trace: the agent's own account of what it did.

// prompt · A system prompt to start from
You answer questions using the user's notes. Rules: - Search the notes before you answer. - If the notes do not contain the answer, say so. Do not guess. - Keep answers to {{number}} sentences or fewer. - Quote the note title you used.

Paste it as the SYSTEM text in agent.ts. Change the highlighted part to suit you.

Why the step limit matters#

Without MAX_STEPS, a confused model can keep calling the same tool forever, and you pay for every round. A limit turns an unbounded failure into a clear error you can read. Six is generous for a one-tool agent; start low and raise it only when a real task needs it.

The model returns a tool request. Who actually runs the search?

Choose one.

// Try it

Add a second tool

Give the agent a way to list the titles of all the notes, so it can answer "what notes do I have?".

  1. Write a listNotes function that returns the title of every note.
  2. Add a list_notes entry to the tools list with a clear description and no inputs.
  3. Handle it inside the loop, next to search_notes.
  4. Ask: "What notes do I have?" and check the trace.
Show a solution

The description is the important part. The loop only needs one new branch.

agent.ts (additions)
const listNotes = () => notes.map((note) => note.title)// in the tools list:{  name: 'list_notes',  description: 'List the titles of every note the user has. Use it when they ask what notes exist.',  input_schema: { type: 'object', properties: {} },},// in the loop, replacing the single search_notes handling:const output =  block.name === 'list_notes'    ? listNotes()    : searchNotes((block.input as { query: string }).query)results.push({ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(output) })

Before you move on#

The loop, working

Was this guide helpful?

These guides stay current because readers tell us what is off. One tap is plenty.