TRENDING
A mountain gorilla looks at the camera from green forest undergrowth
October 28, 2026
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
A bronze-framed hourglass with white sand beside a small Ferris wheel ornament on a wooden table against a teal wall
October 28, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
Six weathered stone columns of the Temple of Apollo at Delphi, Greece, standing on a stone platform above a valley of olive groves
October 28, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
Vintage silver stopwatch with a chain on a black background, its dial showing seconds from 5 to 60
October 28, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
A wooden Tower of Hanoi puzzle on a market table, with three pegs and stacked discs of different sizes
October 28, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
28 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A flock of snow geese in flight, seen as dark silhouettes against an orange and red sunset sky with layered clouds
How to Run Zero-Downtime Schema Migrations on PostgreSQL 18 and Block Unsafe ALTER TABLE Statements With a Python Lint Gate
October 28, 2026
An old green steel truss bridge resting on temporary supports beside a new arch bridge under completion on a river in Portland, Oregon
The Angular 2 Rewrite Turns a Migration Lesson Into a Security Bill: AngularJS Still Draws 533,480 Downloads a Week, and 10 Advisories Have No Open-Source Fix
October 28, 2026
Hundreds of yellow rubber ducks floating in a river enclosure during a duck race
WordPress 7.1.4 Closes an Unauthenticated Race in Its Own Update Routine, but 19 Older Branches Had No Backport Hours After Release
October 28, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 270 Posts
News 273 Posts
Learning Hub 243 Posts
Home/Learning Hub/How to Build an AI Agent with Function Calling in Node.js and Ollama
Learning Hub

How to Build an AI Agent with Function Calling in Node.js and Ollama

Learn how to build an AI agent in Node.js that calls real tools such as a weather API and a calculator, using Ollama's local function-calling API with no external API key or framework required.

July 26, 2026 13 Min Read
73

Every “AI agent” is ultimately a language model wired up to a loop: ask it something, let it decide whether it needs outside information, run whatever it asks for, and hand the result back until it has enough to answer. The part that actually does something in the real world, calling a weather API, reading a file, hitting a database, is not the model. It is your code. The model’s only job is to say which function to call and with what arguments. That mechanism is called function calling (also called tool calling), and a recent freeCodeCamp tutorial by Zia Ullah walks through building one in Node.js against Google’s Gemini API.

Table Of Content

  • What you need before you start
  • Step 1: Install Ollama and pull a tool-calling model
  • Step 2: Watch a tool call happen before writing any code
  • Step 3: Set up your Node.js project
  • Step 4: Define your tool schemas
  • Step 5: Implement the tools for real
  • Step 6: Build the agentic loop
  • Step 7: Build a command-line interface
  • Step 8: Run it and watch the loop actually loop
  • Common mistakes and how to verify each step worked
  • Verifying everything works end to end

This tutorial teaches the same underlying mechanism, the request and response loop, the tool schema format, and the common failure modes, but builds it against Ollama’s tool-calling API instead, using a model that runs entirely on your own machine. That means no API key to request, no per-call bill, and no rate limit to hit while you experiment. Every command and code sample below was written by actually running it against a live local Ollama installation, including two gotchas this tutorial found the hard way: a silent data-loss bug in how JavaScript serializes certain numbers, and a crash when the command-line tool’s input stream runs out unexpectedly. Both are explained and fixed in the code below.

Three terms are worth defining before the first command, since the rest of the tutorial leans on all three:

Tool schema is a JSON description of a function you are willing to let the model call: its name, a plain-English description of what it does, and the shape of the arguments it expects. The model never sees your actual JavaScript code. It only ever sees this description.

Tool call is the model’s response when it decides a tool schema matches what the user asked for. Instead of returning a normal text answer, the model returns a structured object naming the tool and the arguments to call it with. The model does not run anything itself; it only asks.

Agentic loop is the code that sits between the model and the outside world: send a message, check whether the model asked for a tool, run that tool if so, send the result back, and repeat until the model responds with a plain answer instead of another tool call. This loop is the one piece of infrastructure every agent needs no matter which model or framework sits behind it, and it is the main thing this tutorial teaches you to build by hand.

