From Chat to Agent
Your agent is the world's most confident intern. Ask it to look at your tsconfig.json and it will happily describe what's probably in there. Ask it to find the bug on line 42 and it will offer a strikingly plausible fix.
It has not opened the file. It cannot. A chatbot has no tools, so it pattern-matches on what code usually looks like and serves that up as analysis.
One tool fixes that. We'll add read and turn the intern from a confident explainer into something that actually opens the file.
Outcome
You have a ToolLoopAgent that, given a prompt, calls a read tool to inspect a known file and reports what it found.
Fast Track
- Create
index.tswith aToolLoopAgent,instructions,model, andstopWhen: stepCountIs(10) - Run it with no tools and watch the chatbot explain what it would do
- Add a
readtool with a ZodinputSchemaand a 500-line cap, then run it again
Hands-on Exercise 1.1
Build the smallest possible agent in index.ts, then add one tool.
Requirements:
- Import
ToolLoopAgent,stepCountIs, andtoolfromai, pluszfromzod - Create an agent with
model: "anthropic/claude-haiku-4-5", brief instructions, andstopWhen: stepCountIs(10) - Add a
readtool that acceptspath, optionaloffset, and optionallimit - Cap output at 500 lines and prefix each line with its line number
Implementation hints:
ToolLoopAgenttakesinstructions,model,tools, andstopWhen. Useinstructions, notsystem- Call the agent with
agent.generate({ prompt }), notagent.generate(prompt) - The
descriptionfield ontool()is a prompt to the model, not a docstring. The model reads it to decide when to call the tool - Resolve paths against the working directory so the agent can't accidentally read files outside the project
The chatbot
Start with the smallest possible agent. No tools, just instructions:
import { ToolLoopAgent, stepCountIs } from "ai";
const cwd = process.argv[2] || process.cwd();
const agent = new ToolLoopAgent({
model: "anthropic/claude-haiku-4-5",
instructions: `You are a coding agent.\nWorking directory: ${cwd}`,
tools: {},
stopWhen: stepCountIs(10),
});
const prompt = process.argv.slice(3).join(" ") || "Hello!";
const { text, steps } = await agent.generate({ prompt });
console.log(text);
console.log(`\n(${steps.length} steps)`);Run it:
bun run index.ts . "What files are in this project?"You'll get a polite, helpful, and entirely fictional response. Something like "I'd be happy to help you explore the project files!" followed by suggestions about what it would look at, if only it could. That's the chatbot.
(1 steps)
One step. No tool calls. The model talked, and that's all it can do.
Use instructions (not system), stopWhen (not stopCondition), and agent.generate({ prompt }) (not agent.generate(prompt)). The names changed between SDK versions. Wrong names silently compile but the agent does not behave the way you'd expect.
One tool changes everything
Now add a read tool. This is what flips the chatbot into an agent:
import { ToolLoopAgent, stepCountIs, tool } from "ai";
import { z } from "zod";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const cwd = resolve(process.argv[2] || process.cwd());
const read = tool({
description: `Read a file from the project. Returns numbered lines.
WHEN TO USE: viewing file contents, checking configs, reading source code.
WHEN NOT TO USE: searching across files (use grep instead).`,
inputSchema: z.object({
path: z.string().describe("File path relative to working directory"),
offset: z.number().optional().describe("Start line (1-indexed)"),
limit: z.number().optional().describe("Max lines to return"),
}),
execute: async ({ path: filePath, offset, limit }) => {
const abs = resolve(cwd, filePath);
const content = readFileSync(abs, "utf-8");
let lines = content.split("\n");
if (offset) lines = lines.slice(offset - 1);
if (limit) lines = lines.slice(0, limit);
const MAX_LINES = 500;
const truncated = lines.length > MAX_LINES;
if (truncated) lines = lines.slice(0, MAX_LINES);
const numbered = lines.map((l, i) => `${(offset || 1) + i}: ${l}`);
return truncated
? numbered.join("\n") + `\n... (truncated at ${MAX_LINES} lines)`
: numbered.join("\n");
},
});
const agent = new ToolLoopAgent({
model: "anthropic/claude-haiku-4-5",
instructions: `You are a coding agent.\nWorking directory: ${cwd}`,
tools: { read },
stopWhen: stepCountIs(10),
});That description field is doing more work than it looks like. The model reads every tool's description before deciding what to do next. WHEN TO USE and WHEN NOT TO USE are not for you. They're the prompt the model uses to pick this tool over another one.
Why the 500-line cap
Notice MAX_LINES = 500. Without it, an unbounded read on a 10,000-line file dumps every line into the context window, and the agent keeps that result around for the rest of the session. One careless read can eat 10% of the available context.
You'll see this discipline applied to every tool in the course. Context management gets its own module later, but the habit starts here in the tool itself.
Try It
Run the agent with a prompt that lines up with what read can actually do:
bun run index.ts . "Read the tsconfig.json"You should see the model call read, then summarize the file. Two steps instead of one:
Here's the tsconfig.json:
- target: ESNext
- moduleResolution: bundler
- strict: true
(2 steps)
That's the whole transformation. One tool call, one response. The model chose read because the description told it when to.
read can inspect a known file. It cannot enumerate a directory. Until you add grep and bash in the next two lessons, stick to prompts like Read the tsconfig.json or Read package.json.
Sanity check the types:
npx tsc --noEmitCommit
git add index.ts
git commit -m "feat(agent): add ToolLoopAgent with read tool"Done-When
index.tsexports aToolLoopAgentwith areadtool- The chatbot version (no tools) returns after one step
- The agent version (with
read) calls the tool and reports the file contents readreturns numbered lines with optional offset and limit- Output truncates at 500 lines with a clear message
npx tsc --noEmitpasses
Create a 1000-line file with seq 1 1000 > /tmp/big.txt, then ask the agent to read it. Remove the MAX_LINES = 500 guard and run it again. Watch the response grow. Now think about what this looks like over a 30-step task. What breaks first, the model's reasoning or the token limit?
Solution
import { ToolLoopAgent, stepCountIs, tool } from "ai";
import { z } from "zod";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const cwd = resolve(process.argv[2] || process.cwd());
const read = tool({
description: `Read a file from the project. Returns numbered lines.
WHEN TO USE: viewing file contents, checking configs, reading source code.
WHEN NOT TO USE: searching across files (use grep instead).`,
inputSchema: z.object({
path: z.string().describe("File path relative to working directory"),
offset: z.number().optional().describe("Start line (1-indexed)"),
limit: z.number().optional().describe("Max lines to return"),
}),
execute: async ({ path: filePath, offset, limit }) => {
const abs = resolve(cwd, filePath);
const content = readFileSync(abs, "utf-8");
let lines = content.split("\n");
if (offset) lines = lines.slice(offset - 1);
if (limit) lines = lines.slice(0, limit);
const MAX_LINES = 500;
const truncated = lines.length > MAX_LINES;
if (truncated) lines = lines.slice(0, MAX_LINES);
const numbered = lines.map((l, i) => `${(offset || 1) + i}: ${l}`);
return truncated
? numbered.join("\n") + `\n... (truncated at ${MAX_LINES} lines)`
: numbered.join("\n");
},
});
const agent = new ToolLoopAgent({
model: "anthropic/claude-haiku-4-5",
instructions: `You are a coding agent.\nWorking directory: ${cwd}`,
tools: { read },
stopWhen: stepCountIs(10),
});
const prompt = process.argv.slice(3).join(" ") || "Hello!";
const { text, steps } = await agent.generate({ prompt });
console.log(text);
console.log(`\n(${steps.length} steps)`);Was this helpful?