Module 2: Tools and Tool Calling

bind_tools and the Tool Calling Flow

Capsule overview

You already know how to create tools with the @tool decorator — Python functions with a name, a description, and an argument schema a model can understand. But creating tools is useless if you don't connect them to a model. How do you tell the model "these are the tools you have available"?

The answer is bind_tools(). This method takes a list of tools and "attaches" them to the model, creating a new instance that knows which tools exist and when to use them. What the model returns when it decides to use a tool isn't the result of the execution — it's an instruction: "I want to call tool X with arguments Y". That instruction arrives inside the AIMessage as a list of tool_calls.

This is the point most people miss at first: the model does NOT execute tools. It only says what it wants to call. You're the one who runs the tool and hands back the result. In this capsule you'll get the full flow, how to inspect tool_calls, and how to steer the model's behavior with tool_choice.


bind_tools(): connecting tools to a model

bind_tools() takes a list of tools and returns a new model that knows about them. The original model isn't modified — you get a fresh version with the tools attached.

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
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, 22°C"

@tool
def calculator(expression: str) -> str:
    """Evaluate a math expression."""
    return str(eval(expression))

model = init_chat_model("openai:gpt-4.1-mini")

model_with_tools = model.bind_tools([get_weather, calculator])

response = model_with_tools.invoke("What's the weather in Madrid?")
print(response.tool_calls)
# [{'name': 'get_weather', 'args': {'city': 'Madrid'}, 'id': 'call_abc123'}]
print(response.content)
# '' (empty when there are tool_calls)

What just happened?

  1. You created two tools: get_weather and calculator
  2. You called model.bind_tools([get_weather, calculator]) to create a model that knows both tools
  3. You invoked the model with a question about the weather
  4. The model didn't answer with text — it answered with an instruction: "call get_weather with city='Madrid'"
  5. That instruction lives in response.tool_calls

The original model (model) is still intact. You can use model without tools and model_with_tools with tools in the same application.


The model does NOT execute tools

This is the most important concept in this capsule, and the most common beginner mistake.

When you call model_with_tools.invoke("What's the weather in Madrid?"), the model does not call the get_weather function. It doesn't reach any API. It doesn't run code. All it does is return a message that says:

"I think you should call the get_weather function with the argument city='Madrid'."

That's it. Execution is your responsibility. This design is on purpose:

  • Safety — You decide whether to run the tool. You can validate arguments, apply rate limiting, or ask the user to confirm
  • Control — You can intercept, modify, or reject tool calls before they run
  • Flexibility — The same tool call can run different ways depending on context
  • Debugging — You can inspect exactly what the model wants to do before anything happens
from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.tools import tool

@tool
def delete_user(user_id: str) -> str:
    """Delete a user from the system."""
    return f"User {user_id} deleted"

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([delete_user])

response = model_with_tools.invoke("Delete user 42")
print(response.tool_calls)
# [{'name': 'delete_user', 'args': {'user_id': '42'}, 'id': 'call_xyz789'}]

# The user was NOT deleted. All you have is the instruction.
# You decide whether to run it.

Inspecting AIMessage.tool_calls

When the model decides to call one or more tools, the response (AIMessage) carries a tool_calls list. Each element is a dictionary with three fields:

FieldTypeDescription
namestrName of the tool the model wants to call
argsdictArguments the model wants to pass to the tool
idstrUnique identifier for the tool call (needed for the ToolMessage you send back)
from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
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, 22°C"

@tool
def calculator(expression: str) -> str:
    """Evaluate a math expression."""
    return str(eval(expression))

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([get_weather, calculator])

response = model_with_tools.invoke("What's the weather in Tokyo?")

print(f"Number of tool calls: {len(response.tool_calls)}")
# 1

for tc in response.tool_calls:
    print(f"  Tool: {tc['name']}")
    print(f"  Args: {tc['args']}")
    print(f"  ID:   {tc['id']}")
# Output:
#   Tool: get_weather
#   Args: {'city': 'Tokyo'}
#   ID:   call_abc123

When there are NO tool calls

If the model decides it doesn't need any tool (because it can answer directly), tool_calls will be empty and content will hold the text answer:

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
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, 22°C"

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([get_weather])

response = model_with_tools.invoke("What's the capital of France?")
print(f"tool_calls: {response.tool_calls}")
# tool_calls: []
print(f"content: {response.content}")
# content: The capital of France is Paris.

