Module 2: Tool Use Fundamentals

2. The @tool decorator and tool schemas

Capsule overview

LangChain's @tool decorator turns a Python function into a tool an LLM can invoke. But "decorating a function" is only the first step. What really determines whether an agent uses your tools well is the schema: the combination of name, description, types and constraints the model reads to decide when and how to call each tool.

A poor schema produces wrong tool calls — badly formatted arguments, tools invoked out of context, or worse: the model ignores the tool when it should be using it. A well-designed schema is literally an instruction for the LLM. The difference between an agent that works 60% of the time and one that works 95% of the time is frequently in the quality of the schemas.

In this capsule you'll master the full progression: from a basic @tool with type hints, through Pydantic schemas with validation and constraints, all the way to async tools and advanced metadata. By the end, every tool you build will have the solidity production requires.


The @tool decorator

The basics: function → tool

@tool takes three things from your function and turns them into the schema the model receives:

  1. Function name → the tool's name
  2. Docstring → the description the model reads
  3. Type hints → the parameters schema (JSON Schema)
from langchain_core.tools import tool

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

print(get_weather.name)
# "get_weather"

print(get_weather.description)
# "Get the current weather for a city."

print(get_weather.args_schema.model_json_schema())
# {
#   "properties": {
#     "city": {"title": "City", "type": "string"}
#   },
#   "required": ["city"],
#   "title": "get_weatherSchema",
#   "type": "object"
# }

The model never sees your code — it only sees the schema. The JSON Schema is generated automatically from the type hints.

Supported type hints

LangChain translates str, int, float, bool, Optional, list, dict directly to JSON Schema. Parameters with a default are optional; without a default they're required.

from typing import Optional
from langchain_core.tools import tool

@tool
def search_products(
    query: str,
    category: str,
    max_price: float,
    in_stock: bool,
    limit: Optional[int] = 10
) -> str:
    """Search products in the catalog."""
    return f"Searching '{query}' in {category}, max ${max_price}, stock={in_stock}, limit={limit}"

# Generated schema: query(string,required), category(string,required),
# max_price(number,required), in_stock(boolean,required), limit(integer|null,default=10)

Direct invocation vs agent invocation

You can invoke a tool directly with .invoke():

result = get_weather.invoke({"city": "Madrid"})
print(result)  # "Weather in Madrid: 22°C, sunny"

result = get_weather.invoke("Madrid")
print(result)  # "Weather in Madrid: 22°C, sunny"

When an agent invokes it, the flow is different: the model generates a tool_call with the arguments, your code runs .invoke(), and the result comes back as a ToolMessage. You'll see that in capsule 03.


Docstrings as instructions for the model

The docstring isn't documentation for humans — it's a direct instruction for the LLM. The model decides which tool to use based on the description.

Anatomy of a good docstring

from langchain_core.tools import tool

@tool
def search_web(query: str) -> str:
    """Search current information on the web using a search engine.

    Use this tool when you need:
    - Current or recent information (news, prices, events)
    - Data that changes frequently (weather, quotes, scores)
    - To verify facts that might be outdated

    Do NOT use this tool for:
    - Mathematical calculations (use calculator)
    - Information you already know for certain
    - Opinion-based or subjective questions
    """
    return f"Web results for '{query}': [up-to-date data]"

@tool
def calculator(expression: str) -> str:
    """Calculate mathematical expressions precisely.

    Use for ANY math operation, even simple ones.
    LLMs make arithmetic mistakes — always use calculator.

    Format: an expression with numbers and the operators +, -, *, /, (), **.
    Valid examples: '15 * 23', '(100 + 50) / 3', '2 ** 10'
    Invalid examples: 'fifteen times twenty-three', 'math.sqrt(16)'
    """
    try:
        allowed = set("0123456789+-*/(). **")
        if not all(c in allowed for c in expression):
            return "Error: only numbers and basic operators are allowed"
        return str(round(eval(expression), 6))
    except Exception as e:
        return f"Error: {e}"

The impact of descriptions

from langchain_core.tools import tool

# ❌ Vague — the model doesn't know when to use it
@tool
def lookup(query: str) -> str:
    """Look things up."""
    return f"Result: {query}"