What you need before you start

  • A Linux, macOS, or Windows (WSL2) machine. This tutorial was tested on Ubuntu 26.04 LTS.
  • Node.js 18 or newer, so the built-in fetch() function is available. Check yours with node --version. Node’s own documentation notes that fetch() shipped in Node 17.5/16.15 but was not marked stable until Node 21, so Node 18 and 20 both work but will print an experimental-feature warning on startup; Node 21 or newer (this tutorial used Node 24.18.0) will not.
  • About 3 GB of free disk space and 8 GB of RAM for the local model used in this tutorial.
  • Comfort with basic JavaScript, specifically async/await and array/object destructuring. No prior agent-building experience is assumed.

Step 1: Install Ollama and pull a tool-calling model

Ollama is a small program that downloads open-weight language models and serves them over a local HTTP API on your own machine. Not every model it can run supports tool calling, so the model choice in the next command matters.

# Context: any Linux machine with sudo available and no existing Ollama install.
# Purpose: install the Ollama runtime and register it as a background service.
curl -fsSL https://ollama.com/install.sh | sh

Expected output: a short download, then lines confirming the Ollama systemd service was created and started. On a machine with no NVIDIA or AMD GPU, you will also see a note that Ollama will run in CPU-only mode; that is fine for this tutorial and is exactly the setup it was tested on.

# Context: Ollama installed and running from the previous command.
# Purpose: download a small model tagged as supporting tools.
ollama pull qwen3.5:4b

Expected output: a “pulling manifest” line, progress bars for each model layer (the largest is about 3.4 GB), then verifying sha256 digest, writing manifest, and success. If your machine has less RAM, Ollama’s model library page for Qwen 3.5 lists smaller tags such as qwen3.5:0.8b, though a smaller model will be less reliable about choosing the right tool.

Do not assume tool support from the model name alone. Confirm it directly:

ollama show qwen3.5:4b

Expected output: a Capabilities section. This tutorial’s run printed completion, vision, tools, and thinking. The word tools in that list is the only thing that matters here; if it is missing, skip ahead to the “Common mistakes” section below before going further with that model.

Step 2: Watch a tool call happen before writing any code

Before wrapping this in JavaScript, it helps to see the raw shape of a tool call once, in isolation, with nothing else going on. Ollama exposes an HTTP API on port 11434 by default. Send it a question and one tool description directly:

# Context: Ollama running locally (verified in Step 1).
# Purpose: see the raw tool_calls response shape before writing any JavaScript.
curl -s http://localhost:11434/api/chat -d '{
  "model": "qwen3.5:4b",
  "messages": [{"role": "user", "content": "What is the weather in Berlin right now?"}],
  "stream": false,
  "tools": [{
    "type": "function",
    "function": {
      "name": "get_weather",
      "description": "Get current weather for a city",
      "parameters": {
        "type": "object",
        "properties": { "city": { "type": "string", "description": "City name" } },
        "required": ["city"]
      }
    }
  }]
}'

Expected output (trimmed to the relevant part; this is a real, unedited capture from writing this tutorial):

"message": {
  "role": "assistant",
  "content": "",
  "thinking": "The user wants to know the weather in Berlin. I have a tool called \"get_weather\"...",
  "tool_calls": [{
    "id": "call_f4f88oxm",
    "function": { "index": 0, "name": "get_weather", "arguments": { "city": "Berlin" } }
  }]
},
"done": true

Three things about this response matter for the code you are about to write. First, content is empty; the model did not answer in prose, it asked for a tool instead. Second, tool_calls is an array, because a model can ask for more than one tool in a single response. Third, arguments is already a parsed JSON object, not a string you need to JSON.parse() yourself. The thinking field is specific to reasoning-capable models like this one; Ollama’s official API documentation shows the same tools request format and tool_calls response shape without a model that thinks out loud, so do not assume every model will include that field.

Step 3: Set up your Node.js project

# Context: a fresh directory, Node.js 18+ already installed.
# Purpose: create the project. No npm packages are required at all: Ollama's
# API is plain HTTP, and Node's built-in fetch() is enough to call it.
mkdir nodejs-ollama-agent && cd nodejs-ollama-agent
mkdir src
npm init -y

Expected output: a generated package.json. Open it and add "type": "module" as a top-level key, which lets you use import/export syntax instead of older require() calls in the files below.

Step 4: Define your tool schemas

A tool schema is the JSON description from Step 2, written once and reused. Create src/tools.js:

export const tools = [
  {
    type: 'function',
    function: {
      name: 'get_weather',
      description: 'Get the current temperature and wind speed for a city',
      parameters: {
        type: 'object',
        properties: {
          city: { type: 'string', description: 'City name, e.g. Berlin' }
        },
        required: ['city']
      }
    }
  },
  {
    type: 'function',
    function: {
      name: 'calculate',
      description: 'Evaluate a basic arithmetic expression, e.g. (15 / 100) * 200',
      parameters: {
        type: 'object',
        properties: {
          expression: { type: 'string', description: 'Arithmetic expression using +, -, *, /, parentheses' }
        },
        required: ['expression']
      }
    }
  },
  {
    type: 'function',
    function: {
      name: 'get_current_time',
      description: 'Get the current date and time in UTC',
      parameters: { type: 'object', properties: {}, required: [] }
    }
  }
];

The description fields are not documentation for you, they are the only information the model has to decide which tool fits a question. A vague description like “does math” gives the model less to work with than “Evaluate a basic arithmetic expression”; if your agent keeps picking the wrong tool later, rewriting these descriptions is usually a faster fix than changing your code.

Step 5: Implement the tools for real

Schemas describe tools, they do not run anything. Create src/functions.js with the actual implementations, one per tool, using a free API for weather so nobody needs to sign up for anything:

async function getWeather({ city }) {
  const geoRes = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`);
  const geoData = await geoRes.json();
  if (!geoData.results || geoData.results.length === 0) {
    return { error: `No location found for "${city}"` };
  }
  const { latitude, longitude, name, country } = geoData.results[0];

  const weatherRes = await fetch(
    `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=temperature_2m,wind_speed_10m`
  );
  const weatherData = await weatherRes.json();

  return {
    location: `${name}, ${country}`,
    temperature_celsius: weatherData.current.temperature_2m,
    wind_speed_kmh: weatherData.current.wind_speed_10m,
    observed_at: weatherData.current.time
  };
}

function calculate({ expression }) {
  if (!/^[0-9+\-*/().\s]+$/.test(expression)) {
    return { error: 'Expression contains characters that are not numbers or + - * / ( )' };
  }
  try {
    const result = Function(`"use strict"; return (${expression})`)();
    if (!Number.isFinite(result)) {
      return { error: `Expression did not produce a finite number (got ${result})` };
    }
    return { expression, result };
  } catch (err) {
    return { error: `Could not evaluate expression: ${err.message}` };
  }
}

function getCurrentTime() {
  return { utc_time: new Date().toISOString() };
}

export const functionHandlers = {
  get_weather: getWeather,
  calculate,
  get_current_time: getCurrentTime
};

Two design choices here are deliberate, not incidental. getWeather calls Open-Meteo’s geocoding endpoint first to turn a city name into coordinates, then a second endpoint for the actual forecast; this is Open-Meteo’s documented two-step pattern and neither endpoint requires an API key. calculate uses the Function constructor rather than the older eval(), guarded by a regular expression that only allows digits, the four basic operators, parentheses, and whitespace before the expression ever reaches it; without that guard, a model or a malicious prompt could ask the agent to run arbitrary JavaScript, since Function and eval both execute whatever string you hand them.

The Number.isFinite check exists because of a real bug this tutorial hit while testing: dividing by zero produces JavaScript’s Infinity, and JSON.stringify({ result: Infinity }) silently turns that into {"result":null}, since JSON has no representation for infinite values. Without the check, the model would see a bare null for a division-by-zero question and have no way to tell that from a real error. Catching this meant a test that deliberately asked the agent “what is 10 divided by 0”, not just reading the code and assuming it was fine.

Step 6: Build the agentic loop

This is the part that turns “a model that can request tools” into an actual agent. Create src/agent.js:

import { tools } from './tools.js';
import { functionHandlers } from './functions.js';

const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434/api/chat';
const MODEL = process.env.OLLAMA_MODEL || 'qwen3.5:4b';
const MAX_TOOL_ROUNDS = 5;

export async function runAgent(userMessage, { verbose = false } = {}) {
  const messages = [{ role: 'user', content: userMessage }];

  for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
    const res = await fetch(OLLAMA_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ model: MODEL, messages, stream: false, tools })
    });

    if (!res.ok) {
      throw new Error(`Ollama request failed: ${res.status} ${await res.text()}`);
    }

    const { message } = await res.json();
    const toolCalls = message.tool_calls;

    if (!toolCalls || toolCalls.length === 0) {
      return message.content;
    }

    if (verbose) {
      console.log(`\n[round ${round + 1}] model requested ${toolCalls.length} tool call(s):`);
    }

    messages.push({ role: 'assistant', content: message.content || '', tool_calls: toolCalls });

    for (const call of toolCalls) {
      const { name, arguments: args } = call.function;
      const handler = functionHandlers[name];
      let result;
      try {
        result = handler ? await handler(args) : { error: `No handler registered for tool "${name}"` };
      } catch (err) {
        result = { error: err.message };
      }

      if (verbose) {
        console.log(`  -> ${name}(${JSON.stringify(args)}) = ${JSON.stringify(result)}`);
      }

      messages.push({ role: 'tool', tool_name: name, content: JSON.stringify(result) });
    }
  }

  return `Stopped after ${MAX_TOOL_ROUNDS} tool-call rounds without a final answer. This usually means the model is stuck retrying a tool; check the tool arguments it is sending.`;
}

Read this as a conversation that grows over time. messages starts with just the user’s question. Each time the model asks for a tool, two things get appended: an assistant message recording that the model made that request, and one tool message per call carrying the result back, using the tool_name field documented in Ollama’s API reference. The whole growing array gets sent back on the next iteration, so the model can see its own prior tool calls and their results, not just the original question.

The for loop, not a while (true), is a deliberate safety choice. If a model gets confused and keeps calling a tool with slightly different bad arguments forever, MAX_TOOL_ROUNDS guarantees the function returns something instead of hanging indefinitely; five rounds is generous for the three simple tools here, but a more complex agent with many tools might need a higher ceiling.

Step 7: Build a command-line interface

Create src/index.js so you can actually talk to the agent:

import readline from 'node:readline/promises';
import { stdin, stdout } from 'node:process';
import { runAgent } from './agent.js';

const rl = readline.createInterface({ input: stdin, output: stdout });

console.log('Node.js + Ollama function-calling agent. Type a question, or "exit" to quit.');

while (true) {
  let question;
  try {
    question = await rl.question('\n> ');
  } catch (err) {
    if (err.code === 'ERR_USE_AFTER_CLOSE') break; // stdin hit EOF (e.g. piped input ran out)
    throw err;
  }

  if (question.trim().toLowerCase() === 'exit') break;

  try {
    const answer = await runAgent(question, { verbose: true });
    console.log(`\n${answer}`);
  } catch (err) {
    console.error(`Error: ${err.message}`);
  }
}

rl.close();

The try/catch around rl.question() is not defensive padding, it fixes a real crash this tutorial hit while testing: piping input into the script (for example with printf 'question\nexit\n' | node src/index.js, which is exactly how every example below was captured) can hit end-of-stream at a point where Node’s promise-based readline throws ERR_USE_AFTER_CLOSE instead of resolving normally. In an interactive terminal session where you type answers by hand, this branch never triggers; it only matters for piped or scripted input, which is common enough in testing and automation that it is worth handling explicitly rather than leaving an unhandled promise rejection.

Step 8: Run it and watch the loop actually loop

node src/index.js

Ask a question that needs two different tools:

> What is the weather in Paris right now, and what is 200 times 3?

Expected output (a real, unedited capture from this tutorial):

[round 1] model requested 1 tool call(s):
  -> get_weather({"city":"Paris"}) = {"location":"Paris, France","temperature_celsius":18.4,"wind_speed_kmh":13.2,"observed_at":"2026-07-26T07:00"}

[round 2] model requested 1 tool call(s):
  -> calculate({"expression":"200 * 3"}) = {"expression":"200 * 3","result":600}

The weather in Paris right now is 18.4C with wind speeds of approximately 13.2 km/h. The data was last recorded at July 26, 2026 (7:00 UTC).

And the calculation you asked for is straightforward:
200 x 3 = 600

This is the loop from Step 6 running for real across two full round trips: the model asked for the weather first, got an answer, and only then asked for the calculation, rather than requesting both tools at once. Whether a model batches multiple tool calls into a single round or spreads them across several is a model decision, not something your code controls; a separate run of this same agent asking about Tokyo’s weather and 15% of 200 returned both tool calls together in a single round instead. Your loop has to handle both patterns, and the code above already does, because it does not assume anything about how many tool calls arrive per round.

Now ask something that needs no tool at all:

> What is the capital of France?
The capital of France is Paris. It has been the country's capital since 1360 and remains so today as a major global city known for its rich history, art museums like the Louvre, landmarks such as the Eiffel Tower, and cultural significance worldwide.

No [round 1] model requested... line appears here, because message.tool_calls was empty on the very first response. The model answered directly from what it already knows, and the if (!toolCalls || toolCalls.length === 0) check in agent.js returned immediately. Giving an agent tools does not force it to use them for everything; a well-trained tool-calling model still answers plainly when a tool would not add anything.

Common mistakes and how to verify each step worked

The model never calls any tool, even when one obviously fits. Run ollama show <model> and check for tools in the Capabilities section first; not every model on Ollama’s library supports tool calling, and passing a tools array to one that does not will typically just be ignored. If tools is listed and it is still not calling anything, make the tool’s description more specific, as shown in Step 4.

The model answers from training data instead of calling an available tool. This is not a bug to fix so much as a behavior to expect. While testing this tutorial, asking the agent “what is 10 divided by 0” sometimes produced a reasoned text answer about why division by zero is undefined, with no call to calculate at all, because the model judged it already knew the answer. For anything where you need the tool’s result specifically, rather than the model’s opinion, say so directly in your prompt or system instructions; do not assume a tool being available means it will always be used.

You reference a model name that does not exist locally. Ollama returns a plain JSON error rather than an HTTP-level failure in this case: {"error":"model 'nonexistent-model' not found"}. That error text comes back inside a normal 404 response body, which is why agent.js checks response.ok and throws with the response body included, instead of trying to read data.message on a response that was never a successful chat completion.

A tool call fails and takes the whole agent down with it. Notice every tool implementation in Step 5 returns an { error: "..." } object instead of throwing, and the loop in Step 6 wraps each handler call in its own try/catch as a second layer of defense. Asking for the weather in a nonsense location returns {"error":"No location found for \"...\""}, which gets sent back to the model like any other tool result; the model then explains to the user that the location could not be found, instead of the whole process crashing.

Silent data loss through JSON. As covered in Step 5, JSON.stringify turns Infinity and NaN into null with no warning. Any tool that does math or division should check Number.isFinite() on its result before returning it, not just wrap the calculation in a try/catch, since this failure mode does not throw an exception at all.

Verifying everything works end to end

Before trusting this agent with anything beyond experimentation, confirm all of the following, all of which were checked while writing this tutorial: ollama show qwen3.5:4b lists tools in its capabilities; a question needing one tool (like asking the time) produces exactly one [round 1] line and a correct answer; a question needing two tools produces either one round with two calls or two sequential rounds, and the final answer uses both results correctly; a question needing no tool at all produces a plain answer with no tool-call line; and a deliberately bad input, such as asking for the weather in a place that does not exist, produces a graceful explanation rather than a crash.

From here, the natural next step is wiring this same runAgent() function behind a small HTTP server so other programs can call it, which only takes a few lines since the agent logic itself does not know or care whether its caller is a CLI loop or an HTTP handler. If you want a standardized way to package tools so they can be reused across different agents and frameworks instead of hand-writing a tools.js file for each project, look at the Model Context Protocol, which this site’s FastMCP and LangChain tutorial covers in more depth.

Tags:

AI AgentsFunction CallingJavaScriptNode.jsOllama

Share

U.S. Air Force Staff Sgt. Tullis Moore and cyber operators from the 175th Cyber Operations, Maryland Air National Guard, monitor cyber activity during Exercise Southern Strike
Previous Post

SentinelOne’s Fast16 Benchmark Turns AI Malware Analysis Into a Recovery Test

U.S. Space Force Guardians monitor workstations in an operations center at Vandenberg Space Force Base, illustrating continuous system observability.
Next Post

OpenTelemetry’s CNCF Graduation Turns AI Agent Observability Into a Standards Problem

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
28 Oct
How to Run Zero-Downtime Schema Migrations on PostgreSQL 18 and Block Unsafe ALTER TABLE Statements With a Python Lint Gate
28 Oct
The Angular 2 Rewrite Turns a Migration Lesson Into a Security Bill: AngularJS Still Draws 533,480 Downloads a Week, and 10 Advisories Have No Open-Source Fix
Trending
October 28, 2026
How to Run Zero-Downtime Schema Migrations on PostgreSQL 18 and Block Unsafe ALTER TABLE Statements With a Python Lint Gate
October 28, 2026
The Angular 2 Rewrite Turns a Migration Lesson Into a Security Bill: AngularJS Still Draws 533,480 Downloads a Week, and 10 Advisories Have No Open-Source Fix
October 28, 2026
WordPress 7.1.4 Closes an Unauthenticated Race in Its Own Update Routine, but 19 Older Branches Had No Backport Hours After Release
October 28, 2026
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
October 28, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026