The question "What's the capital of France?" doesn't need any tool — the model just knows it. So it answers with plain text.

The key rule

Situationcontenttool_calls
The model answers directlyThe answer text[] (empty list)
The model wants to call tools"" (empty or brief)A list with one or more tool calls

tool_choice: controlling when the model uses tools

By default, the model decides on its own whether to use a tool. But you can steer that behavior with the tool_choice parameter in bind_tools().

"auto" — the model decides (default)

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
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, 22°C"

@tool
def calculator(expression: str) -> str:
    """Evaluate a math expression."""
    return str(eval(expression))

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools(
    [get_weather, calculator],
    tool_choice="auto"
)

response = model_with_tools.invoke("Hi, how are you?")
print(f"tool_calls: {response.tool_calls}")
# tool_calls: [] (it doesn't need tools to say hello)
print(f"content: {response.content}")
# content: Hi! I'm doing well, thanks. How can I help you?

With "auto", the model looks at each question and decides whether any tool is relevant. If not, it answers with plain text.

"any" — force it to use some tool

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
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, 22°C"

@tool
def calculator(expression: str) -> str:
    """Evaluate a math expression."""
    return str(eval(expression))

model = init_chat_model("openai:gpt-4.1-mini")
model_forced = model.bind_tools(
    [get_weather, calculator],
    tool_choice="any"
)

response = model_forced.invoke("Hi, how are you?")
print(f"tool_calls: {response.tool_calls}")
# tool_calls: [{'name': 'get_weather', 'args': {'city': '...'}, 'id': '...'}]
# The model was FORCED to call some tool, even when it made no sense

With "any", the model always generates at least one tool call. Even if the question has nothing to do with the available tools. Use it carefully — it's useful when you know every interaction must result in a tool call.

Forcing a specific tool

You can pass a tool's name so the model always calls it:

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
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, 22°C"

@tool
def calculator(expression: str) -> str:
    """Evaluate a math expression."""
    return str(eval(expression))

model = init_chat_model("openai:gpt-4.1-mini")
model_weather_only = model.bind_tools(
    [get_weather, calculator],
    tool_choice="get_weather"
)

response = model_weather_only.invoke("What's 2 + 2?")
print(f"tool_calls: {response.tool_calls}")
# [{'name': 'get_weather', 'args': {'city': '...'}, 'id': '...'}]
# It forced get_weather even though the question was about math

Forcing a specific tool is useful for pipelines where you know exactly which tool must run — for example, an API endpoint that always has to call a data-extraction tool.


Comparison: tool_choice "auto" vs "any" vs specific

tool_choiceBehaviorWhen to use it
"auto" (default)The model decides whether to use tools or answer with textConversational chat, general assistants
"any"The model MUST call at least one toolPipelines where every interaction needs a tool
"tool_name"The model MUST call that specific toolDedicated endpoints, deterministic flows

A side-by-side example

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
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, 22°C"

@tool
def calculator(expression: str) -> str:
    """Evaluate a math expression."""
    return str(eval(expression))

model = init_chat_model("openai:gpt-4.1-mini")
tools = [get_weather, calculator]
question = "What's the capital of France?"

for choice in ["auto", "any"]:
    m = model.bind_tools(tools, tool_choice=choice)
    r = m.invoke(question)
    has_calls = len(r.tool_calls) > 0
    print(f"tool_choice='{choice}': tool_calls={has_calls}, content='{r.content[:50]}'")
# Output:
# tool_choice='auto': tool_calls=False, content='The capital of France is Paris.'
# tool_choice='any': tool_calls=True, content=''

With "auto", the model answers directly because it doesn't need tools. With "any", it's forced to generate an unnecessary tool call.


Multiple tools in bind_tools

You can pass any number of tools to bind_tools(). The model reads each one's description and decides which to use based on the question:

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
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, 22°C"

@tool
def calculator(expression: str) -> str:
    """Evaluate a math expression."""
    return str(eval(expression))

@tool
def search_web(query: str) -> str:
    """Search the web for information."""
    return f"Results for '{query}': [result 1, result 2]"

@tool
def translate(text: str, target_language: str) -> str:
    """Translate text into another language."""
    return f"Translation of '{text}' into {target_language}: [translation]"

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([get_weather, calculator, search_web, translate])

