Until now Claude could only talk. It could not check a number in your database, count words exactly, or save a note. Tool use (also called function calling) changes that. You describe some functions to Claude, and when it needs one, it asks your server to run it. This is the idea behind almost every "AI assistant that can do things". In this lesson you will build it and watch every step in your Angular page.
The idea in one picture
Imagine a manager (Claude) sitting in an office with a helper (your server). The manager cannot leave the office. When the manager needs a fact, like how many words are in a document, the manager writes a note: "Please run the function count_words on this text." The helper does the job and brings back the result. The manager then reads the result and continues. Importantly, the manager never does the work itself. Claude never runs your function. Your code does.
This is a big safety point. Claude only asks. Your code decides whether to do it, checks the request, and does the work. You are always in control.
The flow, step by step
- You send the user's message and a list of tools that Claude may use.
- Claude thinks. If it can answer directly, it answers and the story ends. If it needs a tool, it replies with a tool_use block: the tool's name, the input it wants to give, and a unique id. The stop reason of this reply is "tool_use".
- Your code reads the request, runs the function and gets a result.
- You send the result back in a new user message, as a tool_result block that carries the same id.
- Claude reads the result and writes the final answer. Or it asks for another tool, and the loop continues.
Because this is a loop, there can be several rounds. A user might ask a question that needs two tools, and Claude may ask for them one after the other, or both at once in the same reply.
Describing a tool
Each tool has three parts. A name, a description and an input schema, which is a JSON Schema that says which inputs the tool takes. Here is one from our lab.
{
name: 'get_job_keywords',
description: 'Find the most frequent skills and keywords in a job description. Use this when the user pastes a job post and wants keywords for their resume.',
input_schema: {
type: 'object',
properties: {
job_text: { type: 'string', description: 'The full job description text' },
max_keywords: { type: 'integer', description: 'How many keywords to return (default 8)' },
},
required: ['job_text'],
},
}
The description is the most important part. Claude uses it to decide when to use the tool. A vague description like "does job things" leads to wrong choices. A good description says what the tool does and when to use it. Also describe each input in plain words, as we did for job_text and max_keywords. Think of it as writing a short manual for a new colleague who cannot ask you questions.
Good tool design is simple: one clear job per tool, a precise name like get_job_keywords instead of do_stuff, small clear inputs, and results that contain only what Claude needs.
The loop in code
Here is the heart of the lab's agent route. Read it slowly.
const messages = [{ role: 'user', content: message }];
for (let turn = 1; turn <= MAX_TURNS; turn++) {
const reply = await client.messages.create({ model: MODEL, max_tokens: 800, tools, messages });
messages.push({ role: 'assistant', content: reply.content });
if (reply.stop_reason !== 'tool_use') return finalAnswer(reply);
const results = [];
for (const block of reply.content) {
if (block.type !== 'tool_use') continue;
const output = runTool(block.name, block.input); // YOUR code runs here
results.push({ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(output) });
}
messages.push({ role: 'user', content: results });
}
Four things deserve attention.
- We add Claude's whole reply (not only its text) to the history, because the tool_use blocks must be present when we send the results.
- We send back one tool_result for every tool_use, and each carries the matching tool_use_id.
- A reply may contain several tool_use blocks at once. Run them all and send all the results together in one user message.
- We set MAX_TURNS. Without a limit, a confused model could loop for a long time and run up your bill. Five or six turns is plenty for simple tasks.
When a tool fails
Tools can fail: the input is wrong, a service is down, a record is missing. Do not crash. Catch the error and send it back as a tool result with is_error set to true and a short message. Claude can often recover: it may fix the input and try again, or explain the problem to the user in a friendly way. A crashed request helps nobody.
Showing the trace in Angular
Because you are the one running the tools, you can record each call. In our route we build a trace: a list with the tool name, the input, the output and whether it was an error. We return it with the final answer. The Angular page shows the answer and below it a numbered list of the tool calls. Seeing this trace is the best way to understand tool use, and it is also what you would show an administrator when debugging a real product. A user rarely needs to see it, but a developer always does.
Tool results are data
A small warning for later lessons: a tool result can contain text from the outside world, like a web page. Treat it as data, never as an instruction. We study this danger (prompt injection) in the next lesson.
Your task: build the Tools page
Give Claude two small tools and show every call it makes in an Angular page.
What you will build
- server/tools.js with two tool definitions (get_job_keywords and count_words) and a runTool() function.
- A POST /api/agent route with the loop, a MAX_TURNS limit, error handling and a trace.
- A Tools page with a text area prefilled with a job post, a Run button, the answer and the list of tool calls.
Steps
- Start from your lesson 8 project, or download the lesson 8 solution.
- Write the two tool definitions with clear descriptions. Write the handlers: for keywords, lower-case the text, split it into words, remove common small words, count and sort; for count_words, split on spaces.
- Create the route with the loop. Push the assistant reply into messages. Run every tool_use. Send one tool_result per tool_use.
- Wrap runTool in try/catch. On error, set is_error and send the message.
- Keep the trace and return it together with the answer text.
- In Angular, show the answer and the numbered trace with input and output for each call.
- Ask a question that needs both tools. Then ask a question that needs none. Then see what happens when you set MAX_TURNS to 1.
Hints
- Use JSON.stringify(output) for the content of the tool_result.
- The assistant message must contain the full reply.content, not just the text.
- To test the error path, make a tool throw an error on purpose and see how Claude explains it.
You know it works when Claude answers with real keywords from the text, the trace shows each tool call in order, and a question that needs no tool gets an answer with an empty trace.
Solution
The tools:
// Tools are plain functions that YOUR code runs. Claude only asks for them.
const STOP = new Set('the a an and or of to in for with on at by is are be as we you our your will can from this that it their they them have has not but if'.split(' '));
export const tools = [
{
name: 'get_job_keywords',
description: 'Find the most frequent skills and keywords in a job description. Use this when the user pastes a job post and wants keywords for their resume.',
input_schema: {
type: 'object',
properties: {
job_text: { type: 'string', description: 'The full job description text' },
max_keywords: { type: 'integer', description: 'How many keywords to return (default 8)' },
},
required: ['job_text'],
},
},
{
name: 'count_words',
description: 'Count the words in a piece of text. Use it when the user asks how long a text is.',
input_schema: {
type: 'object',
properties: { text: { type: 'string', description: 'The text to count' } },
required: ['text'],
},
},
];
const handlers = {
get_job_keywords({ job_text, max_keywords = 8 }) {
const counts = new Map();
for (const word of String(job_text).toLowerCase().match(/[a-z][a-z+#.]{2,}/g) || []) {
if (!STOP.has(word)) counts.set(word, (counts.get(word) || 0) + 1);
}
const top = [...counts].sort((a, b) => b[1] - a[1]).slice(0, Math.min(Number(max_keywords) || 8, 20));
return { keywords: top.map(([word, times]) => ({ word, times })) };
},
count_words({ text }) {
return { words: String(text).trim().split(/\s+/).filter(Boolean).length };
},
};
export function runTool(name, input) {
const fn = handlers[name];
if (!fn) throw new Error(`Unknown tool: ${name}`);
return fn(input || {});
}
The route with the loop:
import { Router } from 'express';
import { client, MODEL, textOf } from '../claude.js';
import { tools, runTool } from '../tools.js';
const router = Router();
const MAX_TURNS = 5; // never let the loop run forever
// POST /api/agent { message } -> { text, trace }
router.post('/agent', async (req, res) => {
const message = String(req.body?.message || '').trim().slice(0, 6000);
if (!message) return res.status(400).json({ error: { message: 'Please type a message.' } });
const messages = [{ role: 'user', content: message }];
const trace = []; // a record of every tool call, shown in the Angular page
for (let turn = 1; turn <= MAX_TURNS; turn++) {
const reply = await client.messages.create({ model: MODEL, max_tokens: 800, tools, messages });
messages.push({ role: 'assistant', content: reply.content });
// No tool requested: this is the final answer.
if (reply.stop_reason !== 'tool_use') {
return res.json({ text: textOf(reply), trace, turns: turn });
}
// Run every tool Claude asked for and send back one result for each.
const results = [];
for (const block of reply.content) {
if (block.type !== 'tool_use') continue;
let output;
let isError = false;
try {
output = runTool(block.name, block.input);
} catch (e) {
output = { error: e.message };
isError = true;
}
trace.push({ tool: block.name, input: block.input, output, isError });
results.push({ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(output), ...(isError ? { is_error: true } : {}) });
}
messages.push({ role: 'user', content: results });
}
res.json({ text: 'Stopped: too many steps. Please try a simpler request.', trace, turns: MAX_TURNS });
});
export default router;
The Angular page:
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 = { tool: string; input: unknown; output: unknown; isError: boolean };
@Component({
selector: 'app-agent',
imports: [FormsModule],
template: `
<h1>Claude with tools</h1>
<p class="hint">Claude can ask our server to run small functions. The trace shows every call it made.</p>
<div class="card">
<label for="m">Your request</label>
<textarea id="m" [(ngModel)]="message" style="min-height:150px"></textarea>
<button (click)="run()" [disabled]="busy() || !message.trim()">{{ busy() ? 'Working...' : 'Run' }}</button>
@if (error()) { <div class="err">{{ error() }}</div> }
</div>
@if (text()) {
<div class="card">
<h2>Answer</h2>
<div class="answer">{{ text() }}</div>
<h2>Tool calls ({{ trace().length }})</h2>
@for (s of trace(); track $index) {
<p><strong>{{ $index + 1 }}. {{ s.tool }}</strong> <span [class]="s.isError ? 'bad' : 'ok'">{{ s.isError ? 'error' : 'ok' }}</span></p>
<pre class="out">in: {{ pretty(s.input) }}
out: {{ pretty(s.output) }}</pre>
} @empty {
<p class="muted">Claude answered without using a tool.</p>
}
</div>
}
`,
})
export class AgentPage {
private http = inject(HttpClient);
message = `Here is a job post:\n"We need a customer support executive. You will answer customer calls, solve billing problems in Zendesk and write clear emails. Experience with Zendesk and strong English communication is required. Zendesk training provided."\n\nWhich keywords should my resume include? Also, how many words is this job post?`;
busy = signal(false);
error = signal('');
text = signal('');
trace = signal<Step[]>([]);
pretty(v: unknown) {
return JSON.stringify(v);
}
async run() {
this.busy.set(true);
this.error.set('');
this.text.set('');
this.trace.set([]);
try {
const r = await firstValueFrom(this.http.post<{ text: string; trace: Step[] }>('/api/agent', { message: this.message }));
this.text.set(r.text);
this.trace.set(r.trace);
} catch (e) {
this.error.set(errorText(e));
} finally {
this.busy.set(false);
}
}
}
Download the lesson 9 solution (zip)
What you learned
- Claude asks for tools; your code runs them. Claude never executes anything itself.
- The loop: send tools, receive tool_use, run, send tool_result, repeat until a final answer.
- Good descriptions and small clear inputs make tools work well.
- Limit the turns, handle errors with is_error, and keep a trace for debugging.
In lesson 10 we make tools safe: validation, ownership checks and human approval.