Implementing the 'Tools-First' Pattern in LangGraph


- Premium Results
- Publish articles on SitePoint
- Daily curated jobs
- Learning Paths
- Discounts to dev tools
7 Day Free Trial. Cancel Anytime.
How to Implement the Tools-First Pattern in LangGraph
- Define strict Pydantic schemas with typed fields, validators, and descriptions for every tool before writing agent logic.
- Decorate each tool function with
@tooland attach itsargs_schemaso the LLM receives precise JSON schemas. - Build a Universal Tool Node class that registers tools, validates inputs against schemas, and returns structured
ToolMessageerrors on failure. - Declare an
AgentStateTypedDict with amessageskey using LangGraph'sadd_messagesreducer. - Bind the tool list to the LLM via
.bind_tools()so every invocation carries full schema context. - Wire the
StateGraphwith an LLM node, the Universal Tool Node, a conditional edge for routing, and a tools→LLM loop. - Set a
recursion_limitwhen invoking the compiled graph to prevent infinite tool-call loops.
Most LangGraph agents that reach production carry a structural flaw: their tools were bolted on after the prompt was written. The result is hallucinated parameters, silent failures, and unpredictable behavior that makes debugging slow and unpredictable. This pattern flips the sequence. It treats tool schemas as the architectural foundation rather than an afterthought. This article walks through building a tools-first LangGraph agent anchored by a Universal Tool Node class, reusable across any StateGraph that follows the messages-key convention, that standardizes tool definition, validation, and error handling.
Prerequisites: Python 3.10+, working familiarity with LangChain and LangGraph concepts, and an OpenAI API key. Set your API key before running any code: export OPENAI_API_KEY='sk-...' (Linux/macOS) or $env:OPENAI_API_KEY='sk-...' (PowerShell).
Tested with: pydantic>=2.0,<3.0, langchain-core>=0.2,<0.3, langgraph>=0.1,<0.2, langchain-openai>=0.1,<0.2. Pin these in a requirements.txt to ensure reproducibility:
pydantic>=2.0,<3.0
langchain-core>=0.2,<0.3
langgraph>=0.1,<0.2
langchain-openai>=0.1,<0.2
Table of Contents
- What Is the Tools-First Pattern?
- Defining Tool Schemas for Reliability
- Building the Universal Tool Node Class
- Wiring the Tools-First Agent Graph
- Running and Testing the Agent
- Next Steps
What Is the Tools-First Pattern?
Tools-First vs. Prompt-First Agent Design
The conventional approach to building LangGraph agents follows a prompt-first sequence: write the system prompt, wire up the LLM node, then attach tools as an afterthought. Developers leave tool schemas loosely defined, skip input validation entirely, and the agent hallucinates parameters because nothing constrains it.
The tools-first pattern inverts this. You define tool schemas with strict types, validators, and descriptions before writing a single line of agent logic. You then build the graph around those schemas. Because the LLM receives tightly constrained tool definitions through bind_tools(), it generates well-formed tool calls more consistently. Schema-first design is intended to reduce hallucinated parameters by giving the model unambiguous structure to conform to, though smaller open-weight models and highly ambiguous multi-step tasks benefit less from this constraint.
The tools-first pattern inverts this. You define tool schemas with strict types, validators, and descriptions before writing a single line of agent logic.
When to Use This Pattern
This pattern pays off for agents managing three or more tools, agents calling structured external APIs, or any agent with production reliability requirements. Three is the rough threshold because below that count, the registry and validation overhead exceeds the benefit you get from centralized schema enforcement. For a simple single-tool conversational agent, you don't need the overhead.
Defining Tool Schemas for Reliability
Structuring Tools with Pydantic Models
The most common tool-calling failures stem from malformed arguments: wrong types, missing required fields, values outside expected ranges. Strict Pydantic schemas at the tool boundary eliminate these failures before execution begins. Field descriptions matter here because they feed directly into the function schema the LLM receives.
from pydantic import BaseModel, Field, field_validator
from langchain_core.tools import tool
class WeatherLookupInput(BaseModel):
"""Input schema for weather lookup."""
city: str = Field(..., description="City name, e.g. 'Berlin'")
units: str = Field(
default="celsius",
description="Temperature units: 'celsius' or 'fahrenheit'"
)
@field_validator("units")
@classmethod
def validate_units(cls, v: str) -> str:
if v not in ("celsius", "fahrenheit"):
raise ValueError("units must be 'celsius' or 'fahrenheit'")
return v
class DatabaseQueryInput(BaseModel):
"""Input schema for database user lookup."""
user_id: int = Field(..., description="Numeric user ID, e.g. 42")
fields: list[str] = Field(
default_factory=lambda: ["name", "email"],
description="List of fields to retrieve"
)
@tool("weather_lookup", args_schema=WeatherLookupInput)
def weather_lookup(city: str, units: str = "celsius") -> str:
"""Look up current weather for a given city."""
# Stub implementation — returns hardcoded data for demonstration only
return f"Weather in {city}: 18°{'C' if units == 'celsius' else 'F'}, partly cloudy"
@tool("database_query", args_schema=DatabaseQueryInput)
def database_query(user_id: int, fields: list[str] | None = None) -> str:
"""Query the user database by user ID."""
if fields is None:
fields = ["name", "email"]
return f"User #{user_id}: name='Alice', email='[email protected]'"
The @tool decorator from LangChain consumes the args_schema directly. Every field description propagates into the JSON schema the LLM sees during function calling.
Adding Error Boundaries to Tool Definitions
When a tool raises an unhandled exception inside a LangGraph node, an unhandled exception breaks state continuity and halts the entire graph. Wrapping tool execution in structured error returns keeps the graph running and gives the LLM a chance to recover.
from dataclasses import dataclass
from typing import Any, Optional
@dataclass
class ToolResult:
success: bool
message: str
data: Optional[Any] = None
def safe_weather_lookup(city: str, units: str = "celsius") -> ToolResult:
try:
result = weather_lookup.invoke({"city": city, "units": units})
return ToolResult(success=True, message=result, data={"city": city})
except Exception as e:
return ToolResult(
success=False,
message=f"Weather lookup failed for '{city}': {str(e)}",
data=None,
)
Returning a ToolResult dataclass with explicit success/failure status, a human-readable message, and an optional data payload means the calling code always receives a coherent response, even on failure. Note: ToolResult is not used inside UniversalToolNode; that class handles errors via ToolMessage directly. safe_weather_lookup illustrates an alternative pattern for use outside the LangGraph node, such as in standalone scripts or non-graph pipelines.
Building the Universal Tool Node Class
Architecture of the Universal Tool Node
LangGraph's langgraph.prebuilt.ToolNode provides similar functionality out of the box; UniversalToolNode extends this with explicit pre-invocation schema validation and structured error returns not present in the prebuilt version.
The Universal Tool Node registers tools, extracts schemas, validates inputs, executes calls, and handles errors inside a single class that plugs into any StateGraph using the messages-key convention. Its __init__ accepts a list of LangChain tools and builds an internal registry mapping tool names to callables. Its __call__ method matches the LangGraph node signature: it takes the current state and returns a state update.
This class handles three failure modes inline: unknown tool names, schema validation failures, and runtime execution errors. In every case, it returns a
ToolMessagewith a descriptive error rather than raising an exception, preserving LangGraph state continuity.
from typing import Any
from langchain_core.messages import AIMessage, ToolMessage
from langchain_core.tools import BaseTool
class UniversalToolNode:
"""A reusable tool-execution node for any LangGraph StateGraph.
Handles tool registry, input validation, execution, and structured
error handling. Drop into any graph that uses a 'messages' state key.
"""
def __init__(self, tools: list[BaseTool]) -> None:
self.tool_registry: dict[str, BaseTool] = {
t.name: t for t in tools
}
def __call__(self, state: dict[str, Any]) -> dict[str, list[ToolMessage]]:
messages = state.get("messages", [])
if not messages:
return {"messages": []}
last_message = messages[-1]
if not isinstance(last_message, AIMessage):
return {"messages": []}
if not last_message.tool_calls:
return {"messages": []}
tool_messages: list[ToolMessage] = []
for tool_call in last_message.tool_calls:
tool_name: str = tool_call["name"]
tool_args: dict[str, Any] = tool_call["args"]
tool_call_id: str = tool_call["id"]
# Check if tool exists in registry
if tool_name not in self.tool_registry:
tool_messages.append(
ToolMessage(
content=f"Error: Tool '{tool_name}' not found. "
f"Available tools: {list(self.tool_registry.keys())}",
tool_call_id=tool_call_id,
name=tool_name,
)
)
continue
tool = self.tool_registry[tool_name]
# Validate input against schema before invocation.
# This catches malformed arguments early with a clear error
# message; tool.invoke() also validates internally, but its
# error path may differ.
if tool.args_schema is not None:
try:
# Pydantic v2: use model_validate, not direct instantiation
tool.args_schema.model_validate(tool_args)
except Exception as validation_error:
tool_messages.append(
ToolMessage(
content=f"Validation error for '{tool_name}': "
f"{str(validation_error)}",
tool_call_id=tool_call_id,
name=tool_name,
)
)
continue
# Execute tool with error boundary
try:
result = tool.invoke(tool_args)
tool_messages.append(
ToolMessage(
content=str(result),
tool_call_id=tool_call_id,
name=tool_name,
)
)
except Exception as exec_error:
tool_messages.append(
ToolMessage(
content=f"Execution error in '{tool_name}': "
f"{type(exec_error).__name__}: {exec_error}",
tool_call_id=tool_call_id,
name=tool_name,
)
)
return {"messages": tool_messages}
This class handles three failure modes inline: unknown tool names, schema validation failures, and runtime execution errors. In every case, it returns a ToolMessage with a descriptive error rather than raising an exception, preserving LangGraph state continuity.
How the Universal Tool Node Handles State
An incoming AIMessage carries a tool_calls list. The Universal Tool Node iterates over each call, validates it against the registered schema, executes the tool, and appends a ToolMessage to the messages list. Three steps, one loop, no branching surprises. LangGraph's state reducer merges these messages into the conversation history. The LLM node sees the tool results on its next invocation, exactly as it would see any other message. The messages key convention is the standard state contract in LangGraph, and the Universal Tool Node honors it without requiring custom state adapters.
Wiring the Tools-First Agent Graph
Defining the State and Nodes
With tool schemas defined and the Universal Tool Node class ready, the graph assembly is direct. The AgentState TypedDict declares a single messages key. The LLM node calls the model with tools bound via .bind_tools().
The code below assumes that weather_lookup, database_query, and UniversalToolNode have been defined earlier in the same file. If you are working in a separate module, import them explicitly (e.g., from tools import weather_lookup, database_query and from tool_node import UniversalToolNode).
from typing import Annotated, TypedDict
from langchain_openai import ChatOpenAI
from langchain_core.messages import BaseMessage
from langgraph.graph import StateGraph, END
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
tools = [weather_lookup, database_query]
# Pin to a dated snapshot for reproducibility. The floating "gpt-4o"
# alias resolves to the latest version and may change behavior without notice.
llm = ChatOpenAI(
model="gpt-4o-2024-08-06",
temperature=0,
request_timeout=30,
).bind_tools(tools)
tool_node = UniversalToolNode(tools)
def llm_node(state: AgentState) -> dict[str, list[BaseMessage]]:
response = llm.invoke(state["messages"])
return {"messages": [response]}
Binding tools at the LLM level means every invocation carries the full schema context, so the model never generates calls against tools it cannot see.
The Routing Logic
The conditional edge checks whether the last message contains tool calls. If it does, the graph routes to the tool node. Otherwise, it routes to END. This binary routing works reliably precisely because strict tool schemas constrain the LLM's output: tool calls are either well-formed or absent.
from langchain_core.messages import AIMessage
def should_continue(state: AgentState) -> str:
messages = state.get("messages", [])
if not messages:
return "end"
last_message = messages[-1]
if isinstance(last_message, AIMessage) and last_message.tool_calls:
return "tools"
return "end"
graph = StateGraph(AgentState)
graph.add_node("llm", llm_node)
graph.add_node("tools", tool_node)
graph.set_entry_point("llm")
graph.add_conditional_edges("llm", should_continue, {"tools": "tools", "end": END})
graph.add_edge("tools", "llm")
agent = graph.compile()
The edge from tools back to llm creates the agent loop: the LLM sees tool results and decides whether to call more tools or produce a final answer. Watch for infinite loops: set a recursion limit with agent.invoke({...}, config={"recursion_limit": 10}). Without this guard, a looping LLM response will consume credits indefinitely.
Running and Testing the Agent
Invoking the Graph
from langchain_core.messages import HumanMessage
result = agent.invoke(
{
"messages": [
HumanMessage(content="What's the weather in Berlin and look up user #42?")
]
},
config={"recursion_limit": 15},
)
for msg in result["messages"]:
print(f"{msg.type}: {msg.content}")
This query is designed to trigger both tools; actual tool selection depends on the model's response. The expected output will show the HumanMessage, the AIMessage with tool calls, two ToolMessage responses, and a final AIMessage summarizing the results.
Debugging Tips
Streaming events with agent.stream() using stream_mode="updates" reveals each node's output in sequence, so you can read each node's output diff and pinpoint the failing step. Verify that "updates" is a supported mode for your LangGraph version; consult the langgraph changelog. As of LangGraph 0.1.x, "updates" and "values" are supported.
The most common pitfalls: mismatched tool names between the @tool decorator and what the LLM generates, missing description strings on Pydantic fields (the LLM relies on these to understand parameters), and forgetting to pass the same tool list to both bind_tools() and UniversalToolNode.
The most common pitfalls: mismatched tool names between the
@tooldecorator and what the LLM generates, missingdescriptionstrings on Pydantic fields (the LLM relies on these to understand parameters), and forgetting to pass the same tool list to bothbind_tools()andUniversalToolNode.
Next Steps
The tools-first principle follows a clear sequence: schema, then tool node, then graph. Extensions worth exploring:
- If your tools hit flaky APIs, add retry logic inside the Universal Tool Node to handle transient failures.
- For sensitive tool calls, implement human-in-the-loop approval using LangGraph's interrupt mechanism.
- When agents issue multiple independent tool calls per turn, enable parallel tool execution to reduce round-trip latency.
The LangGraph documentation and LangChain tool-calling guides provide detailed coverage of each of these capabilities.