The word “agent” is used for almost everything in AI today, so it helps to start with a plain definition. An AI agent is a program where a language model decides what to do next, uses tools to do it, looks at the result, and repeats until the goal is reached. The key words are “decides” and “repeats”. A single question and answer is not an agent. A loop in which the model chooses the steps is.
In lesson 9 of the Claude API course you already built the core of this: Claude asked for a tool, your code ran it, and you sent back the result. In this lesson you turn that into a reusable, safe agent with limits and memory. You will also learn when you should not use an agent at all, which is just as important.
Workflow or agent?
There are two ways to build a multi-step AI feature, and the difference matters for cost, speed and safety.
- A workflow is a fixed path that you write. For example: step 1 extract the skills from the resume, step 2 extract the skills from the job post, step 3 compare, step 4 write advice. The model does the work inside each step, but your code decides the order. A workflow is predictable, cheap, and easy to test.
- An agent chooses its own path. You give it a goal and tools, and the model decides which tool to call, in what order, and when to stop. An agent is flexible, but it is harder to predict, can take many steps, and can go wrong in new ways.
Here is an everyday comparison. A workflow is a recipe: always the same steps, in the same order. An agent is a cook who looks in the fridge and decides what to make. The cook can handle surprises, but you cannot know the exact dinner in advance, and sometimes the cook wastes food.
The practical rule is: use the simplest thing that works. If you can write the steps down, write a workflow. Use an agent only when the number or order of steps depends on what is found along the way, for example when answering a question may need one search or five searches.
The agent loop
Every agent, in every framework, is built on the same small loop:
- Send the goal, the tool descriptions and the conversation so far to the model.
- If the model answers with plain text, that is the final answer. Stop.
- If the model asks for tools, run them in your code and collect the results.
- Add the model's request and your results to the conversation.
- Go back to step 1.
That is all. Frameworks add features around this loop, but the loop is the engine. Look at how short the loop is in our lab. It is a function called runAgent, and we will reuse it in the next lesson:
import { client, MODEL } from './claude.js';
// A reusable agent loop. An agent is: a model + tools + a loop + limits.
//
// tools the tool definitions Claude may choose from
// handlers { toolName: (input) => result }, plain functions that YOUR code runs
// maxTurns hard limit on model calls
// maxTokens hard limit on total tokens (input + output) for the whole run
export async function runAgent({ system, user, tools = [], handlers = {}, maxTurns = 6, maxTokens = 12000, maxReplyTokens = 700 }) {
const messages = [{ role: 'user', content: user }];
const trace = [];
const usage = { input: 0, output: 0 };
for (let turn = 1; turn <= maxTurns; turn++) {
const started = Date.now();
const reply = await client.messages.create({
model: MODEL,
max_tokens: maxReplyTokens,
system,
...(tools.length ? { tools } : {}),
messages,
});
usage.input += reply.usage?.input_tokens || 0;
usage.output += reply.usage?.output_tokens || 0;
messages.push({ role: 'assistant', content: reply.content });
const text = reply.content.filter((b) => b.type === 'text').map((b) => b.text).join('');
if (reply.stop_reason !== 'tool_use') {
return { text, trace, turns: turn, stopReason: 'done', usage };
}
const results = [];
for (const block of reply.content.filter((b) => b.type === 'tool_use')) {
let output;
let isError = false;
try {
if (!handlers[block.name]) throw new Error(`Unknown tool: ${block.name}`);
output = await handlers[block.name](block.input || {});
} catch (e) {
output = { error: e.message };
isError = true;
}
trace.push({ turn, tool: block.name, input: block.input, output, isError, ms: Date.now() - started });
results.push({ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(output).slice(0, 6000), ...(isError ? { is_error: true } : {}) });
}
messages.push({ role: 'user', content: results });
if (usage.input + usage.output > maxTokens) {
return { text: 'Stopped: the token budget for this run was used up.', trace, turns: turn, stopReason: 'budget', usage };
}
}
return { text: 'Stopped: the agent reached its step limit.', trace, turns: maxTurns, stopReason: 'max_turns', usage };
}
Limits: the part that keeps you safe
An agent without limits is like a taxi with the meter off and no destination. It might drive in circles while you pay. Our loop has three limits, and each one protects against a real failure.
- A step limit (`maxTurns`). The model might keep calling tools forever, for example if a search keeps returning nothing useful. After six model calls we stop.
- A token budget (`maxTokens`). Each loop sends the whole conversation again, so the cost grows with every step. If the total tokens pass the budget, we stop and say so.
- A reply limit (`maxReplyTokens`). Each model call has a maximum length, so no single step can write a huge text.
When a limit is hit, the function returns a clear stopReason: done, max_turns or budget. The page shows it. A user who sees “stopped because: budget” understands what happened. A user who sees a spinner forever does not.
Also notice what happens when a tool fails. We do not crash the loop. We catch the error, put { error: ... } into the result and mark it is_error. The model reads the error and can try something else. This makes agents more robust than plain code, because the model is a flexible error handler.
Tools: keep them small and clear
The model chooses tools by reading their descriptions. A vague description gives wrong choices. Write each description like instructions for a new colleague: what the tool does, when to use it, and what it returns. Give each tool one clear job. In this lab the agent has two tools:
search_docssearches our help-center documents with the hybrid search from lesson 16. It is read-only, so it is safe.remembersaves one short fact about the user. It changes state, so we limit it: at most 20 facts, with a short length for each.
Whenever you add a tool that changes something (sends an email, writes a file, deletes a record), think about what the worst case is if the model calls it at the wrong time with the wrong input. Lesson 21 shows how to put a human in the loop for such tools.
Memory: two kinds
A model has no memory between requests. Everything it “remembers” must be sent again. When people say an agent has memory, they usually mean one of two things:
- Short-term memory is the conversation so far. It lives inside the loop as the
messageslist. It disappears when the run ends, unless you save it. - Long-term memory is information kept between runs: the user's job title, their city, their preferences. You store it outside the model (in a database, a file, or a vector store) and put the relevant part into the system prompt of the next run.
In our route, the remember tool writes facts to a Map per session, and the next run puts those facts into the system prompt under “Facts you already know about this user”. Try it: say “I am a delivery driver in Pune. Remember that.” Then start a new request and ask what to put on your resume. The agent knows your job without being told again.
Be careful with memory. Store only what is useful, let users see and delete it (our page has a Forget button), and never store secrets. Also remember that memory can be wrong or can be poisoned: if a malicious document tells the agent to “remember” something false, you have saved a lie. Treat saved facts as data with limits, and validate them.
Where agents fail
- Loops. The model repeats the same tool call. A step limit stops this.
- Wrong tool or wrong input. Better descriptions and strict input checks help.
- Too many tools. With twenty tools, the model gets confused. Start with a few.
- Plan drift. On long tasks the model forgets the original goal. Repeat the goal in the system prompt.
- Cost surprises. Every step repeats the history. Watch the usage numbers.
- False confidence. The final answer can sound sure even when a tool failed. Show the trace so a person can check.
Your task: build an agent with limits and memory
Build an agent that answers career questions using the document search, remembers facts about the user, and always stops safely.
What you will build
- server/agents.js with a reusable runAgent function that has a step limit, a token budget and a stop reason.
- server/routes/agent-loop.js with two tools, search_docs and remember, and per-session memory.
- An Angular page that shows the final answer, the stop reason, the token total, a trace of every tool call with its time, and the saved memory.
Steps
- Write runAgent. It takes a system prompt, a user goal, tools, handlers and limits. Loop up to maxTurns. After each model call add the usage to a total.
- In the loop, if the reply is not a tool request, return the text with stopReason done. Otherwise run each requested handler inside try/catch and send back every result, marking failures with is_error.
- After running tools, check the token total. If it is above the budget, return stopReason budget. If the loop ends by itself, return max_turns.
- Write the route. Keep a Map of memory by session id. Build the system prompt with the known facts. Add the two tools. Limit the memory to 20 facts.
- Write a DELETE route that clears a session's memory.
- Build the page. Create a random session id when the page loads. Show the trace and memory. Test with a goal that needs a search, a goal that needs no tool, and a goal that tells the agent a fact to remember.
Hints
- The tool_result for every tool_use must be sent back in the same user message, in the same order. Missing one causes an API error.
- Cut tool outputs to a safe size before sending them back.
- To test the limits, set maxTurns to 1 temporarily and check that the stop reason changes.
You know it works when the trace shows each tool call with its time, the memory shows the saved fact, a second run in the same session already knows the fact, a different session does not, and lowering the limits produces a clear stop reason.
Solution
You have seen agents.js above. Here are the route and the Angular page.
import { Router } from 'express';
import { runAgent } from '../agents.js';
import { retrieve } from '../rag/hybrid.js';
const router = Router();
// Long-term memory: facts the agent chose to remember, kept per browser session.
// (In production this would live in a database, not in server memory.)
const memory = new Map(); // sessionId -> { facts: { key: value } }
const tools = [
{
name: 'search_docs',
description: 'Search the help-center documents about resumes, ATS, cover letters, interviews, job search and privacy. Returns the best matching passages. Call it before answering any question about those topics.',
input_schema: { type: 'object', properties: { query: { type: 'string', description: 'A short search query' } }, required: ['query'] },
},
{
name: 'remember',
description: 'Save one short fact about the user (for example their job title or city) so it can be used in later conversations.',
input_schema: { type: 'object', properties: { key: { type: 'string' }, value: { type: 'string' } }, required: ['key', 'value'] },
},
];
// POST /api/agent-loop { goal, sessionId } -> { text, trace, turns, stopReason, usage, facts }
router.post('/agent-loop', async (req, res) => {
const goal = String(req.body?.goal || '').trim().slice(0, 1500);
if (!goal) return res.status(400).json({ error: { message: 'Please describe a goal.' } });
const sessionId = String(req.body?.sessionId || 'default').slice(0, 60);
const mem = memory.get(sessionId) || { facts: {} };
memory.set(sessionId, mem);
const known = Object.entries(mem.facts).map(([k, v]) => `- ${k}: ${v}`).join('\n') || '(nothing yet)';
const system = `You are a careful career assistant. Work toward the user's goal step by step.
Use search_docs to look things up before you answer. Use remember when the user tells you a lasting fact about themselves.
When you have enough information, reply with a short final answer and stop calling tools.
Facts you already know about this user:
${known}`;
const handlers = {
search_docs: ({ query }) => ({ passages: retrieve(String(query), { mode: 'hybrid', k: 3 }).map((p) => ({ source: p.source, text: p.text })) }),
remember: ({ key, value }) => {
if (Object.keys(mem.facts).length >= 20) throw new Error('Memory is full.');
mem.facts[String(key).slice(0, 40)] = String(value).slice(0, 200);
return { saved: true };
},
};
const result = await runAgent({ system, user: goal, tools, handlers, maxTurns: 6 });
res.json({ ...result, facts: mem.facts });
});
// DELETE /api/agent-loop/memory { sessionId } -> forget everything
router.delete('/agent-loop/memory', (req, res) => {
memory.delete(String(req.body?.sessionId || 'default').slice(0, 60));
res.json({ cleared: true });
});
export default router;
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { errorText } from '../util';
type Step = { turn: number; tool: string; input: unknown; output: unknown; isError: boolean; ms: number };
type Run = { text: string; trace: Step[]; turns: number; stopReason: string; usage: { input: number; output: number }; facts: Record<string, string> };
@Component({
selector: 'app-agent-loop',
imports: [FormsModule],
template: `
<h1>An agent with limits and memory</h1>
<p class="hint">Give the agent a goal. It decides which tools to use, and the loop stops at a step limit or token budget.</p>
<div class="card">
<label for="g">Goal</label>
<textarea id="g" [(ngModel)]="goal" style="min-height:110px"></textarea>
<button (click)="run()" [disabled]="busy() || !goal.trim()">{{ busy() ? 'Working...' : 'Run agent' }}</button>
<button class="secondary" (click)="forget()" [disabled]="busy()">Forget memory</button>
@if (error()) { <div class="err">{{ error() }}</div> }
</div>
@if (result(); as r) {
<div class="card">
<h2>Final answer</h2>
<div class="answer">{{ r.text }}</div>
<p class="muted">Stopped because: <strong>{{ r.stopReason }}</strong> after {{ r.turns }} model call(s), {{ r.usage.input + r.usage.output }} tokens.</p>
<h2>Trace</h2>
@for (s of r.trace; track $index) {
<p><strong>Turn {{ s.turn }}: {{ s.tool }}</strong> <span [class]="s.isError ? 'bad' : 'ok'">{{ s.isError ? 'error' : 'ok' }}</span> <span class="muted">{{ s.ms }} ms</span></p>
<pre class="out">in: {{ json(s.input) }}
out: {{ json(s.output) }}</pre>
}
<h2>Memory</h2>
<pre class="out">{{ json(r.facts) }}</pre>
</div>
}
`,
})
export class AgentLoopPage {
private http = inject(HttpClient);
private sessionId = Math.random().toString(36).slice(2);
goal = 'I am a delivery driver in Pune. Remember that. Then tell me what to put at the top of my resume.';
busy = signal(false);
error = signal('');
result = signal<Run | null>(null);
json(v: unknown) {
return JSON.stringify(v);
}
async run() {
this.busy.set(true);
this.error.set('');
try {
this.result.set(await firstValueFrom(this.http.post<Run>('/api/agent-loop', { goal: this.goal, sessionId: this.sessionId })));
} catch (e) {
this.error.set(errorText(e));
} finally {
this.busy.set(false);
}
}
async forget() {
await firstValueFrom(this.http.request('DELETE', '/api/agent-loop/memory', { body: { sessionId: this.sessionId } }));
this.result.set(null);
}
}
What you learned
- An agent is a model in a loop that chooses tools. A workflow is a fixed path. Prefer the simplest one that works.
- Every agent needs limits: steps, tokens and reply length, and a clear reason for stopping.
- Tool errors should go back to the model as results so that it can recover.
- Memory is short-term (the conversation) or long-term (saved outside the model). Store little, validate it, and let users delete it.
In the next lesson you will split work between several specialised agents and learn when that helps and when it only adds cost.