CrewAI CAPTCHA Tool: Solve reCAPTCHA and Turnstile

To build a CrewAI CAPTCHA tool, subclass BaseTool, give it a schema that refuses bad input, and call the CapSkip Python SDK from _run. To keep the token away from the model, set result_as_answer, which makes it the task’s answer. Three CrewAI settings then decide whether the tool is safe to leave running: the tool cache, which can replay a dead token, the usage limit, which counts per run, and the retry path, which can re-run a failing tool by itself.
What you need
- CapSkip installed on a Windows PC, with reCAPTCHA v2 and Turnstile solving enabled.
- Python 3.10 to 3.13. crewai 1.15.35 declares 3.10 or later and below 3.14, and capskip declares 3.10 or later. On Python 3.14.0, pip resolved
crewai[anthropic]to the old crewai 0.11.2 with only a warning, so the commands below usepy -3.13. If it says 3.13 is not installed, get it from python.org first. I ran Windows 11 with crewai 1.15.35 (the newest release on PyPI on 5 November 2026), capskip 1.6.0, anthropic 0.73.0 and pydantic 2.12.5, on Python 3.13.7 and 3.11.7, not on 3.10 or 3.12. - A PowerShell prompt, and a page you own with a reCAPTCHA v2 checkbox. The form helper in Step 3 expects a hidden field named
_csrfand an email field, so change the names to match your form. - A route to CapSkip. Local mode (127.0.0.1) is reachable from the same PC only. Server mode makes CapSkip take API calls from other machines too. The mode is set under connection settings in the setup guide.
- An Anthropic API key, only for the agent run in Step 3. The tool runs without one.
How this was tested: against a mock CapSkip and a mock sign-up page that I wrote, with the real CrewAI and the real SDK. A scripted local server answered in the Anthropic Messages format, so CrewAI’s real wrapper sent real request bodies and I read them. No real model was called, and the model id anthropic/claude-opus-5-5 never reached a real API, so nothing here shows how a real model picks arguments or reads an error. Job counts are in.php requests received by the mock.
Run this in PowerShell. It calls the virtual environment’s Python by path, so you never activate it.
# pip install "crewai[anthropic]" capskip mkdir crewai-captcha cd crewai-captcha py -3.13 -m venv .venv .\.venv\Scripts\python -m pip install "crewai[anthropic]" capskip .\.venv\Scripts\python -c "import crewai, capskip; print(crewai.__version__)"
Set your own values in the same window. CAPTCHA_ALLOWED_HOSTS matters most for safety: the model never sets it, and an empty value refuses every call. A bare host also allows ports 80 and 443, and host:port allows exactly that port.
# PowerShell: set these once per window, before you run any file $env:CAPTCHA_ALLOWED_HOSTS = "your-site.example" # the sites the tool may solve for $env:PAGE_URL = "https://your-site.example/signup" # your own page $env:SITEKEY = "YOUR_SITEKEY" # its data-sitekey value $env:ANTHROPIC_API_KEY = "YOUR_PROVIDER_KEY" # only run_crew.py needs this
Step 1: write the CrewAI CAPTCHA tool
Save the next two blocks as one file, captcha_tool.py. The first holds the settings and the argument schema, where CrewAI checks what the model sent. The SolveArgs.kind field takes one of two values, the sitekey is a short plain string, and the page address must match a host you listed. The Python package does not read CAPSKIP_HOST or any other variable, so the file passes the key, host and port in by hand.
# captcha_tool.py, part 1: settings, arguments and the host check
import os
from typing import Literal, Type
from urllib.parse import urlparse
from capskip import ApiException, CapSkip, CapSkipError
from capskip import NetworkException, TimeoutException
from crewai.tools import BaseTool
from crewai.tools.tool_failure import ToolFailure
from pydantic import BaseModel, Field, field_validator
solver = CapSkip(
apiKey=os.getenv("CAPSKIP_API_KEY", "capskip"),
host=os.getenv("CAPSKIP_HOST", "127.0.0.1"),
port=int(os.getenv("CAPSKIP_PORT", "8080")),
)
MINTED = set() # every token this tool has returned
# Set by you, never by the model. Empty means every call is refused.
RAW_HOSTS = os.getenv("CAPTCHA_ALLOWED_HOSTS", "").lower().split(",")
CAPTCHA_ALLOWED_HOSTS = {h.strip() for h in RAW_HOSTS if h.strip()}
# A bare host also allows ports 80 and 443. Write host:port to allow one port.
ALLOWED_NETLOCS = CAPTCHA_ALLOWED_HOSTS | {
h + port for h in CAPTCHA_ALLOWED_HOSTS if ":" not in h
for port in (":80", ":443")}
class SolveArgs(BaseModel):
kind: Literal["recaptcha_v2", "turnstile"] = Field(
description="Which CAPTCHA the page shows")
sitekey: str = Field(
min_length=10, max_length=100, pattern=r"^[A-Za-z0-9_-]+$",
description="The data-sitekey value from the page")
page_url: str = Field(
max_length=2048,
description="Full http or https address of the page with the CAPTCHA")
@field_validator("page_url")
@classmethod
def host_allowed(cls, value):
# urlparse silently drops tabs and newlines, so only printable
# ASCII without spaces may reach it
if not (value.isascii() and value.isprintable()) or " " in value:
raise ValueError("page_url is not a valid address")
try:
parts = urlparse(value)
except ValueError:
raise ValueError("page_url is not a valid address")
if (parts.scheme not in ("http", "https")
or parts.netloc.lower() not in ALLOWED_NETLOCS):
raise ValueError("page_url is not a site this tool may solve for")
return valueAppend the second block. Four settings in it are the point of this guide. result_as_answer makes the token the task’s answer. cache_function says never to cache. max_usage_count caps calls per run. And a failure comes back as a ToolFailure, a CrewAI class that marks a call as failed without raising. The model reads its message and code, and its ToolFailure.retryable flag is a note for you.
# captcha_tool.py, part 2: the tool (appended to part 1)
def never_cache(_args, _result):
return False # a token is single use, so never replay one
def fail(message, code, retryable=False):
return ToolFailure(message=message, code=code, retryable=retryable)
class SolveCaptchaTool(BaseTool):
name: str = "solve_captcha"
description: str = (
"Solve a reCAPTCHA v2 or Turnstile on a page and return its token. "
"The token is single use. A reCAPTCHA token expires within about "
"two minutes and a Turnstile token within five.")
args_schema: Type[BaseModel] = SolveArgs
result_as_answer: bool = True
cache_function: object = never_cache
max_usage_count: int = 3
def _run(self, kind: str, sitekey: str, page_url: str):
call = solver.recaptcha if kind == "recaptcha_v2" else solver.turnstile
try:
token = call(sitekey, page_url)["code"]
MINTED.add(token)
return token
except TimeoutException:
return fail("CapSkip timed out. Try once more.", "timeout", True)
except NetworkException:
return fail("CapSkip did not answer correctly (not running, "
"or an HTTP error). Do not retry.",
"capskip_unreachable")
except ApiException as exc:
if "UNSOLVABLE" in str(exc):
return fail("This CAPTCHA could not be solved. Try once more.",
"unsolvable", True)
return fail("CapSkip refused the job. Do not retry.",
"capskip_rejected")
except CapSkipError:
return fail("The arguments were refused. Do not retry.",
"bad_arguments")Save the next block as try_tool.py. In the project folder, run .\.venv\Scripts\python try_tool.py. It calls the tool by hand, needs no Anthropic key and prints the token length. The Python SDK sets no HTTP timeout, so a CapSkip that takes the connection and never answers would block this file forever. Step 3 adds a watchdog.
# try_tool.py: call the tool by hand, no model needed
import os
from captcha_tool import SolveCaptchaTool
tool = SolveCaptchaTool()
result = tool.run(
kind="recaptcha_v2",
sitekey=os.environ["SITEKEY"],
page_url=os.environ["PAGE_URL"],
)
print("%d characters" % len(result) if isinstance(result, str) else result)The second check is a call the tool must refuse. Save it as try_hostile.py. The first line it prints says the arguments failed validation, and CapSkip receives no task.
# try_hostile.py: a call the tool must refuse before CapSkip hears of it
import os
from captcha_tool import SolveCaptchaTool
try:
SolveCaptchaTool().run(
kind="recaptcha_v2",
sitekey=os.environ["SITEKEY"],
page_url="https://evil.example/signup",
)
except ValueError as exc:
print(str(exc).splitlines()[0])Step 2: what the model sees, and what it can get past
I pointed CrewAI’s Anthropic provider at the scripted server through the ANTHROPIC_BASE_URL variable and logged the request. It carried no tool choice, temperature or thinking field. The tool arrived with strict: true and an enum for the kind field, but the sitekey had lost its length limits and pattern, and the page address its length limit, because CrewAI strips those keywords for Anthropic’s strict mode. So those limits live only in your tool: CrewAI checks the arguments against args_schema when the tool runs, before the usage count or the SDK. I could not test whether the real API accepts this request.
I sent about 100 argument sets straight to the tool, from two separate attack lists, and a few through the agent loop.
| Argument | Result | Jobs |
|---|---|---|
| Another host, a lookalike such as 127.0.0.1.evil.example, a user name in the address, a backslash, an IPv6 literal | Refused | 0 |
| A tab or newline inside the host, a space around the address, a NUL, a non-breaking space or a line break with a header in the path | Refused | 0 |
| Any port other than 80 and 443, or an empty port | Refused | 0 |
| A file: or javascript: address, an empty one, one of 5,000 characters | Refused | 0 |
| A sitekey of 9, 101 or 1,000,000 characters, or one with a newline and text | Refused | 0 |
| A kind the tool does not offer, such as hcaptcha | Refused | 0 |
| An extra argument named proxy | Dropped, never sent on | 1 |
| The allowed host in capital letters | Accepted | 1 |
Nothing set in CAPTCHA_ALLOWED_HOSTS | Refused | 0 |
Only printable ASCII without spaces reaches the parser, because urlparse drops tabs and newlines before it reads the host, and the check then matches the whole host and port. A refusal goes back to the model as error text, 858 characters for the 1,000,000 character sitekey. A refusal does not use up max_usage_count, and a model that kept sending a bad address was stopped by CrewAI’s repeat check after five calls, with no job. Under the raise policy in Step 3 a refusal ends the run instead.
Step 3: run the CrewAI CAPTCHA tool in a crew and post the token
The agent picks the arguments and your script does the rest, so the model never holds a token. First the form helper, submit_form.py. It is plain Python, not a tool, and a variant of the poster in the LangChain CAPTCHA tool guide. It follows no redirects and refuses a form whose action differs from the page in scheme, host or port.
# submit_form.py: post the token to your own form, in plain Python
import re
from urllib.parse import urljoin, urlsplit
import requests
CSRF = r'name="_csrf" value="([^"]+)"'
ACTION = r'<form[^>]*action="([^"]*)"'
def same_origin(a, b):
return urlsplit(a)[:2] == urlsplit(b)[:2] # scheme and host:port
def post_form(page_url, token, email):
http = requests.Session()
# A redirect is not followed, so a page that bounces you gets no token
page = http.get(page_url, timeout=15, allow_redirects=False)
if page.status_code != 200:
return "page_answered_%d" % page.status_code
csrf = re.search(CSRF, page.text)
action = re.search(ACTION, page.text)
if not (csrf and action):
return "form_not_recognised"
target = urljoin(page_url, action.group(1))
if not same_origin(target, page_url):
return "form_posts_elsewhere"
fields = {"_csrf": csrf.group(1), "email": email,
"g-recaptcha-response": token}
reply = http.post(target, data=fields, headers={"Referer": page_url},
timeout=15, allow_redirects=False)
return "form_answered_%d" % reply.status_codeI tried it against a naive poster that follows redirects and trusts the form action. Over both http and https, a redirect to another host name, or a form action on one, put an issued token on that host, and the helper sent nothing there. It also refuses an https page whose form action is http on the same host and port, and a redirect inside the same host, so put the final address in PAGE_URL.
Now run_crew.py. The raise policy makes a failed tool call stop the run and reach your code as an exception, where ToolFailure.retryable is readable. The script retries once and accepts only a token the tool minted. Start it with .\.venv\Scripts\python run_crew.py.
# run_crew.py: one agent, one tool, one task, and your own retry loop
import os
from crewai import Agent, Crew, LLM, Task
from crewai.tools import ToolExecutionFailedError
from crewai_core.paths import db_storage_path
from captcha_tool import MINTED, SolveCaptchaTool
from submit_form import post_form
PAGE_URL = os.environ["PAGE_URL"]
SITEKEY = os.environ["SITEKEY"]
agent = Agent(
role="CAPTCHA solver",
goal="Get one CAPTCHA token for the page you are given",
backstory="You call solve_captcha once and answer with its token.",
llm=LLM(model=os.getenv("CAPTCHA_MODEL", "anthropic/claude-opus-5-5")),
tools=[SolveCaptchaTool()],
max_iter=6,
)
task = Task(
description="Solve the reCAPTCHA v2 on %s. Its sitekey is %s."
% (PAGE_URL, SITEKEY),
expected_output="The token and nothing else.",
agent=agent,
)
# raise: a failed tool call stops the run and comes back to this script
crew = Crew(agents=[agent], tasks=[task], tool_failure_policy="raise")
def forget_outputs():
# CrewAI keeps the last kickoff's answer, the token, in this file
path = os.path.join(db_storage_path(), "latest_kickoff_task_outputs.db")
if os.path.exists(path):
os.remove(path)
def get_token(extra_tries=1):
for attempt in range(extra_tries + 1):
try:
token = crew.kickoff().raw
forget_outputs()
except ToolExecutionFailedError as exc:
failure = exc.record.failure
print("attempt", attempt, "failed:", failure.code)
if not failure.retryable:
raise
continue
if token not in MINTED: # the agent answered in words instead
raise RuntimeError("no token in the answer")
return token
raise RuntimeError("no token after %d tries" % (extra_tries + 1))
if __name__ == "__main__":
token = get_token()
print("token:", len(token), "characters")
print("form:", post_form(PAGE_URL, token, "[email protected]"))On the mock, one unsolvable CAPTCHA and then a good one printed a single failed attempt and a token, and the helper returned a 200 answer: 2 model requests and 2 jobs. A model that answered in words, and one that invented a token, both hit the RuntimeError. On every failed attempt CrewAI also prints two Event pairing mismatch warnings to stdout and one ERROR line to stderr that names the tool and the failure code. The retry still succeeds.
The watchdog covers the hang. It runs the crew in a child process and stops it after a limit in seconds, which you can pass after the file name. The default of 900 allows two attempts, because the SDK polls a reCAPTCHA job for up to 300 seconds each time. Run .\.venv\Scripts\python watchdog.py instead of the crew.
# watchdog.py: stop a crew that hangs on a CapSkip that never answers
import subprocess
import sys
limit = int(sys.argv[1]) if len(sys.argv) > 1 else 900
try:
run = subprocess.run([sys.executable, "run_crew.py"], timeout=limit)
sys.exit(run.returncode)
except subprocess.TimeoutExpired:
sys.exit("run_crew.py was stopped after %d seconds" % limit)Against a mock CapSkip that accepts the job and never answers, a limit of 15 ended the run after 15.1 seconds with a message that run_crew.py was stopped, and exit code 1. The mock had received 1 job, and no Python process was left holding the connection. The hang is the mock’s own rule, so a real CapSkip may fail differently.
Two warnings about the token. With verbose=True, CrewAI printed it to the console. CrewAI also saves the last kickoff’s answer, token included, in latest_kickoff_task_outputs.db under CrewAI in your local app data folder. I found no switch in the source to stop that write, so the script deletes the file after each good run. The token appeared in none of the request bodies sent to the model.
How CrewAI handles failures, retries, cache_function and max_usage_count
Rows use the Anthropic wrapper against the scripted server, except the two marked text path, which used an in-process stand-in model without function calling, so CrewAI used its ReAct text format.
| Situation | Model requests | Jobs |
|---|---|---|
| One unsolvable CAPTCHA, tool returns a ToolFailure | 2 | 1 |
Same, but the SDK exception escapes _run | 2 | 1 |
| Same exception escaping, text path | 2 | 3 |
| Same ToolFailure, text path | 2 | 1 |
| Model repeats the call after each failure, limit of 3 | 9 | 3 |
| Same, no limit | 6 | 5 |
| Raise policy, one unsolvable CAPTCHA | 1 | 1 |
On the native path an escaping exception reaches the model as the text Error executing tool plus the SDK message, and nothing re-runs it. A ToolFailure shows the model only your message and code. On the text path CrewAI re-ran a raising tool by itself, turning one failure into three jobs, so return the failure.
max_usage_count limits jobs, not model requests: with a limit of 3 the model made 9 requests and the mock saw 3 jobs. The counter restarts at every run: five kickoffs of one crew gave five jobs under a limit of 3. With no limit, CrewAI stopped the loop after the fifth identical call and recorded a repeated call failure. ToolFailure.retryable decided nothing in these runs, and the source only records it and passes it to tracing.
Caching is the dangerous one. In 1.15.35 a crew leaves its tool cache off unless you pass Crew(cache=True): two kickoffs with the same arguments made 2 jobs even with CrewAI’s default cache function. With the cache on and that default they made 1 job and returned the same token twice, and the mock site refused the second post as already used. With never_cache in the tool, the cache on made 2 jobs again. A ToolFailure is never cached. See reCAPTCHA token expiration for how long a real token lasts, and CrewAI’s tools documentation covers the cache function and the failure policies.
Errors you may meet
| What you see | Cause | Fix |
|---|---|---|
| pip installs crewai 0.11.2 | Python 3.14 is outside crewai 1.15.35 | Make the environment with py -3.13 |
| page_url is not a site this tool may solve for | The host and port are not in CAPTCHA_ALLOWED_HOSTS, or it is unset | Set CAPTCHA_ALLOWED_HOSTS |
| attempt 0 failed: unsolvable | CapSkip gave up on this CAPTCHA | Retry in your own code, as get_token does |
| failed: capskip_unreachable | Nothing answered at CAPSKIP_HOST, or CapSkip returned an HTTP error | Launch CapSkip and make sure the host and port match its settings |
| failed: capskip_rejected | CapSkip refused the job, such as a wrong API key | Check the key and the API docs error table |
| no token in the answer | The agent answered in words | Do not post. Run it again |
| The site rejects the token as a duplicate | The cache replayed an old token | Keep never_cache in the tool |
| The script never ends | CapSkip accepted the connection and never answered | Run it under watchdog.py |
FAQ
Can the crew call a CapSkip that runs on another PC?
Yes. Switch Server mode on in CapSkip, give that PC a static public IP if the crew runs outside your network, and set CAPSKIP_HOST, CAPSKIP_PORT, CAPSKIP_API_KEY where the crew runs. The traffic is plain HTTP, so use a private network or a VPN and switch on API key validation. CapSkip allows every address on its port when Server mode starts, so add a Windows Firewall block rule for the addresses you do not trust. The machine stays yours and solves stay unmetered. I exercised only the variables, not a remote machine or a hosted platform.
Do I need this if my agent is Claude Code?
No. Claude Code and other MCP clients get CAPTCHA solving from CapSkip’s MCP server, with no tool code, and the MCP CAPTCHA solver page covers it.
Does this work for Turnstile?
The tool accepts kind turnstile, and the mock received a Turnstile job carrying the sitekey. Cloudflare says a Turnstile token is valid for 300 seconds and once. A challenge page needs extra values that this tool does not send, according to the CapSkip SDK notes, and the form helper posts the reCAPTCHA field, so change the field name for a Turnstile form.
The short version
A good CrewAI CAPTCHA tool makes the schema do the refusing, returns failures instead of raising them, turns the cache off for the tool and keeps retries in your own code. These guards held in every test above. With a captcha bypass running on your own Windows PC, each attempt costs nothing per solve, and the Python CAPTCHA solver page lists the SDK calls for the other CAPTCHA types.
