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 roleSourceWithout 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 roleSourceThe detailed structure
web_search(query) — the tool
- A single
@tooldecorator 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
answerandresultsare 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
contentisn’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
ChatLiteLLMfromMODELand assembles the ReAct loop withcreate_agent(model, tools=[web_search]) ChatLiteLLMwraps LiteLLM as a LangChain model — LiteLLM does provider routing (which API),ChatLiteLLMis the interfacecreate_agentexpectsagent.invoke({"messages": […]})runs reason→call-tool→observe- When it finishes, the last message’s
contentis cleaned bymessage_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.
samples/tavily_1June 28, 2026The key parts
- A tool is a function — the whole tool is one
@tool-wrappedweb_search(query). Its docstring is the model’s manual, so the model reads it to judge when to search. - The loop wires the calls —
create_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
MODELin.envto 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.


