By the end of this tutorial, you’ll have a working Claude agent that can fetch live web data, parse HTML content, and synthesize real-time information into structured responses — with proper error handling for the dynamic content that breaks most naive implementations. Claude agents web browsing capability is one of the highest-leverage skills you can add to any LLM workflow, and the implementation is more straightforward than most tutorials suggest.
The core mechanism is Claude’s tool use API. You define a browse_url tool, Claude decides when to call it, you execute the HTTP request and return the content, then Claude synthesizes a final answer. No browser automation required for most use cases — just clean HTTP requests and HTML parsing.
- Install dependencies — Set up the Anthropic SDK, httpx, and BeautifulSoup
- Define the browsing tool — Write the tool schema Claude will use to request URLs
- Build the HTTP fetcher — Handle requests, redirects, and rate limits
- Parse and clean HTML — Strip noise and extract readable content
- Wire up the agentic loop — Connect tool calls to Claude’s response cycle
- Add error handling — Graceful degradation for blocked sites and dynamic content
Step 1: Install Dependencies
You need four packages. httpx over requests because it handles async and has better timeout control. beautifulsoup4 with lxml for fast parsing. anthropic for the SDK.
pip install anthropic httpx beautifulsoup4 lxml python-dotenv
Pin your versions in production. The Anthropic SDK has had breaking changes between minor versions:
anthropic==0.34.2
httpx==0.27.0
beautifulsoup4==4.12.3
lxml==5.2.2
Step 2: Define the Browsing Tool Schema
Claude’s tool use works by you declaring what tools exist, Claude emitting a tool_use block when it wants to call one, and you returning the result as a tool_result message. The schema needs to be precise — Claude uses the description to decide when and how to call the tool.
import anthropic
client = anthropic.Anthropic()
# Define the tool Claude will use to request web content
TOOLS = [
{
"name": "browse_url",
"description": (
"Fetches the content of a URL and returns the readable text. "
"Use this to retrieve current information from websites, news articles, "
"documentation, or any publicly accessible web page. "
"Returns the page title, main text content, and any relevant links."
),
"input_schema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The full URL to fetch, including https://"
},
"focus": {
"type": "string",
"description": "Optional: specific section or information to prioritize when parsing"
}
},
"required": ["url"]
}
}
]
Step 3: Build the HTTP Fetcher
Most tutorials skip the headers. Don’t. Without a realistic User-Agent, you’ll get 403s from Cloudflare on roughly 40% of production sites. Also set timeouts — a hung request will stall your entire agent loop.
import httpx
from typing import Optional
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
}
def fetch_url(url: str, timeout: int = 15) -> dict:
"""
Fetch a URL and return status + raw HTML.
Returns a dict with 'success', 'content', and 'error' keys.
"""
try:
with httpx.Client(
headers=HEADERS,
follow_redirects=True,
timeout=timeout
) as client:
response = client.get(url)
response.raise_for_status()
return {
"success": True,
"content": response.text,
"status_code": response.status_code,
"final_url": str(response.url) # captures redirects
}
except httpx.TimeoutException:
return {"success": False, "error": f"Request timed out after {timeout}s"}
except httpx.HTTPStatusError as e:
return {"success": False, "error": f"HTTP {e.response.status_code}: {e.response.reason_phrase}"}
except Exception as e:
return {"success": False, "error": str(e)}
Step 4: Parse and Clean HTML
Raw HTML fed to Claude is wasteful and expensive. A typical news article page is 50–200KB of HTML; the actual article content is 2–5KB. Stripping scripts, styles, and nav elements before sending to Claude cuts token costs significantly — and Claude’s responses are better when it’s not wading through boilerplate.
from bs4 import BeautifulSoup
def parse_html(html: str, focus: Optional[str] = None) -> str:
"""
Extract readable text from HTML. Removes scripts, styles,
nav elements, and other non-content tags.
"""
soup = BeautifulSoup(html, "lxml")
# Remove noise elements
for tag in soup(["script", "style", "nav", "footer", "header",
"aside", "advertisement", "noscript", "iframe"]):
tag.decompose()
# Try to find the main content area first
main_content = (
soup.find("main") or
soup.find("article") or
soup.find(id="content") or
soup.find(class_="content") or
soup.body
)
if not main_content:
return soup.get_text(separator="\n", strip=True)[:8000]
# Extract title separately
title = soup.title.string if soup.title else "No title"
# Get clean text
text = main_content.get_text(separator="\n", strip=True)
# Remove excessive blank lines
lines = [line for line in text.splitlines() if line.strip()]
clean_text = "\n".join(lines)
# Cap at ~6000 chars to stay within reasonable token limits
# This is roughly $0.0006 of input tokens on claude-3-5-haiku
if len(clean_text) > 6000:
clean_text = clean_text[:6000] + "\n\n[Content truncated...]"
return f"Title: {title}\n\nContent:\n{clean_text}"
Step 5: Wire Up the Agentic Loop
This is where Claude agents web browsing actually comes together. The loop runs until Claude stops calling tools and returns a final text response. Keep the loop bounded — runaway agents that keep browsing are a real cost problem. I cap at 5 tool calls per query; adjust based on your use case.
import json
def execute_tool_call(tool_name: str, tool_input: dict) -> str:
"""Execute a tool call and return the result as a string."""
if tool_name == "browse_url":
url = tool_input["url"]
focus = tool_input.get("focus")
fetch_result = fetch_url(url)
if not fetch_result["success"]:
return f"Failed to fetch {url}: {fetch_result['error']}"
parsed = parse_html(fetch_result["content"], focus=focus)
return parsed
return f"Unknown tool: {tool_name}"
def browse_agent(user_query: str, max_tool_calls: int = 5) -> str:
"""
Run a browsing agent loop. Claude will call browse_url as needed,
then return a synthesized final answer.
"""
messages = [{"role": "user", "content": user_query}]
tool_call_count = 0
while tool_call_count < max_tool_calls:
response = client.messages.create(
model="claude-3-5-haiku-20241022", # ~$0.0008/1K input tokens — good for browsing tasks
max_tokens=2048,
tools=TOOLS,
messages=messages
)
# Append Claude's response to the message history
messages.append({"role": "assistant", "content": response.content})
# If Claude is done (no tool calls), return the text response
if response.stop_reason == "end_turn":
text_blocks = [b.text for b in response.content if hasattr(b, "text")]
return "\n".join(text_blocks)
# Process all tool calls in this response
tool_results = []
for block in response.content:
if block.type == "tool_use":
tool_call_count += 1
print(f"[Tool call {tool_call_count}] {block.name}: {block.input.get('url', '')}")
result = execute_tool_call(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result
})
# Return tool results to Claude
messages.append({"role": "user", "content": tool_results})
return "Max tool calls reached. Partial results may be in the conversation history."
# Example usage
if __name__ == "__main__":
result = browse_agent(
"What is the current price of Bitcoin and what happened in the market today?"
)
print(result)
This runs on claude-3-5-haiku-20241022. For a single query with one browse call, you’re looking at roughly $0.001–0.003 total depending on page size. For high-volume use cases, check out strategies for cutting API costs with prompt caching — you can cache the system prompt and tool definitions across requests.
Step 6: Add Error Handling for Dynamic Content
JavaScript-rendered pages are the biggest failure mode here. Sites like Twitter/X, many SaaS dashboards, and most SPAs return near-empty HTML without JavaScript execution. Your fetch will succeed (200 OK) but return <1KB of content.
def is_content_empty(text: str, min_chars: int = 200) -> bool:
"""Detect if parsed content is suspiciously short (likely JS-rendered)."""
# Strip the title line for the check
content_only = text.replace("Title:", "").replace("Content:", "").strip()
return len(content_only) < min_chars
def fetch_with_fallback(url: str) -> str:
"""
Try plain HTTP fetch first. If content is too thin,
suggest alternative sources or explain the limitation.
"""
result = fetch_url(url)
if not result["success"]:
return f"Error: {result['error']}"
parsed = parse_html(result["content"])
if is_content_empty(parsed):
# For JS-heavy sites, suggest alternatives rather than silently failing
return (
f"Warning: The page at {url} appears to use JavaScript rendering "
f"and returned minimal content. "
f"Consider using the site's API directly, or try a cached version "
f"via https://webcache.googleusercontent.com/search?q=cache:{url}"
)
return parsed
For full JS rendering support, you’d integrate Playwright or Puppeteer. That adds ~2s latency per request and significant infrastructure complexity. I’d only go there if your use case specifically requires it — most informational queries work fine with static fetching. If you need computer-use-style browser automation, that’s a different architecture entirely.
You should also build fallback logic at the agent level. When browsing fails repeatedly, you don’t want Claude silently hallucinating — read our guide on graceful degradation patterns for Claude agents to handle this systematically.
Common Errors and How to Fix Them
Error 1: Tool result returns nothing and Claude hallucinates
This happens when execute_tool_call raises an exception and you haven’t caught it, so the tool result is empty. Claude will often invent content rather than admit failure.
Fix: Always return a string from tool calls — never let them raise. Wrap everything in try/except and return error messages as the tool result content. Claude handles “this tool failed because X” well and will either retry or tell the user honestly.
Error 2: 429 rate limiting from target sites
If your agent browses the same domain multiple times (e.g., scraping multiple pages of a site), you’ll hit rate limits fast.
Fix: Add a simple domain-level rate limiter. Track the last request time per domain and insert a delay if you’ve hit the same domain within 2 seconds. For production agents hitting many sites, consider a proxy rotation service — Bright Data and Oxylabs both have Python SDKs.
import time
from collections import defaultdict
_last_request_time = defaultdict(float)
def rate_limited_fetch(url: str, min_delay: float = 2.0) -> dict:
from urllib.parse import urlparse
domain = urlparse(url).netloc
elapsed = time.time() - _last_request_time[domain]
if elapsed < min_delay:
time.sleep(min_delay - elapsed)
result = fetch_url(url)
_last_request_time[domain] = time.time()
return result
Error 3: Context window overflow on multi-step research
If the agent browses 4–5 pages, each returning 6,000 characters of content, you’re putting 24,000+ characters into the message history. On Claude 3.5 Haiku, that’s manageable, but costs add up and you approach context limits on longer sessions.
Fix: Summarize tool results before adding them back to the message history. After parsing, run a quick compression pass — ask Claude (in a separate call) to summarize the key facts from each page in 2–3 sentences. This keeps the context lean. For complex multi-step research workflows, this pairs well with prompt chaining techniques to break the task across multiple focused calls.
Production Considerations Before You Ship
A few things that will bite you if you skip them:
- robots.txt compliance — Check
/robots.txtbefore crawling. For automated agents hitting sites at scale, ignoring this is a legal and ethical problem. - Logging tool calls — Log every URL your agent fetches. When something goes wrong, you need to know exactly what was retrieved. See our piece on observability for Claude agents for a complete logging setup.
- Input validation — Claude might sometimes generate or hallucinate URLs. Validate that URLs start with
https://and aren’t pointing at internal network addresses (SSRF protection). - Cost monitoring — A browsing agent that calls 5 tools per query at $0.002/call is $0.01 per user request. At 1,000 requests/day that’s $10/day. Not alarming, but worth tracking from day one.
If you’re planning to deploy this on serverless infrastructure, the stateless request model works well — each agent invocation is independent. The comparison of serverless platforms for Claude agents covers the latency and cold-start tradeoffs worth considering for real-time browsing workloads.
What to Build Next
Add a search tool alongside browse_url. Right now the agent needs to know the exact URL it wants. Add a second tool that wraps the Brave Search API or SerpAPI — this lets Claude search for relevant URLs first, then browse the best results. The two-tool combination (search + browse) unlocks proper autonomous research workflows. Brave Search API costs $3/1,000 queries, which is reasonable for production use. The agentic loop code above handles multiple tools with zero changes — just add the search tool definition to your TOOLS list and handle the new tool name in execute_tool_call.
Frequently Asked Questions
Can Claude browse the web natively without any tool setup?
No. The base Claude API has a knowledge cutoff and no live internet access. You have to implement web browsing yourself via the tool use API, as shown in this tutorial. Anthropic’s Claude.ai web interface has browsing capabilities, but those aren’t exposed through the API for you to use directly in your own agents.
How do I handle websites that require login to access content?
Pass session cookies in the request headers — store them as environment variables or in a secrets manager. For OAuth-protected APIs, implement the auth flow separately and pass the Bearer token in headers. Never hardcode credentials in your tool implementation. For truly headless auth flows, Playwright with persistent browser contexts is the cleanest approach.
What’s the difference between this tool-use approach and using MCP for web browsing?
The Model Context Protocol (MCP) is a standardized way to expose tools to Claude, particularly useful when you want to share tool implementations across multiple agents or use pre-built integrations. The tool-use approach in this tutorial is self-contained and doesn’t require MCP infrastructure — it’s faster to get running but less reusable at scale. If you’re building multiple agents that all need browsing, MCP is worth the setup overhead.
How do I browse JavaScript-heavy SPAs without a headless browser?
You have three options: use a pre-rendered cache service like Google’s cache or archive.org, find the underlying API the SPA calls (most SPAs fetch JSON from a REST endpoint you can hit directly), or bite the bullet and integrate Playwright. For most production use cases, finding the underlying API is the cleanest approach — it’s faster, cheaper, and more reliable than browser automation.
How much does a web-browsing Claude agent cost to run per query?
On claude-3-5-haiku-20241022 with one browse call per query and typical page content (~1,500 tokens in, ~500 tokens out): roughly $0.0015–0.003 per query. With 3–4 browse calls for a research task, expect $0.005–0.01 per query. These are ballpark figures — actual costs depend on page content volume and your response length.
Put this into practice
Try the Web Vitals Optimizer agent — ready to use, no setup required.
Editorial note: API pricing, model capabilities, and tool features change frequently — always verify current details on the vendor’s website before building in production. Code examples are tested at time of writing; pin your dependency versions to avoid breaking changes. Some links in this article may be affiliate links — we may earn a commission if you sign up, at no extra cost to you.

