Blog
 / 
AI Tools
October 1, 2026

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

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.

PathWho decides to searchReach for it whenWhat you give up
create_agent with YouSearchToolThe modelYou want the standard tool loop and will add limits through middlewareRouting and state belong to the prebuilt graph
Custom StateGraph with ToolNodeThe model, inside routing you writeYou need a search budget, an error policy, an approval step, or extra nodes around the searchYou write and test the wiring
Fixed search nodeYour graph, on every requestEvery request is a lookup and you want one predictable call per questionNo 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, a ToolException subclass, 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 plain RuntimeError, 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, and ToolNode runs 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 route enforces it, counting web_search calls requested since the last human message, so the count resets on each new user turn. The fourth request goes to over_budget, which answers every blocked call with a ToolMessage because 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_error is annotated with ToolException, and ToolNode reads that annotation to decide what to catch (ToolNode reference). SearchUnavailable and the invalid-argument error ToolNode raises for bad model arguments are both ToolExceptions, so both go back to the model as messages. A RuntimeError is 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 StateGraph defaults to 1,000 steps since LangGraph 1.0.6 (graph API docs) and create_agent compiles with 9,999 (langchain 1.4.3 source), so neither stops a loop early. Pass your own as a top-level config key, not under configurable. 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?

SymptomCauseFix
The run ends with a raw exception after one failed searchToolNode re-raises every tool exception except invalid-argument errors, a default since langgraph-prebuilt 1.0.1 (issue 6486) that 1.1.0 still hasRaise 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 loadSelf-serve accounts default to 10 requests per second on /v1/search, shared by parallel tool calls and concurrent usersHonor Retry-After, cap concurrency in your service, and email [email protected] about higher limits
Tool messages crowd out the questioncount defaults to 10 per section, so one call can return web and news hitsSet count, k, and n_snippets_per_hit, or format results yourself as run_search does
The agent searches in circlesA StateGraph allows 1,000 steps by default and create_agent allows 9,999Enforce a budget in the router or with ToolCallLimitMiddleware, and pass recursion_limit
A paused approval never resumes after a restartInMemorySaver keeps state in process memoryUse a durable checkpointer and resume with the same thread_id
The agent follows instructions found in a pageSearch results are untrusted text that enters the model's contextGive 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

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.

    Share Article:

  1. LI Test

  2. LI Test

Related resources.

What Is MCP Web Search? Live Results for Model Context Protocol Agents

What Is MCP Web Search? Live Results for Model Context Protocol Agents

October 12, 2026

Blog

How to Build a LlamaIndex Web Search Retriever with the You.com Web Search API

October 9, 2026

Blog

How to Add a Web Search Plugin to Codex CLI with the You.com MCP Server

October 8, 2026

Blog

How to Build LangGraph Integrations with the You.com Web Search API

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

How to Add Web Search to AutoGen Agents With the You.com MCP Server

October 2, 2026

Blog