Module 2: Tool Use Fundamentals

5. Built-in Tools and External Tools

Capsule overview

So far you've built tools by hand: Python functions with @tool, Pydantic schemas, and an execution loop that orchestrates them. That's fine for local logic (a calculator, the date, static data). But an agent that can only do arithmetic and read dictionaries is useless for anything real. The moment your agent connects to an external API — searching the web, checking the weather, reading Wikipedia — is the moment it goes from being a demo to being useful.

LangChain ships built-in tools: ready-to-use tools that install as packages and connect to the agent exactly like your custom @tool functions. TavilySearchResults for high-quality web search, DuckDuckGoSearchRun for search without an API key, WikipediaQueryRun for encyclopedic lookups. They're @tools decorated by someone else — same interface, same mechanics. There's no magic.

The second half of this capsule matters just as much: creating wrappers for external APIs. Not every API you need has a LangChain built-in tool. But any REST API can be wrapped in a @tool with requests and error handling. The pattern is always the same: you take an API, wrap it in a function, decorate it with @tool, and your agent uses it like any other tool. This is the skill that lets you connect your agent to any service in the world.


LangChain's Built-in Tools

What built-in tools are

Built-in tools are pre-built functions that follow the same interface as your custom @tools. Internally they're classes inheriting from BaseTool that expose .invoke(), .name, .description, and .args_schema. When you do bind_tools([tavily_search, my_custom_tool]), the model can't tell one from the other — both are tools with JSON schemas.

Installation

pip install langchain-community langchain-openai

pip install -qU duckduckgo-search    # For DuckDuckGo
pip install -qU wikipedia            # For Wikipedia
pip install -qU langchain-tavily     # For Tavily

TavilySearch — High-Quality Web Search

Tavily is a search engine designed for AI agents. It returns structured results (title, URL, relevant content) optimized for LLMs — not raw HTML. It's the search LangChain recommends.

Setup and basic use:

import os
os.environ["TAVILY_API_KEY"] = "tvly-..."  # https://tavily.com — free tier: 1000 req/month

from langchain_tavily import TavilySearch

tavily_search = TavilySearch(max_results=3)

print(tavily_search.name)         # "tavily_search"
print(tavily_search.description)  # "A search engine optimized for comprehensive..."

results = tavily_search.invoke("latest AI agents news 2026")
# [{"title": "OpenAI Launches...", "url": "https://...", "content": "..."}]

Connecting it to an agent:

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage, ToolMessage

model = init_chat_model("openai:gpt-4.1-mini")
tools = [tavily_search]
model_with_tools = model.bind_tools(tools)
tools_by_name = {t.name: t for t in tools}

messages = [HumanMessage(content="What is the Model Context Protocol and who created it?")]
response = model_with_tools.invoke(messages)

# The model decides to search:
# [{"name": "tavily_search", "args": {"query": "Model Context Protocol creator"}, "id": "call_abc"}]

messages.append(response)
for tc in response.tool_calls:
    result = tools_by_name[tc["name"]].invoke(tc["args"])
    messages.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))

final = model_with_tools.invoke(messages)
print(final.content)
# "The Model Context Protocol (MCP) was created by Anthropic in November 2024..."

Advanced configuration:

tavily_search = TavilySearch(
    max_results=5,
    topic="news",                                    # "general" or "news"
    include_domains=["arxiv.org", "github.com"],
    exclude_domains=["pinterest.com"],
)

DuckDuckGoSearchRun — Search Without an API Key

DuckDuckGo is the ideal option for learning and prototyping. It requires no API key, has no signup process, and works immediately.

from langchain_community.tools import DuckDuckGoSearchRun

ddg_search = DuckDuckGoSearchRun()

print(ddg_search.name)  # "duckduckgo_search"

result = ddg_search.invoke("LangGraph state machine agents 2026")
# "LangGraph is a library for building stateful, multi-actor applications..."

Unlike Tavily, which returns a list of objects, DuckDuckGo returns a string with the results concatenated. Less structure, but it works with zero configuration.

A version with more control:

from langchain_community.tools import DuckDuckGoSearchResults

ddg_results = DuckDuckGoSearchResults(num_results=4)
result = ddg_results.invoke("FastAPI async Python")
# "[snippet: FastAPI is a modern..., title: FastAPI, link: https://fastapi.tiangolo.com], ..."

WikipediaQueryRun — Encyclopedic Knowledge

Wikipedia is ideal for established concepts, biographies, historical facts and technical definitions. Not useful for recent news, but for reference knowledge it's unbeatable.

from langchain_community.tools import WikipediaQueryRun
from langchain_community.utilities import WikipediaAPIWrapper

wiki = WikipediaQueryRun(api_wrapper=WikipediaAPIWrapper(
    top_k_results=2,
    doc_content_chars_max=2000,
    lang="en"
))

print(wiki.name)  # "wikipedia"

result = wiki.invoke("Transformer deep learning architecture")
# "Page: Transformer (deep learning architecture)\n Summary: A transformer is..."

doc_content_chars_max matters: Wikipedia returns long articles. Without a limit, you send too much text to the model and burn tokens for nothing. 2000-4000 characters is usually enough.

Why two classes? WikipediaAPIWrapper handles the connection to the API. WikipediaQueryRun wraps it as a tool with the standard interface. This wrapper + runner pattern is common across LangChain's community tools.


Inspecting Built-in Tools

Every built-in tool has the same interface as your custom @tools:

tavily = TavilySearch(max_results=3)
ddg = DuckDuckGoSearchRun()
wiki = WikipediaQueryRun(api_wrapper=WikipediaAPIWrapper())

for t in [tavily, ddg, wiki]:
    print(f"Name: {t.name}")
    print(f"Description: {t.description[:80]}...")
    print(f"Args: {t.args_schema.model_json_schema()}")
    print("---")

name, description, args_schema — exactly what you saw in capsule 02. When you do model.bind_tools([tavily, ddg, wiki, my_custom_tool]), the model receives 4 JSON schemas identical in structure. It doesn't know (or care) which is built-in and which is custom.


Creating Wrappers for External APIs

The universal pattern

Any REST API can be turned into a tool:

import requests
from langchain_core.tools import tool

@tool
def descriptive_name(parameter: str) -> str:
    """A clear description of what this tool does and when to use it.

    Args:
        parameter: What this parameter represents.
    """
    try:
        response = requests.get(
            "https://api.example.com/endpoint",
            params={"key": parameter},
            timeout=10
        )
        response.raise_for_status()
        data = response.json()
        return f"Result: {data['relevant_field']}"
    except requests.exceptions.Timeout:
        return "Error: the API did not respond in time (10s timeout)"
    except requests.exceptions.HTTPError as e:
        return f"HTTP error: {e.response.status_code}"
    except Exception as e:
        return f"Unexpected error: {e}"

Three rules: always set a timeout (without one, a hung API blocks your agent indefinitely), always handle errors (return descriptive strings, not exceptions), and return only what's relevant (don't pass the whole raw JSON — extract the fields that matter).

Example 1: Open-Meteo — Real Weather (No API Key)

Open-Meteo is a free weather API with no signup. Perfect for learning.

import requests
from langchain_core.tools import tool

@tool
def get_current_weather(latitude: float, longitude: float) -> str:
    """Get the current weather for a geographic location.

    Examples: Mexico City(19.43,-99.13), Madrid(40.42,-3.70), Buenos Aires(-34.60,-58.38).

    Args:
        latitude: Latitude (-90 to 90).
        longitude: Longitude (-180 to 180).
    """
    try:
        response = requests.get(
            "https://api.open-meteo.com/v1/forecast",
            params={
                "latitude": latitude, "longitude": longitude,
                "current": "temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code",
                "timezone": "auto"
            },
            timeout=10
        )
        response.raise_for_status()
        current = response.json()["current"]

        weather_descriptions = {
            0: "clear", 1: "mostly clear", 2: "partly cloudy",
            3: "cloudy", 45: "fog", 51: "light drizzle",
            61: "light rain", 63: "moderate rain", 65: "heavy rain",
            71: "light snow", 80: "showers", 95: "thunderstorm",
        }
        desc = weather_descriptions.get(current["weather_code"], "variable conditions")

        return (
            f"Current weather: {current['temperature_2m']}°C, {desc}. "
            f"Humidity: {current['relative_humidity_2m']}%. "
            f"Wind: {current['wind_speed_10m']} km/h."
        )
    except requests.exceptions.Timeout:
        return "Error: Open-Meteo did not respond in time (10s timeout)"
    except requests.exceptions.HTTPError as e:
        return f"Open-Meteo HTTP error: {e.response.status_code}"
    except (KeyError, TypeError) as e:
        return f"Error processing the Open-Meteo response: {e}"
    except Exception as e:
        return f"Unexpected error fetching the weather: {e}"


print(get_current_weather.invoke({"latitude": 19.43, "longitude": -99.13}))
# "Current weather: 24.3°C, partly cloudy. Humidity: 42%. Wind: 12.5 km/h."

Notice: the model knows the coordinates of common cities. LLMs have enough geographic knowledge to map cities to coordinates. Including examples in the docstring calibrates the expected format.

Example 2: JSONPlaceholder — A Practice REST API

JSONPlaceholder is a free fake REST API. It simulates a backend with users, posts, comments — ideal for practicing without API keys or rate limits.

import json
import requests
from langchain_core.tools import tool

@tool
def search_posts(query: str, max_results: int = 5) -> str:
    """Search for posts whose title or body contains the search term.

    Args:
        query: The search term.
        max_results: Maximum results (default: 5, max: 10).
    """
    try:
        response = requests.get("https://jsonplaceholder.typicode.com/posts", timeout=10)
        response.raise_for_status()
        posts = response.json()

        matches = [
            {"id": p["id"], "title": p["title"], "preview": p["body"][:100]}
            for p in posts
            if query.lower() in p["title"].lower() or query.lower() in p["body"].lower()
        ][:min(max_results, 10)]

        if not matches:
            return f"No posts found matching '{query}'"
        return json.dumps(matches, ensure_ascii=False, indent=2)
    except Exception as e:
        return f"Error searching posts: {e}"

Example 3: A Wrapper with an API Key

When the API requires authentication, read the key from environment variables. Never hardcode keys in your code:

import os
import requests
from langchain_core.tools import tool

@tool
def convert_currency(amount: float, from_currency: str, to_currency: str) -> str:
    """Convert currency using current rates. E.g.: USD to EUR.

    Args:
        amount: The amount to convert.
        from_currency: Source code (USD, EUR, MXN).
        to_currency: Target code (EUR, GBP, JPY).
    """
    try:
        resp = requests.get(
            f"https://open.er-api.com/v6/latest/{from_currency.upper()}", timeout=10
        )
        resp.raise_for_status()
        rate = resp.json()["rates"].get(to_currency.upper())
        if not rate:
            return f"Error: currency '{to_currency}' not found"
        converted = round(amount * rate, 2)
        return f"{amount} {from_currency.upper()} = {converted} {to_currency.upper()} (rate: {rate})"
    except Exception as e:
        return f"Conversion error: {e}"

Tool Adapters

StructuredTool.from_function

Sometimes you already have functions that do what you need, but they aren't LangChain tools. StructuredTool.from_function converts them without modifying the original:

from langchain_core.tools import StructuredTool


def calculate_bmi(weight_kg: float, height_m: float) -> str:
    """Compute the Body Mass Index."""
    bmi = weight_kg / (height_m ** 2)
    category = "underweight" if bmi < 18.5 else "normal" if bmi < 25 else "overweight" if bmi < 30 else "obese"
    return f"BMI: {bmi:.1f} ({category})"


