PINGDOM_CHECK

#ExtractSummit2026 The world's largest web scraping conference returns. Austin Oct 7–8 · Dublin Nov 10–11.

Register now
Data Services
Pricing
Login
Try Zyte APIContact Sales
  • Unblocking and Extraction

    Zyte API

    The ultimate API for web scraping. Avoid website bans and access a headless browser or AI Parsing

    Ban Handling

    Headless Browser

    AI Extraction

    SERP

    Enterprise

    DocumentationSupport

    Hosting and Deployment

    Scrapy Cloud

    Run, monitor, and control your Scrapy spiders however you want to.

    Coding Agent Add-Ons

    Agentic Web Data

    Plugins that give coding agents the context to build production Scrapy projects. Starts with Claude Code.

  • Data Services
  • Pricing
  • Browse

    • BlogArticles, podcasts, videos
    • Case studiesCustomer outcomes
    • White papersIn-depth reports
    • DocumentationGuides & API reference
    • EventsConferences, webinars, recordings

    Subscribe

    • NewsletterSwiftly delivered
    • Join our community2,000+ web scraping engineers
  • Product and E-commerce

    From e-commerce and online marketplaces

    Data for AI

    Collect and structure web data to feed AI

    Job Posting

    From job boards and recruitment websites

    Real Estate

    From Listings portals and specialist websites

    News and Article

    From online publishers and news websites

    Search

    Search engine results page data (SERP)

    Social Media

    From social media platforms online

  • Meet Zyte

    Our story, people and values

    Contact us

    Get in touch

    Support

    Knowledge base and raise support tickets

    Terms and Policies

    Accept our terms and policies

    Open Source

    Our open source projects and contributions

    Web Data Compliance

    Guidelines and resources for compliant web data collection

    Join the team building the future of web data
    We're Hiring
    Trust Center
    Security, compliance & certifications
Login
Try Zyte APIContact Sales
All articles
AI71, 71 articles
Data quality15, 15 articles
Developer interest59, 59 articles
Integration2, 2 articles
Open-source50, 50 articles
Proxies35, 35 articles
Scraping practice35, 35 articles
Scraping strategy47, 47 articles
Search results4, 4 articles
Web data74, 74 articles
Web scraping APIs49, 49 articles
Scrapy47, 47 articles
Scrapy Cloud26, 26 articles
Web Scraping Copilot11, 11 articles
Zyte API67, 67 articles
AI & Machine Learning3, 3 articles
Automotive3, 3 articles
E-commerce & retail33, 33 articles
Entertainment & Streaming2, 2 articles
Financial Services8, 8 articles
Government2, 2 articles
Market Research & Intelligence7, 7 articles
Media & publishing11, 11 articles
Real Estate2, 2 articles
Recruitment & HR3, 3 articles
Transportation & Logistics2, 2 articles
Travel & hospitality3, 3 articles
iPaaS2, 2 articles
Large language model29, 29 articles
MCP3, 3 articles
Python110, 110 articles
Scraping at Scale7, 7 articles
Scraping Fundamentals11, 11 articles
Web Scraping Industry Report20, 20 articles

Appearance

Discord Community
BlogHarness Engineering, part 4: giving your agent a custom fetch tool that survives the real web
ArticleTutorial / How-to

Harness Engineering, part 4: giving your agent a custom fetch tool that survives the real web

Your AI agent is as powerful as the tools it has access to. Here's a tutorial on how you can create your own custom agent tool using Claude Agent SDK for zyte which makes getting structured data from web a breeze.

Ayan Pahwa · Developer Advocate

August 5, 2026

Harness Engineering, part 4: giving your agent a custom fetch tool that survives the real web

This series keeps on growing, because there’s so much to do in this space, so here I am back with part 4 talking about building custom agent tools. These tools along with other parts make up a harness. Let’s dive in.

Here is a page that quietly breaks AI agents. Fetch https://auto.hylnd7.com/ with an ordinary HTTP client, convert the result to text, and this is everything you get:

1## Checking your browser...
2Please wait while we verify your request.
Copy

Seventy characters. No error, no exception, no warning. That page is a demo auto parts store whose first page lists eight products, sitting behind a JavaScript security check. An HTTP client does not run JavaScript, so it gets the check instead of the store.
Now picture an agent asked to read that page and list the products. It calls its fetch tool, the tool succeeds, and it reads what it was handed. Nothing tells it a store exists. At best it reports no products; at worst it invents plausible ones.
No prompt fixes this and no bigger model fixes it, because neither can recover information the agent never received. A better tool fixes it.
This article builds one. The worked example is a web fetch backed by Zyte API, but the shape is the point: by the end you will have the pattern for any capability your agent lacks.

First, what a tool actually is

A language model cannot do anything on its own except produce text. Tool calling changes that, and it is simpler than it sounds.
You describe a function to the model: its name, what it is for, what arguments it takes. When the model needs it, it does not run anything; it emits a structured request meaning "call zyte_page_fetch with url=https://example.com". Your code runs the function and sends the result back. The model reads it and either answers or asks again.
That cycle, repeated until the model stops asking, is the agent loop. Writing it yourself is not hard; the reason to use an SDK is everything around it: retries, streaming, context management, permissions, and tools you did not have to build.

Choosing where to build

Anthropic ships four different things here, and the names blur together. Their own comparison splits them like this:

Option What it is
Agent SDK "A library that runs the agent loop in your own process, in Python or TypeScript."
Claude Code CLI "The terminal interface, built for daily interactive use."
Client SDK "Direct access to the Anthropic API rather than to Claude Code. You implement the tool loop yourself."
Managed Agents "Hosted REST API, a separate product from the Agent SDK. Anthropic runs the agent and the sandbox."
For this example I went with Agent SDK: it is essentially Claude Code packaged as a library, so the loop, context handling, and built-in tools come for free, and it runs in your process under your credentials. Adding a tool takes a few dozen lines.
That last point is really about the harness: the machinery around the model that decides what it can perceive and do. This is part four of a series on it, after what a harness is, designing a data extraction agent's tools, and headless mode as the minimal harness. This one builds a tool the harness does not ship with.
Everything below is verified against claude-agent-sdk 0.2.128.

Where the built-in tools stop

The Agent SDK inherits Claude Code's built-in tools, and the shape of that set is telling. Read, Write, Edit, Bash, Glob, Grep and friends are all about the machine the agent runs on. Only WebSearch and WebFetch are built for retrieving web content.
WebFetch is more capable than people assume. From Anthropic's tools reference:
| WebFetch takes a URL and a prompt describing what to extract. It fetches the page, converts the response to Markdown when the server returns HTML, and runs the prompt against the content using a small, fast model. For most fetches, Claude receives that model's answer, not the raw page. The conversion step is not configurable.
So it already converts HTML to markdown; a custom tool will not beat it on tidiness. The two real gaps are different:
BUT, It cannot get past the bot wall. Whatever it does with the HTML afterward, it still has to obtain the HTML, and for our store that means the security check. The reference suggests curl via Bash as the raw-page escape hatch, and that hits the same wall. No amount of prompting closes a capability gap.
Here is Claude Code trying exactly that on our store:
image
The terminal labels the tool Fetch; its canonical name is WebFetch, which is the string you use in allowed_tools and permission rules. The final step here routes through a Zyte skill rather than the custom tool we build below, but the unblocker is what gets the page either way.
Notice what happened there, because it is the good outcome: the fetch failed loudly, so the model knew it had nothing and looked for another route. Compare the case this article opened with, where the bot wall returns HTTP 200 and a well-formed page. Nothing signals failure, so the challenge page enters context as legitimate content and the model ends up describing a URL it never saw. That is when training data leaks in: a plausible auto parts store can be assembled entirely from priors, without a single fetched fact. A tool that fails loudly beats one that fails quietly.
It is lossy on purpose. The same reference is blunt about the trade: "This makes WebFetch lossy by design. The extraction prompt determines what reaches Claude, so a result that says a page doesn't mention something may only mean the prompt didn't ask about it." For a summary that is fine. For an agent reasoning over a whole page, an answer indistinguishable from an absence is a problem.
So the tool we want fetches pages that fight back and returns the whole page, not another model's reading of it.

Building the tool

A tool in the Agent SDK is three things: a description that gives an idea to the model about when to use it, a JSON schema describing its inputs, and an async function that does the work. The @tool decorator binds them together.
Start with the description and the schema:

1_DESCRIPTION = (
2    "Fetch a URL through the Zyte API and return the page as clean, readable markdown "
3    "with links kept inline as [text](url). Zyte applies anti-ban / bot-bypass and "
4    "JavaScript rendering, so use this in place of the built-in web fetch whenever a "
5    "page is blocked, returns 403/CAPTCHA, or needs JavaScript to render."
6)
7_SCHEMA = {
8    "type": "object",
9    "properties": {
10        "url": {"type": "string", "description": "The absolute URL to fetch."},
11        "render": {
12            "type": "boolean",
13            "description": "Render JavaScript in a headless browser first (default true, "
14            "best for blocked/JS pages). Set false for a cheaper HTTP-only fetch of static pages.",
15        },
16    },
17    "required": ["url"],
18}
Copy

Refer docs :

  • https://code.claude.com/docs/en/agent-sdk/overview
  • https://code.claude.com/docs/en/agent-sdk/custom-tools
    Spend real effort on that description. It is not documentation for humans; it is the only thing the model consults when deciding whether this tool fits. Write it prescriptively: say when to call it, not just what it does. The clause about pages "blocked, returns 403/CAPTCHA, or needs JavaScript" is what makes the model reach for this instead of a built-in.
    Now the handler. It lives inside a factory function so configuration like the API key is captured in a closure, never passed through the model:
1import asyncio
2from claude_agent_sdk import tool
3from .core import DEFAULT_MAX_CHARS, ZyteError, fetch_markdown
4def make_zyte_tool(*, api_key=None, default_render=True,
5                   max_chars=DEFAULT_MAX_CHARS, timeout=120):
6    @tool("zyte_page_fetch", _DESCRIPTION, _SCHEMA)
7    async def zyte_page_fetch(args):
8        try:
9            # fetch_markdown is blocking (urllib), so keep it off the event loop.
10            md = await asyncio.to_thread(
11                fetch_markdown,
12                args["url"],
13                render=args.get("render", default_render),
14                api_key=api_key,
15                max_chars=max_chars,
16                timeout=timeout,
17            )
18        except ZyteError as e:
19            return {"content": [{"type": "text", "text": str(e)}], "is_error": True}
20        except Exception as e:
21            return {"content": [{"type": "text", "text": f"zyte_page_fetch failed: {e!r}"}],
22                    "is_error": True}
23        return {"content": [{"type": "text", "text": md}]}
24    return zyte_page_fetch
Copy

Three details there carry more weight than their line count suggests.
asyncio.to_thread is not optional. The fetch underneath uses urllib, which blocks, and a blocking call inside an async handler stalls the whole agent loop. Pushing it to a thread keeps the loop responsive.
Failure return is_error: True instead of raising. An exception escaping the handler kills the run. An error result becomes a message the model can read, so it can retry or explain the problem to the user. This is also what stops the opening failure mode from repeating: a fetch that fails loudly is one the agent can react to.
The API key never enters the schema. It is read from the environment inside the handler, so it is never serialized into anything the model sees. To be precise about the boundary: your Zyte key stays local, while the page content and your Anthropic credentials do go to Anthropic, because that is where the model runs.

Keeping the fetch itself framework-free

The fetching and conversion live in a separate module that imports no framework, just the Python standard library. It calls Zyte API's extract endpoint with HTTP Basic auth, asking for browser-rendered HTML when render is true and a plain HTTP body when it is not:

1def fetch_html(url, *, render=True, api_key=None, timeout=120):
2    key = _resolve_key(api_key)
3    if render:
4        return _post({"url": url, "browserHtml": True}, key, timeout).get("browserHtml") or ""
5    b64 = _post({"url": url, "httpResponseBody": True}, key, timeout).get("httpResponseBody") or ""
6    return base64.b64decode(b64).decode("utf-8", errors="replace") if b64 else ""
Copy

A small HTMLParser subclass then walks the HTML, drops script, style, head, noscript, svg, template, and iframe, emits headings and list items as markdown, and resolves relative links to absolute ones so the agent can follow them.
Splitting the code this way buys two things. The conversion happens in your process, so raw HTML never costs a token: for our store, 5,216 characters of HTML become 1,400 of markdown, roughly 3.7 times smaller. That is a saving against raw HTML, not against WebFetch, which converts too. The second benefit is portability, below.
Here is the whole round trip, and the one line that matters is where the conversion sits:
image
One warning if you write your own converter: whitespace is fiddlier than it looks. Ours gave no separator to <td> and <span> at first, so a table row arrived as Oil Filter$12.50. Fixture tests catch that immediately.

Wiring it into an agent

Tools reach the agent through an in-process MCP server. It speaks the Model Context Protocol, but "in-process" is the important half: no MCP subprocess to supervise, no socket to open.

1import asyncio
2from claude_agent_sdk import ClaudeAgentOptions, query
3from zyte_agent_tools import create_zyte_server
4async def main():
5    options = ClaudeAgentOptions(
6        model="sonnet",
7        mcp_servers={"zyte": create_zyte_server()},
8        allowed_tools=["mcp__zyte__zyte_page_fetch"],
9        # Built-in web tools off, so anything the agent reports came through Zyte.
10        disallowed_tools=["WebFetch", "WebSearch"],
11        permission_mode="dontAsk",
12        max_turns=10,
13    )
14    async for message in query(prompt=PROMPT, options=options):
15        ...  # handle the stream
16asyncio.run(main())
Copy

The naming rule catches people out, so plainly: the dictionary key you mount the server under becomes the tool's prefix. Mounting under "zyte" produces mcp__zyte__zyte_page_fetch, and that full name goes in allowed_tools. Change the key to "web" and it becomes mcp__web__zyte_page_fetch.
permission_mode="dontAsk" matters for an unattended script; without it the run stops to ask you to approve the tool call. And disallowed_tools makes the demo prove something: with the built-in web tools off, the agent's only web-fetch route is Zyte.

Watching it work

Asked to list every product with its price, here is one run verbatim (the model's exact phrasing varies between runs):

1[tool] ToolSearch {'query': 'select:mcp__zyte__zyte_page_fetch', 'max_results': 3}
2[result] 0 chars returned
3[tool] mcp__zyte__zyte_page_fetch {'url': 'https://auto.hylnd7.com/'}
4[result] 1400 chars returned
5| Product | Price |
6|---|---|
7| Premium Ceramic Brake Pads | $45.99 |
8| Synthetic Oil Filter | $12.50 |
9| Iridium Spark Plug (Pack of 4) | $38.00 |
10| High Output Alternator | $189.99 |
11| LED Headlight Assembly (Left) | $250.00 |
12| AGM Car Battery | $210.00 |
13| Performance Air Intake System | $299.99 |
14| All-Weather Floor Mats (Set) | $89.95 |
15Note: this is page 1 of 13 (pagination shown at the bottom of the page) — only these 8 products were listed on the page fetched.
16[done] 3 turns
Copy

(MCP tools are deferred by default, so the run opens with a ToolSearch call fetching the schema. The 0 chars is a quirk of this script's counter, not a failed lookup.)
Eight products with correct prices, in three turns, from a page that yields seventy characters to a plain fetch.
That closing note is the most encouraging part of the run. The converter preserved the pagination row, so the agent saw thirteen pages existed and volunteered that its answer covered one. Compare the failure we opened on: eight products presented as a complete catalog. Here it knew the boundary of what it had read.
The limits deserve naming. Those pagination controls are <button> elements with no URLs, so the agent could see more pages existed but had no link to follow; reaching them needs a second tool or a known URL pattern. And the fetch is not infallible: this target occasionally returns access-denied instead of the store, which is why the handler returns is_error: True rather than raising. A bad fetch arrives as something the agent can report, not as silence.

Reusing this

Because the tool is an ordinary in-process MCP server, dropping it into an existing agent is a dictionary merge plus one entry in allowed_tools:

1options = ClaudeAgentOptions(
2    mcp_servers={"zyte": create_zyte_server(), **your_existing_servers},
3    allowed_tools=[*your_existing_tools, "mcp__zyte__zyte_page_fetch"],
4)
Copy

That works because create_zyte_server() returns a plain dict shaped like {'type': 'sdk', 'name': 'zyte', 'instance': <server>}, and mcp_servers accepts it alongside stdio, SSE, and HTTP configs. To your agent, an in-process tool is indistinguishable from a remote one.
To fold the tool into a server of your own instead, make_zyte_tool() hands you the bare tool object:

1from claude_agent_sdk import create_sdk_mcp_server
2from zyte_agent_tools import make_zyte_tool
3server = create_sdk_mcp_server(name="web", tools=[make_zyte_tool(), your_other_tool])
Copy

This is where the framework-free split pays off. The adapter is specific to the Agent SDK; the function underneath is not, so the same fetch backs a LangChain tool in five lines:

1from langchain_core.tools import tool
2from zyte_agent_tools import fetch_markdown
3@tool
4def zyte_page_fetch(url: str, render: bool = True) -> str:
5    """Fetch a URL via Zyte's unblocker and return clean markdown with links."""
6    return fetch_markdown(url, render=render)
Copy

For wider reach, wrap that same function in a standalone MCP server and it works anywhere MCP does.

Try it yourself

The full source is at https://github.com/zytelabs/zyte-agent-tools about 170 lines for the fetch and converter, about 90 for the SDK adapter. It is an unofficial reference implementation meant to demonstrate the pattern, not a supported Zyte product. For Zyte's officially maintained agent tooling, see the Zyte add-ons for agent skills, Codex, and GitHub.
You will need Python 3.10 or later, a Zyte API key from a free Zyte API trial, and an Anthropic API key. Anthropic's guidance is that "unless previously approved," third-party products built on the Agent SDK should use API key authentication rather than claude.ai login.

1git clone https://github.com/zytelabs/zyte-agent-tools.git
2cd zyte-agent-tools
3pip install -e ".[agent-sdk]"
4export ZYTE_API_KEY="your-zyte-key"
5export ANTHROPIC_API_KEY="your-anthropic-key"
6python examples/store_agent.py        # the run shown above
Copy

That last command is the script that produced the transcript above. For a no-credit check, pip install -e ".[test]" and run python -m pytest: offline, no keys needed.
The broader point outlasts this tool. An agent SDK hands you a harness, and a harness is only as capable as the tools hanging off it. When your agent fails, the useful question is usually not how to prompt around it but which tool is missing. If you are building agents that lean on live web data, robust agentic AI workflows built on rapid web data covers the same ground from the data side.

Try Zyte API

Build your first scraper in minutes

Free trial, no credit card. From a single request to production in an afternoon.

Get started

Ayan Pahwa

Developer Advocate

Ayan is a developer advocate at Zyte. Ayan writes hands-on, personal-project-driven content about applying AI agents and LLMs to real scraping problems — his "Harness Engineering" series explains what an agent harness is and how to build one for data extraction, and he documents…

  • X (Twitter)
  • LinkedIn
  • GitHub
  • Website
More from this author

In this article

  • First, what a tool actually is
  • Choosing where to build
  • Where the built-in tools stop
  • Building the tool
  • Keeping the fetch itself framework-free
  • Wiring it into an agent
  • Watching it work
  • Reusing this
  • Try it yourself

Follow

Get the latest

Zyte and the data web in your inbox — or wherever you already are.

Subscribe

Or follow elsewhere

The Community · Newsletter

The best of Zyte and the data web, in your inbox.

One curated edition — new articles, product updates, and the stories shaping the data web. No noise.

Services

Zyte Data

Coding tools & hacks straight to your inbox. Bi-weekly dosage of all things code.

Explore Zyte Data

Web Scraping API

Zyte API

Coding tools & hacks straight to your inbox. Bi-weekly dosage of all things code.

Sign Up

Developers

Zyte Developers

Coding tools & hacks straight to your inbox. Bi-weekly dosage of all things code.

Join Us
    • Zyte API
    • Ban Handling
    • AI Extraction
    • SERP
    • Enterprise
    • Scrapy Cloud
    • Agentic Web Data
    • Pricing
    • Product & E-commerce
    • Data for AI
    • Job Posting
    • Real Estate
    • News & Articles
    • Search
    • Social Media
    • Blog
    • Learn
    • Case Studies
    • Webinars
    • White Papers
    • Join our community
    • Documentation
    • Meet Zyte
    • Contact us
    • Jobs
    • Support
    • Terms and Policies
    • Trust Center
    • Do not sell
    • Cookie settings
    • Web Data Compliance
    • Open Source
    • What is Web Scraping
    • Web Scraping in Python: Ultimate Guide
    • Stop getting blocked, start scraping
  • EWDCI logoMost loved workplace certificateZyte rewardISO 27001 iconG2 rewardG2 rewardG2 reward
    XFacebookInstagramYouTubeLinkedInDiscord

    © Zyte Group Limited 2026