teachyou.ai academy
← All posts
LangChain

LangChain Custom Tools: Writing Your Own Tool Class

Pramod Dutta · Jul 1, 2026 · 16 min read

Why the built-in tools stop working for you

Every LangChain tutorial starts the same way. You import a search tool, maybe a calculator, wire them into an agent, and watch it "reason" its way to an answer. It feels like magic for about a week. Then a real project lands on your desk — call an internal pricing API, query a Postgres table, hit a legacy SOAP endpoint your company has run since 2009 — and the built-in tool catalog has nothing for you.

This is the point where most people discover that LangChain custom tools are not an advanced, optional feature. They are the actual job. An LLM agent is only as useful as the actions it can take, and the actions it can take are only as good as the tools you hand it. If you can't write a clean, well-typed, well-documented custom tool, you can't build agents that do anything beyond chat.

The good news is that writing a custom tool in LangChain is not hard once you understand the two paths available to you: the quick @tool decorator for simple, stateless functions, and the BaseTool subclass for anything that needs configuration, shared state, or async execution. This article walks through both, with working code, common failure modes, and the schema details that determine whether your agent calls your tool correctly or hallucinates arguments that don't exist.

We'll build a handful of real tools along the way — a unit converter, an order-lookup tool backed by a mock database, and a rate-limited API wrapper — so you have patterns you can lift directly into your own codebase.

The two ways to build a tool

LangChain gives you two mechanisms for turning a Python function into something an LLM can call.

The first is the @tool decorator, which wraps a plain function and infers most of what it needs — name, description, argument schema — from the function signature, type hints, and docstring. This is the fastest path and covers the majority of real use cases: wrapping an API call, running a calculation, querying a database with a couple of parameters.

The second is subclassing BaseTool, LangChain's abstract base class for tools. This gives you full control: you define _run and _arun methods yourself, you can hold configuration or client objects as instance attributes, you get proper separation between sync and async code paths, and you can override how errors are reported back to the agent.

The rule of thumb I give students in LangChain Tutorial 2026 is this: start with @tool. Reach for BaseTool the moment your tool needs to hold state — an API client with credentials, a database connection pool, a rate limiter, retry configuration — or the moment the sync/async story gets complicated enough that inferring behavior from a single function isn't enough.

Let's look at both, starting with the fast path.

Building your first tool with the @tool decorator

The simplest possible custom tool is a typed Python function with a docstring and a decorator on top.

from langchain_core.tools import tool

@tool
def celsius_to_fahrenheit(celsius: float) -> float:
    """Convert a temperature from Celsius to Fahrenheit.

    Args:
        celsius: The temperature in degrees Celsius.
    """
    return (celsius * 9 / 5) + 32

That's a complete, agent-ready tool. LangChain inspects the function signature to build a JSON schema for the arguments, uses the function name (celsius_to_fahrenheit) as the tool name, and uses the docstring as the tool description — which is the single most important piece of text in this whole exercise, because it's what the LLM reads to decide whether and how to call your tool.

You can inspect what LangChain generated:

print(celsius_to_fahrenheit.name)
print(celsius_to_fahrenheit.description)
print(celsius_to_fahrenheit.args)

That last line prints the inferred JSON schema, something like {'celsius': {'title': 'Celsius', 'type': 'number'}}. This schema is what gets sent to the model provider as part of the tool-calling API, and it's what the model uses to construct valid arguments. Get the type hints wrong — say, forgetting that celsius should be a float and leaving it untyped — and you'll get a much vaguer schema, which means a much less reliable agent.

Multi-argument tools work the same way:

from langchain_core.tools import tool

@tool
def convert_currency(amount: float, from_currency: str, to_currency: str) -> str:
    """Convert an amount of money from one currency to another using a fixed demo rate table.

    Args:
        amount: The amount of money to convert.
        from_currency: Three-letter currency code to convert from, e.g. 'USD'.
        to_currency: Three-letter currency code to convert to, e.g. 'INR'.
    """
    rates = {
        ("USD", "INR"): 83.2,
        ("USD", "EUR"): 0.92,
        ("EUR", "USD"): 1.09,
    }
    rate = rates.get((from_currency.upper(), to_currency.upper()))
    if rate is None:
        return f"No conversion rate available for {from_currency} to {to_currency}."
    converted = round(amount * rate, 2)
    return f"{amount} {from_currency.upper()} = {converted} {to_currency.upper()}"

Notice the tool returns a plain string, not a raw float or a dict. This matters more than it looks like it should. Tool outputs get fed back into the conversation as a ToolMessage, and the model reads that content as text. A well-formed, human-readable string reduces the chance the model misinterprets a bare number or mangles a dict repr when it summarizes the result for the user.

Controlling the schema with Pydantic

Docstring-and-type-hints inference works fine for simple tools, but once your arguments get more complex — optional fields, nested objects, validation rules like "must be a positive integer" — you want explicit control. LangChain lets you pass a Pydantic model as the args_schema.

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

class OrderLookupInput(BaseModel):
    order_id: str = Field(description="The unique order ID, formatted like ORD-12345.")
    include_line_items: bool = Field(
        default=False,
        description="Whether to include individual line items in the response.",
    )

@tool("lookup_order", args_schema=OrderLookupInput)
def lookup_order(order_id: str, include_line_items: bool = False) -> str:
    """Look up the status and details of a customer order by its order ID."""
    order = MOCK_ORDERS.get(order_id)
    if not order:
        return f"No order found with ID {order_id}."

    summary = f"Order {order_id}: status={order['status']}, total=${order['total']}"
    if include_line_items:
        items = ", ".join(f"{i['qty']}x {i['name']}" for i in order["items"])
        summary += f". Items: {items}"
    return summary

This buys you two things. First, Pydantic validates the arguments before your function body ever runs — a malformed order_id type or a missing required field gets rejected at the schema layer, not halfway through your business logic. Second, the Field(description=...) text becomes part of the schema the model sees, so you can give per-argument guidance ("formatted like ORD-12345") that dramatically improves how reliably the model fills in the right value.

This is also where I tell people to stop being stingy with descriptions. A tool named lookup_order with the description "Look up the status and details of a customer order by its order ID" will get called correctly far more often than one that just says "Looks up an order." The model has no access to your source code — the description and schema *are* the entire interface it can see.

Subclassing BaseTool for real control

The decorator approach breaks down as soon as your tool needs to carry configuration around — an API key, a database session, retry settings — because a bare function has nowhere to put that state except globals or closures, both of which get awkward fast. This is where BaseTool earns its keep.

from typing import Optional, Type
from langchain_core.tools import BaseTool
from langchain_core.callbacks import CallbackManagerForToolRun, AsyncCallbackManagerForToolRun
from pydantic import BaseModel, Field
import httpx


class WeatherInput(BaseModel):
    city: str = Field(description="City name, e.g. 'Bengaluru' or 'Austin'.")
    units: str = Field(default="metric", description="'metric' or 'imperial'.")


class WeatherLookupTool(BaseTool):
    name: str = "weather_lookup"
    description: str = (
        "Fetch the current weather for a given city. "
        "Use this whenever the user asks about temperature, rain, or general conditions."
    )
    args_schema: Type[BaseModel] = WeatherInput

    api_key: str
    base_url: str = "https://api.example-weather.com/v1/current"
    timeout_seconds: float = 5.0

    def _run(
        self,
        city: str,
        units: str = "metric",
        run_manager: Optional[CallbackManagerForToolRun] = None,
    ) -> str:
        try:
            response = httpx.get(
                self.base_url,
                params={"q": city, "units": units, "appid": self.api_key},
                timeout=self.timeout_seconds,
            )
            response.raise_for_status()
        except httpx.HTTPStatusError as exc:
            return f"Weather API returned an error for '{city}': {exc.response.status_code}."
        except httpx.RequestError:
            return f"Could not reach the weather service while looking up '{city}'. Try again shortly."

        data = response.json()
        temp = data["main"]["temp"]
        condition = data["weather"][0]["description"]
        return f"{city}: {temp}° ({units}), {condition}."

    async def _arun(
        self,
        city: str,
        units: str = "metric",
        run_manager: Optional[AsyncCallbackManagerForToolRun] = None,
    ) -> str:
        async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
            try:
                response = await client.get(
                    self.base_url,
                    params={"q": city, "units": units, "appid": self.api_key},
                )
                response.raise_for_status()
            except httpx.HTTPStatusError as exc:
                return f"Weather API returned an error for '{city}': {exc.response.status_code}."
            except httpx.RequestError:
                return f"Could not reach the weather service while looking up '{city}'. Try again shortly."

        data = response.json()
        temp = data["main"]["temp"]
        condition = data["weather"][0]["description"]
        return f"{city}: {temp}° ({units}), {condition}."

A few things to notice here. The api_key and base_url are instance attributes — because BaseTool is itself a Pydantic model, you declare fields the same way you would on any Pydantic class, and you instantiate the tool with real configuration:

weather_tool = WeatherLookupTool(api_key="your-real-key-here")

This is the pattern that finally lets you stop stuffing API keys into global variables or module-level constants. Each instance of the tool is self-contained, which also means you can create multiple instances with different configurations — a staging weather tool and a production weather tool — and hand the correct one to the correct agent.

