Give a CrewAI agent a website_screenshot tool in one Python file. The tool is a BaseTool subclass that calls the RenderScreenshot API through our Python SDK, 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,renderscreenshot1.0.0 (Python SDK from GitHub),pydantic2.12.5. The tool's logic ran against a stubbed API client, and one real HTTP call to the API checked the error path with an invalid key. 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" "git+https://github.com/Render-Screenshot/rs-python.git"
The Python SDK is not on PyPI yet, so it installs from GitHub. See the Python SDK docs.
The tool file
Save this as renderscreenshot_tool.py. It is the exact file we tested.
"""CrewAI tool that captures web pages with the RenderScreenshot API.""" from __future__ import annotations import os import time from typing import Literal, Optional, Type from crewai.tools import BaseTool from pydantic import BaseModel, Field, PrivateAttr from renderscreenshot import Client, RenderScreenshotError, TakeOptions Preset = Literal['og_card', 'twitter_card', 'link_preview', 'full_page', 'mobile', 'desktop_hd'] 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 _client: Client = PrivateAttr() def __init__(self, api_key: Optional[str] = None, **kwargs): super().__init__(**kwargs) self._client = Client(api_key or os.environ['RS_API_KEY'], timeout=60.0) def _run(self, url: str, preset: str = 'desktop_hd', selector: Optional[str] = None, block_cookie_banners: bool = True) -> str: options = TakeOptions.url(url).preset(preset).format('webp') if selector: options = options.element(selector) if block_cookie_banners: options = options.block_cookie_banners() for attempt in range(3): try: shot = self._client.take_json(options) return (f"Screenshot of {url}: {shot.get('url')} " f"({shot.get('width')}x{shot.get('height')}, cached={shot.get('cached')})") except RenderScreenshotError as e: # 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 = e.http_status in (408, 429) or e.http_status >= 500 if transient and attempt < 2: time.sleep(e.retry_after or 2 ** attempt) continue # Return the error as text so the agent can change its plan. return f'Screenshot failed ({e.code}, HTTP {e.http_status}): {e.message}' 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 | SDK call | API field | Notes |
|---|---|---|---|
url |
TakeOptions.url(url) |
url |
Must be a public http(s) URL |
preset |
.preset(...) |
preset |
Sizes in the presets reference: og_card 1200x630, desktop_hd 1920x1080, mobile 390x844 |
selector |
.element(selector) |
capture.selector |
One element instead of the viewport |
block_cookie_banners |
.block_cookie_banners() |
block.cookie_banners |
On by default here |
| (fixed) | .format('webp') |
output.format |
Smaller files than PNG for photos and UI |
| (fixed) | Client(..., timeout=60.0) |
n/a | SDK 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 each path. 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. That is why the tool decides on retries from http_status, not from the SDK's flag.
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= |
pip cannot find renderscreenshot |
SDK not on PyPI yet | Install from the GitHub URL above |
| 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