Web Search for LLMs: Tavily Search API

· combray's blog


Summary #

For adding web search to your LLM agent, use Tavily (@tavily/core)[1]. Unlike traditional SERP APIs (Serper, SerpAPI) that just return search result metadata, Tavily searches AND extracts content from pages in a single call, returning text optimized for LLM context windows[2].

Key metrics: Tavily is used by LangChain as their default search integration[3]. The free tier provides 1,000 API credits/month—enough for ~500-1000 searches depending on settings[4]. For comparison, Serper offers 2,500 free Google searches but returns only titles/snippets, requiring you to build separate content extraction[5].

Why Tavily wins for your use case: You need code/API lookup with minimal configuration. Tavily's include_answer parameter can even return an LLM-generated summary of results[1]. One API call does what would require 3-4 calls with Serper (search → get URLs → fetch pages → extract content).

Philosophy & Mental Model #

Tavily is designed as "the web access layer for AI agents"[2]. The mental model:

  1. Search + Extract = One Call: Traditional flow requires: SERP API → URLs → HTTP fetch → HTML parse → clean text. Tavily does all of this internally and returns clean, relevant content snippets.

  2. Relevance Scoring: Results include a score field (0-1) indicating relevance. Use this to decide how many results to include in your LLM context.

  3. Depth Control: search_depth: "basic" (1 credit) returns snippets. search_depth: "advanced" (2 credits) retrieves more sources and enables chunking[1].

  4. Content Budget: The max_results and chunks_per_source parameters let you control how much content you're injecting into your prompt. More isn't always better—aim for focused, relevant context.

Key abstractions:

Setup #

Step 1: Get API Key #

  1. Go to tavily.com
  2. Sign up (no credit card required)
  3. Copy your API key (starts with tvly-)

Step 2: Install the SDK #

1npm install @tavily/core

Step 3: Configure Environment #

Add to mise.toml:

1[env]
2TAVILY_API_KEY = "{{env.TAVILY_API_KEY}}"

Or set directly:

1export TAVILY_API_KEY="tvly-your-api-key"

Step 4: Create Client #

1import { tavily } from "@tavily/core";
2
3const tvly = tavily({ apiKey: process.env.TAVILY_API_KEY });

Core Usage Patterns #

Pattern 1: Basic Search for LLM Context #

The simplest pattern—search and get content ready for your LLM:

 1import { tavily } from "@tavily/core";
 2
 3const tvly = tavily({ apiKey: process.env.TAVILY_API_KEY });
 4
 5async function searchForContext(query: string): Promise<string> {
 6  const response = await tvly.search(query, {
 7    searchDepth: "basic",
 8    maxResults: 5,
 9  });
10
11  // Format results for LLM context
12  return response.results
13    .map((r) => `### ${r.title}\nSource: ${r.url}\n\n${r.content}`)
14    .join("\n\n---\n\n");
15}
16
17// Usage
18const context = await searchForContext("TypeScript zod validation examples");

Pattern 2: Search with LLM-Generated Answer #

Get Tavily to summarize results for you (uses their LLM internally):

 1async function searchWithAnswer(query: string) {
 2  const response = await tvly.search(query, {
 3    includeAnswer: "advanced", // "basic" for shorter, "advanced" for detailed
 4    maxResults: 5,
 5  });
 6
 7  return {
 8    answer: response.answer, // Direct answer to include in your response
 9    sources: response.results.map((r) => ({ title: r.title, url: r.url })),
10  };
11}
12
13// Usage
14const { answer, sources } = await searchWithAnswer(
15  "How do I set up ESLint with TypeScript in 2025?"
16);
17
18console.log("Answer:", answer);
19console.log("Sources:", sources);

Pattern 3: Code/Documentation Search with Raw Content #

When you need the full page content (e.g., documentation pages):

 1async function searchDocs(query: string) {
 2  const response = await tvly.search(query, {
 3    searchDepth: "advanced", // Required for better content extraction
 4    includeRawContent: "markdown", // Get full page as markdown
 5    maxResults: 3, // Fewer results since each has more content
 6    includeDomains: [
 7      "developer.mozilla.org",
 8      "nodejs.org",
 9      "typescriptlang.org",
10      "docs.github.com",
11    ],
12  });
13
14  return response.results.map((r) => ({
15    title: r.title,
16    url: r.url,
17    snippet: r.content,
18    fullContent: r.rawContent, // Full markdown content
19    relevance: r.score,
20  }));
21}

For finding recent updates, releases, or announcements:

 1async function searchRecentNews(query: string) {
 2  const response = await tvly.search(query, {
 3    topic: "news", // Optimized for recent content
 4    timeRange: "week", // "day", "week", "month", "year"
 5    maxResults: 5,
 6  });
 7
 8  return response.results.map((r) => ({
 9    title: r.title,
10    url: r.url,
11    content: r.content,
12    publishedDate: r.publishedDate, // Only available with topic: "news"
13  }));
14}
15
16// Usage
17const releases = await searchRecentNews("Node.js release 2025");

Pattern 5: Integration with Gemini Agent #

Combine Tavily search with your Gemini agent as a function tool:

  1import { GoogleGenAI, Type } from "@google/genai";
  2import { tavily } from "@tavily/core";
  3
  4const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
  5const tvly = tavily({ apiKey: process.env.TAVILY_API_KEY });
  6
  7// Define search tool for Gemini
  8const webSearchTool = {
  9  name: "web_search",
 10  description:
 11    "Search the web for current information, documentation, code examples, or recent news",
 12  parameters: {
 13    type: Type.OBJECT,
 14    properties: {
 15      query: {
 16        type: Type.STRING,
 17        description: "The search query",
 18      },
 19      searchType: {
 20        type: Type.STRING,
 21        enum: ["general", "news", "docs"],
 22        description: "Type of search: general, news (recent), or docs (technical)",
 23      },
 24    },
 25    required: ["query"],
 26  },
 27};
 28
 29// Execute search when Gemini calls the tool
 30async function executeWebSearch(
 31  query: string,
 32  searchType: string = "general"
 33): Promise<string> {
 34  const options: any = {
 35    maxResults: 5,
 36    includeAnswer: "basic",
 37  };
 38
 39  if (searchType === "news") {
 40    options.topic = "news";
 41    options.timeRange = "week";
 42  } else if (searchType === "docs") {
 43    options.searchDepth = "advanced";
 44    options.includeDomains = [
 45      "developer.mozilla.org",
 46      "docs.python.org",
 47      "typescriptlang.org",
 48      "nodejs.org",
 49    ];
 50  }
 51
 52  const response = await tvly.search(query, options);
 53
 54  // Format for LLM consumption
 55  let result = "";
 56  if (response.answer) {
 57    result += `## Summary\n${response.answer}\n\n`;
 58  }
 59  result += "## Sources\n";
 60  result += response.results
 61    .map((r) => `### ${r.title}\n${r.url}\n\n${r.content}`)
 62    .join("\n\n");
 63
 64  return result;
 65}
 66
 67// Use in agent loop
 68async function runAgent() {
 69  const chat = ai.chats.create({
 70    model: "gemini-3-pro-preview",
 71    config: {
 72      tools: [{ functionDeclarations: [webSearchTool] }],
 73      systemInstruction: `You are a helpful coding assistant. Use web_search to find current documentation, code examples, or news when needed.`,
 74    },
 75  });
 76
 77  const response = await chat.sendMessage({
 78    message: "How do I use the new Bun test runner?",
 79  });
 80
 81  if (response.functionCalls?.length) {
 82    const call = response.functionCalls[0];
 83    const searchResult = await executeWebSearch(
 84      call.args.query as string,
 85      call.args.searchType as string
 86    );
 87
 88    // Send search results back to Gemini
 89    const finalResponse = await chat.sendMessage({
 90      message: [
 91        {
 92          functionResponse: {
 93            name: call.name,
 94            response: { result: searchResult },
 95          },
 96        },
 97      ],
 98    });
 99
100    console.log(finalResponse.text);
101  }
102}

Pattern 6: Extract Content from Specific URLs #

When you already have URLs and need to extract content:

 1async function extractContent(urls: string[]) {
 2  // Costs 1 credit per 5 successful URLs
 3  const response = await tvly.extract(urls);
 4
 5  return response.results.map((r) => ({
 6    url: r.url,
 7    content: r.rawContent,
 8    success: !r.error,
 9  }));
10}
11
12// Usage: Extract from GitHub README or docs pages
13const content = await extractContent([
14  "https://github.com/anthropics/anthropic-sdk-python",
15  "https://docs.anthropic.com/claude/docs/intro-to-claude",
16]);

Anti-Patterns & Pitfalls #

Don't: Use advanced depth for simple queries #

1// Bad - wastes credits on simple lookups
2const response = await tvly.search("what is typescript", {
3  searchDepth: "advanced", // 2 credits instead of 1
4  includeRawContent: true, // Even more processing
5  maxResults: 20, // Overkill
6});

Why it's wrong: Basic search is sufficient for most queries. Advanced depth is only worth it when you need deeper content extraction or chunking. You're paying double for marginal benefit.

Instead: Match depth to need #

 1// Good - basic search for simple queries
 2const simple = await tvly.search("what is typescript", {
 3  searchDepth: "basic",
 4  maxResults: 3,
 5});
 6
 7// Good - advanced only when you need full content
 8const detailed = await tvly.search("typescript discriminated unions tutorial", {
 9  searchDepth: "advanced",
10  includeRawContent: "markdown",
11  maxResults: 3,
12});

Don't: Request more results than you'll use #

1// Bad - 20 results but LLM context can only fit 3-5
2const response = await tvly.search(query, {
3  maxResults: 20,
4  includeRawContent: true,
5});
6
7// Then only using first 3...
8const context = response.results.slice(0, 3);

Why it's wrong: You're paying for content extraction on results you won't use. More results also slow down the API response.

Instead: Request what you need #

1// Good - request only what fits your context window
2const response = await tvly.search(query, {
3  maxResults: 5,
4  includeRawContent: true,
5});

Don't: Ignore the relevance score #

1// Bad - blindly using all results
2const context = response.results.map((r) => r.content).join("\n\n");

Why it's wrong: Low-relevance results add noise to your LLM context and can hurt response quality.

Instead: Filter by score #

1// Good - only use highly relevant results
2const relevantResults = response.results.filter((r) => r.score > 0.5);
3
4const context = relevantResults.map((r) => r.content).join("\n\n");

Don't: Hardcode API key #

1// Bad - exposed in source control
2const tvly = tavily({ apiKey: "tvly-abc123xyz" });

Why it's wrong: API keys in code get leaked via git history, logs, or bundle inspection.

Instead: Use environment variables #

1// Good
2const tvly = tavily({ apiKey: process.env.TAVILY_API_KEY });
3
4// Even better - fail fast if missing
5const apiKey = process.env.TAVILY_API_KEY;
6if (!apiKey) throw new Error("TAVILY_API_KEY environment variable required");
7const tvly = tavily({ apiKey });

Don't: Use Tavily for raw SERP data #

1// Bad - Tavily abstracts away SERP structure
2const response = await tvly.search("best laptops 2025");
3// No access to: knowledge graphs, featured snippets, shopping results, image carousels

Why it's wrong: Tavily is optimized for LLM consumption, not SERP analysis. It doesn't expose Google's rich SERP features.

Instead: Use Serper for SERP analysis #

 1// Good - use Serper when you need raw Google data
 2const response = await fetch("https://google.serper.dev/search", {
 3  method: "POST",
 4  headers: {
 5    "X-API-KEY": process.env.SERPER_API_KEY,
 6    "Content-Type": "application/json",
 7  },
 8  body: JSON.stringify({ q: "best laptops 2025" }),
 9});
10
11const data = await response.json();
12// Access: data.knowledgeGraph, data.peopleAlsoAsk, data.shopping, etc.

Caveats #

Alternative: Serper (When You Need Raw Google Data) #

If you need actual Google SERP data (knowledge graphs, featured snippets, People Also Ask), use Serper instead:

 1async function googleSearch(query: string) {
 2  const response = await fetch("https://google.serper.dev/search", {
 3    method: "POST",
 4    headers: {
 5      "X-API-KEY": process.env.SERPER_API_KEY!,
 6      "Content-Type": "application/json",
 7    },
 8    body: JSON.stringify({
 9      q: query,
10      num: 10,
11    }),
12  });
13
14  const data = await response.json();
15
16  return {
17    knowledgeGraph: data.knowledgeGraph,
18    organic: data.organic, // { title, link, snippet, position }[]
19    peopleAlsoAsk: data.peopleAlsoAsk,
20    relatedSearches: data.relatedSearches,
21  };
22}

Serper pricing: 2,500 free searches, then $50/month for 50,000 searches ($0.001 each)[5].

References #

[1] Tavily Search API Reference - Official API documentation with full parameter and response schemas

[2] Tavily Homepage - "The Web Access Layer for AI Agents"

[3] Tavily vs Serper API Comparison - Detailed comparison of output formats and use cases

[4] Tavily Credits & Pricing - Credit costs and plan tiers

[5] Serper.dev - Fast and cheap Google Search API, 2,500 free queries

[6] The Complete Guide to Web Search APIs for AI Applications in 2025 - Comprehensive comparison of search API options

[7] Tavily JavaScript SDK Quick Start - Official SDK setup and examples

[8] @tavily/core on npm - Official npm package

[9] 7 Free Web Search APIs for AI Agents - Overview of free tier options

[10] Beyond Tavily - AI Search APIs in 2025 - Alternative options and when to use them

last updated: