Tool calling from scratch with LangChain and Node.js

13 min read
On this page

This post is about tool calling.

A model on its own can only write text. It has no clock. It can’t check the weather. It can’t look anything up. Ask it for live data and it either says it can’t, or it makes something up.

Tools fix that. A tool is a function you hand to the model. Now the model can do real things: call an API, read a database, do math you trust.

Here is the whole idea in one picture:

   you ask a question
        │
        ▼
   ┌─► MODEL ───────────────┐
   │     │                  │
   │  need a tool?          │
   │   │        │           │
   │  yes       no          │
   │   │        │           │
   │   ▼        ▼           │
   │  TOOL    answer        │
   │   │                    │
   └───┘  feed the result back

I’ll go through ten small lessons. We start with one tool and the simplest agent. We end by building the loop by hand. Each lesson adds one idea.

These examples use LangChain 1.x, the version that is current in October 2026.

The pieces you’ll use

A few names show up again and again:

  • tool — wraps your function so the model can call it.
  • createAgent — makes an agent that runs the tool loop for you.
  • z (zod) — describes the tool’s inputs.
  • ToolMessage — a tool’s result, sent back to the model.

Setup

mkdir tools-demo && cd tools-demo
npm init -y
npm pkg set type=module
npm install langchain @langchain/langgraph @langchain/openrouter zod

You need Node.js 20.6 or newer and an OpenRouter API key from openrouter.ai.

These few lines are shared by the lessons below.

import { createAgent, tool } from "langchain";
import { ChatOpenRouter } from "@langchain/openrouter";
import * as z from "zod";

// Paste your key here. Fine for a script on your own machine.
process.env.OPENROUTER_API_KEY = "sk-or-v1-paste-your-key-here";

const MODEL = new ChatOpenRouter({
  model: "openai/gpt-4o-mini",
  temperature: 0,
});

const ask = (agent, text) =>
  agent.invoke({ messages: [{ role: "user", content: text }] });
const lastText = (result) => result.messages.at(-1).content;

Now the lessons.

Lesson 1: no tools (the problem)

Here’s an agent with no tools. Ask it for live data.

   you: "What's the weather in Paris right now?"
        │
        ▼
      MODEL  ── has no way to check
        │
        ▼
   "I can't access live weather."
async function lesson1() {
  const agent = createAgent({ model: MODEL, tools: [] });
  const result = await ask(agent, "What's the weather in Paris right now?");
  console.log(lastText(result)); // "I can't check live weather" or a guess
}

The model has no window to the outside world. A tool is that window.

Lesson 2: your first tool

A tool is four things:

   ┌───────────────────────────────────────────────────┐
   │ name         get_weather                           │
   │ description  "Get the current weather for a city"  │  ← the model reads
   │ schema       { city: string }                      │     these to decide
   │ function     async ({city}) => "22°C and sunny"    │  ← your code runs this
   └───────────────────────────────────────────────────┘
  • The name and description tell the model what the tool is for.
  • The schema tells it what arguments to pass. We write it with zod.
  • The function is your code. It runs when the tool is called.
const getWeather = tool(
  async ({ city }) => `It's 22°C and sunny in ${city}.`,
  {
    name: "get_weather",
    description: "Get the current weather for a city",
    schema: z.object({
      city: z.string().describe("The city to look up, e.g. 'Paris'"),
    }),
  },
);

async function lesson2() {
  // A tool is just a function. You can call it yourself:
  console.log(await getWeather.invoke({ city: "Paris" }));
  // "It's 22°C and sunny in Paris."
}

No model yet. We just built the tool and called it by hand, to show it’s nothing magic. It’s a function with a label.

Lesson 3: give the tool to an agent

Now hand the tool to the agent. Ask the same question as lesson 1.

async function lesson3() {
  const agent = createAgent({ model: MODEL, tools: [getWeather] });
  const result = await ask(agent, "What's the weather in Paris right now?");
  console.log(lastText(result)); // "It's 22°C and sunny in Paris."
}

