This is part of the AI Agents series. All code is at github.com/achintmehta/langchain.

The problem: LLMs can only produce text

Everything the model has done so far in this series is generate text. It cannot check today's weather, query your database, search the web, or send an email, it has no hands. Retrieval (Parts 3–6) worked around this for knowledge by pushing relevant text into the prompt. But plenty of tasks need actions, and for that the model needs a way to ask your code to do something on its behalf.

That mechanism is tool calling (you will also hear function calling). It is worth understanding at the protocol level before any framework wraps it, because every agent in the rest of this series is built on this one idea.

How tool calling actually works

There is no magic: the model never executes anything itself. The dance has four steps:

  1. You advertise. Along with the conversation, you send the model a list of available tools, each with a name, a description, and a schema of its parameters.
  2. The model requests. Instead of (or as well as) replying with text, the model can respond with a structured tool call: "run get_weather with {"city": "Paris"}". This arrives as data on the response object, not as prose.
  3. Your code executes. Your program sees the request, runs the actual Python function, and gets the result. The model is not involved in this step at all.
  4. You report back. You append the result to the conversation as a special ToolMessage and call the model again. Now it can use the real result to write its answer, or request another tool call.

The model's only new capability is step 2: emitting a well-formed request. Deciding whether to honour that request, and actually doing the work, stays entirely in your code, which is also why guardrails and human approval (coming later in the series) have a natural place to hook in.

Defining a tool with @tool

In LangChain, any Python function becomes a tool with the @tool decorator:

from langchain_core.tools import tool

@tool
def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    return f"The weather in {city} is sunny and 22°C."

Three parts of this definition do real work, and none of them is decoration:

  • The function name (get_weather) is what the model will ask for by name.
  • The docstring is the tool's advertisement. It is the only thing the model reads to decide whether this tool fits the task, so write it the way you would explain the tool to a colleague. A vague docstring produces a model that never calls the tool, or calls it for the wrong things.
  • The type hints (city: str) become the parameter schema, which is how the model knows to supply a string called city.

Advertising tools with bind_tools

bind_tools attaches your tool list to the client, so every request advertises them to the model:

llm_with_tools = llm.bind_tools([get_weather])

Now watch what comes back when the question needs the tool:

from langchain_core.messages import HumanMessage

response = llm_with_tools.invoke([HumanMessage(content="What's the weather in Paris?")])

print(response.content)      # ""  — often empty when the model wants a tool
print(response.tool_calls)
# [{'name': 'get_weather', 'args': {'city': 'Paris'}, 'id': 'call_abc123', 'type': 'tool_call'}]

This is the crucial moment to stare at: the model did not answer the question, and it did not run anything. It returned a request, a dict with the tool's name, the arguments it chose, and an id. If you ask something that needs no tool ("What is 2 + 2?"), tool_calls comes back empty and content holds a normal text answer. The model chooses, per turn, between speaking and asking.

Closing the loop by hand

Frameworks will automate this shortly, but doing it manually once makes everything that follows legible. Execute the request, report the result, and let the model finish:

from langchain_core.messages import HumanMessage, ToolMessage

messages = [HumanMessage(content="What's the weather in Paris?")]

# 1. Model requests a tool call
response = llm_with_tools.invoke(messages)
messages.append(response)                      # keep the request in history

# 2. Execute each requested call ourselves
for tool_call in response.tool_calls:
    result = get_weather.invoke(tool_call["args"])   # actually runs the function
    messages.append(ToolMessage(
        content=result,
        tool_call_id=tool_call["id"]           # ties the result to the request
    ))

# 3. Call the model again with the result in the conversation
final = llm_with_tools.invoke(messages)
print(final.content)
# "The weather in Paris is currently sunny and 22°C."

Note the tool_call_id: when the model requests several tools at once, the id is how each result is matched back to the request that caused it. The conversation the model sees at the end reads like a transcript: human asked → I requested get_weather(Paris) → the tool said "sunny, 22°C" → now I can answer.

More than one tool

Give the model a toolbox and it picks per request:

@tool
def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    return f"The weather in {city} is sunny and 22°C."

@tool
def convert_currency(amount: float, from_currency: str, to_currency: str) -> str:
    """Convert an amount from one currency to another using current rates."""
    return f"{amount} {from_currency} is 92.50 {to_currency}."

llm_with_tools = llm.bind_tools([get_weather, convert_currency])

Ask about the weather and it requests get_weather; ask about euros and it requests convert_currency; ask "what is the capital of France?" and it just answers. The docstrings are doing the routing, which is why they deserve as much care as your prompts. (When the toolbox grows to dozens of tools, advertising all of them on every call gets expensive and confusing, the dynamic tool selection pattern in the next part addresses exactly that.)

The missing piece: the loop

Look back at the manual example and notice what it cannot do: if the model's second response requested another tool call, our straight-line code would just stop. A real assistant needs a loop, call the model, if it requested tools then execute them and go again, otherwise return the answer, plus state to carry the growing message history, and rules about when to stop.

Writing that loop by hand is possible. Writing it with branching, retries, human approval, and multiple cooperating agents is where hand-rolled code turns to spaghetti. That control-flow problem is exactly what LangGraph exists to solve, and it is where this series goes next.

What's next

The next part builds real agents with LangGraph: the ReAct loop (the tool-calling loop you just met, made robust), reflection, multi-agent supervisors, dynamic tool selection, and human-in-the-loop approval before a tool actually runs.