bmi_tool = StructuredTool.from_function(
    func=calculate_bmi,
    name="calculate_bmi",
    description="Compute BMI given weight in kg and height in meters.",
)

print(bmi_tool.invoke({"weight_kg": 75, "height_m": 1.75}))
# "BMI: 24.5 (normal)"

When to use from_function vs @tool

@tool is cleaner for new functions. StructuredTool.from_function is useful when:

  • The function already exists and you don't want to modify it (it comes from another library, another team)
  • You need to override the name or description without touching the original function
  • You want to create tools dynamically at runtime

Creating tools dynamically from configuration

import requests
from langchain_core.tools import StructuredTool

api_configs = [
    {
        "name": "random_joke",
        "description": "Get a random joke. Takes no arguments.",
        "url": "https://official-joke-api.appspot.com/random_joke",
        "extract": lambda d: f"{d['setup']}{d['punchline']}"
    },
    {
        "name": "random_fact",
        "description": "Get a random fun fact. Takes no arguments.",
        "url": "https://uselessfacts.jsph.pl/api/v2/facts/random?language=en",
        "extract": lambda d: d["text"]
    },
]


def make_api_tool(config):
    def call_api() -> str:
        try:
            resp = requests.get(config["url"], timeout=10)
            resp.raise_for_status()
            return config["extract"](resp.json())
        except Exception as e:
            return f"Error: {e}"

    return StructuredTool.from_function(
        func=call_api, name=config["name"], description=config["description"],
    )


dynamic_tools = [make_api_tool(cfg) for cfg in api_configs]
for t in dynamic_tools:
    print(f"{t.name}: {t.invoke({})}")

This pattern scales: you can keep 50 APIs in a JSON file and generate 50 tools in a loop. In Module 7 (MCP), you'll see how this concept evolves: tools get loaded dynamically from MCP servers at runtime.


Comparison: Built-in vs Custom Tools

AspectBuilt-in ToolsCustom @tool Wrappers
Setuppip install + configYou write the whole function
MaintenanceLangChain updates the packageYou maintain the code
Schema qualityProfessional, well documentedUp to you
Error handlingBasic (varies by tool)Total control
CustomizationLimited to the exposed parametersUnlimited
Supported APIsOnly the ones LangChain integratesAny REST/GraphQL API
Ideal forPopular APIs (search, wiki)Your own or niche APIs