Same question, real answer. The agent noticed the tool, called it, and used the result. You didn’t write any of that wiring. createAgent did.

Lesson 4: what actually happens

This is the most important lesson. Let’s slow it down.

The model does not run the tool. It can’t. It only asks for it. The agent runs the tool and feeds the result back. Then the model answers.

   you: "Weather in Tokyo?"
        │
        ▼
   ┌─► MODEL
   │     │
   │     ▼
   │  did it ask for a tool?
   │     │               │
   │    yes              no
   │     │               │
   │     ▼               ▼
   │  run the tool    final answer ─► done
   │     │
   │     ▼
   │  result becomes a ToolMessage
   │     │
   └─────┘  send it back to the model

You can see every step by printing all the messages:

async function lesson4() {
  const agent = createAgent({ model: MODEL, tools: [getWeather] });
  const result = await ask(agent, "What's the weather in Tokyo?");

  for (const message of result.messages) {
    if (message.tool_calls?.length) {
      console.log("model asked for:", message.tool_calls.map((c) => c.name));
    }
    console.log(`${message.getType()}: ${message.content}`);
  }
}

You’ll see four messages, in order:

   human:  What's the weather in Tokyo?
   ai:     (no text — it asked for get_weather instead)
   tool:   It's 22°C and sunny in Tokyo.
   ai:     The weather in Tokyo is 22°C and sunny.

That’s the loop. Ask → run → feed back → answer. The agent repeats it until the model stops asking for tools.

Lesson 5: many tools

Give the agent more than one tool. Now the model has to pick.

It picks by reading each tool’s name and description. So good descriptions matter. A vague description makes the model choose wrong.

   tools:  get_weather   get_time   multiply

   you: "What time is it in Tokyo?"
        │
        ▼
   MODEL reads each tool's name + description
        │
        ▼
   picks get_time, ignores the other two
const getTime = tool(
  async ({ city }) => `It's 3:00 PM in ${city}.`,
  {
    name: "get_time",
    description: "Get the current local time in a city",
    schema: z.object({ city: z.string() }),
  },
);

const multiply = tool(
  async ({ a, b }) => `${a * b}`,
  {
    name: "multiply",
    description: "Multiply two numbers together",
    schema: z.object({ a: z.number(), b: z.number() }),
  },
);

async function lesson5() {
  const agent = createAgent({
    model: MODEL,
    tools: [getWeather, getTime, multiply],
  });

  console.log(lastText(await ask(agent, "What time is it in Tokyo?"))); // get_time
  console.log(lastText(await ask(agent, "What is 23 times 19?"))); // multiply
}

The agent routes each question to the right tool. You didn’t write an if. The model did the choosing.

Lesson 6: parallel tool calls

Sometimes the model needs two things at once. It can ask for several tool calls in a single step. They run together, not one after another.

   you: "Compare the weather in Paris and Tokyo."
        │
        ▼
      MODEL asks for TWO calls at once
        │
     ┌──┴───┐
     ▼      ▼
  get_weather  get_weather     ← run together
   (Paris)      (Tokyo)
     │      │
     └──┬───┘
        ▼
   both results go back ─► one answer
async function lesson6() {
  const agent = createAgent({ model: MODEL, tools: [getWeather] });
  const result = await ask(agent, "Compare the weather in Paris and Tokyo.");

  for (const message of result.messages) {
    if (message.tool_calls?.length > 1) {
      console.log(`model asked for ${message.tool_calls.length} calls at once`);
    }
  }
  console.log(lastText(result));
}

Two calls, one round trip. If they were slow API calls, running them together is much faster than one at a time.

Lesson 7: when a tool fails

Real tools fail. An API times out. A database is down. If the tool just throws, the whole agent crashes.

The fix: catch the error and turn it into a normal ToolMessage. Then the model reads it and can apologise or try something else.

   run the tool
        │
   did it throw?
     │         │
    no        yes
     │         │
     ▼         ▼
   result   catch it → a friendly ToolMessage
     │         │
     └────┬────┘
          ▼
   back to the model (it never crashes)

We do this with middleware. wrapToolCall runs around every tool call.

import { createMiddleware, ToolMessage } from "langchain";

const flakyWeather = tool(
  async () => {
    throw new Error("weather service is down");
  },
  {
    name: "get_weather",
    description: "Get the current weather for a city",
    schema: z.object({ city: z.string() }),
  },
);

const handleToolErrors = createMiddleware({
  name: "HandleToolErrors",
  wrapToolCall: async (request, handler) => {
    try {
      return await handler(request); // the real tool call
    } catch (error) {
      return new ToolMessage({
        content: `Tool failed: ${error.message}. Tell the user to try later.`,
        tool_call_id: request.toolCall.id,
      });
    }
  },
});

async function lesson7() {
  const agent = createAgent({
    model: MODEL,
    tools: [flakyWeather],
    middleware: [handleToolErrors],
  });

  console.log(lastText(await ask(agent, "What's the weather in Paris?")));
  // The agent says it can't check right now, instead of crashing.
}

If you want more of what middleware can do, I wrote a whole post about it: LangChain and LangGraph middleware.

Lesson 8: let a tool’s output be the answer

Normally the loop is: model → tool → model → answer. The model gets the tool result and writes a reply.

But sometimes the tool’s output is the answer. There’s nothing for the model to add. Set returnDirect: true and the loop stops at the tool, saving one model call.

   normal tool:    model → tool → model → answer
   returnDirect:   model → tool → answer     (one less model call)
const fetchOrder = tool(
  async ({ orderId }) => `Order ${orderId} shipped, arriving in 2 days.`,
  {
    name: "fetch_order_status",
    description: "Look up the status of an order by its id",
    schema: z.object({ orderId: z.string() }),
    returnDirect: true, // stop here; the tool's text is the final answer
  },
);

Use it when the tool already returns something user-ready, like a status line or a receipt.

Lesson 9: build the loop yourself

createAgent builds the loop for you. But the loop is small enough to build by hand, and seeing it makes the whole thing click.

It’s a LangGraph graph with two nodes:

  • agent — calls the model.
  • tools — runs whatever tool the model asked for.

A conditional edge checks the model’s reply. Tool call? Go to tools, then loop back. No tool call? Go to END.

   START
     │
     ▼
   agent ◄───────────────┐
     │                   │
  asked for a tool?      │
   │        │            │
  yes       no           │
   │        │            │
   ▼        ▼            │
  tools    END           │
   │                     │
   └──── back to agent ──┘
import { StateGraph, START, END, MessagesAnnotation } from "@langchain/langgraph";
import { ToolNode, toolsCondition } from "@langchain/langgraph/prebuilt";

async function lesson9() {
  const tools = [getWeather];
  const modelWithTools = MODEL.bindTools(tools);

  const graph = new StateGraph(MessagesAnnotation)
    .addNode("agent", async (state) => {
      const reply = await modelWithTools.invoke(state.messages);
      return { messages: [reply] };
    })
    .addNode("tools", new ToolNode(tools))
    .addEdge(START, "agent")
    .addConditionalEdges("agent", toolsCondition) // "tools" or END
    .addEdge("tools", "agent") // loop back
    .compile();

  const result = await graph.invoke({
    messages: [{ role: "user", content: "Weather in Paris?" }],
  });
  console.log(result.messages.at(-1).content);
}

Three new pieces:

  • bindTools tells the model which tools exist, so it can ask for them.
  • ToolNode is a ready-made node that runs the asked-for tool.
  • toolsCondition is a ready-made router: “tool call → tools, else → END”.

This graph is basically what createAgent gives you. If the graph parts feel new, I explain them step by step in the LangGraph post.

Lesson 10: the whole thing, one file

Here’s a small agent with three tools and error handling, in one runnable file. Save it as tools-agent.mjs.

   ┌──────────── your agent ─────────────┐
   │   model: gpt-4o-mini                 │
   │   tools: get_weather, get_time,      │
   │          multiply                    │
   └──────────────────┬───────────────────┘
                      │
       the loop runs until the model has
       an answer, calling tools as needed
// tools-agent.mjs
import { createAgent, tool, createMiddleware, ToolMessage } from "langchain";
import { ChatOpenRouter } from "@langchain/openrouter";
import * as z from "zod";

// Paste your key here. Fine for a script on your own machine.
process.env.OPENROUTER_API_KEY = "sk-or-v1-paste-your-key-here";

const MODEL = new ChatOpenRouter({
  model: "openai/gpt-4o-mini",
  temperature: 0,
});

// ---- three tools ----
const getWeather = tool(
  async ({ city }) => `It's 22°C and sunny in ${city}.`,
  {
    name: "get_weather",
    description: "Get the current weather for a city",
    schema: z.object({ city: z.string().describe("City name, e.g. 'Paris'") }),
  },
);

const getTime = tool(
  async ({ city }) => `It's 3:00 PM in ${city}.`,
  {
    name: "get_time",
    description: "Get the current local time in a city",
    schema: z.object({ city: z.string() }),
  },
);

const multiply = tool(
  async ({ a, b }) => `${a * b}`,
  {
    name: "multiply",
    description: "Multiply two numbers together",
    schema: z.object({ a: z.number(), b: z.number() }),
  },
);

// ---- catch tool errors so the agent never crashes ----
const handleToolErrors = createMiddleware({
  name: "HandleToolErrors",
  wrapToolCall: async (request, handler) => {
    try {
      return await handler(request);
    } catch (error) {
      return new ToolMessage({
        content: `Tool failed: ${error.message}. Tell the user to try later.`,
        tool_call_id: request.toolCall.id,
      });
    }
  },
});

const agent = createAgent({
  model: MODEL,
  tools: [getWeather, getTime, multiply],
  middleware: [handleToolErrors],
});

const questions = [
  "What's the weather in Paris?",
  "What time is it in Tokyo?",
  "What is 23 times 19?",
];

for (const question of questions) {
  const result = await agent.invoke({
    messages: [{ role: "user", content: question }],
  });
  console.log(`\nQ: ${question}`);
  console.log(`A: ${result.messages.at(-1).content}`);
}

Running it

node tools-agent.mjs

You’ll see each question routed to the right tool:

   Q: What's the weather in Paris?
   A: It's 22°C and sunny in Paris.

   Q: What time is it in Tokyo?
   A: It's 3:00 PM in Tokyo.

   Q: What is 23 times 19?
   A: 23 times 19 is 437.

One agent, three tools, and it picks the right one every time.

About that hardcoded key

A key pasted into a script on your own machine is fine. A key in a script that ends up in a git repo is not. Bots scan public GitHub for keys.

Before you commit, drop the line that sets the key, and run like this:

node --env-file=.env tools-agent.mjs

Put OPENROUTER_API_KEY=sk-or-v1-... in a .env file, and add .env to .gitignore.

Where to go from here

  • Make the tools real. Replace the fake strings with a fetch to a weather API or a database query. The rest of the code doesn’t change.
  • Write careful descriptions. The model only knows a tool from its name and description, so that text is your real prompt.
  • Let a tool remember things. A remember tool can save facts, which is the start of agent memory.
  • Ask before risky tools run. Wrap a send_email tool so a human has to approve it first.
  • You can also pass the model as a string, like "openrouter:openai/gpt-4o-mini", instead of building a ChatOpenRouter. Both work.

That’s the whole idea. A tool is a function with a label. The model asks for it, your code runs it, and the answer goes back. Everything else is just more tools.


Share: