· Doc version 1.0

Building a web-search agent: asking today's FX rate

"Today's FX rate?" is something a model can't answer alone. We build a LangGraph agent with a single Tavily web-search tool to see how a tool bridges past the training cutoff.

A web-search agent — asking today's FX rate.

Related concepts

The Tools concept uses “asking today’s FX rate” as an example.
A model’s knowledge stops at its training cutoff, so a value that changes daily — like an exchange rate — is out of reach; a web-search tool is what bridges that gap. Here we turn that example into a working agent.

What we’re building

An agent that, given a question, decides on its own “I should search for this,” queries the web through Tavily, reads the fresh results, and answers with the rate and its sources.

flowchart LR
  q["question — today's USD/KRW rate?"] --> model["model"]
  model -- query --> tool["web_search · Tavily"]
  tool --> data[("results · sources")]
  data --> model
  model --> ans["answer + sources"]
  class model roleModel
  class tool roleTool
  class data roleSource

Without the tool the model can only guess at a stale value; with one tool, the answer is grounded in today’s web.

Reading the code

The overall structure

The whole flow in app.py splits into three functions.

  • main() wires the model and the agent,
  • web_search() is the tool the model calls,
  • and message_text() cleans up the final answer.
flowchart TB
  q["question (sys.argv)"] --> build["main() — build model + agent"]
  build --> react{"ReAct loop"}
  react -- reason --> model["model (ChatLiteLLM)"]
  model -- tool call --> tool["web_search(query)"]
  tool --> tavily[("Tavily.search")]
  tavily -- answer · results --> tool
  tool -- observe --> react
  react -- done --> msg["message_text(content)"]
  msg --> out["print → stdout"]
  class model roleModel
  class tool roleTool
  class tavily roleSource

The detailed structure

  • A single @tool decorator turns a plain Python function into a tool the model can call
  • Its docstring is the model’s manual — the model reads it to decide when to call
  • Inside, _tavily.search(query, include_answer=True, max_results=5) hits Tavily
  • The returned answer and results are folded into one block of text — the next reasoning step’s input
@tool
def web_search(query: str) -> str:
    """Search the web for current information, returning a short answer with sources.

    Use this for anything past the model's training cutoff — prices, exchange
    rates, news, today's facts.
    """
    res = _tavily.search(query, search_depth="basic", include_answer=True, max_results=5)
    lines = []
    if res.get("answer"):
        lines.append(res["answer"])
    for r in res.get("results", []):
        lines.append(f"- {r['title']} ({r['url']})")
    return "\n".join(lines) or "No results."

message_text(content) — cleaning the output

  • A reply’s content isn’t uniformly shaped — cloud models return a string, some local models a list of blocks like [{type: "text", …}, …]
  • For a list, it keeps only the text of type == "text" blocks
  • For a string, it passes straight through
  • So any provider prints as one clean line
def message_text(content) -> str:
    """Flatten an assistant message's content to plain text (cloud models return a
    string; some local models return a list of blocks)."""
    if isinstance(content, list):
        return "".join(
            part.get("text", "")
            for part in content
            if isinstance(part, dict) and part.get("type") == "text"
        )
    return content

main() — the wiring

  • Builds ChatLiteLLM from MODEL and assembles the ReAct loop with create_agent(model, tools=[web_search])
  • ChatLiteLLM wraps LiteLLM as a LangChain model — LiteLLM does provider routing (which API), ChatLiteLLM is the interface create_agent expects
  • agent.invoke({"messages": […]}) runs reason→call-tool→observe
  • When it finishes, the last message’s content is cleaned by message_text() and printed
  • Whether to call the tool — and whether to call again — is entirely the loop’s decision
def main() -> None:
    question = " ".join(sys.argv[1:]) or "오늘 USD/KRW 환율은?"

    # MODEL chooses the provider (claude-opus-4-8 / gpt-4o / gemini/gemini-2.5-flash).
    model = ChatLiteLLM(model=os.environ.get("MODEL", "claude-opus-4-8"), temperature=0)
    agent = create_agent(model, tools=[web_search])

    result = agent.invoke({"messages": [{"role": "user", "content": question}]})
    print(message_text(result["messages"][-1].content))

The import says langchain, but what create_agent returns is a LangGraph graph.
What the same loop looks like wired with just LiteLLM + LangGraph — no glue layer — is covered in a separate comparison.

The implementation

A LangGraph ReAct loop with a single web_search tool. The model is routed through LiteLLM, so the same code runs on Claude, OpenAI, or Gemini.

Related sampleTavily web-search agent — asking today's FX rateA ~40-line LangGraph ReAct agent with a single websearch tool backed by Tavily. Ask it something only current data can answer — "today's USD→KRW rate" — and it searches the web, reads the fresh results, and answers with the number and its sources. The model is routed through LiteLLM, so the same code works with Anthropic Claude, OpenAI, or Google AI Studio (Gemini). Change MODEL in .env, never the code.samples/tavily_1June 28, 2026

The key parts

  • A tool is a function — the whole tool is one @tool-wrapped web_search(query). Its docstring is the model’s manual, so the model reads it to judge when to search.
  • The loop wires the callscreate_agent(model, tools=[web_search]) runs reason→call-tool→observe, deciding whether to call the tool and whether to call again after seeing the result.
  • The result becomes evidence — Tavily’s answer and sources feed back into reasoning, so the model answers with a looked-up value instead of a guess.
  • The provider is swappable — change MODEL in .env to run the same code on a different model.

Swap the tool for scraping (Firecrawl) in the same loop and it reads a page in as Markdown; swap it for browser automation and it pulls in a different kind of “now” data. The full set of tool kinds is laid out in the Tools concept.

Related tools

Related writing