Claude's Messages API lets a tool return images, not only text. That is all you need to give Claude eyes on the web: define a screenshot_url tool, call RenderScreenshot when Claude uses it, and put the image in the tool_result. This page has the complete Python file, tested end to end against mocks of both APIs, plus the limits that bite in long agent runs.
For the same tool in other frameworks, see the screenshot API for AI agents overview.
What you can build
- "Look at this page" in a chat product: users paste a URL, Claude captures it and answers questions about what is on screen.
- Release checks: Claude opens your staging pages after a deploy, compares them with its instructions and writes a pass or fail note with the reasons.
- Research assistants: Claude reads a competitor page as text when it can, and captures it when the content is in charts, images or a JavaScript app.
Prerequisites
- Python 3.10 or later
pip install anthropic httpx(tested withanthropic1.14.0,httpx0.28.1)ANTHROPIC_API_KEYfrom the Claude ConsoleRS_API_KEY, a RenderScreenshot key that starts withrs_live_(create one free)
How the loop works
1. You send: user message + tools=[screenshot_url] 2. Claude replies: stop_reason "tool_use", block {name: "screenshot_url", input: {url: ...}} 3. Your code: POST /v1/screenshot → image bytes + X-Cache-URL header 4. You send: assistant turn + user turn with {type: "tool_result", tool_use_id, content: [ {type: "image", ...}, {type: "text", ...} ]} 5. Claude replies: stop_reason "end_turn" with the answer (or another tool_use; repeat)
The complete file
Save as claude_screenshot_tool.py:
"""Give Claude a screenshot tool backed by RenderScreenshot. Usage: RS_API_KEY=rs_live_... ANTHROPIC_API_KEY=... python claude_screenshot_tool.py "What is on https://example.com?" """ import base64 import os import sys import anthropic import httpx RS_BASE_URL = os.environ.get("RS_BASE_URL", "https://api.renderscreenshot.com") MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-opus-5-5") SCREENSHOT_TOOL = { "name": "screenshot_url", "description": ( "Capture a screenshot of a public web page and return it as an image. " "Use it when you need to see the rendered page: layout, visual state, " "charts, or text that only appears after JavaScript runs. " "Use full_page only when content below the first screen matters." ), "input_schema": { "type": "object", "properties": { "url": {"type": "string", "description": "Absolute http(s) URL to capture."}, "full_page": {"type": "boolean", "description": "Capture the whole scrollable page. Default false."}, "width": {"type": "integer", "minimum": 320, "maximum": 1920, "description": "Viewport width in px. Default 1280."}, }, "required": ["url"], }, } def capture(url: str, full_page: bool = False, width: int = 1280) -> tuple[bytes, str, str]: body = { "url": url, "viewport": {"width": width, "height": 800}, "output": {"format": "jpeg", "quality": 80}, "block": {"cookie_banners": True, "ads": True}, "wait": {"until": "networkidle", "timeout": 30}, } if full_page: body["capture"] = {"mode": "full_page", "scroll": {"max_height": 4000}} resp = httpx.post( f"{RS_BASE_URL}/v1/screenshot", headers={"Authorization": f"Bearer {os.environ['RS_API_KEY']}"}, json=body, timeout=60, ) resp.raise_for_status() media_type = resp.headers.get("content-type", "image/jpeg").split(";")[0] return resp.content, media_type, resp.headers.get("x-cache-url", "") def run_tool(tool_input: dict) -> list[dict]: try: image, media_type, cache_url = capture( tool_input["url"], tool_input.get("full_page", False), tool_input.get("width", 1280) ) except httpx.HTTPStatusError as err: # Give the model the API error so it can retry or explain. return [{"type": "text", "text": f"Screenshot failed: HTTP {err.response.status_code} {err.response.text[:300]}"}] return [ { "type": "image", "source": {"type": "base64", "media_type": media_type, "data": base64.b64encode(image).decode()}, }, {"type": "text", "text": f"Captured {tool_input['url']}. Public image URL: {cache_url}"}, ] def main(question: str) -> None: client = anthropic.Anthropic() messages = [{"role": "user", "content": question}] for _ in range(5): # hard stop so a confused model cannot loop forever response = client.messages.create( model=MODEL, max_tokens=1024, tools=[SCREENSHOT_TOOL], messages=messages ) messages.append({"role": "assistant", "content": response.content}) if response.stop_reason != "tool_use": print("".join(block.text for block in response.content if block.type == "text")) print(f"input tokens: {response.usage.input_tokens}", file=sys.stderr) return results = [ {"type": "tool_result", "tool_use_id": block.id, "content": run_tool(block.input)} for block in response.content if block.type == "tool_use" ] messages.append({"role": "user", "content": results}) sys.exit("Stopped after 5 tool rounds.") if __name__ == "__main__": main(sys.argv[1] if len(sys.argv) > 1 else "Describe https://example.com in one sentence.")
Run it:
export RS_API_KEY=rs_live_xxxxx export ANTHROPIC_API_KEY=sk-ant-xxxxx python claude_screenshot_tool.py "Is there a cookie banner on https://example.com?"
Test record. Tested on 2026-10-15 with Python 3.14 and the versions above. ANTHROPIC_BASE_URL and RS_BASE_URL pointed at local mocks: the Claude mock answered with a tool_use block, then an end_turn text block. We checked the second request: it carried a tool_result with an image block (media_type taken from the response's Content-Type) followed by a text block with the cache URL. A wrong RS_API_KEY produced a 401 that reached Claude as text instead of crashing the loop. We did not call the live Claude API in this test.
How the tool input maps to the API
| Tool input (chosen by Claude) | RenderScreenshot field | Set by |
|---|---|---|
url |
url |
Claude |
full_page: true |
capture.mode: "full_page", capture.scroll.max_height: 4000 |
Claude chooses, your code caps the height |
width |
viewport.width (height fixed at 800) |
Claude, inside 320 to 1920 |
| (none) | output.format: "jpeg", output.quality: 80 |
Your code |
| (none) | block.cookie_banners, block.ads |
Your code |
| (none) | wait.until: "networkidle", wait.timeout: 30 |
Your code |
| (none) | Authorization: Bearer rs_live_... |
Your code, from the environment |
Keep the model's choices few. Every extra option in input_schema is one more way for captures to differ between runs. Full parameter list: POST /v1/screenshot.
Limits that matter on the Claude side
From Anthropic's vision docs, checked 2026-10-15:
| Limit | Value | What to do |
|---|---|---|
| Token cost per image | ceil(w/28) × ceil(h/28) after downscaling |
Keep viewport.width at 1280 or less on standard-tier models |
| Downscale threshold | 1568 px long edge or 1568 tokens (standard tier); 2576 px or 4784 tokens (Claude 4.7 and later) | Cap full-page height; crop with capture.mode: "element" |
| Max image size | 10 MB base64 on the Claude API, 5 MB on Bedrock and Vertex | JPEG at quality 80 keeps a 1280x800 capture far below this |
| Max dimensions | 8000x8000 px | scroll.max_height 4000 stays inside |
| More than 20 images in one request | A stricter per-image size limit applies; Anthropic advises keeping each side at 2000 px or less | Long agent runs count images from earlier turns too |
The last row is the one that surprises people. Images inside earlier tool_result blocks count toward the 20-image threshold on every later request. An agent that captures 25 pages in one conversation can start getting invalid_request_error for "many-image requests" if its full-page captures are taller than 2000 px. Two fixes: keep full-page captures at max_height 2000 or less, or replace old image blocks with their text summary and the X-Cache-URL once Claude has used them.
Base64 or URL?
The file above sends base64. Claude also accepts {"type": "image", "source": {"type": "url", "url": ...}}, and the X-Cache-URL header is a public CDN link that needs no key. A URL keeps your request body small, which helps because each turn resends the whole history. The trade-off: Anthropic must be able to fetch the URL, and the link expires with the cache TTL (24 hours by default, up to 30 days with cache.ttl). Bedrock and Vertex accept base64 only. See caching.
Error handling and retries
| What happens | What the API returns | What your tool should do |
|---|---|---|
| Bad or missing key | 401 authentication_error |
Fail the run; the model cannot fix it |
| Out of credits | 403 authorization_error, code insufficient_credits |
Fail the run and alert a person |
| Too many requests | 429 rate_limit_error with Retry-After |
Wait Retry-After seconds, retry once in code |
| Page timed out or blocked | 422 target_error, retryable flag |
Return the message to Claude; it may try another URL |
| Invalid URL from the model | 400 validation_error, param: "url" |
Return the message to Claude so it can correct the URL |
Errors that only a person can fix should stop the loop. Errors the model can act on should go back in the tool_result as text, optionally with "is_error": true on the block. See error codes.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Claude says it cannot see an image | You returned base64 as a text block | Return an image block with source.type: "base64" |
invalid_request_error about media type |
media_type does not match the bytes |
Read it from the response Content-Type, as the file does |
| Claude describes a cookie dialog, not the page | Consent banner covers the content | Keep block.cookie_banners: true; add page.hide for stubborn banners |
| Full-page answers are vague | Tall image was downscaled | Lower max_height or capture an element |
| The loop never ends | Model keeps calling the tool | Keep the round limit; tell Claude in the system prompt when to stop |
| Costs climb in long chats | Old images are resent every turn | Drop old image blocks after use |
Next steps
- Get a free RenderScreenshot key and run the file.
- Building with LangChain on top of Claude? The LangChain integration wraps the same call as a LangChain tool.
- To pick capture sizes by token cost, read screenshots for GPT and Claude vision.