questions = [
    "What's the weather in Buenos Aires?",
    "What's 15 * 37?",
    "Find information about LangChain",
    "Translate 'hello world' into Spanish",
]

for q in questions:
    response = model_with_tools.invoke(q)
    if response.tool_calls:
        tc = response.tool_calls[0]
        print(f"Q: {q}")
        print(f"   → Tool: {tc['name']}, Args: {tc['args']}")
    else:
        print(f"Q: {q}")
        print(f"   → Text: {response.content[:60]}")
    print()
# Output:
# Q: What's the weather in Buenos Aires?
#    → Tool: get_weather, Args: {'city': 'Buenos Aires'}
#
# Q: What's 15 * 37?
#    → Tool: calculator, Args: {'expression': '15 * 37'}
#
# Q: Find information about LangChain
#    → Tool: search_web, Args: {'query': 'LangChain'}
#
# Q: Translate 'hello world' into Spanish
#    → Tool: translate, Args: {'text': 'hello world', 'target_language': 'Spanish'}

The model picks the right tool based on each one's description ("""docstring"""). That's why clear, specific descriptions matter so much — they're the instructions the model uses to decide.


Connection to the project

In the Assistant with External Tools (Capsule 08), you'll use bind_tools() to connect at least three tools to the model: a weather API, web search, and a calculator. The model will decide on every conversation turn which tool to use (or whether to answer directly).

The base pattern will be:

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
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, 22°C"

@tool
def calculator(expression: str) -> str:
    """Evaluate a math expression."""
    return str(eval(expression))

@tool
def search_web(query: str) -> str:
    """Search the web for information."""
    return f"Results for '{query}': ..."

model = init_chat_model("openai:gpt-4.1-mini")
assistant = model.bind_tools([get_weather, calculator, search_web])

In the next capsule, you'll learn to run those tool calls and hand the results back to the model — closing the loop that makes the assistant actually work.


Troubleshooting

Problem 1: the model doesn't call any tool

Symptom: response.tool_calls is always empty, even though the question should trigger a tool. Cause: The tool's description isn't clear enough for the model to connect it to the question. Fix: Improve the tool's docstring. The model uses the description to decide when to call it.

# Bad — vague description
@tool
def process(data: str) -> str:
    """Process data."""
    return data

# Good — clear, specific description
@tool
def get_weather(city: str) -> str:
    """Get the current weather for a city, including temperature and conditions."""
    return f"The weather in {city} is sunny, 22°C"

Problem 2: the model calls the wrong tool

Symptom: The model picks calculator when it should pick get_weather. Cause: The tool descriptions overlap or are ambiguous. Fix: Make each description unique and unambiguous. If you have similar tools, spell out the difference.

# Bad — similar descriptions
@tool
def tool_a(query: str) -> str:
    """Search for information."""
    ...

@tool
def tool_b(query: str) -> str:
    """Find data."""
    ...

# Good — differentiated descriptions
@tool
def search_web(query: str) -> str:
    """Search the internet for information using a search engine."""
    ...

@tool
def query_database(sql: str) -> str:
    """Run a SQL query against the internal database."""
    ...

Problem 3: bind_tools() throws an error

Symptom: TypeError or ValueError when calling bind_tools(). Cause: The tools aren't in the right shape. Every tool needs a name, a description, and a valid argument schema. Fix: Make sure you're using the @tool decorator and that the function has a docstring and type hints:

# Bad — no docstring, no type hints
@tool
def my_tool(x):
    return x

# Good — with docstring and type hints
@tool
def my_tool(x: str) -> str:
    """A clear description of what the tool does."""
    return x

Exercises

Exercise 1: basic bind_tools (Easy)

Create two tools: convert_currency(amount: float, from_currency: str, to_currency: str) and get_exchange_rate(from_currency: str, to_currency: str). Use bind_tools() to connect them to a model. Try 3 different questions and show which tool the model picked for each.

See solution
from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.tools import tool

@tool
def convert_currency(amount: float, from_currency: str, to_currency: str) -> str:
    """Convert an amount from one currency to another."""
    rates = {"USD_EUR": 0.92, "EUR_USD": 1.09, "USD_MXN": 17.15, "MXN_USD": 0.058}
    key = f"{from_currency}_{to_currency}"
    rate = rates.get(key, 1.0)
    result = amount * rate
    return f"{amount} {from_currency} = {result:.2f} {to_currency}"

