Ask a plain chatbot “What is the temperature in Tokyo right now?” and you get an honest, useless answer: it doesn’t have real-time data, try a weather website. The model can’t look anything up. Give it a way to ask your code to run a function, and a loop that feeds the result back, and it becomes an agent.
Under the hype, an agent is a small program: an LLM plus tools plus a loop plus memory. This tutorial builds one from scratch in plain JavaScript on Node.js 22, with no framework, using a local model (qwen2.5:3b via Ollama, on a CPU, no API key). It gets three real tools (live weather from Open-Meteo, a safe calculator, and a notes file on disk), conversation memory, error handling and a max-steps guard. Every run in the video is real, including a mistake by the small model and how the agent copes.
⚡ TL;DR – Tool calling: describe tools as JSON schemas, the model replies with
tool_calls, your code runs them, you send results back with roletool. – The model never runs code. It only asks. Your program decides what happens. – Two stop conditions: no tool calls (done) and a max-steps limit (safety). – Validate model-supplied input before running it, especially anything you evaluate. – Return tool errors to the model as results; it can often recover on the next step.
Before you start
- Node.js 22+ (built-in
fetch). - Ollama with a tool-capable model:
ollama pull qwen2.5:3b. Bigger models work with the same code and make fewer mistakes. - No API keys: Open-Meteo’s geocoding and forecast APIs are free for non-commercial use.
Step 1: Talk to the model
// llm.js
export async function chat(messages, tools = []) {
const r = await fetch('http://localhost:11434/api/chat', {
method: 'POST',
body: JSON.stringify({ model: 'qwen2.5:3b', messages, tools, stream: false, options: { temperature: 0 } }),
});
if (!r.ok) throw new Error(`Ollama HTTP ${r.status}`);
return (await r.json()).message; // { role, content, tool_calls? }
}
Call it with no tools and the Tokyo question, and you reproduce the “I don’t have real-time data” answer. That’s the baseline.
Step 2: Understand tool calling in four steps
- You describe each tool: a name, a description, and a JSON schema for its arguments.
- When the model needs one, instead of writing text it replies with
tool_calls, for exampleget_weatherwith{ "city": "Tokyo" }. - Your code runs that function.
- You send the result back as a message with role
tool, and the model uses it to answer.
Step 3: Build the tools
// tools.js
async function getWeather({ city }) {
const g = await (await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`)).json();
if (!g.results?.length) throw new Error(`City not found: ${city}`);
const { latitude, longitude, name, country } = g.results[0];
const f = await (await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}` +
`¤t=temperature_2m,wind_speed_10m`)).json();
return { place: `${name}, ${country}`, ...f.current };
}
function calculator({ expression }) {
if (!/^[\d\s.+\-*/()]+$/.test(expression)) throw new Error('Only numbers and + - * / ( ) allowed');
return { result: Function(`"use strict"; return (${expression})`)() };
}
import { readFile, writeFile } from 'node:fs/promises';
const load = async () => JSON.parse(await readFile('notes.json', 'utf8').catch(() => '[]'));
async function saveNote({ text }) {
const notes = await load();
notes.push({ text, at: new Date().toISOString() });
await writeFile('notes.json', JSON.stringify(notes, null, 2));
return { saved: true, count: notes.length };
}
const readNotes = async () => ({ notes: await load() });
export const impl = { get_weather: getWeather, calculator, save_note: saveNote, read_notes: readNotes };
Why each design choice:
- Geocode first. Open-Meteo’s forecast API wants latitude and longitude, not a city name.
temperature_2mandwind_speed_10mare its names for temperature 2 m above ground and wind 10 m up. - A calculator at all. LLMs aren’t reliable at arithmetic, so real code does the maths.
- The regex. You are about to evaluate a string produced by a model. Allow only digits, spaces, dots, brackets and the four operators; reject everything else before evaluating.
Step 4: Describe the tools to the model
const fn = (name, description, properties = {}, required = []) =>
({ type: 'function', function: { name, description, parameters: { type: 'object', properties, required } } });
const str = description => ({ type: 'string', description });
export const schemas = [
fn('get_weather', 'Current temperature and wind for a city', { city: str('City name, e.g. Tokyo') }, ['city']),
fn('calculator', 'Evaluate an arithmetic expression exactly', { expression: str('e.g. (21.4 - 3) * 2') }, ['expression']),
fn('save_note', 'Save a short note for later', { text: str('Note text') }, ['text']),
fn('read_notes', 'Read all saved notes'),
];
The descriptions are the only thing the model knows about your tools. Clear descriptions lead directly to better tool choices.
Step 5: Write the agent loop
// agent.js
const SYSTEM = 'Use tools for facts, maths and notes. Never guess a temperature or a calculation. Answer briefly.';
export function createAgent({ maxSteps = 6, log = console.log } = {}) {
const messages = [{ role: 'system', content: SYSTEM }]; // memory lives here
return async function ask(userText) {
messages.push({ role: 'user', content: userText });
for (let step = 1; step <= maxSteps; step++) {
const msg = await chat(messages, schemas);
messages.push(msg);
if (!msg.tool_calls?.length) return msg.content; // stop condition 1
for (const call of msg.tool_calls) {
const { name, arguments: args } = call.function;
log(`step ${step}: ${name}(${JSON.stringify(args)})`);
let result;
try {
if (!impl[name]) throw new Error(`Unknown tool ${name}`);
result = await impl[name](args);
} catch (e) {
result = { error: e.message }; // let the model see it
}
messages.push({ role: 'tool', content: JSON.stringify(result) });
}
}
return `Stopped after ${maxSteps} steps without a final answer.`; // stop condition 2
};
}
The model thinks, acts, observes the result, and thinks again. Without the step limit, a confused model could loop forever and burn time or money.
Step 6: Add memory
Memory is simply the messages array that persists between ask() calls. Ask “What’s the weather in Tokyo?”, then “And how much warmer is it than 15 degrees?”, and the second answer can use the first result because it’s still in the conversation. Memory grows with every turn, so for long sessions, trim or summarize old messages to fit the model’s context window.
Step 7: Run it and test the guards
node run.js "What is the temperature in Tokyo right now?"
node run.js "Save a note: buy coffee filters"
node run.js "What is 1234 * 5678?"
Then try to break it: ask about a city that doesn’t exist (the tool throws, the error goes back, and the model tells the user), or ask it to calculate something with letters in the expression (the regex rejects it). In the video, the small model makes a real mistake during these runs and the error-as-result design lets it recover instead of crashing.
Troubleshooting
- The model answers without calling a tool. Strengthen the system prompt (“Never guess a temperature”) and the tool description. Smaller models need explicit hints.
argumentsis a string, not an object. Normalize:typeof args === 'string' ? JSON.parse(args) : args.- Loops on the same tool. Usually the result is unclear. Return small, explicit JSON, and keep the step limit low (6 is plenty here).
- Slow responses on CPU. Expected with local models. Keep tool results short and the system prompt brief.
Best practices
- Least privilege: give the agent only the tools a task needs.
- Validate every argument in the tool, not in the prompt.
- Log each step so you can see why it did something.
- Never pass model output to
eval, a shell or SQL without strict validation.
When to use a framework
LangGraph.js, the Vercel AI SDK and the OpenAI Agents SDK add streaming, persistence, tracing, multi-agent graphs and provider switching. They’re worth it once you need those. For one agent with a few tools, the 40-line loop above is easier to read and debug, and knowing it makes framework behaviour much less mysterious.
Edge cases to handle before you trust the agent
- Malformed tool arguments. Models sometimes send a number as a string or leave a field out. Validate arguments before you run the tool, and if they’re invalid, return the error message to the model as the tool result, not as a crash. It usually corrects itself on the next turn.
- Unknown tool names. If the model invents a tool, reply with
{"error":"unknown tool, available: get_weather, calculate"}instead of throwing. - Runaway loops. Keep the max-iterations guard, and also stop when the same tool is called with identical arguments twice in a row.
- Huge tool outputs. Truncate or summarise results before adding them to the message history, or you’ll blow through the context window within a few turns.
function safeResult(value, max = 4000) {
const s = typeof value === 'string' ? value : JSON.stringify(value);
return s.length > max ? s.slice(0, max) + '…[truncated]' : s;
}
Log every tool call with its arguments and duration. When the agent gives a wrong answer, that log tells you whether the model chose badly or the tool returned bad data.
FAQ
Is a bigger model required?
No. The agent code doesn’t care which model you use. A bigger model just makes better decisions.
Can the model call several tools at once?
It can return multiple tool_calls. The loop runs them in order and returns every result before the next model call.
How is this related to MCP?
MCP standardizes how tools are discovered and called across apps. Your impl functions could be moved into an MCP server unchanged.
Official documentation
Key takeaways
- Agent = LLM + tools + loop + memory.
- The model only asks; your code decides and executes.
- Two stop conditions: a final answer, and a hard step limit.
- Feed errors back as results, and validate inputs before running anything.
Watch: 100 Days of AI on YouTube
Short videos from CheatCoders, one AI engineering topic per day.
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- Claude Code vs Cursor vs GitHub Copilot: Choose by Workflow, and Set Up One Repo That Works With All Three
- AI Agent Frameworks in 2025: LangGraph vs CrewAI vs AutoGen vs Raw API
- Python String Methods: Every str Method With Real Production Examples
- REST API Design Best Practices: The Patterns That Make APIs a Joy to Use
Deep-dive PDF
Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.
PDF for this post is being prepared. Subscribe now and we’ll unlock downloads sitewide as packs go live.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.