Give a CrewAI agent a website_screenshot tool in one Python file. The tool is a BaseTool subclass that calls the RenderScreenshot API over HTTP, returns a public image URL with its size, retries only errors that can succeed on retry, and caps how many captures one run can make. No browser runs inside your crew.
This integration is part of our guide to giving AI agents web vision.
Last tested: 2026-10-15 with Python 3.12,
crewai1.15.28,pydantic2.12.5. The first version of the tool used the Python SDK'stake_json(); its logic ran against a stubbed client, and one real call with an invalid key checked the error path. SDK 1.0.0'stake_json()does not send theAccept: application/jsonheader that the API needs to return JSON, so the tool below calls the API withhttpxinstead. On 2026-10-18 we ran thishttpxversion (Python 3.14,httpx0.28.1,pydantic2.14.1) against a local mock that returns the API's JSON shape: a success, a 429 then a success, and a 401. That run used a stand-inBaseToolclass, notcrewaiitself. The full crew was built but not run, because a run needs an LLM key.
What you can build
- Pre-launch page review. A reviewer agent captures each landing page and reports whether the headline and the main button are above the fold, with the screenshot URL as evidence.
- Competitor change log. A research crew captures a list of pricing pages every week and writes what changed, linking each capture.
- Bug report enrichment. A triage agent receives a URL from a ticket, captures it on desktop and on mobile, and attaches both links to its summary.
Prerequisites
- Python 3.10 to 3.13 (CrewAI 1.15.28 does not support 3.14 yet)
- A RenderScreenshot API key in
RS_API_KEY(create a free account) - An LLM key for CrewAI (for example
OPENAI_API_KEY)
pip install "crewai==1.15.28" httpx
The tool calls the HTTP API directly, so it needs no SDK. The request body follows the POST endpoint reference.
The tool file
Save this as renderscreenshot_tool.py.
"""CrewAI tool that captures web pages with the RenderScreenshot API.""" from __future__ import annotations import os import time from typing import Literal, Optional, Type import httpx from crewai.tools import BaseTool from pydantic import BaseModel, Field, PrivateAttr Preset = Literal['og_card', 'twitter_card', 'link_preview', 'full_page', 'mobile', 'desktop_hd'] RS_BASE_URL = os.environ.get('RS_BASE_URL', 'https://api.renderscreenshot.com') class ScreenshotInput(BaseModel): url: str = Field(..., description='Public http(s) URL of the page to capture.') preset: Preset = Field('desktop_hd', description='Capture preset. Use full_page for long pages.') selector: Optional[str] = Field(None, description='CSS selector to capture one element instead of the page.') block_cookie_banners: bool = Field(True, description='Dismiss cookie consent banners before capture.') class RenderScreenshotTool(BaseTool): name: str = 'website_screenshot' description: str = ( 'Capture a screenshot of a public web page and return a public image URL plus its size. ' 'Use it when you need to see how a page looks. It does not return the page text.' ) args_schema: Type[BaseModel] = ScreenshotInput max_usage_count: Optional[int] = 20 # each call costs credits; cap it per run _http: httpx.Client = PrivateAttr() def __init__(self, api_key: Optional[str] = None, **kwargs): super().__init__(**kwargs) # Accept: application/json makes the API return JSON with a CDN link instead of image bytes. self._http = httpx.Client( base_url=RS_BASE_URL, headers={ 'Authorization': f"Bearer {api_key or os.environ['RS_API_KEY']}", 'Accept': 'application/json', }, timeout=60.0, ) def _run(self, url: str, preset: str = 'desktop_hd', selector: Optional[str] = None, block_cookie_banners: bool = True) -> str: body = {'url': url, 'preset': preset, 'output': {'format': 'webp'}} if selector: body['capture'] = {'mode': 'element', 'selector': selector} if block_cookie_banners: body['block'] = {'cookie_banners': True} for attempt in range(3): resp = self._http.post('/v1/screenshot', json=body) if resp.status_code == 200: shot = resp.json() image, cache = shot['image'], shot.get('cache', {}) return (f"Screenshot of {url}: {image['url']} " f"({image.get('width')}x{image.get('height')}, cached={cache.get('hit')})") # Retry only throttling, timeouts and server errors. A 4xx such as a bad key # will not succeed on a second try, so never retry it. transient = resp.status_code in (408, 429) or resp.status_code >= 500 if transient and attempt < 2: time.sleep(int(resp.headers.get('retry-after', 2 ** attempt))) continue try: error = resp.json().get('error', {}) except ValueError: error = {} # Return the error as text so the agent can change its plan. return (f"Screenshot failed ({error.get('code', 'unknown')}, HTTP {resp.status_code}): " f"{error.get('message', resp.text[:200])}") return 'Screenshot failed after 3 attempts.'
Design choices, and why:
- A narrow preset list. The agent picks from six presets instead of free-form width and height, so every capture has a known size and a known cost.
- Your code owns the risky options. Cookies, headers and auth are not in the schema. If a page needs a login, set it in
_run, never from model input. - Errors come back as text. CrewAI passes the string to the agent, which can try another URL or report the failure.
Use it in a crew
"""Example crew: a visual reviewer that captures pages and reports what it sees.""" import os from crewai import Agent, Crew, Process, Task from renderscreenshot_tool import RenderScreenshotTool screenshot = RenderScreenshotTool() # reads RS_API_KEY reviewer = Agent( role='Landing page reviewer', goal='Check that each landing page shows its headline and primary button above the fold', backstory='You review marketing pages before launch and report problems with evidence.', tools=[screenshot], llm=os.environ.get('CREW_LLM', 'gpt-4o-mini'), allow_delegation=False, ) review = Task( description=( 'Capture {url} with the desktop_hd preset. Report whether the headline and the main ' 'call-to-action button are visible in the first screen. Include the screenshot URL.' ), expected_output='A short verdict (pass or fail), the reasons, and the screenshot URL.', agent=reviewer, ) crew = Crew(agents=[reviewer], tasks=[review], process=Process.sequential) if __name__ == '__main__': print(crew.kickoff(inputs={'url': 'https://example.com'}))
A text-only model cannot look at the image behind the URL. For the "is the button visible" judgment, give the reviewer a vision-capable model, or add a step that sends the image URL to one. The capture itself works with any model.
Parameter mapping
| Tool argument | API field | Notes |
|---|---|---|
url |
url |
Must be a public http(s) URL |
preset |
preset |
Sizes in the presets reference: og_card 1200x630, desktop_hd 1920x1080, mobile 390x844 |
selector |
capture.mode: "element" + capture.selector |
One element instead of the viewport |
block_cookie_banners |
block.cookie_banners |
On by default here |
| (fixed) | output.format: "webp" |
Smaller files than PNG for photos and UI |
| (fixed) | Accept: application/json header |
JSON response with image.url, image.width, image.height and cache.hit |
| (fixed) | httpx.Client(timeout=60.0) |
HTTP timeout in seconds |
Errors, retries and limits
| Situation | What the tool does |
|---|---|
| HTTP 429 (rate limit) | Waits Retry-After seconds (or 1 s, then 2 s) and retries, up to 3 attempts |
| HTTP 408 or 5xx | Same backoff and retry |
| HTTP 400, 401, 403, 422 | Returns the error text at once; no retry |
| 21st call in one run | CrewAI blocks it: "has reached its usage limit" |
We tested these paths with the first, SDK-based version of the tool. A stubbed 429 followed by success made two calls and returned the URL. A real call with an invalid key came back as text in 0.7 s with no retries. With max_usage_count=1, CrewAI refused the second call and returned a usage_limit message to the agent.
One finding from the live call: SDK 1.0.0 reported the 401 as code internal_error and put the API's whole error object in message. Its retryable flag also said True. SDK 1.0.2 reads the nested error object correctly. The tool still decides on retries from the HTTP status, which works with every SDK version.
Each capture uses credits; the API reports the amount in the X-Credits-Used header (see response headers). Rate limits depend on your plan (see rate limits); keep max_usage_count below what one run should ever need.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
KeyError: 'RS_API_KEY' |
Key not in the environment | export RS_API_KEY=rs_live_... or pass api_key= |
KeyError: 'image' on success |
The request did not ask for JSON | Keep the Accept: application/json header on the client |
| CrewAI install fails on Python 3.14 | crewai 1.15.28 requires < 3.14 |
Use Python 3.12 or 3.13 |
| "Screenshot failed (..., HTTP 401)" | Wrong or revoked key | Check the key in the dashboard |
| Agent describes an image it never saw | Text-only LLM guessing from the URL | Use a vision model for visual judgments |
| Agent stops after N captures | max_usage_count reached |
Raise it, or split the work into several runs |
| Element capture shows the whole viewport | Selector not found | Pass a selector that exists on the page |
Related
- LlamaIndex integration: the same capture as a FunctionTool and an image reader
- LangChain integration: returning the image itself to the model
- AI agents use case: architecture and cost at volume