The other thing to notice is that _run and _arun are separate methods. If you only implement _run, LangChain will run it in a thread pool when an async caller invokes .ainvoke() on the tool, which works but adds thread-pool overhead. If your underlying operation is genuinely async — an async HTTP client, an async database driver — implement _arun directly and you get real, non-blocking concurrency, which matters a lot once you have an agent calling several tools per turn.

Error handling that doesn't crash the agent loop

A tool that raises an unhandled exception is one of the fastest ways to make an agent look broken to an end user. When a Python exception propagates out of _run, most agent executors catch it, but the resulting behavior — a raw traceback dumped into the conversation, or the whole chain halting — is rarely what you want.

The pattern I push in LangChain Tutorial 2026 is: catch known failure modes inside the tool and return a descriptive string, and let handle_tool_error on the agent side catch anything you didn't anticipate.

class DatabaseQueryTool(BaseTool):
    name: str = "query_customer_db"
    description: str = "Run a read-only lookup against the customer database by customer ID."
    args_schema: Type[BaseModel] = CustomerIdInput

    connection_string: str

    def _run(self, customer_id: str, run_manager=None) -> str:
        if not customer_id.strip():
            return "Error: customer_id was empty. Ask the user for a valid customer ID."

        try:
            record = self._fetch(customer_id)
        except TimeoutError:
            return "The customer database timed out. This is usually temporary — try again in a moment."
        except PermissionError:
            return "Access to the customer database was denied. This tool cannot serve this request."

        if record is None:
            return f"No customer found with ID '{customer_id}'. Double-check the ID and try again."

        return f"Customer {customer_id}: {record['name']}, plan={record['plan']}, status={record['status']}"

    def _fetch(self, customer_id: str) -> Optional[dict]:
        # real implementation would query a connection pool here
        ...

The key idea: every return path is a string the model can reason about and relay to the user in plain language. "Error: customer_id was empty" is a sentence the model can act on — it will typically ask the user for the missing value on the next turn. A stack trace is not something the model can act on; it will either repeat the failing call or hallucinate an explanation.

For the unexpected cases you can't anticipate, both @tool and BaseTool support a handle_tool_error parameter:

@tool(handle_tool_error=True)
def risky_lookup(query: str) -> str:
    """Look something up. May fail unpredictably in this demo."""
    if "fail" in query:
        raise ValueError("Simulated backend failure")
    return f"Result for {query}"

With handle_tool_error=True, an exception gets converted to a ToolMessage containing the exception text instead of propagating up and killing the run. You can also pass a string or a callable to customize exactly what gets sent back:

@tool(handle_tool_error="The lookup service is temporarily unavailable. Please try again.")
def risky_lookup(query: str) -> str:
    """Look something up."""
    ...

Use this as a safety net, not a substitute for handling the errors you already know about inside the function body.

Structured output with return_direct and artifacts

Two options are worth knowing once your tools move past toy examples. The first is return_direct. Setting it to True tells the agent executor to stop reasoning and hand the tool's output straight back to the user as the final answer, skipping another round-trip through the LLM.

@tool(return_direct=True)
def generate_invoice_pdf(order_id: str) -> str:
    """Generate a PDF invoice for the given order and return a download link."""
    url = create_invoice_and_upload(order_id)
    return f"Your invoice is ready: {url}"

This is useful for tools whose output is already a complete, user-facing answer — a generated link, a confirmation message — where routing it back through the model would just add latency and the risk of the model paraphrasing something it shouldn't touch, like a URL.

The second is the content-and-artifact pattern, which matters when a tool needs to return something large or non-text alongside a summary for the model. You do this by setting response_format="content_and_artifact" and returning a tuple:

from langchain_core.tools import tool

@tool(response_format="content_and_artifact")
def run_sql_report(query: str) -> tuple[str, dict]:
    """Run a read-only SQL report and return a short summary plus the full result set."""
    rows = execute_readonly_query(query)
    summary = f"Query returned {len(rows)} rows."
    return summary, {"rows": rows}

The model only ever sees summary as the ToolMessage content — that's what stays in the conversation history and token budget. The full rows payload rides along as an artifact that your application code can pull out of the message and render in a UI, log, or hand to a downstream chart-rendering step. This separation is what keeps large tool outputs from silently blowing up your context window while still giving your application access to the full data.

Binding tools to a model and letting it call them

Once a tool exists, the last step is exposing it to the model. With modern chat models in LangChain, this is a single call:

from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o", temperature=0)
llm_with_tools = llm.bind_tools([convert_currency, lookup_order, weather_tool])

response = llm_with_tools.invoke("What's the weather in Bengaluru right now?")
print(response.tool_calls)

response.tool_calls gives you a list of dicts — tool name, arguments, and a call ID — that the model wants to invoke. Note that bind_tools does not execute anything; it only tells the model what's available and lets the model decide what to call and with what arguments. Actually running the tool and feeding the result back is your job, typically inside a loop:

messages = [HumanMessage("What's the weather in Bengaluru right now?")]
ai_msg = llm_with_tools.invoke(messages)
messages.append(ai_msg)

for call in ai_msg.tool_calls:
    selected_tool = {"weather_lookup": weather_tool}[call["name"]]
    tool_result = selected_tool.invoke(call["args"])
    messages.append(ToolMessage(content=tool_result, tool_call_id=call["id"]))

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

If you're using a prebuilt agent executor — LangGraph's create_react_agent, for instance — this loop is handled for you, and all you supply is the list of tool objects. But understanding what's happening under the hood matters, because when a tool misbehaves in production, you need to know exactly where in this loop things went sideways: was it a bad schema, a bad argument from the model, or a bad return value from your function?

Testing your tools like real code

A custom tool is still a function with a contract, and it deserves the same testing discipline as any other piece of application logic — arguably more, because a broken tool doesn't just fail loudly, it produces confidently wrong answers that get relayed to end users in natural language.

def test_lookup_order_found():
    result = lookup_order.invoke({"order_id": "ORD-12345", "include_line_items": True})
    assert "status=" in result
    assert "Items:" in result

def test_lookup_order_not_found():
    result = lookup_order.invoke({"order_id": "ORD-does-not-exist"})
    assert "No order found" in result

def test_convert_currency_unknown_pair():
    result = convert_currency.invoke({"amount": 10, "from_currency": "USD", "to_currency": "JPY"})
    assert "No conversion rate available" in result

Note the .invoke({...}) calling convention — both @tool-decorated functions and BaseTool instances are Runnable objects, so they support .invoke(), .batch(), and .ainvoke() the same way any other LangChain component does. This means you can unit test a tool in complete isolation, without spinning up a model or an agent at all, which is exactly how these tests should run in CI — fast, deterministic, and independent of any LLM API call.

It's also worth writing a schema test: invoke the tool with a missing required field and confirm Pydantic rejects it before your business logic runs. That single test catches an entire class of "the model forgot an argument" bugs before they reach production.

Common mistakes that break tool calling

A few patterns show up over and over in code review, and they're worth calling out directly.

  • Vague docstrings. "Does a lookup" tells the model nothing about when to use the tool or what arguments mean. Write descriptions as if you're briefing a new engineer who has never seen your codebase — because that's functionally what the model is.
  • Untyped arguments. A parameter typed as Any or left without a hint produces a weak schema, which leads to the model guessing wildly at what to pass.
  • Returning raw objects instead of strings. Returning a dict, a DataFrame, or a custom class instance from a tool often gets stringified in a way that's unreadable to the model. Format it yourself.
  • Doing too much in one tool. A single "do_everything" tool with a dozen optional parameters is harder for a model to call correctly than three small, focused tools. Split by responsibility.
  • Forgetting idempotency for side-effecting tools. If a tool sends an email or charges a card, make sure retries or duplicate calls from the agent don't cause duplicate side effects — add idempotency keys where the underlying API supports them.
  • Not validating inputs defensively. Even with a Pydantic schema, a model can pass a syntactically valid but semantically wrong value — an order ID that's the right shape but references someone else's order. Your tool's business logic still needs its own authorization checks.

Wrapping up

Custom tools are where LangChain stops being a demo framework and starts being infrastructure. The @tool decorator gets you from zero to a working, schema-correct tool in a few lines for the common case, and BaseTool gives you the structure to hold real configuration, split sync from async cleanly, and control exactly how errors and large payloads flow back into the conversation. Neither path is "the advanced one" — they're just suited to different jobs, and most production agents end up using both.

The pattern that actually determines whether your agent is reliable isn't the decorator versus the subclass. It's the discipline you put into descriptions, type hints, and error strings — because that's the entire surface area the model has to reason about. Get that right, and tool calling stops feeling like a gamble and starts feeling like an API you designed on purpose.

If you want to go deeper — building multi-tool agents, wiring in retrieval alongside custom tools, and debugging tool-calling failures step by step with real production examples — that's exactly what we cover hands-on in LangChain Tutorial 2026, our full course on building agentic systems with LangChain.

LangChain Custom Tools: Writing Your Own Tool Class · TeachYou Academy