How to Add Web Search to LangGraph Agents With the You.com Web Search API

TLDR: To add web search to a LangGraph agent, give it a tool that calls the You.com Web Search API: a POST to https://ydc-index.io/v1/search with an X-API-Key header. Use the langchain-youdotcom tool with create_agent for the quickest start. Write your own tool and wire it into a StateGraph with ToolNode when you need a search budget, a rule for which errors stop a run, or an approval step.
LangGraph is the runtime under LangChain's create_agent, so adding web search comes down to how much of the loop you want to own. This guide covers three layers and the controls the graph itself provides: a cap on searches per question, a rule for which failures stop a run, an approval step before a query leaves your network, and offline tests. Package setup is in our LangChain web search tool guide, and the choice between create_agent, Deep Agents, and a raw StateGraph is mapped in our LangChain agents documentation guide.
The examples need Python 3.10 or later, because LangGraph v1 dropped Python 3.9. Names and signatures were checked in October 2026 against langgraph 1.2.12, langchain 1.4.3, and langchain-youdotcom 0.4.0. Create a key on the You.com platform and export it as YDC_API_KEY.
Which path should a LangGraph search agent take?
Three layers cover most cases. They differ in who decides to search and how much wiring you maintain.
| Path | Who decides to search | Reach for it when | What you give up |
|---|---|---|---|
create_agent with YouSearchTool | The model | You want the standard tool loop and will add limits through middleware | Routing and state belong to the prebuilt graph |
Custom StateGraph with ToolNode | The model, inside routing you write | You need a search budget, an error policy, an approval step, or extra nodes around the search | You write and test the wiring |
| Fixed search node | Your graph, on every request | Every request is a lookup and you want one predictable call per question | No follow-up searches, and the user's message becomes the query unless you rewrite it |
LangGraph v1 deprecated create_react_agent in favor of create_agent from the langchain package (migration guide), a rename that catches older tutorials. create_agent still returns a compiled LangGraph graph, so the layers combine: a custom graph can run a create_agent search agent as one node (create_agent reference). To connect the hosted MCP server instead of owning a tool, see our MCP server guide.
What is the quickest way to give a LangGraph agent web search?
Install what the examples in this guide use, then pass the package's tool to create_agent.
pip install -U langgraph langchain langchain-openai langchain-youdotcom requests
export YDC_API_KEY="your-key-here"
export OPENAI_API_KEY="your-key-here"
The tool is named you_search and takes one argument, query. It returns one string with the title, URL, and content of each hit (package source). Set the size of that string yourself. With no settings the API returns up to 10 web results, plus up to 10 news results when the query has news intent, because count defaults to 10 per section (API reference). The wrapper below bounds it:
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from langchain_youdotcom import YouAPIWrapper, YouSearchTool
search = YouSearchTool(
api_wrapper=YouAPIWrapper(
count=5, # up to 5 web and 5 news hits per call
k=6, # hard cap on documents returned to the model
n_snippets_per_hit=2, # at most two snippets per hit
)
)
agent = create_agent(
model=ChatOpenAI(model="gpt-4o-mini"),
tools=[search],
system_prompt=(
"Use you_search for facts that may have changed since your training data. "
"Cite the URLs you relied on."
),
)
result = agent.invoke(
{"messages": [{"role": "user", "content": "What changed in the latest LangGraph release?"}]}
)
print(result["messages"][-1].content)
count caps each section at the API, k caps the documents the wrapper keeps, and n_snippets_per_hit caps snippets per hit. In 0.4.0, YouSearchAPIWrapper is an alias of YouAPIWrapper, and livecrawl is deprecated in favor of extraction. The You.com LangGraph integration page shows the same tool in a prebuilt agent and in two custom graphs.
Own a small module instead when any of these matter. The package lets SDK errors propagate, and the default ToolNode handler re-raises everything except invalid-argument errors, so one rate-limited search ends the run. Its output has no result numbers to cite. And its request fields are fixed when you build the wrapper, because the tool's own input is only query.
How do you write the search tool yourself?
The module below keeps the HTTP call in a plain function, run_search, with a thin @tool on top, which is what makes it testable without a model. It asks for highlights, so each result carries the passages that address the query instead of keyword-matched snippets (retrieve page content). The docstring is what the model reads when deciding whether to call the tool (LangChain tools docs), so it says when to use it. The endpoint contract is in our Web Search API guide.
import os
import requests
from langchain.tools import ToolException, tool
SEARCH_URL = "https://ydc-index.io/v1/search"
class SearchUnavailable(ToolException):
"""A failure the model can act on: timeout, rate limit, or server error."""
def format_results(web, max_passages=2):
"""Number the hits and keep the passages that answer the query."""
if not web:
return "No results. Rephrase the query once, or answer without search and say so."
blocks = []
for number, hit in enumerate(web, start=1):
contents = hit.get("contents") or {}
passages = (
contents.get("highlights")
or hit.get("snippets")
or [hit.get("description") or ""]
)
text = " ".join(p for p in passages[:max_passages] if p)
blocks.append(f"[{number}] {hit.get('title') or 'Untitled'}\n{hit.get('url', '')}\n{text}")
return "\n\n".join(blocks)
def run_search(query, count=5, freshness=""):
"""Call the You.com Web Search API and return compact text for the model."""
key = os.environ.get("YDC_API_KEY", "").strip()
if not key:
raise RuntimeError("YDC_API_KEY is not set")
body = {
"query": query,
"count": count,
"extraction": {"extraction_mode": "highlights"},
}
if freshness:
body["freshness"] = freshness
try:
resp = requests.post(
SEARCH_URL, headers={"X-API-Key": key}, json=body, timeout=15
)
except requests.RequestException as exc:
raise SearchUnavailable(
f"The search request failed ({type(exc).__name__})."
) from exc
if resp.status_code == 429 or resp.status_code >= 500:
retry = resp.headers.get("Retry-After")
hint = f" Retry-After: {retry}." if retry else ""
raise SearchUnavailable(f"Search returned HTTP {resp.status_code}.{hint}")
if resp.status_code >= 400:
# 401, 402, 403, 422: a key, billing, or request problem. Rephrasing cannot fix it.
raise RuntimeError(f"You.com rejected the search request: HTTP {resp.status_code}")
return format_results((resp.json().get("results") or {}).get("web") or [])
@tool
def web_search(query: str) -> str:
"""Search the live web. Use it for facts that may have changed since your
training data, such as releases, prices, and news. Returns numbered results
with title, URL, and key passages."""
return run_search(query)
- Two exception types. A timeout, 429, or 5xx raises
SearchUnavailable, aToolExceptionsubclass, because the model can act on it by rephrasing, waiting, or answering without search. A missing key or an HTTP 401, 402, 403, or 422 raises a plainRuntimeError, because rephrasing cannot fix a revoked key or an empty balance (You.com error reference). - Retry-After is passed along. On a 429 the API sends
Retry-After, and the rate limit docs tell clients to honor it. Self-serve accounts default to 10 requests per second on/v1/search, andToolNoderuns parallel tool calls together, so one run can burst against the same limit as the rest of your traffic. - Highlights are in the base price. Billing lists snippets or highlights under the $5.00 per 1,000 calls rate (as of October 2026). Only full-page extraction is billed separately, at $1.00 per 1,000 pages crawled live (billing). Our highlights write-up covers why they suit agents.
- An explicit timeout. The requests library never times out unless you pass
timeout(requests docs), so a stalled connection would hold a graph step open indefinitely.
How do you wire the tool into a StateGraph?
The graph has three working nodes: model calls the chat model with the tool bound, tools is the prebuilt ToolNode, and over_budget answers search requests beyond the budget. A router after the model chooses among them. The listing also holds an approve node, covered in the approval section below.
from typing import Literal
from langchain.messages import SystemMessage, ToolMessage
from langchain.tools import ToolException
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
from langgraph.types import Command, interrupt
from youcom_search import web_search
MAX_SEARCHES = 3
SYSTEM = SystemMessage(
"Use web_search for facts that may have changed since your training data. "
f"Search at most {MAX_SEARCHES} times per question. Cite the URLs you relied on, "
"and say so when the results do not answer the question."
)
BLOCKED = (
"Search budget used up for this question. Answer from the results you "
"already have and say what is missing."
)
DECLINED = "A reviewer declined this search. Answer without it."
def searches_this_turn(messages):
"""Count web_search calls the model has requested since the last human message."""
count = 0
for message in reversed(messages):
if message.type == "human":
break
if message.type == "ai":
count += sum(1 for call in message.tool_calls if call["name"] == "web_search")
return count
def route(state: MessagesState) -> Literal["tools", "over_budget", "__end__"]:
last = state["messages"][-1]
if not getattr(last, "tool_calls", None):
return END
if searches_this_turn(state["messages"]) > MAX_SEARCHES:
return "over_budget"
return "tools"
def over_budget(state: MessagesState):
"""Answer every blocked call, because each tool call needs a ToolMessage."""
calls = state["messages"][-1].tool_calls
return {
"messages": [
ToolMessage(BLOCKED, tool_call_id=call["id"], name=call["name"])
for call in calls
]
}
def approve(state: MessagesState) -> Command[Literal["tools", "model"]]:
"""Pause once per step, however many searches the model asked for."""
calls = state["messages"][-1].tool_calls
queries = [call["args"].get("query") for call in calls]
if interrupt({"action": "web_search", "queries": queries}) == "approve":
return Command(goto="tools")
declined = [
ToolMessage(DECLINED, tool_call_id=call["id"], name=call["name"])
for call in calls
]
return Command(goto="model", update={"messages": declined})
def explain_tool_error(exc: ToolException) -> str:
return f"{exc} Answer without search, or try one rephrased query."
def build_graph(model, checkpointer=None, require_approval=False):
bound = model.bind_tools([web_search])
def call_model(state: MessagesState):
return {"messages": [bound.invoke([SYSTEM] + state["messages"])]}
builder = StateGraph(MessagesState)
builder.add_node("model", call_model)
builder.add_node(
"tools", ToolNode([web_search], handle_tool_errors=explain_tool_error)
)
builder.add_node("over_budget", over_budget)
if require_approval:
builder.add_node("approve", approve)
builder.add_edge(START, "model")
builder.add_conditional_edges(
"model",
route,
{
"tools": "approve" if require_approval else "tools",
"over_budget": "over_budget",
END: END,
},
)
builder.add_edge("tools", "model")
builder.add_edge("over_budget", "model")
return builder.compile(checkpointer=checkpointer)
Build it with any chat model that supports tool calling, and pass a recursion limit:
from langchain_openai import ChatOpenAI
from search_graph import build_graph
graph = build_graph(ChatOpenAI(model="gpt-4o-mini"))
result = graph.invoke(
{"messages": [{"role": "user", "content": "What changed in the latest LangGraph release?"}]},
config={"recursion_limit": 20},
)
print(result["messages"][-1].content)
- The budget lives in the router, not the prompt. The prompt asks for at most three searches and
routeenforces it, countingweb_searchcalls requested since the last human message, so the count resets on each new user turn. The fourth request goes toover_budget, which answers every blocked call with aToolMessagebecause each tool call in the history needs a matching one (LangChain tools docs). A model that ignores the note and asks again is stopped by the recursion limit. - Errors split by who can fix them.
explain_tool_erroris annotated withToolException, andToolNodereads that annotation to decide what to catch (ToolNode reference).SearchUnavailableand the invalid-argument errorToolNoderaises for bad model arguments are bothToolExceptions, so both go back to the model as messages. ARuntimeErroris not caught, so the run stops. The message names the exception type and omits the raw text, which may carry sensitive or internal detail (LangChain middleware docs). - Two limits, two jobs. The budget stops needless searches and the recursion limit stops everything else. A
StateGraphdefaults to 1,000 steps since LangGraph 1.0.6 (graph API docs) andcreate_agentcompiles with 9,999 (langchain 1.4.3 source), so neither stops a loop early. Pass your own as a top-level config key, not underconfigurable. Three searches, one blocked request, and a final answer take roughly ten steps, so 20 leaves headroom. - What it costs. At the $5.00 per 1,000 calls list price (as of October 2026), three searches cost at most $0.015 per question, plus model tokens.
The same controls exist in create_agent as middleware: ToolCallLimitMiddleware caps tool calls, ToolRetryMiddleware retries with backoff, and ToolErrorMiddleware (langchain 1.3.14 or later) turns selected exceptions into messages (built-in middleware docs). Write them in the graph when the rule depends on state middleware cannot see, or when a blocked step should branch to another node.
When should search be a fixed node instead of a tool?
A fixed node removes the model from the decision: the graph searches on every request, adds the results to the messages, and calls the model once. The You.com LangGraph page shows search then summarize, and search then extract the top URLs then synthesize.
You gain a predictable cost and a simpler graph. One search per question is $0.005 at the list price (as of October 2026), the model needs no tool-calling support, and every step is a plain function you can test. You lose the second search: if the first results miss, nothing in the graph asks again.
You also own the query. The documented example passes the user's last message straight through, which suits a short factual question and fails for a long request or a follow-up that depends on earlier turns. Add a rewrite step that turns the conversation into one query. The extract variant spends more on purpose: the Contents API is $1.00 per 1,000 pages, so three extracted URLs add $0.003 to the $0.005 search. A cheap router in front, a rule or a small model call, can use conditional edges to send only questions that need the web to the search node.
How do you add an approval step before a query leaves your network?
Whatever the model writes into query is sent to a third-party API, and the model composes it from the conversation, so anything in the conversation can end up in it. Zero data retention limits what You.com stores and is an option on enterprise agreements (ZDR docs); it does not change what your agent sends. If a person or a filter should see queries first, add the gate to the graph.
LangGraph's interrupt() pauses a run, saves state through a checkpointer, and returns a payload to the caller; you resume with Command(resume=...) (interrupts docs). The approve node above does this once per step, with every query from that step in one payload. Turn it on with require_approval=True, compile with a checkpointer, and pass a thread ID:
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from search_graph import build_graph
graph = build_graph(
ChatOpenAI(model="gpt-4o-mini"),
checkpointer=InMemorySaver(),
require_approval=True,
)
config = {"configurable": {"thread_id": "ticket-4812"}, "recursion_limit": 20}
question = {"messages": [{"role": "user", "content": "What changed in the latest LangGraph release?"}]}
paused = graph.invoke(question, config)
print(paused["__interrupt__"][0].value)
# {'action': 'web_search', 'queries': ['langgraph latest release notes']}
final = graph.invoke(Command(resume="approve"), config)
print(final["messages"][-1].content)
Any answer other than "approve" takes the declined branch: a ToolMessage for each blocked call, then back to the model. The gate is a node rather than an interrupt() inside the tool because resume values are matched by the order of interrupt calls within a node (interrupts docs) and ToolNode runs parallel tool calls together, so two interrupting tools in one step would depend on timing.
Two more rules. The node restarts from the top on resume, so nothing with side effects belongs before interrupt(). And InMemorySaver is for tests, because production needs a durable checkpointer. In create_agent, the equivalent is HumanInTheLoopMiddleware with a checkpointer (middleware docs).
What breaks LangGraph search agents in production?
| Symptom | Cause | Fix |
|---|---|---|
| The run ends with a raw exception after one failed search | ToolNode re-raises every tool exception except invalid-argument errors, a default since langgraph-prebuilt 1.0.1 (issue 6486) that 1.1.0 still has | Raise ToolException for failures the model can act on and pass a typed handler, or set handle_tool_errors=True. In create_agent, use ToolErrorMiddleware |
| 429 responses under load | Self-serve accounts default to 10 requests per second on /v1/search, shared by parallel tool calls and concurrent users | Honor Retry-After, cap concurrency in your service, and email api@you.com about higher limits |
| Tool messages crowd out the question | count defaults to 10 per section, so one call can return web and news hits | Set count, k, and n_snippets_per_hit, or format results yourself as run_search does |
| The agent searches in circles | A StateGraph allows 1,000 steps by default and create_agent allows 9,999 | Enforce a budget in the router or with ToolCallLimitMiddleware, and pass recursion_limit |
| A paused approval never resumes after a restart | InMemorySaver keeps state in process memory | Use a durable checkpointer and resume with the same thread_id |
| The agent follows instructions found in a page | Search results are untrusted text that enters the model's context | Give the model no tools that act on instructions found in results, and read our prompt injection guide |
The first row is the surprising one, because the default changed in a patch release. Pin exact versions and run the tests below before each upgrade.
How do you test the graph without calling the API?
Test in layers, cheapest first. The first two need no model and no network: patch requests.post to check the request you send, the text you return, and which failures raise which exception, then call route and searches_this_turn with stand-in messages to check the budget at its edges. They run in milliseconds because both are plain functions.
import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import requests
from langchain.tools import ToolException
from langgraph.graph import END
import youcom_search
from search_graph import MAX_SEARCHES, route, searches_this_turn
HIT = {
"title": "Release notes",
"url": "https://example.com/release",
"contents": {"highlights": ["first passage", "second passage", "third passage"]},
}
def response(status=200, payload=None, headers=None):
resp = MagicMock(status_code=status, headers=headers or {})
resp.json.return_value = payload or {}
return resp
@patch.dict("os.environ", {"YDC_API_KEY": "test-key"})
class RunSearchTests(unittest.TestCase):
@patch("youcom_search.requests.post")
def test_formats_numbered_highlights(self, post):
post.return_value = response(payload={"results": {"web": [HIT]}})
text = youcom_search.run_search("langgraph release")
self.assertIn("[1] Release notes", text)
self.assertIn("first passage second passage", text)
self.assertNotIn("third passage", text)
sent = post.call_args.kwargs
self.assertEqual(sent["headers"], {"X-API-Key": "test-key"})
self.assertEqual(sent["json"]["extraction"], {"extraction_mode": "highlights"})
self.assertEqual(sent["timeout"], 15)
@patch("youcom_search.requests.post")
def test_empty_results_tell_the_model_what_to_do(self, post):
post.return_value = response(payload={"results": {}})
self.assertTrue(youcom_search.run_search("q").startswith("No results."))
@patch("youcom_search.requests.post")
def test_rate_limit_is_something_the_model_can_act_on(self, post):
post.return_value = response(429, headers={"Retry-After": "2"})
with self.assertRaises(youcom_search.SearchUnavailable) as caught:
youcom_search.run_search("q")
self.assertIn("Retry-After: 2", str(caught.exception))
@patch("youcom_search.requests.post", side_effect=requests.Timeout())
def test_timeout_is_something_the_model_can_act_on(self, post):
with self.assertRaises(youcom_search.SearchUnavailable):
youcom_search.run_search("q")
@patch("youcom_search.requests.post")
def test_bad_key_stops_the_run(self, post):
post.return_value = response(401)
with self.assertRaises(RuntimeError) as caught:
youcom_search.run_search("q")
self.assertNotIsInstance(caught.exception, ToolException)
def test_missing_key_stops_the_run(self):
with patch.dict("os.environ", {"YDC_API_KEY": ""}):
with self.assertRaises(RuntimeError):
youcom_search.run_search("q")
def ai(count):
calls = [{"name": "web_search", "id": f"call-{i}"} for i in range(count)]
return SimpleNamespace(type="ai", tool_calls=calls)
HUMAN = SimpleNamespace(type="human")
TOOL = SimpleNamespace(type="tool")
class BudgetTests(unittest.TestCase):
def test_counts_only_the_current_turn(self):
history = [HUMAN, ai(1), TOOL, HUMAN, ai(1), TOOL, ai(1)]
self.assertEqual(searches_this_turn(history), 2)
def test_the_last_allowed_search_runs_and_the_next_is_blocked(self):
allowed = [HUMAN] + [ai(1), TOOL] * (MAX_SEARCHES - 1) + [ai(1)]
blocked = allowed + [TOOL, ai(1)]
self.assertEqual(route({"messages": allowed}), "tools")
self.assertEqual(route({"messages": blocked}), "over_budget")
def test_a_plain_answer_ends_the_run(self):
self.assertEqual(route({"messages": [HUMAN, ai(0)]}), END)
if __name__ == "__main__":
unittest.main()
The third layer runs the whole graph with a scripted chat model. LangChain's unit testing guide documents GenericFakeChatModel, which returns AIMessage objects, including ones with tool_calls, from an iterator, plus InMemorySaver for multi-turn state. The base bind_tools raises NotImplementedError and the fake does not override it, so subclass the fake with a bind_tools that returns self. Script four search requests, patch requests.post, and assert that three reach the API and the fourth gets the budget note.
Offline tests prove your logic, not the provider's behavior, so run the graph once against real keys with recursion_limit and a small budget before you ship it.
Related Guides
- How to Add a Web Search Tool to a LangChain Agent With the You.com Web Search API
- LangChain Agents Documentation: create_agent, Middleware, and What Changed Since 0.3
- How to Add Web Search to Agent Tool Calling With the You.com Web Search API
- What Is the You.com Web Search API? Endpoint, Pricing, and Limits
- How to Set Up the You.com MCP Server for Web Search in Any Client
Frequently Asked Questions
LangGraph depends on langchain-core, which supplies the tool interface and message types, so you already have it. The examples in this guide also import tool and the message classes from the langchain package, and create_agent lives there. You can import the same names from langchain-core if you want a smaller install.
Start with create_agent, the replacement for the deprecated create_react_agent. It runs on LangGraph, and its middleware covers retries, tool-call limits, and human approval. Move to a custom StateGraph when you need routing or state the prebuilt does not expose, such as a per-question search budget in the graph, a branch that skips the model, or extra nodes around the search. The search tool stays the same.
With the default ToolNode handler, the run stops. The default returns only invalid-argument errors to the model and re-raises every other exception, including a timeout or a 429 from your HTTP call. Catch the failures the model can act on with a typed handler or handle_tool_errors=True, and let configuration errors such as a bad API key propagate.
Set a cap your task justifies, and enforce it in code, not only in the prompt. This guide uses three: the router counts requests since the last human message and refuses the fourth. At the $5.00 per 1,000 calls list price (as of October 2026), three searches cost at most $0.015 per question. Keep a recursion limit as the backstop.
LI Test
LI Test
Share Article:
Related resources.

What Is MCP Web Search? Live Results for Model Context Protocol Agents
October 12, 2026
Blog

How to Build LangGraph Integrations with the You.com Web Search API
October 7, 2026
Blog

How to Add Web Search to AutoGen Agents With the You.com MCP Server
October 2, 2026
Blog