# ✅ Precise — the model knows exactly when and how
@tool
def search_knowledge_base(query: str) -> str:
    """Search the company's internal knowledge base.

    Contains: policies, technical documentation, product FAQs.
    Does NOT contain: external information, news, market prices.
    Be specific: 'refund policy for annual subscriptions', not 'refunds'.
    """
    return f"KB result for '{query}': [relevant document]"

Rules for effective docstrings

  1. What it does in the first line
  2. When to use it — specific use cases
  3. When NOT to use it — prevents wrong invocations
  4. Examples of valid and invalid inputs
  5. Limitations — what it can't do

Tool schemas with Pydantic

Basic type hints work for simple tools. But when you need validation, per-field descriptions, defaults with constraints, or complex arguments, Pydantic is the answer.

From type hints to Pydantic

from langchain_core.tools import tool
from pydantic import BaseModel, Field

class SearchInput(BaseModel):
    query: str = Field(
        description="Search term. Be specific: 'AI agents market size 2025' instead of 'AI'"
    )
    max_results: int = Field(
        default=5,
        ge=1,
        le=20,
        description="Maximum number of results. Between 1 and 20."
    )

@tool(args_schema=SearchInput)
def search_advanced(query: str, max_results: int = 5) -> str:
    """Search current information on the web."""
    return f"Searching '{query}', max={max_results}"

print(search_advanced.args_schema.model_json_schema())
# {
#   "properties": {
#     "query": {
#       "description": "Search term. Be specific...",
#       "type": "string"
#     },
#     "max_results": {
#       "default": 5,
#       "description": "Maximum number of results. Between 1 and 20.",
#       "maximum": 20, "minimum": 1,
#       "type": "integer"
#     }
#   },
#   "required": ["query"],
#   "type": "object"
# }

With Pydantic, every field has its own description that the model reads. The ge=1, le=20 translates to minimum/maximum in JSON Schema, and the model respects those ranges.

A complex schema with enums and optional fields

from langchain_core.tools import tool
from pydantic import BaseModel, Field
from typing import Optional
from enum import Enum

class Priority(str, Enum):
    low = "low"
    medium = "medium"
    high = "high"
    critical = "critical"

class CreateTicketInput(BaseModel):
    title: str = Field(
        description="Short, descriptive ticket title. Maximum 100 characters.",
        max_length=100
    )
    description: str = Field(
        description="Detailed description of the problem or request."
    )
    priority: Priority = Field(
        default=Priority.medium,
        description="Priority: low, medium, high, critical. Use 'critical' only for service outages."
    )
    assignee: Optional[str] = Field(
        default=None,
        description="Assignee's email. If not specified, it's assigned automatically."
    )
    tags: list[str] = Field(
        default_factory=list,
        description="Tags for classification. Examples: 'bug', 'feature', 'billing'."
    )

@tool(args_schema=CreateTicketInput)
def create_ticket(
    title: str, description: str, priority: str = "medium",
    assignee: Optional[str] = None, tags: list[str] = []
) -> str:
    """Create a support ticket. Use when the user reports a technical problem
    or requests a feature that needs follow-up."""
    assigned = assignee or "auto-assign"
    tag_str = ", ".join(tags) if tags else "no tags"
    return f"Ticket: '{title}' | {priority} | {assigned} | [{tag_str}]"

result = create_ticket.invoke({
    "title": "Login fails with SSO",
    "description": "Google SSO is not working.",
    "priority": "high",
    "tags": ["bug", "auth"]
})
print(result)
# Ticket: 'Login fails with SSO' | high | auto-assign | [bug, auth]

Automatic validation

When you use args_schema, Pydantic validates the arguments before your function runs:

from langchain_core.tools import tool
from pydantic import BaseModel, Field

class TemperatureInput(BaseModel):
    city: str = Field(description="City name", min_length=1)
    units: str = Field(default="celsius", pattern="^(celsius|fahrenheit)$")

@tool(args_schema=TemperatureInput)
def get_temperature(city: str, units: str = "celsius") -> str:
    """Get the current temperature for a city."""
    temps = {"Madrid": 22, "Tokyo": 18}
    temp_c = temps.get(city, 20)
    if units == "fahrenheit":
        temp_c = temp_c * 9/5 + 32
    return f"{city}: {temp_c}°{'F' if units == 'fahrenheit' else 'C'}"

print(get_temperature.invoke({"city": "Tokyo", "units": "fahrenheit"}))
# "Tokyo: 64.4°F"

try:
    get_temperature.invoke({"city": ""})  # min_length=1 → ValidationError
except Exception as e:
    print(f"Validation failed: {e}")

If the model passes city: "", Pydantic rejects it before your function runs.


Async tools

Tools that do I/O operations (HTTP calls, database queries, file reads) block the event loop if they're synchronous. In an agent that runs multiple tools, or in a FastAPI server, that kills performance.

When to use async

ScenarioAsync?Why
HTTP request to an external API✅ YesI/O-bound, frees the event loop
In-memory math calculation❌ NoCPU-bound, async doesn't help
Reading/writing files✅ YesI/O-bound
Database query✅ YesI/O-bound
Agent in FastAPI (concurrent)✅ YesAvoids blocking other requests

An async tool with httpx

import httpx
from langchain_core.tools import tool
from pydantic import BaseModel, Field

class WeatherInput(BaseModel):
    city: str = Field(description="City name in English")
    units: str = Field(default="metric", description="'metric' (°C) or 'imperial' (°F)")

@tool(args_schema=WeatherInput)
async def get_weather_async(city: str, units: str = "metric") -> str:
    """Get the current weather for a city using the OpenWeatherMap API."""
    try:
        async with httpx.AsyncClient(timeout=10) as client:
            response = await client.get(
                "https://api.openweathermap.org/data/2.5/weather",
                params={"q": city, "appid": "YOUR_KEY", "units": units, "lang": "en"}
            )
            if response.status_code == 404:
                return f"City '{city}' not found."
            response.raise_for_status()
            data = response.json()
            symbol = "°C" if units == "metric" else "°F"
            return (
                f"{city}: {data['main']['temp']}{symbol}, "
                f"{data['weather'][0]['description']}, "
                f"humidity {data['main']['humidity']}%"
            )
    except httpx.TimeoutException:
        return f"Timeout fetching weather for {city}"
    except httpx.HTTPError as e:
        return f"HTTP error: {e}"

Use ainvoke to call async tools. In capsule 03, you'll see how the agent handles concurrency when it runs async tools inside the loop.


Tool metadata

Beyond the argument schema, you can control the tool's name, return behavior and schema.

Custom name

from langchain_core.tools import tool

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

print(search.name)
# "web_search" (not "search")

Useful when the function name isn't descriptive, or when you have tools with similar names.

return_direct

With return_direct=True, the tool's result goes straight back to the user, without passing through the LLM again:

from langchain_core.tools import tool

@tool(return_direct=True)
def get_stock_price(symbol: str) -> str:
    """Get the current price of a stock. Returns directly to the user."""
    prices = {"AAPL": 195.50, "GOOGL": 178.30, "MSFT": 425.10}
    price = prices.get(symbol.upper())
    if price:
        return f"${symbol.upper()}: ${price:.2f} USD"
    return f"Symbol '{symbol}' not found"

Normal flow: model → tool_call → execute → result → model rephrases → user. With return_direct: model → tool_call → execute → result → straight to the user.

Use it when: the result is already readable (a price, a status, a simple value) and you want to save an LLM call. Don't use it when: the result needs context or the model has to interpret raw data.

Putting it all together: name + Pydantic + return_direct

from langchain_core.tools import tool
from pydantic import BaseModel, Field
from enum import Enum

class SearchDepth(str, Enum):
    basic = "basic"
    advanced = "advanced"

class WebSearchInput(BaseModel):
    query: str = Field(description="Specific, detailed search term")
    num_results: int = Field(default=5, ge=1, le=10, description="Results to return")
    search_depth: SearchDepth = Field(
        default=SearchDepth.basic,
        description="'basic' for quick lookups, 'advanced' for deep research."
    )

@tool("tavily_search", args_schema=WebSearchInput, return_direct=False)
def search_web(query: str, num_results: int = 5, search_depth: str = "basic") -> str:
    """Search current information on the web with advanced configuration."""
    return f"[{search_depth}] Searching '{query}', top {num_results} results..."

print(search_web.name)          # "tavily_search"
print(search_web.return_direct)  # False

Inspecting the full schema (debug)

import json

print(f"Name: {search_web.name}")
print(f"Description: {search_web.description}")
print(f"Return direct: {search_web.return_direct}")
print(f"Schema:\n{json.dumps(search_web.args_schema.model_json_schema(), indent=2)}")

If the model calls your tool badly, inspect the schema — it's probably missing context.


Comparison: basic @tool vs Pydantic schema

Aspect@tool with type hints@tool with a Pydantic args_schema
Per-field description❌ Types onlyField(description=...) per field
Validation❌ Basic types onlyge, le, min_length, pattern
Default values✅ Basic (= 5)✅ With constraints (default=5, ge=1)
Enums❌ Not supportedEnum as a field type
Optional fieldsOptional[str] = None✅ With description and default
Nested models❌ Not supported✅ BaseModel inside BaseModel
Lines of code~3-5~10-20
When to use it1-2 simple args3+ args, validation, or complex args
Tool call qualityAcceptableBetter — more context for the model

Practical rule: If your tool has more than 2 arguments, or if any argument needs constraints or a description, use Pydantic.


Connection with the project

In this module's project (capsule 08), you'll build an agent with 5 real external tools (web search, weather, calculator, file reader, datetime). Each one will use what you learned here: Pydantic schemas, descriptive docstrings, async where it makes sense, and error handling.

The schemas you design determine how well the agent uses each tool. A poor schema = vague queries, invalid arguments, ignored tools. The quality of the agent starts in the schema.

In the next capsule (03), you'll connect these tools to the model with bind_tools(), inspect tool_calls in an AIMessage, and use tool_choice to control when the model can or must use tools.


Troubleshooting

Problem 1: "The model doesn't use my tool when it should"

Symptom: You ask something your tool solves, but it answers with text.

Cause: The docstring doesn't clearly describe when to use the tool, or there's another tool with a similar description.

Fix: Improve the docstring with explicit "Use when..." and "Do NOT use when...". Check there's no overlap between tools.

Problem 2: "The model passes invalid arguments"

Symptom: It passes max_results: 100 when the max is 20, or an empty string.

Cause: The schema has no visible constraints.

Fix: Use Pydantic with Field(ge=1, le=20, description="Between 1 and 20"). Include the range in the description — the model reads both.

Problem 3: "A Pydantic ValidationError crashes my agent"

Symptom: The agent stops with a validation error.

Cause: The model generated arguments that don't pass validation.

Fix: Wrap the invocation in a try/except that returns a ToolMessage with a descriptive error. The model can correct itself if it gets feedback:

try:
    result = my_tool.invoke(tc["args"])
except Exception as e:
    result = f"Error invoking {tc['name']}: {e}. Check the arguments."

Problem 4: "My async tool never returns"

Symptom: The agent hangs.

Cause: You're not using ainvoke for async tools, or there's no timeout.

Fix: Always include a timeout on network calls (httpx.AsyncClient(timeout=10)). Use await tool.ainvoke(). Handle TimeoutError.

Problem 5: "I don't know whether to use type hints or Pydantic"

Fix: Quick heuristic:

  • 1-2 simple args (str, int) with no constraints → type hints
  • 3+ args, or any arg with constraints → Pydantic
  • Enums, nested models or complex validation → always Pydantic

Exercises

Exercise 1: A tool with an effective docstring (Easy)

Create convert_currency(amount: float, from_currency: str, to_currency: str) with a docstring that states when to use it, when not to, and what currency format it expects.

View solution
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 using current exchange rates.

    Use for: currency conversions, comparing international prices.
    Do NOT use for: general math calculations (use calculator).

    Currencies in 3-letter ISO 4217 code: USD, EUR, MXN, GBP, JPY.
    Example: amount=100, from_currency='USD', to_currency='MXN'
    """
    rates = {"USD_MXN": 17.15, "USD_EUR": 0.92, "EUR_USD": 1.09, "MXN_USD": 0.058}
    key = f"{from_currency}_{to_currency}"
    rate = rates.get(key, 1.0)
    converted = round(amount * rate, 2)
    return f"{amount} {from_currency} = {converted} {to_currency} (rate: {rate})"

print(convert_currency.invoke({
    "amount": 100, "from_currency": "USD", "to_currency": "MXN"
}))
# "100 USD = 1715.0 MXN (rate: 17.15)"

Exercise 2: A Pydantic schema with validation (Easy)

Create a SendEmailInput schema with: to (a valid email), subject (max 200 chars), body, priority (enum: normal, urgent), cc (optional list[str]). Implement the complete tool.

View solution
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from typing import Optional
from enum import Enum

class EmailPriority(str, Enum):
    normal = "normal"
    urgent = "urgent"

class SendEmailInput(BaseModel):
    to: str = Field(description="Recipient's email. Must contain @.")
    subject: str = Field(description="Email subject.", max_length=200)
    body: str = Field(description="Email body in plain text.")
    priority: EmailPriority = Field(
        default=EmailPriority.normal,
        description="'normal' for regular emails, 'urgent' only for critical matters."
    )
    cc: Optional[list[str]] = Field(
        default=None, description="List of emails to copy. Optional."
    )

@tool(args_schema=SendEmailInput)
def send_email(
    to: str, subject: str, body: str,
    priority: str = "normal", cc: Optional[list[str]] = None
) -> str:
    """Send an email to a recipient.

    Use when the user asks to send, write or draft an email.
    Requires at minimum a recipient, a subject and a body.
    """
    cc_str = f", CC: {', '.join(cc)}" if cc else ""
    return f"Email sent to {to}{cc_str}\nSubject: {subject}\nPriority: {priority}"

print(send_email.invoke({
    "to": "mike@example.com",
    "subject": "Weekly report",
    "body": "The report is attached.",
}))
# Email sent to mike@example.com
# Subject: Weekly report
# Priority: normal

Exercise 3: An async tool with error handling (Medium)

Create an async fetch_github_repo(owner: str, repo: str) that queries the GitHub API and returns name, stars and language. Handle 404, timeout and general errors.

View solution
import asyncio
import httpx
from langchain_core.tools import tool
from pydantic import BaseModel, Field

@tool
async def fetch_github_repo(owner: str, repo: str) -> str:
    """Get information about a public GitHub repository.
    Returns name, stars and language. Use codes like 'langchain-ai/langchain'.
    """
    try:
        async with httpx.AsyncClient(timeout=10) as client:
            response = await client.get(f"https://api.github.com/repos/{owner}/{repo}")
            if response.status_code == 404:
                return f"Repository '{owner}/{repo}' not found."
            response.raise_for_status()
            data = response.json()
            return (
                f"{data['full_name']} | Stars: {data['stargazers_count']:,} | "
                f"Lang: {data.get('language', 'N/A')}"
            )
    except httpx.TimeoutException:
        return f"Timeout fetching '{owner}/{repo}'."
    except Exception as e:
        return f"Error: {type(e).__name__}: {e}"

result = asyncio.run(fetch_github_repo.ainvoke({"owner": "langchain-ai", "repo": "langchain"}))
print(result)

Exercise 4: Compare the generated schemas (Medium)

Create create_reminder(title, minutes, recurring) two ways: (A) type hints only, (B) with Pydantic. Print both JSON Schemas and list 3 concrete differences.

View solution
import json
from langchain_core.tools import tool
from pydantic import BaseModel, Field

@tool
def reminder_basic(title: str, minutes: int, recurring: bool = False) -> str:
    """Create a reminder."""
    return f"Reminder '{title}' in {minutes}min"

class ReminderInput(BaseModel):
    title: str = Field(description="Reminder title", min_length=1, max_length=100)
    minutes: int = Field(description="Minutes until it fires", ge=1, le=10080)
    recurring: bool = Field(default=False, description="If True, it repeats")

@tool(args_schema=ReminderInput)
def reminder_pydantic(title: str, minutes: int, recurring: bool = False) -> str:
    """Create a reminder that notifies the user."""
    return f"Reminder '{title}' in {minutes}min"

for name, t in [("A", reminder_basic), ("B", reminder_pydantic)]:
    print(f"=== {name} ===")
    print(json.dumps(t.args_schema.model_json_schema(), indent=2))
# B has: a description per field, minimum/maximum on minutes, minLength/maxLength on title

Exercise 5: A tool with return_direct and metadata (Medium)

Create get_system_status with the custom name "health_check", return_direct=True, and a Pydantic schema with service (enum: api, database, cache) and verbose (bool).

View solution
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from enum import Enum

class Service(str, Enum):
    api = "api"
    database = "database"
    cache = "cache"

class StatusInput(BaseModel):
    service: Service = Field(description="Service to check: api, database, or cache")
    verbose: bool = Field(default=False, description="If True, include detailed metrics")

@tool("health_check", args_schema=StatusInput, return_direct=True)
def get_system_status(service: str, verbose: bool = False) -> str:
    """Check the health status of an internal service.

    Use when the user asks whether a service is working, or for diagnostics.
    """
    statuses = {
        "api": {"status": "healthy", "latency": "45ms", "uptime": "99.98%"},
        "database": {"status": "healthy", "latency": "12ms", "connections": "23/100"},
        "cache": {"status": "degraded", "latency": "120ms", "hit_rate": "78%"},
    }
    info = statuses.get(service, {"status": "unknown"})
    if verbose:
        details = " | ".join(f"{k}: {v}" for k, v in info.items())
        return f"[{service.upper()}] {details}"
    return f"[{service.upper()}] Status: {info['status']}"

print(get_system_status.name)  # "health_check"
print(get_system_status.invoke({"service": "cache", "verbose": True}))
# [CACHE] status: degraded | latency: 120ms | hit_rate: 78%

Exercise 6: Design schemas for a 3-tool agent (Hard)

Design Pydantic schemas and complete tools for a research agent: search_papers (searches papers by topic and year range), summarize_text (summarizes long text with a configurable max_sentences), save_note (saves a note with title, content and tags). Each tool with a Pydantic schema, a docstring saying when to use / not use it, and at least one Field with a constraint.

View solution
from langchain_core.tools import tool
from pydantic import BaseModel, Field

class SearchPapersInput(BaseModel):
    topic: str = Field(description="Specific topic: 'transformer attention' instead of 'AI'")
    year_from: int = Field(default=2020, ge=2000, le=2026, description="Earliest year")
    max_results: int = Field(default=5, ge=1, le=20, description="Maximum papers")

class SummarizeInput(BaseModel):
    text: str = Field(description="Text to summarize")
    max_sentences: int = Field(default=3, ge=1, le=10, description="Sentences in the summary")

class SaveNoteInput(BaseModel):
    title: str = Field(description="Unique title", min_length=1, max_length=200)
    content: str = Field(description="Note content")
    tags: list[str] = Field(default_factory=list, description="Tags: ['RAG', 'attention']")

@tool(args_schema=SearchPapersInput)
def search_papers(topic: str, year_from: int = 2020, max_results: int = 5) -> str:
    """Search academic papers on arXiv. Do NOT use for news or software docs."""
    return f"Papers on '{topic}' (from {year_from}): 1. Attention Is All You Need..."

@tool(args_schema=SummarizeInput)
def summarize_text(text: str, max_sentences: int = 3) -> str:
    """Summarize long text. Use after searching papers to condense them."""
    return ". ".join(text.split(". ")[:max_sentences]) + "."

@tool(args_schema=SaveNoteInput)
def save_note(title: str, content: str, tags: list[str] = []) -> str:
    """Save a research note. Use for important findings or ideas."""
    tag_str = ", ".join(tags) if tags else "no tags"
    return f"Note saved: '{title}' [{tag_str}] ({len(content)} chars)"

Summary

In this capsule you learned:

  • @tool uses the name, the docstring (→ description) and the type hints (→ JSON Schema) to build the schema
  • The docstring is an instruction for the model: what it does, when to use it, when not to, examples
  • Pydantic schemas add per-field descriptions, validation (ge, le, min_length), enums, optional fields
  • Async tools for I/O-bound operations (HTTP, DB) — essential in concurrent servers
  • Metadata: custom name, return_direct, args_schema for fine-grained control
  • A poor schema = an unreliable agent. The quality of the agent starts in the schema
  • The progression: type hints → docstrings → Pydantic → async + error handling → complete metadata

Next capsule: bind_tools and the tool calling flow — you'll connect these tools to the model, see how to inspect tool_calls in an AIMessage, and learn tool_choice to control when the model can or must use tools.


Additional resources

  1. LangChain — How to create tools — Official guide for creating tools with @tool
  2. Pydantic Field Types — Complete reference for Field() with validators and constraints
  3. JSON Schema Specification — What the model actually receives when you call bind_tools
  4. OpenAI Function Calling Guide — How OpenAI implements tool calling
  5. Anthropic Tool Use — Anthropic's implementation, compatible with LangChain
  6. httpx — Async HTTP Client — The recommended async HTTP client for production tools
  7. LangChain Tool Calling — Connecting tools to the model and managing tool calls
  8. aiohttp Documentation — An async HTTP alternative for high-performance tools