@tool
def get_exchange_rate(from_currency: str, to_currency: str) -> str:
    """Get the current exchange rate between two currencies."""
    rates = {"USD_EUR": 0.92, "EUR_USD": 1.09, "USD_MXN": 17.15}
    key = f"{from_currency}_{to_currency}"
    rate = rates.get(key, "unknown")
    return f"1 {from_currency} = {rate} {to_currency}"

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([convert_currency, get_exchange_rate])

questions = [
    "Convert 100 dollars to euros",
    "What's the exchange rate from USD to MXN?",
    "What's the capital of Spain?",
]

for q in questions:
    response = model_with_tools.invoke(q)
    if response.tool_calls:
        tc = response.tool_calls[0]
        print(f"Q: {q}")
        print(f"   Tool: {tc['name']}, Args: {tc['args']}")
    else:
        print(f"Q: {q}")
        print(f"   Direct answer: {response.content[:60]}")
    print()
# Output:
# Q: Convert 100 dollars to euros
#    Tool: convert_currency, Args: {'amount': 100.0, 'from_currency': 'USD', 'to_currency': 'EUR'}
#
# Q: What's the exchange rate from USD to MXN?
#    Tool: get_exchange_rate, Args: {'from_currency': 'USD', 'to_currency': 'MXN'}
#
# Q: What's the capital of Spain?
#    Direct answer: The capital of Spain is Madrid.

Explanation: The model distinguishes between "convert" (which implies an amount) and "exchange rate" (only the currencies). The third question triggers no tool because it has nothing to do with currencies.

Exercise 2: inspecting tool_calls (Easy)

Write a function inspect_response(response) that takes an AIMessage and prints out everything available in detail: whether there are tool_calls (name, args, id of each), whether there's content, and the usage metadata. Try it with at least 2 different invocations (one that triggers tools and one that doesn't).

See solution
from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langchain_core.messages import AIMessage

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

def inspect_response(response: AIMessage):
    print(f"{'=' * 50}")
    print(f"Type: {type(response).__name__}")
    print(f"Content: '{response.content[:80]}'" if response.content else "Content: (empty)")

    if response.tool_calls:
        print(f"Tool calls: {len(response.tool_calls)}")
        for i, tc in enumerate(response.tool_calls):
            print(f"  [{i}] name: {tc['name']}")
            print(f"      args: {tc['args']}")
            print(f"      id:   {tc['id']}")
    else:
        print("Tool calls: none")

    if response.usage_metadata:
        usage = response.usage_metadata
        print(f"Tokens: {usage['input_tokens']} in / {usage['output_tokens']} out")
    print(f"{'=' * 50}\n")

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([get_weather])

response1 = model_with_tools.invoke("What's the weather in Lima?")
inspect_response(response1)
# Output:
# ==================================================
# Type: AIMessage
# Content: (empty)
# Tool calls: 1
#   [0] name: get_weather
#       args: {'city': 'Lima'}
#       id:   call_abc123
# Tokens: 45 in / 15 out
# ==================================================

response2 = model_with_tools.invoke("What is Python?")
inspect_response(response2)
# Output:
# ==================================================
# Type: AIMessage
# Content: 'Python is a high-level programming language...'
# Tool calls: none
# Tokens: 12 in / 35 out
# ==================================================

Explanation: inspect_response gives you a full view of the AIMessage. When there are tool calls, content is usually empty. When there aren't, content holds the answer. This inspection pattern is handy for debugging.

Exercise 3: comparing tool_choice (Medium)

Create three different tools. Run the same question with tool_choice="auto", tool_choice="any", and tool_choice="tool_name". Print each configuration's result to compare the behavior.

See solution
from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
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, 22°C"

@tool
def calculator(expression: str) -> str:
    """Evaluate a math expression."""
    return str(eval(expression))

@tool
def translate(text: str, target_language: str) -> str:
    """Translate text into another language."""
    return f"Translation: {text} → [{target_language}]"

model = init_chat_model("openai:gpt-4.1-mini")
tools = [get_weather, calculator, translate]

question = "What's the capital of Japan?"

configs = [
    ("auto", "auto"),
    ("any", "any"),
    ("get_weather", "get_weather"),
    ("calculator", "calculator"),
]