When to use each?

  • Built-in: If a community tool exists for your API, use it. It's tested, documented, and updated alongside the ecosystem.
  • Custom: If the API is yours, your company's, or has no built-in tool → a custom wrapper.
  • Mixed (the most common case): 2-3 built-in tools (search, wiki) + 2-3 custom wrappers (your domain's APIs). That's how this module's project works.

Connection to the Project

In this module's project (capsule 08), you'll build an agent with 5 real external tools. Now you have the pieces:

  • Capsule 02: You know how to create tools with @tool and schemas
  • Capsule 03: You know how to connect them with bind_tools()
  • Capsule 04: You know how to orchestrate them with the tool execution loop
  • Capsule 05 (this one): You know how to use built-in tools AND create wrappers for external APIs

The project combines built-in tools (DuckDuckGo or Tavily for search) with custom wrappers (weather, GitHub, or whatever you choose). The next capsule (06) will teach you robust error handling — the last ingredient before the project.

In the evolving project (Modules 4-10):

  • M4: These tools get integrated into a StateGraph — each tool call is a step of the graph
  • M7: The same tools get reimplemented as MCP servers — separating tool logic from agent logic
  • M8: Each specialized agent (researcher, analyst, writer) has its own subset of tools

Troubleshooting

Problem 1: "TAVILY_API_KEY not set" or "401 Unauthorized"

Cause: You didn't configure the API key, or the key is invalid.

Fix: Verify the environment variable exists and is correct:

import os
print(os.environ.get("TAVILY_API_KEY", "NOT CONFIGURED"))

from dotenv import load_dotenv
load_dotenv()  # Loads variables from .env into the environment

Register a free key at tavily.com. If you just want to experiment, use DuckDuckGo, which needs no key.

Problem 2: Rate limiting — "429 Too Many Requests"

Cause: Too many requests in a short window. Common with DuckDuckGo in fast loops.

Fix: Retry with exponential backoff — time.sleep(2 ** attempt) between attempts (1s, 2s, 4s). Detect status_code == 429 and retry before reporting an error.

Problem 3: "Connection timed out" or slow responses

Cause: The external API is slow or unreachable. Common with free APIs.

Fix: Always set timeout=10 in requests.get(). For extra resilience, implement a fallback to another API (e.g. if Tavily fails, try DuckDuckGo).

Problem 4: "ModuleNotFoundError: No module named 'duckduckgo_search'"

Cause: The built-in tool's dependency isn't installed.

Fix: pip install -qU duckduckgo-search (DuckDuckGo), pip install -qU wikipedia (Wikipedia), pip install -qU langchain-tavily (Tavily). Each built-in tool has its own dependencies.

Problem 5: The tool returns too much text and the model gets confused

Cause: APIs like Wikipedia return articles thousands of characters long.

Fix: Cap it with doc_content_chars_max=1500 in WikipediaAPIWrapper. For custom tools, truncate: result[:2000] + "\n... [truncated]" if it exceeds the limit. Rule of thumb: if the result goes past 2000-3000 characters, you're sending more context than you need.


Exercises

Exercise 1: Explore built-in tool schemas (Easy)

Instantiate DuckDuckGoSearchRun and WikipediaQueryRun. For each, print: name, description, and the JSON Schema of args_schema. Which parameters does each accept?

See solution
from langchain_community.tools import DuckDuckGoSearchRun, WikipediaQueryRun
from langchain_community.utilities import WikipediaAPIWrapper

ddg = DuckDuckGoSearchRun()
wiki = WikipediaQueryRun(api_wrapper=WikipediaAPIWrapper())

for t in [ddg, wiki]:
    print(f"=== {t.name} ===")
    print(f"Description: {t.description}")
    print(f"Schema: {t.args_schema.model_json_schema()}")
    print()

# Both accept a single `query` parameter of type `string`.
# The schema is what the model receives via bind_tools() — if it says
# query: string, that's the only thing the model can pass.

Exercise 2: An Open-Meteo wrapper with cities (Medium)

Create get_city_weather(city: str) that maps city names to coordinates and calls Open-Meteo. Support at least 5 Latin American cities. If the city isn't in the map, return a descriptive error.

See solution
import requests
from langchain_core.tools import tool

CITY_COORDS = {
    "mexico city": (19.43, -99.13),
    "buenos aires": (-34.60, -58.38),
    "bogotá": (4.71, -74.07),
    "lima": (-12.04, -77.03),
    "santiago": (-33.45, -70.67),
    "guadalajara": (20.67, -103.35),
    "medellín": (6.25, -75.56),
    "montevideo": (-34.90, -56.19),
}

@tool
def get_city_weather(city: str) -> str:
    """Get the current weather for a Latin American city.
    Available: Mexico City, Buenos Aires, Bogotá, Lima,
    Santiago, Guadalajara, Medellín, Montevideo.

    Args:
        city: The city name.
    """
    coords = CITY_COORDS.get(city.lower())
    if not coords:
        available = ", ".join(c.title() for c in CITY_COORDS)
        return f"City '{city}' not found. Available: {available}"

    try:
        resp = requests.get(
            "https://api.open-meteo.com/v1/forecast",
            params={
                "latitude": coords[0], "longitude": coords[1],
                "current": "temperature_2m,relative_humidity_2m,wind_speed_10m",
                "timezone": "auto"
            },
            timeout=10
        )
        resp.raise_for_status()
        c = resp.json()["current"]
        return (
            f"Weather in {city.title()}: {c['temperature_2m']}°C, "
            f"humidity {c['relative_humidity_2m']}%, wind {c['wind_speed_10m']} km/h"
        )
    except Exception as e:
        return f"Error fetching the weather for {city}: {e}"

The city→coordinates mapping is the "adapter" that converts a friendly input (a name) into what the API needs (lat/lon). The docstring lists the available cities so the model knows which ones it can use.

Exercise 3: An agent with DuckDuckGo + Wikipedia (Medium)

Build an agent with DuckDuckGoSearchRun and WikipediaQueryRun. Input: "What is CRISPR and what's the latest news about its use in 2026?" Watch how the model decides which tool to use for each part.

See solution
from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_community.tools import DuckDuckGoSearchRun, WikipediaQueryRun
from langchain_community.utilities import WikipediaAPIWrapper
from langchain_core.messages import HumanMessage, ToolMessage

ddg = DuckDuckGoSearchRun()
wiki = WikipediaQueryRun(api_wrapper=WikipediaAPIWrapper(
    top_k_results=1, doc_content_chars_max=2000, lang="en"
))

model = init_chat_model("openai:gpt-4.1-mini")
tools = [ddg, wiki]
model_with_tools = model.bind_tools(tools)
tools_by_name = {t.name: t for t in tools}

messages = [HumanMessage(
    content="What is CRISPR and what's the latest news about its use in 2026?"
)]

for i in range(5):
    response = model_with_tools.invoke(messages)
    messages.append(response)
    if not response.tool_calls:
        print(f"ANSWER:\n{response.content}")
        break
    for tc in response.tool_calls:
        print(f"  Tool: {tc['name']}({tc['args']})")
        result = tools_by_name[tc["name"]].invoke(tc["args"])
        messages.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))

# The model uses Wikipedia for the concept (stable knowledge)
# and DuckDuckGo for the news (recent information).
# Complementary tools — each shines in a different domain.

Exercise 4: Create tools dynamically from configuration (Hard)

Define 3 public APIs as configuration dictionaries. Use StructuredTool.from_function to generate the tools automatically. Connect them all to the model with bind_tools().

See solution
import requests
from langchain_core.tools import StructuredTool
from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage, ToolMessage

api_registry = [
    {
        "name": "random_joke",
        "description": "Get a random joke. Takes no arguments.",
        "url": "https://official-joke-api.appspot.com/random_joke",
        "extract": lambda d: f"{d['setup']}{d['punchline']}"
    },
    {
        "name": "random_fact",
        "description": "Get a random fun fact. Takes no arguments.",
        "url": "https://uselessfacts.jsph.pl/api/v2/facts/random?language=en",
        "extract": lambda d: d["text"]
    },
    {
        "name": "random_dog",
        "description": "Get the URL of a random dog image. Takes no arguments.",
        "url": "https://dog.ceo/api/breeds/image/random",
        "extract": lambda d: f"Image: {d['message']}"
    },
]

def create_api_tool(config):
    def call() -> str:
        try:
            resp = requests.get(config["url"], timeout=10)
            resp.raise_for_status()
            return config["extract"](resp.json())
        except Exception as e:
            return f"Error: {e}"
    return StructuredTool.from_function(
        func=call, name=config["name"], description=config["description"],
    )

dynamic_tools = [create_api_tool(cfg) for cfg in api_registry]

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools(dynamic_tools)
tools_by_name = {t.name: t for t in dynamic_tools}

messages = [HumanMessage(content="Tell me a joke and a fun fact")]
response = model_with_tools.invoke(messages)
messages.append(response)

for tc in response.tool_calls:
    result = tools_by_name[tc["name"]].invoke(tc["args"])
    messages.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))

final = model_with_tools.invoke(messages)
print(final.content)

# 3 dynamic tools without writing a single @tool. This scales: 50 APIs in JSON → 50 tools.

Exercise 5: A research agent with 4 tools (Hard)

Build an agent with: DuckDuckGoSearchRun, WikipediaQueryRun, get_city_weather (from Exercise 2), and a search_github_repos tool that queries https://api.github.com/search/repositories. Test it with: "Research what Rust is, find popular Rust repos on GitHub, and tell me the weather in Santiago."

See solution
from dotenv import load_dotenv
load_dotenv()

import requests
from langchain.chat_models import init_chat_model
from langchain_community.tools import DuckDuckGoSearchRun, WikipediaQueryRun
from langchain_community.utilities import WikipediaAPIWrapper
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, ToolMessage, SystemMessage

ddg = DuckDuckGoSearchRun()
wiki = WikipediaQueryRun(api_wrapper=WikipediaAPIWrapper(
    top_k_results=1, doc_content_chars_max=1500, lang="en"
))

# Reuse get_city_weather from Exercise 2

@tool
def search_github_repos(query: str, max_results: int = 3) -> str:
    """Search GitHub repos. Returns name, stars, description.
    Args:
        query: The search term.
        max_results: Maximum results (1-5).
    """
    try:
        resp = requests.get("https://api.github.com/search/repositories",
            params={"q": query, "sort": "stars", "per_page": min(max_results, 5)},
            headers={"Accept": "application/vnd.github.v3+json"}, timeout=10)
        resp.raise_for_status()
        return "\n".join(
            f"- {r['full_name']} ({r['stargazers_count']} stars): {r['description'] or 'N/A'}"
            for r in resp.json()["items"]
        ) or "No repos found"
    except Exception as e:
        return f"Error: {e}"

model = init_chat_model("openai:gpt-4.1-mini")
all_tools = [ddg, wiki, get_city_weather, search_github_repos]
model_with_tools = model.bind_tools(all_tools)
tools_by_name = {t.name: t for t in all_tools}

def research_agent(user_input: str) -> str:
    messages = [
        SystemMessage(content="You are a research assistant. Answer in English."),
        HumanMessage(content=user_input)
    ]
    for i in range(6):
        response = model_with_tools.invoke(messages)
        messages.append(response)
        if not response.tool_calls:
            return response.content or "No answer"
        for tc in response.tool_calls:
            print(f"  [{tc['name']}] {tc['args']}")
            try:
                result = tools_by_name[tc["name"]].invoke(tc["args"])
            except Exception as e:
                result = f"Error: {e}"
            messages.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))
    return "Limit reached"

print(research_agent(
    "Research what Rust is, find popular repos on GitHub, and the weather in Santiago"
))
# [wikipedia] {'query': 'Rust programming language'}
# [search_github_repos] {'query': 'rust'}
# [get_city_weather] {'city': 'Santiago'}
#
# 4 tools (2 built-in + 2 custom), 3 real APIs, parallel calling.

Summary

In this capsule you learned:

  • LangChain's built-in tools (TavilySearch, DuckDuckGoSearchRun, WikipediaQueryRun) are pre-built tools — same interface, same bind_tools()
  • Tavily offers AI-optimized search (requires an API key, free tier 1000 req/month)
  • DuckDuckGo requires no API key — ideal for learning and prototyping
  • Wikipedia is perfect for stable encyclopedic knowledge (cap doc_content_chars_max)
  • The universal pattern for wrappers: requests.get() + try/except + return a descriptive string + always a timeout
  • StructuredTool.from_function turns existing functions into tools without using @tool
  • Tools can be created dynamically from configuration — useful when the APIs vary
  • In production, you mix built-in + custom: LangChain's search and wiki + wrappers for your domain APIs

Next capsule: Tool Validation and Error Handling — validation with Pydantic, handling network errors, timeouts, retry logic, and graceful degradation. The last ingredient before the project.


Additional Resources

  1. LangChain Tool Integrations — The complete catalog of built-in tools (100+)
  2. Tavily API Documentation — Official docs, advanced configuration
  3. LangChain DuckDuckGo Tool — The official guide
  4. Open-Meteo API — The free weather API used in the examples
  5. GitHub REST API — Reference for building wrappers
  6. LangChain StructuredTool Reference — The StructuredTool API
  7. JSONPlaceholder — A fake REST API for prototyping wrappers
  8. LangChain Custom Tools Guide — The official guide to creating custom tools