Skip to main content
Last verified: 2026-06-03

Overview

Add live web search and page extraction to a Cartesia Line voice agent using loopback tools backed by Tavily. The agent can answer questions about current events, fresh facts, and specific URLs mid-call.

Prerequisites

  • Python 3.10+
  • A Cartesia API key (CARTESIA_API_KEY) from Cartesia keys
  • A Tavily API key (TAVILY_API_KEY) from the Tavily dashboard
  • An LLM provider key — OPENAI_API_KEY for the example below
  • The Cartesia CLI for local chat testing (Line quickstart)

Installation

pip install cartesia-line tavily-python
Set the three keys in your environment before running the agent:
export CARTESIA_API_KEY="..."
export TAVILY_API_KEY="..."
export OPENAI_API_KEY="..."

Quick start

Define two loopback tools backed by AsyncTavilyClient — one for search, one for full-page extraction — and pass them to an LlmAgent. Run the file, then connect with cartesia chat 8000.
"""Voice research agent: Cartesia Line + Tavily."""

from datetime import datetime
import os
from typing import Annotated, Optional

from tavily import AsyncTavilyClient

from line.llm_agent import LlmAgent, LlmConfig, ToolEnv, end_call, loopback_tool
from line.voice_agent_app import AgentEnv, CallRequest, VoiceAgentApp

SYSTEM_PROMPT = """Today is {today}. You are a research assistant on a live voice call.

You have two web tools:

1. web_search — Find relevant pages. Use for current events, prices, news, or anything
   that needs fresh data. Start here.
2. web_extract — Pull full content from a specific URL. Use only when a search snippet
   is too thin, or the user names a link they want read.

Lead with the answer. Two or three sentences unless the user asks for more. Name your
source when it matters. If results conflict or look stale, say so. Use end_call when
the user wraps up.

This is a voice call. Plain sentences only — no markdown, no lists, no special characters."""

INTRODUCTION = (
    "Hey! I'm your research assistant, powered by Tavily and Cartesia. "
    "Ask me anything and I'll dig it up live."
)

EXTRACT_MAX_CHARS = 3000


class TavilyTools:
    # One client per agent so the httpx connection pool is reused across tool calls.
    def __init__(self, api_key: str):
        self._client = AsyncTavilyClient(api_key=api_key, client_source="cartesia-line-agent")

    @loopback_tool
    async def web_search(
        self,
        ctx: ToolEnv,
        query: Annotated[str, "The search query. Be specific."],
        time_range: Annotated[
            Optional[str],
            "Optional recency filter: 'day', 'week', 'month', or 'year'.",
        ] = None,
    ) -> str:
        """Search the web for current information."""
        # 'fast' depth keeps latency in the budget for a voice turn.
        kwargs: dict = {"query": query, "search_depth": "fast", "max_results": 5}
        if time_range is not None:
            kwargs["time_range"] = time_range
        response = await self._client.search(**kwargs)

        results = response.get("results", [])
        if not results:
            return "No relevant information found."

        parts = [f"Search results for: '{query}'\n"]
        for i, r in enumerate(results, start=1):
            parts.append(f"\n--- Source {i}: {r['title']} (score {r.get('score', 0):.2f}) ---\n")
            if r.get("content"):
                parts.append(f"{r['content']}\n")
            parts.append(f"URL: {r['url']}\n")
        return "".join(parts)

    @loopback_tool
    async def web_extract(
        self,
        ctx: ToolEnv,
        url: Annotated[str, "The URL to extract content from."],
    ) -> str:
        """Extract the full content of a webpage given its URL."""
        response = await self._client.extract(urls=[url])

        results = response.get("results", [])
        if not results:
            failed = response.get("failed_results", [])
            if failed:
                return f"Extraction failed for {url}: {failed[0].get('error', 'unknown error')}"
            return "No content could be extracted from that URL."

        raw = results[0].get("raw_content", "")
        if not raw:
            return "The page was reached but no readable content was found."
        if len(raw) > EXTRACT_MAX_CHARS:
            raw = raw[:EXTRACT_MAX_CHARS] + "\n\n[Content truncated]"
        return f"Extracted content from {url}:\n\n{raw}"


def _require_env(name: str, where: str) -> str:
    value = os.environ.get(name)
    if not value:
        raise RuntimeError(f"{name} is not set. Get one at {where} and export it before running.")
    return value


async def get_agent(env: AgentEnv, call_request: CallRequest):
    tavily = TavilyTools(api_key=_require_env("TAVILY_API_KEY", "https://app.tavily.com/home"))
    return LlmAgent(
        model="openai/gpt-4o-mini",
        api_key=_require_env("OPENAI_API_KEY", "https://platform.openai.com/api-keys"),
        tools=[tavily.web_search, tavily.web_extract, end_call],
        config=LlmConfig(
            system_prompt=SYSTEM_PROMPT.format(today=datetime.now().strftime("%Y-%m-%d")),
            introduction=INTRODUCTION,
            max_tokens=600,
            temperature=0.7,
        ),
    )


app = VoiceAgentApp(get_agent=get_agent)

if __name__ == "__main__":
    app.run()
Run it:
python main.py
# in another terminal:
cartesia chat 8000
You’ll hear: “Hey! I’m your research assistant, powered by Tavily and Cartesia…” Try asking about recent news or pointing it at a specific URL.

Configuration

Both tools accept the standard Tavily parameters — these are the ones worth tuning for a voice agent:
ParameterTypeDefaultDescription
search_depthstring"fast""fast" for voice latency; "advanced" for deeper retrieval at higher latency.
max_resultsint5Number of search results returned to the LLM.
time_rangestringNoneRecency filter — "day", "week", "month", "year".
EXTRACT_MAX_CHARSint3000Local cap on extracted page content to keep the LLM context tight.
See the Tavily Search API reference for the full parameter set, including topic, include_domains, and country.

Resources