How to Add a LangChain CAPTCHA Tool to an Agent With CapSkip

langchain captcha - How to Add a LangChain CAPTCHA Tool to an Agent With CapSkip

To solve a LangChain CAPTCHA, give the agent a tool that calls CapSkip, and keep the token out of the conversation. The obvious tool returns the token to the model, which puts about 1,700 characters into the next model call and a second copy into every call after that. A better tool returns a short handle, keeps the token on your side and spends it in a second tool. The solve tool should catch CapSkip’s exceptions, because an exception that escapes a tool ends the whole run. CapSkip runs on your own Windows PC, so an agent that retries has no per-solve bill. This guide builds that tool pair with LangChain 1.4 and runs it with no API key.

What you need

  • CapSkip running on a Windows PC, with the reCAPTCHA solver switched on. The setup guide walks through the first start.
  • Python 3.10 or later. LangChain 1.4 and the capskip 1.3.0 package both need it. Everything below ran on Windows 11 with Python 3.14.0, langchain 1.4.6, langchain-core 1.6.12, langgraph 1.2.16, pydantic 2.14.1 and requests 2.34.2. The blocks also passed on Python 3.11.7. Python 3.10, 3.12 and 3.13 were not run.
  • A PowerShell prompt. The commands use PowerShell syntax.
  • A sign-up page that you own, with a reCAPTCHA v2 checkbox. The example form has a hidden field named _csrf and a field named email. The submit tool posts those two plus the token, so change the names to match your form.
  • An address for the solver. In Local mode CapSkip answers on 127.0.0.1 for that device only. In Server mode it listens on your network address or public IP, so an agent on another PC, a VPS or a hosted platform can call it over the API. Both are set under connection settings.

No language model was called while writing this guide. The agent runs use a scripted model that makes the tool calls in a fixed order, so the wiring runs for real and says nothing about how a real model behaves. The measurements come from a mock CapSkip and a mock sign-up page on the same PC, used with the real LangChain and the real SDK. This guide calls them the mock runs and the mock page. The accept and reject rules of the mock page, the way the mock queues solves and every timing are the mock’s own, on this PC. One solve against a running CapSkip, using Google’s public test sitekey and default settings, took 12.9 seconds and returned a token of 1,700 characters. Solve times vary from run to run.

Why a tool needs more care than a script

In a script you pick the arguments and you read the result. In an agent the model picks the arguments, the model reads the result, and a page the agent has read can try to steer the model. That changes four things. The result lands in the conversation and is sent again on every later call. An exception inside the tool is not handled for you. The model can ask for another solve as often as it likes. And a sitekey and address that came from the model need checking before they reach CapSkip. An agent that drives a browser by itself has the same issues, and the Browser Use guide covers that route. This one is a plain function the model can call.

Step 1: create the project

Use a virtual environment in a new folder. The commands run the environment’s own Python by its path, so you do not need to activate it. The langchain package pulls in langgraph, langchain-core and pydantic, about 40 packages in all. The model providers are separate packages that you add only when you use a real model.

# pip install langchain capskip
mkdir langchain-captcha
cd langchain-captcha
python -m venv .venv
.\.venv\Scripts\python -m pip install langchain capskip
.\.venv\Scripts\python -c "import capskip, langchain; print(langchain.__version__)"

Use create_agent from langchain.agents for the agent. The older helpers are gone. AgentExecutor, initialize_agent and create_react_agent raise ImportError when imported from langchain.agents. The create_react_agent in langgraph.prebuilt still runs, and it prints a LangGraphDeprecatedSinceV10 warning that tells you to import create_agent from langchain.agents. A tutorial that uses any of them is out of date.

Step 2: the first tool, and why it is the wrong shape

Save this as naive_tool.py. It is the shortest useful tool: solve the CAPTCHA and give the token back. The SDK reads no environment variables, so the file reads the host, port and key itself. The key only matters if you switched on API key validation in CapSkip. Every file reads the page address from PAGE_URL and the sitekey from SITEKEY, so set both once in your PowerShell window, from your own sign-up page. CapSkip answers with an error for a page that carries no reCAPTCHA v2 widget.

# PowerShell: set these once per window, before you run any file
$env:PAGE_URL = "https://your-site.example/signup"   # your own page
$env:SITEKEY = "YOUR_SITEKEY"                        # its data-sitekey

Then run naive_tool.py with .\.venv\Scripts\python naive_tool.py.

# naive_tool.py: the first version, token straight back to the model
import os
from capskip import CapSkip
from langchain.tools import tool

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")),
)


@tool
def solve_recaptcha(sitekey: str, page_url: str) -> str:
    """Solve a reCAPTCHA v2 checkbox and return the token."""
    return solver.recaptcha(sitekey, page_url)["code"]


if __name__ == "__main__":
    args = {
        "sitekey": os.getenv("SITEKEY", "YOUR_SITEKEY"),
        "page_url": os.getenv(
            "PAGE_URL", "https://example.com/page-with-recaptcha"),
    }
    token = solve_recaptcha.invoke(args)
    print(len(token), "characters")

It works, and it prints the length of the token. That length is the problem. A reCAPTCHA token that was solved against Google’s public test sitekey came to about 1,700 characters. When the tool returns it, the token is stored once in the tool result. The model then has to pass it to the next tool as an argument, so it is stored a second time in the model’s own tool call. Every call after that carries both copies. A mock run counted the characters sent to the model on each of three calls, as message text plus tool call arguments. It used a scripted model, a one character system prompt, a 2,000 character mock token and, for the first row, a second tool that takes the token as an argument. A real system prompt and the tool schemas add the same amount to both rows:

DesignCall 1Call 2Call 3Total
Token returned and passed back112,0964,1466,253
Short handle returned11152253416

For scale, a random 2,000 character string counted about 1,400 tokens with an OpenAI tokenizer. Other models count differently. This is a cost and size argument, not a claim about how often a model copies a long string wrongly. It is also one more place where a copy error could break a token that works once. The fix is to keep the token on your side and give the model a handle.

Step 3: settings for the real tools

The real tools live in one file, captcha_tools.py, which you build from four pasted parts. Save this first part. It reads the settings, builds the client and declares the small amount of state the tools share. ALLOWED_HOSTS comes from your own PAGE_URL, never from the model.

# captcha_tools.py, part 1: settings
import logging
import os
import re
import secrets
import threading
import time
from typing import Literal
from urllib.parse import urljoin, urlparse

import requests
from capskip import ApiException, CapSkip, CapSkipError
from capskip import NetworkException, TimeoutException
from langchain.tools import tool

log = logging.getLogger("captcha_tools")
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")),
)
PAGE_URL = os.getenv("PAGE_URL", "https://example.com/page-with-recaptcha")
SITEKEY = os.getenv("SITEKEY", "YOUR_SITEKEY")
ALLOWED_HOSTS = {urlparse(PAGE_URL).hostname}  # set by you, not the model
MAX_SOLVES = 3        # CapSkip calls allowed per run
TOKEN_TTL = 100       # seconds a stored token may wait for its submit
_slots = threading.Semaphore(2)  # at or below Max. Threads in CapSkip
_vault = {}           # handle -> (token, page_url, born)
_used = [0]
_lock = threading.Lock()

Step 4: check the host before anything else

The model chooses the page address that goes to CapSkip, and text on a page the agent read can push it toward another one. Enforce the address inside the tool, because a rule in the prompt can be talked around. Append this to captcha_tools.py.

# captcha_tools.py, part 2: guards (appended to part 1)
def host_ok(url):
    try:
        u = urlparse(url)
        u.port  # raises ValueError on a malformed port
    except ValueError:
        return False
    return (u.scheme in ("http", "https") and u.username is None
            and u.hostname in ALLOWED_HOSTS)


def reset_run():
    _vault.clear()
    _used[0] = 0

The check touches the port on purpose. A check that compared only the host name would let an address such as http://127.0.0.1:18512.evil.example/page through, because the parser still reports 127.0.0.1 as the host when the port is malformed. Reading the port raises ValueError for that case, and the function then refuses the address. It also refuses file:, javascript: and ftp: addresses, empty strings and addresses with a user name in them. It compares the host only, not the port. The submit tool in Step 6 runs the same check on the form’s action address.

Step 5: the solve tool

Append this to captcha_tools.py. Three decisions in it matter. The tool catches every CapSkip exception and returns a small dictionary, because an exception that escapes the tool ends the whole agent run. It counts every CapSkip call it makes, failed ones included, so a model that keeps asking cannot keep CapSkip busy. And it stores the token in a dictionary and returns only a handle.

# captcha_tools.py, part 3: the solve tool (appended to part 2)
def fail(error, retryable=False):
    return {"ok": False, "error": error, "retryable": retryable}


@tool
def solve_recaptcha(
    sitekey: str, page_url: str, kind: Literal["v2", "v3"] = "v2"
) -> dict:
    """Solve a reCAPTCHA on a page and return a handle for submit_form.

    sitekey is the data-sitekey value on the page. page_url is the
    full URL of the page that shows the CAPTCHA. The token itself is
    kept out of the conversation; pass the returned handle on.
    """
    if not host_ok(page_url):
        return fail("host_not_allowed")
    with _lock:
        if _used[0] >= MAX_SOLVES:
            return fail("solve_budget_used_up")
        _used[0] += 1
    try:
        with _slots:
            result = solver.recaptcha(sitekey, page_url, version=kind)
    except TimeoutException:
        return fail("timeout", retryable=True)
    except NetworkException as exc:
        log.warning("CapSkip unreachable: %s", exc)
        return fail("capskip_unreachable")
    except ApiException as exc:
        log.warning("CapSkip refused the job: %s", exc)
        unsolvable = "UNSOLVABLE" in str(exc)
        return fail("unsolvable" if unsolvable else "capskip_rejected",
                    retryable=unsolvable)
    except CapSkipError as exc:
        return fail("bad_arguments: %s" % exc)
    handle = "cap_" + secrets.token_hex(3)
    _vault[handle] = (result["code"], page_url, time.monotonic())
    return {"ok": True, "handle": handle, "valid_for_s": TOKEN_TTL}

The docstring is what the model reads, so say there what each argument is and that the token stays out of the conversation. A tool with no docstring fails at definition time with ValueError: Function must have a docstring if description not provided. The Literal type becomes an enum in the schema. A value outside it, such as v4, never reaches the function, and CapSkip is not called. The model sees a message that starts Error invoking tool ‘solve_recaptcha’, repeats the arguments it sent, names kind: Input should be ‘v2’ or ‘v3’ and ends with Please fix the error and try again.

What happens when a tool raises

This is why the solve tool returns dictionaries. In the mock runs, create_agent turned only bad-argument errors into a message for the model. Every other exception left agent.invoke unchanged and ended the run. Each of these came out of the agent, with the mock standing in for CapSkip:

CaseWhat escaped agent.invoke
The solve cannot be doneApiException: ERROR_CAPTCHA_UNSOLVABLE
recaptchaTimeout of 2 secondsTimeoutException: timeout 2.0 exceeded, after 3.8 seconds
CapSkip is not runningNetworkException: Max retries exceeded, WinError 10061, after 2.0 seconds
A call that sets version v3 and invisible togetherValidationException: invisible is only supported for reCAPTCHA v2.

Setting handle_tool_error to True on a tool does not help here. It covers LangChain’s own ToolException, and none of CapSkip’s exceptions is one. The timeout fired late because the SDK checks its limit only between polls. The tool above returns a small dictionary instead, with ok set to false, an error name such as unsolvable and retryable set to true, which the model can act on. Only an error message that contains UNSOLVABLE counts as retryable. Any other API error, such as a wrong API key, comes back as capskip_rejected and is not retryable. Detail such as the connection error text goes to the log, not to the model. If you would rather keep the tools raw, the agent library has two hooks. ToolErrorMiddleware takes an on_error function that returns text for the model, and if that function returns nothing the exception still ends the run. The wrap_tool_call decorator lets you write the try and except yourself. Both kept the run alive in the mock runs.

Step 6: the submit tool

Append the last part of captcha_tools.py. The submit tool spends a handle. It pops the token out of the dictionary, so a handle works once, and it refuses a handle older than TOKEN_TTL. The token expires, so the file allows 100 seconds, under the two minutes that Google documents. That leaves about 20 seconds for the page fetch and the post. The submit tool has no address argument, because the page address was stored when the token was solved. The form’s own action address is page text too, so the tool runs the same host check on it and does not follow redirects. The model cannot choose where the token is posted.

# captcha_tools.py, part 4: the submit tool (appended to part 3)
@tool
def submit_form(handle: str, email: str) -> dict:
    """Submit the sign-up form with a handle from solve_recaptcha.

    A handle works once. After a failed submit, solve again.
    """
    entry = _vault.pop(handle, None)  # pop: a handle is single use
    if entry is None:
        return fail("unknown_or_used_handle")
    token, page_url, born = entry
    if time.monotonic() - born > TOKEN_TTL:
        return fail("handle_expired", retryable=True)
    try:
        session = requests.Session()
        page = session.get(page_url, timeout=15, allow_redirects=False)
        html = page.text
        csrf = re.search(r'name="_csrf" value="([^"]+)"', html)
        action = re.search(r'<form[^>]*action="([^"]*)"', html)
        if not (csrf and action):
            return fail("form_not_recognised")
        target = urljoin(page_url, action.group(1))
        if not host_ok(target):  # the form action is page text too
            return fail("host_not_allowed")
        reply = session.post(
            target,
            data={"_csrf": csrf.group(1), "email": email,
                  "g-recaptcha-response": token},
            headers={"Referer": page_url},  # requests sends none itself
            timeout=15,
        )
    except requests.RequestException:
        return fail("site_unreachable")
    said = re.sub(r"<[^>]+>", " ", reply.text).strip()[:80]
    return {"ok": reply.status_code == 200, "status": reply.status_code,
            "page_says": said}

Requests sends no Referer header, and a site that checks it answers 403. The tool sets one. The field names in the code above are the example form’s, and a form that does not match returns form_not_recognised. A second submit with the same handle returns unknown_or_used_handle, and the mock site saw exactly one post. The tool hands the first 80 characters of the page’s reply back to the model. Text from a page is a way for outside words to reach the model, so return only the status if you do not want that. Posting the raw token twice made the mock answer 403 with token already used, which is the mock’s rule. See reCAPTCHA token expiration for how long a real token lasts.

Step 7: a scripted model, no API key

Save this as fake_model.py. It is a small chat model that returns the tool calls you script, in order. The agent library calls bind_tools on a model, and this one accepts the call and ignores the schemas. It proves that your tools, their state and the agent loop work. It cannot show how a real model chooses arguments or reads an error.

# fake_model.py: a scripted chat model, no API key needed
# It calls the tools exactly as scripted, so the wiring runs for
# real. It says nothing about how a real model behaves.
from typing import Any, List

from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage
from langchain_core.outputs import ChatGeneration, ChatResult


def calls(*items) -> AIMessage:
    """One AI turn asking for tool calls: (name, args, id) each."""
    return AIMessage(content="", tool_calls=[
        {"name": n, "args": a, "id": i, "type": "tool_call"}
        for n, a, i in items
    ])


def call(name: str, args: dict, cid: str) -> AIMessage:
    return calls((name, args, cid))


class ScriptedChat(BaseChatModel):
    script: List[Any]  # AIMessages, or functions(messages) -> AIMessage
    seen: List[Any] = []  # what each model call was sent
    i: int = 0

    @property
    def _llm_type(self) -> str:
        return "scripted"

    def bind_tools(self, tools, **kwargs):
        return self  # the agent calls this; we ignore the schemas

    def _generate(self, messages, stop=None, run_manager=None, **kw):
        self.seen.append(list(messages))
        item = self.script[self.i]
        self.i += 1
        msg = item(messages) if callable(item) else item
        return ChatResult(generations=[ChatGeneration(message=msg)])

Step 8: build the agent and run it

Save this as run_agent.py. Without CAPTCHA_AGENT_MODEL it uses the scripted model. With the variable set to a provider and model name, it builds a real model through init_chat_model. The system prompt tells the model to stop when a tool says an error is not retryable. Run it with .\.venv\Scripts\python run_agent.py.

# run_agent.py: wire the tools into an agent and run it
import json
import os

from langchain.agents import create_agent
from langchain.messages import AIMessage
from captcha_tools import PAGE_URL, SITEKEY, reset_run
from captcha_tools import solve_recaptcha, submit_form

SYSTEM = (
    "Sign the user up on the page. Call solve_recaptcha, then "
    "submit_form with the handle it returns. Never invent a handle. "
    "If a tool says retryable is false, stop and report the error."
)


def build_model():
    name = os.getenv("CAPTCHA_AGENT_MODEL")  # "provider:model-id"
    if name:
        from langchain.chat_models import init_chat_model
        return init_chat_model(name)
    # No API key: a scripted model that calls the tools in order.
    from fake_model import ScriptedChat, call
    first = call("solve_recaptcha",
                 {"sitekey": SITEKEY, "page_url": PAGE_URL}, "c1")

    def second(msgs):  # read the handle out of the first tool result
        handle = json.loads(msgs[-1].content)["handle"]
        args = {"handle": handle, "email": "[email protected]"}
        return call("submit_form", args, "c2")

    return ScriptedChat(script=[first, second, AIMessage(content="Done.")])


agent = create_agent(build_model(), [solve_recaptcha, submit_form],
                     system_prompt=SYSTEM)

if __name__ == "__main__":
    reset_run()
    task = "Sign up [email protected] at %s. Sitekey: %s"
    out = agent.invoke({"messages": [("user", task % (PAGE_URL, SITEKEY))]})
    for m in out["messages"]:
        print(m.type, "|", str(m.content or m.tool_calls)[:100])

A run prints the conversation: the user message, a solve call, the tool result that holds only a handle, a submit call, the page’s answer, and Done. On the mock page the tool result held only a handle and its lifetime, and the page accepted one post.

Use a real model

Install the provider package for your model and set its key and the model name. Nothing in the tools changes. This was run only against a local stand-in that speaks the OpenAI and Anthropic request formats, with dummy keys, using langchain-openai 1.7.2 and langchain-anthropic 1.7.6. No real provider was called.

# pip install langchain-anthropic   (or langchain-openai for OpenAI)
.\.venv\Scripts\python -m pip install langchain-anthropic
$env:PAGE_URL = "https://your-site.example/signup"   # your own page
$env:SITEKEY = "YOUR_SITEKEY"
$env:ANTHROPIC_API_KEY = "YOUR_PROVIDER_KEY"
$env:CAPTCHA_AGENT_MODEL = "anthropic:YOUR_MODEL_ID"
.\.venv\Scripts\python run_agent.py

The stand-in showed what each wrapper sends. ChatOpenAI posts the tool as a function with name, description and parameters, to /v1/chat/completions. ChatAnthropic posts it with name, description and input_schema, to /v1/messages. Neither sent tool_choice, strict or parallel_tool_calls by default. The schema had required sitekey and page_url, and kind as an enum with a default. With strict switched on, LangChain rewrites the schema so every property is required, and whether a provider accepts that was not tested.

Parallel calls and async tools

A model can ask for several tools in one turn, and LangChain runs them at the same time. One 2 second solve takes 3.8 seconds in the mock runs, because the SDK waits 0.25, 0.5, 1 and 2 seconds between polls. Three of them in one turn also finished in 3.8 seconds when the mock had room for all three. With the mock limited to one thread, the mock queued the extras and the same turn took 7.8 seconds. The semaphore named _slots in captcha_tools.py caps how many jobs this process has open at once. It saved no time in the mock runs, and a cap below the mock’s thread count only cost time: with the mock at 2 threads, a cap of 1 took 11.3 seconds against 7.5 seconds with a cap of 2. Its purpose is to keep a burst of parallel calls from queueing more jobs than CapSkip’s Max. Threads, which is 10 for reCAPTCHA by default, because a job that waits past the Wait Timeout of 250 seconds is failed. That part was not tested. Set the cap to 2 or higher, up to Max. Threads.

A plain def tool is the safe default. Under agent.ainvoke, LangChain runs it in a worker thread, so the event loop stays free: on this PC the loop never stalled for more than 0.02 seconds during a 2 second solve. An async def tool that calls the sync CapSkip client inside it does the opposite. It blocked the loop for the whole 3.7 seconds. If you want async, use AsyncCapSkip, which stalled the loop for up to 0.3 seconds at a time, because it builds a new HTTP client for each request, at about 0.15 seconds each. Save this as async_tool.py. It needs no other file. One tool can serve both paths:

# async_tool.py: one tool that works under .invoke and .ainvoke
import asyncio
import os

from capskip import AsyncCapSkip, CapSkip
from langchain_core.tools import StructuredTool

KEY = os.getenv("CAPSKIP_API_KEY", "capskip")
HOST = os.getenv("CAPSKIP_HOST", "127.0.0.1")
PORT = int(os.getenv("CAPSKIP_PORT", "8080"))
PAGE_URL = os.getenv("PAGE_URL", "https://example.com/page-with-recaptcha")


def solve_sync(sitekey: str, page_url: str) -> dict:
    token = CapSkip(apiKey=KEY, host=HOST, port=PORT).recaptcha(
        sitekey, page_url)["code"]
    return {"ok": True, "token_chars": len(token)}


async def solve_async(sitekey: str, page_url: str) -> dict:
    client = AsyncCapSkip(apiKey=KEY, host=HOST, port=PORT)
    result = await client.recaptcha(sitekey, page_url)
    return {"ok": True, "token_chars": len(result["code"])}


solve = StructuredTool.from_function(
    func=solve_sync,
    coroutine=solve_async,
    name="solve_recaptcha",
    description="Solve a reCAPTCHA v2 on a page; report the token size.",
)

if __name__ == "__main__":
    args = {"sitekey": os.getenv("SITEKEY", "YOUR_SITEKEY"),
            "page_url": PAGE_URL}
    print(solve.invoke(args))
    print(asyncio.run(solve.ainvoke(args)))

An async-only tool called with agent.invoke fails with NotImplementedError: StructuredTool does not support sync invocation. The two functions above avoid that.

Stop a model that keeps asking

The solve counter keeps CapSkip at three jobs, and it does not stop the model from asking. Nothing else stops it soon. With create_agent the default recursion limit is 9,999 steps. A scripted model that never stopped made about 5,000 model calls in under a minute on this PC before GraphRecursionError: Recursion limit of 9999 reached without hitting a stop condition. Pass recursion_limit with a value of 7 in the config argument of agent.invoke to set your own. A limit on the tool itself is cleaner, and the agent library has one. Save this as limited_agent.py next to captcha_tools.py and fake_model.py, which it imports, and run it with .\.venv\Scripts\python limited_agent.py. Its scripted model never stops asking for a solve.

# limited_agent.py: stop a model that keeps asking for solves
from langchain.agents import create_agent
from langchain.agents.middleware import ToolCallLimitMiddleware
from captcha_tools import PAGE_URL, SITEKEY, reset_run
from captcha_tools import solve_recaptcha, submit_form
from fake_model import ScriptedChat, call

# A scripted model that never stops asking for a solve.
args = {"sitekey": SITEKEY, "page_url": PAGE_URL}
model = ScriptedChat(script=[
    (lambda msgs: call("solve_recaptcha", args, "c%d" % len(msgs)))
] * 50)

agent = create_agent(
    model,
    [solve_recaptcha, submit_form],
    middleware=[ToolCallLimitMiddleware(
        tool_name="solve_recaptcha", run_limit=3, exit_behavior="end")],
)

if __name__ == "__main__":
    reset_run()
    out = agent.invoke({"messages": [("user", "go")]})
    print(len(model.seen), "model calls")
    print(out["messages"][-1].content)

With exit_behavior set to end, the run stopped on the fourth request and CapSkip had received three jobs. The last message said that the solve_recaptcha tool call limit was reached, with 4 of 3 calls. The error option raises ToolCallLimitExceededError instead. The default, continue, blocks the tool and tells the model not to call it again, and a model that ignores that keeps going until the recursion limit, so set one of the other two.

Two runs in one process

The vault in captcha_tools.py is a module variable, so two agent runs in one process share it, and reset_run clears it for both. For one run at a time that is fine. For concurrent runs, keep the vault per run. A tool parameter typed ToolRuntime is filled in by LangChain and does not appear in the schema the model sees. Pass a context object to each invoke call, and each run gets its own vault. Save this as runtime_tools.py next to fake_model.py, which it imports, and run it with .\.venv\Scripts\python runtime_tools.py:

# runtime_tools.py: a per-run vault through ToolRuntime (hidden arg)
import json
import os
from dataclasses import dataclass, field

from capskip import CapSkip
from langchain.agents import create_agent
from langchain.messages import AIMessage
from langchain.tools import ToolRuntime, tool
from langchain_core.utils.function_calling import convert_to_openai_tool

from fake_model import ScriptedChat, call

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")),
)
PAGE_URL = os.getenv("PAGE_URL", "https://example.com/page-with-recaptcha")


@dataclass
class RunState:  # one instance per agent.invoke(..., context=...)
    vault: dict = field(default_factory=dict)  # handle -> token


@tool
def solve_recaptcha(
    sitekey: str, page_url: str, runtime: ToolRuntime[RunState]
) -> dict:
    """Solve a reCAPTCHA and return a handle for use_token."""
    token = solver.recaptcha(sitekey, page_url)["code"]
    handle = "cap_%d" % (len(runtime.context.vault) + 1)
    runtime.context.vault[handle] = token
    return {"ok": True, "handle": handle}


@tool
def use_token(handle: str, runtime: ToolRuntime[RunState]) -> dict:
    """Spend a handle from solve_recaptcha. It works once."""
    token = runtime.context.vault.pop(handle, None)
    if token is None:
        return {"ok": False, "error": "unknown_or_used_handle"}
    return {"ok": True, "token_chars": len(token)}


if __name__ == "__main__":
    # the model-facing schema has no "runtime" property
    schema = convert_to_openai_tool(solve_recaptcha)
    print(list(schema["function"]["parameters"]["properties"]))
    args = {"sitekey": os.getenv("SITEKEY", "YOUR_SITEKEY"),
            "page_url": PAGE_URL}
    def second(msgs):  # read the handle out of the first tool result
        handle = json.loads(msgs[-1].content)["handle"]
        return call("use_token", {"handle": handle}, "c2")

    model = ScriptedChat(script=[
        call("solve_recaptcha", args, "c1"), second, AIMessage(content="Done."),
    ])
    agent = create_agent(model, [solve_recaptcha, use_token],
                         context_schema=RunState)
    run = RunState()
    out = agent.invoke({"messages": [("user", "go")]}, context=run)
    print(out["messages"][-2].content, "| vault left:", run.vault)

The first line it prints lists the two properties the model sees, sitekey and page_url, with no runtime. After the run the vault is empty. The solve counter in captcha_tools.py is a module variable too, and runtime_tools.py leaves out the host check, the budget, the time limit and the error handling to stay short, so carry those over if you copy it. A tool built this way only works inside an agent. The captcha_tools.py pair also ran unchanged in a hand-built LangGraph graph with ToolNode and tools_condition, with no warnings on langgraph 1.2.16. The ToolRuntime version needs context_schema=RunState on the graph and a context argument on invoke.

Run the agent against CapSkip on another machine

The agent and the solver do not have to share a PC. Switch CapSkip to Server mode under connection settings, and it listens on your network address or public IP. A static public IP is recommended for stable remote access. Then set the three variables the file already reads. The solver is still your own Windows machine, and solves are still unmetered.

# CapSkip on another Windows PC, in Server mode: set these first
$env:PAGE_URL = "https://your-site.example/signup"   # your own page
$env:SITEKEY = "YOUR_SITEKEY"
$env:CAPSKIP_HOST = "192.168.1.50"
$env:CAPSKIP_PORT = "8080"
$env:CAPSKIP_API_KEY = "YOUR_API_KEY"   # only if API key validation is on
.\.venv\Scripts\python run_agent.py

Mind three things. The SDK talks to CapSkip over plain HTTP, so the API key travels in clear text. Keep that link on a private network or a VPN, and turn on API key validation. CapSkip opens its port to every address when Server mode starts, so a Windows Firewall block rule for the addresses you do not expect is the way to narrow it. Put only the address in CAPSKIP_HOST, and use an IP address: a refused connection took about 2 seconds to 127.0.0.1 and about 4 to localhost on this PC. A value that starts with http:// fails with a NetworkException that says it could not resolve the host http. A value with the port on the end fails with a message that the host or port is not valid. The remote setup was not run for this guide, only the variables were read.

Common errors with a LangChain CAPTCHA tool

What you seeCauseFix
ImportError: cannot import name ‘AgentExecutor’ from ‘langchain.agents’An old tutorialUse create_agent
LangGraphDeprecatedSinceV10 warningcreate_react_agent imported from langgraph.prebuiltImport create_agent from langchain.agents
ValueError: Function must have a docstringThe tool has no docstring and no descriptionAdd a docstring
Error invoking tool, then: Input should be ‘v2’ or ‘v3’The model sent a value outside the LiteralNothing to change. A real model should read it and correct the value, which was not tested
NotImplementedError: StructuredTool does not support sync invocationAn async-only tool called with invokeGive the tool a sync function too
agent.invoke ends with a CapSkip exceptionThe tool let it escapeCatch it in the tool and return a dictionary
capskip_unreachableCapSkip is not running, or the port is wrong. A refused local port fails in about 2 seconds, and a remote PC that is switched off took about 21 seconds, because the sync client sets no HTTP timeout of its ownStart CapSkip and check CAPSKIP_HOST and CAPSKIP_PORT
capskip_rejectedCapSkip refused the job for a reason other than an unsolvable CAPTCHA, such as a wrong API keyRead the log line and fix the key or the arguments
host_not_allowedThe page address is not on the host of PAGE_URLSet PAGE_URL to your page
solve_budget_used_upMAX_SOLVES reached in this runRaise it, or fix why the solves fail
unknown_or_used_handle or handle_expiredThe handle was spent, or waited past TOKEN_TTLSolve again
form_not_recognisedThe page has no field named _csrf, or no form actionChange the field names in submit_form
GraphRecursionErrorThe model never reached a final answerSet recursion_limit and a tool call limit
A 403 that mentions the RefererThe site checks it and requests sent noneKeep the Referer header in submit_form

FAQ

Do I need an API key for a language model to try this?

Not to run the tools. The scripted model makes the calls, and CapSkip needs no key unless you switched on API key validation. To see a real model use the tools, set a provider key and CAPTCHA_AGENT_MODEL. How a real model behaves with these tools was not tested for this guide.

Should the agent drive a browser instead?

That depends on the page. This tool suits a plain form that you can post to. If the CAPTCHA only works inside a real browser session, drive the browser from your own code and hand the token to the page there, not through the model. The Browser Use guide shows an agent that runs the browser itself.

What if my agent is Claude Code or another MCP client?

Then you do not write the tool. CapSkip has an MCP server for that, and the MCP CAPTCHA solver page explains it.

Can the agent run on a hosted platform?

A hosted platform that runs your Python can call a CapSkip that you run on your own Windows PC, and the solver never moves to the platform. Put CapSkip in Server mode, give the machine a static public IP and set CAPSKIP_HOST. The key and the page address then travel in clear text over plain HTTP, so prefer a VPN or a private network, and add a Windows Firewall block rule for other addresses. No hosted platform was tested for this guide.

The short version

A LangChain CAPTCHA tool should return a handle and not the token, catch every CapSkip exception and return a small dictionary, check the page address inside the function, count solves, and sit behind a tool call limit. Keep the token in a dictionary on your side and spend it in a second tool. Use a plain def tool unless you need async.

An agent that retries until it succeeds is only affordable when each retry is free, and with a captcha solver on your own Windows machine every retry costs a few seconds and nothing else.