for label, choice in configs:
    m = model.bind_tools(tools, tool_choice=choice)
    r = m.invoke(question)

    if r.tool_calls:
        tc = r.tool_calls[0]
        print(f"tool_choice='{label}': → {tc['name']}({tc['args']})")
    else:
        print(f"tool_choice='{label}': → Text: '{r.content[:50]}'")
# Output:
# tool_choice='auto': → Text: 'The capital of Japan is Tokyo.'
# tool_choice='any': → get_weather({'city': 'Tokyo'})
# tool_choice='get_weather': → get_weather({'city': 'Tokyo'})
# tool_choice='calculator': → calculator({'expression': '...'})

Explanation: With "auto", the model answers directly because it doesn't need tools. With "any", it's forced to pick something — and it picks get_weather because the question mentions a place. With a specific name, it always calls that tool regardless of relevance.

Exercise 4: a tool router (Medium)

Write a function route_question(question) that uses bind_tools with tool_choice="auto" and returns a dictionary with type ("tool_call" or "direct_response"), tool_name (if it applies), tool_args (if it applies), and content (if it applies). Try it with 5 varied questions.

See solution
from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
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, 22°C"

@tool
def calculator(expression: str) -> str:
    """Evaluate a math expression."""
    return str(eval(expression))

@tool
def search_web(query: str) -> str:
    """Search the internet for up-to-date information."""
    return f"Results for '{query}': ..."

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([get_weather, calculator, search_web])

def route_question(question: str) -> dict:
    response = model_with_tools.invoke(question)

    if response.tool_calls:
        tc = response.tool_calls[0]
        return {
            "type": "tool_call",
            "tool_name": tc["name"],
            "tool_args": tc["args"],
            "content": None,
        }
    return {
        "type": "direct_response",
        "tool_name": None,
        "tool_args": None,
        "content": response.content,
    }

questions = [
    "What's the weather in Santiago?",
    "What's 1024 / 16?",
    "What is a microservice?",
    "Find the latest AI news",
    "Hi, how are you?",
]

for q in questions:
    result = route_question(q)
    if result["type"] == "tool_call":
        print(f"[TOOL] {q}")
        print(f"       → {result['tool_name']}({result['tool_args']})")
    else:
        print(f"[TEXT] {q}")
        print(f"       → {result['content'][:60]}")
    print()
# Output:
# [TOOL] What's the weather in Santiago?
#        → get_weather({'city': 'Santiago'})
#
# [TOOL] What's 1024 / 16?
#        → calculator({'expression': '1024 / 16'})
#
# [TEXT] What is a microservice?
#        → A microservice is an architectural pattern that splits...
#
# [TOOL] Find the latest AI news
#        → search_web({'query': 'latest AI news'})
#
# [TEXT] Hi, how are you?
#        → Hi! I'm doing well, thanks. How can I help you?

Explanation: route_question acts as a router — it works out whether the question needs a tool or a direct answer. This pattern is the foundation of how agents decide what to do on each turn.


Summary

In this capsule you learned:

  • bind_tools() connects a list of tools to a model, creating a new instance that knows about them
  • The model does NOT execute tools — it only returns instructions in tool_calls saying what it wants to call and with what arguments
  • AIMessage.tool_calls is a list of dicts with name, args, and id — each one represents an execution request
  • tool_choice controls when the model uses tools: "auto" (it decides), "any" (force one), or a specific name
  • When the model doesn't need tools, tool_calls is empty and content holds the text answer
  • The tool descriptions (docstrings) are critical — the model uses them to decide which one to call
  • You can pass Pydantic schemas as well as @tool functions to bind_tools()

Next capsule: Tool Execution Loop — you'll learn to run the tools the model asks for, build ToolMessage objects with the results, and close the loop so the model can produce the final answer.


Further reading

  1. Tool Calling — LangChain Docs — Full conceptual guide to tool calling
  2. How to use chat models to call tools — Step-by-step tutorial with examples
  3. bind_tools API Reference — Reference for the bind_tools method
  4. OpenAI Function Calling — OpenAI's implementation
  5. Anthropic Tool Use — Anthropic's implementation
  6. Google Gemini Function Calling — Google's implementation
  7. AIMessage API Reference — Reference for AIMessage and tool_calls
  8. How to force a specific tool call — Guide to tool_choice

Module 2 — LangChain & LangGraph: From Chains to Agents